chore(claude-do): merge chore(ui): IWorkerClient.MergeProgressEvent-Forwarder entfer

ClaudeDo-Task: 2feaa8b5-0484-4b11-9519-6627f0bb95a3
This commit is contained in:
mika kuns
2026-09-10 13:35:54 +02:00
8 changed files with 26 additions and 69 deletions
@@ -52,13 +52,6 @@ public interface IWorkerClient : INotifyPropertyChanged
/// total.</summary> /// total.</summary>
event Action<string, string, int, int>? OperationProgressEvent; event Action<string, string, int, int>? OperationProgressEvent;
/// <summary>(taskId, phase, elapsedSeconds) — phase of an in-flight single-task merge
/// ("merging" | "verifying"). Fires while the MergeTask call itself is still pending, so the
/// waiting UI can show what it's blocked on; the verify phase re-fires every 30 s. A thin
/// forwarder over <see cref="OperationProgressEvent"/> kept for existing consumers (elapsed
/// seconds riding in the generic "current" slot).</summary>
event Action<string, string, int>? MergeProgressEvent;
event Action<string, string>? PlanningMergeStartedEvent; event Action<string, string>? PlanningMergeStartedEvent;
event Action<string, string>? PlanningSubtaskMergedEvent; event Action<string, string>? PlanningSubtaskMergedEvent;
/// <summary>(planningTaskId, subtaskId, conflictedFiles, externallyDriven). externallyDriven /// <summary>(planningTaskId, subtaskId, conflictedFiles, externallyDriven). externallyDriven
+1 -6
View File
@@ -67,7 +67,6 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public event Action<UsageSnapshotDto>? UsageUpdatedEvent; public event Action<UsageSnapshotDto>? UsageUpdatedEvent;
public event Action<string, string, int, int>? OperationProgressEvent; public event Action<string, string, int, int>? OperationProgressEvent;
public event Action<string, string, int>? MergeProgressEvent;
public event Action<string, string>? PlanningMergeStartedEvent; public event Action<string, string>? PlanningMergeStartedEvent;
public event Action<string, string>? PlanningSubtaskMergedEvent; public event Action<string, string>? PlanningSubtaskMergedEvent;
@@ -189,11 +188,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
_hub.On<string, string, int, int>("OperationProgress", (opKey, phase, current, total) => _hub.On<string, string, int, int>("OperationProgress", (opKey, phase, current, total) =>
{ {
Dispatcher.UIThread.Post(() => Dispatcher.UIThread.Post(() => OperationProgressEvent?.Invoke(opKey, phase, current, total));
{
OperationProgressEvent?.Invoke(opKey, phase, current, total);
MergeProgressEvent?.Invoke(opKey, phase, current);
});
}); });
_hub.On<string, string>("PlanningMergeStarted", (planningTaskId, targetBranch) => _hub.On<string, string>("PlanningMergeStarted", (planningTaskId, targetBranch) =>
@@ -1262,19 +1262,14 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
using var op = ApproveOp.Begin(Loc.T("ops.merge.merging")); using var op = ApproveOp.Begin(Loc.T("ops.merge.merging"));
// Subscribed only for the duration of the call: the worker broadcasts to every // Subscribed only for the duration of the call: the worker broadcasts to every
// client, and a VM left on this event would keep reacting to merges for other tasks. // client, and a VM left on this event would keep reacting to merges for other tasks.
void OnMergeProgress(string progressTaskId, string phase, int elapsedSeconds)
{
if (progressTaskId != taskId || phase != MergePhaseVerifying) return;
ApproveOp.Report(Loc.T("ops.merge.verifying", FormatElapsed(elapsedSeconds)));
}
// Separate event: MergeProgressEvent forwards elapsed-seconds-only (verifying), but the
// rebase phase needs the branch count too, which only rides the untrimmed OperationProgress.
void OnOperationProgress(string opKey, string phase, int current, int total) void OnOperationProgress(string opKey, string phase, int current, int total)
{ {
if (opKey != taskId || phase != MergePhaseRebasing) return; if (opKey != taskId) return;
ApproveOp.Report(FormatRebasing(current, total)); if (phase == MergePhaseVerifying)
ApproveOp.Report(Loc.T("ops.merge.verifying", FormatElapsed(current)));
else if (phase == MergePhaseRebasing)
ApproveOp.Report(FormatRebasing(current, total));
} }
_worker.MergeProgressEvent += OnMergeProgress;
_worker.OperationProgressEvent += OnOperationProgress; _worker.OperationProgressEvent += OnOperationProgress;
try try
{ {
@@ -1298,7 +1293,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
} }
finally finally
{ {
_worker.MergeProgressEvent -= OnMergeProgress;
_worker.OperationProgressEvent -= OnOperationProgress; _worker.OperationProgressEvent -= OnOperationProgress;
} }
} }
@@ -94,7 +94,6 @@ public sealed partial class MergeModalViewModel : ViewModelBase
// Subscribed only for the duration of the call: the worker's broadcast reaches every // Subscribed only for the duration of the call: the worker's broadcast reaches every
// client, and a transient VM left on that event would outlive its window. // client, and a transient VM left on that event would outlive its window.
ProgressMessage = Loc.T("vm.merge.progressMerging"); ProgressMessage = Loc.T("vm.merge.progressMerging");
_worker.MergeProgressEvent += OnMergeProgress;
_worker.OperationProgressEvent += OnOperationProgress; _worker.OperationProgressEvent += OnOperationProgress;
try try
{ {
@@ -148,34 +147,23 @@ public sealed partial class MergeModalViewModel : ViewModelBase
} }
finally finally
{ {
_worker.MergeProgressEvent -= OnMergeProgress;
_worker.OperationProgressEvent -= OnOperationProgress; _worker.OperationProgressEvent -= OnOperationProgress;
ProgressMessage = null; ProgressMessage = null;
IsBusy = false; IsBusy = false;
} }
} }
private void OnMergeProgress(string taskId, string phase, int elapsedSeconds)
{
if (taskId != TaskId) return;
// Rebasing is handled by OnOperationProgress (needs the branch total, which this
// elapsed-seconds-only forwarder drops) -- don't overwrite it with the generic fallback.
if (phase == MergePhaseRebasing) return;
ProgressMessage = phase switch
{
MergePhaseVerifying => Loc.T("vm.merge.progressVerifying", FormatElapsed(elapsedSeconds)),
_ => Loc.T("vm.merge.progressMerging"),
};
}
// Separate event: MergeProgressEvent forwards elapsed-seconds-only (verifying), but the
// rebase phase needs the branch count too, which only rides the untrimmed OperationProgress.
private void OnOperationProgress(string opKey, string phase, int current, int total) private void OnOperationProgress(string opKey, string phase, int current, int total)
{ {
if (opKey != TaskId || phase != MergePhaseRebasing) return; if (opKey != TaskId) return;
ProgressMessage = total > 0 ProgressMessage = phase switch
? $"{Loc.T("ops.worker.rebasingAfterMerge")} ({current}/{total})" {
: Loc.T("ops.worker.rebasingAfterMerge"); MergePhaseRebasing => total > 0
? $"{Loc.T("ops.worker.rebasingAfterMerge")} ({current}/{total})"
: Loc.T("ops.worker.rebasingAfterMerge"),
MergePhaseVerifying => Loc.T("vm.merge.progressVerifying", FormatElapsed(current)),
_ => Loc.T("vm.merge.progressMerging"),
};
} }
/// Mirrors TaskMergeService.PhaseVerifying — a hub payload token, not a display string. /// Mirrors TaskMergeService.PhaseVerifying — a hub payload token, not a display string.
+1 -2
View File
@@ -202,8 +202,7 @@ launch specs · worktrees · agents/settings/lists · reports/notes/prep · diag
- `PrepLine` - `PrepLine`
- `PrepFinished` - `PrepFinished`
- `OperationProgress` (generic `(opKey, phase, current, total)` channel; merge phases are its - `OperationProgress` (generic `(opKey, phase, current, total)` channel; merge phases are its
first producer — `IWorkerClient.MergeProgressEvent` on the Ui side is a thin forwarder over it first producer)
for existing consumers, elapsed seconds riding in the `current` slot)
- `PlanningMergeStarted` - `PlanningMergeStarted`
- `PlanningSubtaskMerged` - `PlanningSubtaskMerged`
- `PlanningMergeConflict` - `PlanningMergeConflict`
@@ -35,7 +35,6 @@ public abstract class StubWorkerClient : IWorkerClient
public event Action<string>? RefineStartedEvent; public event Action<string>? RefineStartedEvent;
public event Action<string, bool, string?>? RefineFinishedEvent; public event Action<string, bool, string?>? RefineFinishedEvent;
public event Action<string, string, int, int>? OperationProgressEvent; public event Action<string, string, int, int>? OperationProgressEvent;
public event Action<string, string, int>? MergeProgressEvent;
public event Action<string, string>? PlanningMergeStartedEvent; public event Action<string, string>? PlanningMergeStartedEvent;
public event Action<string, string>? PlanningSubtaskMergedEvent; public event Action<string, string>? PlanningSubtaskMergedEvent;
public event Action<string, string, IReadOnlyList<string>, bool>? PlanningMergeConflictEvent; public event Action<string, string, IReadOnlyList<string>, bool>? PlanningMergeConflictEvent;
@@ -64,7 +63,6 @@ public abstract class StubWorkerClient : IWorkerClient
public void RaisePlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList<string> files, bool externallyDriven) public void RaisePlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList<string> files, bool externallyDriven)
=> PlanningMergeConflictEvent?.Invoke(planningTaskId, subtaskId, files, externallyDriven); => PlanningMergeConflictEvent?.Invoke(planningTaskId, subtaskId, files, externallyDriven);
public void RaiseOperationProgress(string opKey, string phase, int current, int total) => OperationProgressEvent?.Invoke(opKey, phase, current, total); public void RaiseOperationProgress(string opKey, string phase, int current, int total) => OperationProgressEvent?.Invoke(opKey, phase, current, total);
public void RaiseMergeProgress(string taskId, string phase, int elapsedSeconds) => MergeProgressEvent?.Invoke(taskId, phase, elapsedSeconds);
public void RaisePlanningMergeStarted(string planningTaskId, string targetBranch) => PlanningMergeStartedEvent?.Invoke(planningTaskId, targetBranch); public void RaisePlanningMergeStarted(string planningTaskId, string targetBranch) => PlanningMergeStartedEvent?.Invoke(planningTaskId, targetBranch);
public void RaisePlanningMergeAborted(string planningTaskId, string? reason = null) => PlanningMergeAbortedEvent?.Invoke(planningTaskId, reason); public void RaisePlanningMergeAborted(string planningTaskId, string? reason = null) => PlanningMergeAbortedEvent?.Invoke(planningTaskId, reason);
public void RaisePlanningCompleted(string planningTaskId) => PlanningCompletedEvent?.Invoke(planningTaskId); public void RaisePlanningCompleted(string planningTaskId) => PlanningCompletedEvent?.Invoke(planningTaskId);
@@ -170,14 +170,6 @@ public class DetailsIslandReviewActionsTests : IDisposable
public override Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) => Gate.Task; public override Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) => Gate.Task;
} }
private static int MergeProgressSubscriberCount(StubWorkerClient worker)
{
var field = typeof(StubWorkerClient).GetField("MergeProgressEvent",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
var del = (Delegate?)field!.GetValue(worker);
return del?.GetInvocationList().Length ?? 0;
}
[Fact] [Fact]
public async Task ApproveReview_SetsIsRunning_AndLocksTheButton_WhileThePendingCallIsInFlight() public async Task ApproveReview_SetsIsRunning_AndLocksTheButton_WhileThePendingCallIsInFlight()
{ {
@@ -209,10 +201,10 @@ public class DetailsIslandReviewActionsTests : IDisposable
var approve = vm.ApproveReviewCommand.ExecuteAsync(null); var approve = vm.ApproveReviewCommand.ExecuteAsync(null);
var merging = vm.ApproveOp.Label; var merging = vm.ApproveOp.Label;
worker.RaiseMergeProgress("some-other-task", "verifying", 30); worker.RaiseOperationProgress("some-other-task", "verifying", 30, 0);
Assert.Equal(merging, vm.ApproveOp.Label); Assert.Equal(merging, vm.ApproveOp.Label);
worker.RaiseMergeProgress("task-approve-2", "verifying", 30); worker.RaiseOperationProgress("task-approve-2", "verifying", 30, 0);
Assert.NotEqual(merging, vm.ApproveOp.Label); Assert.NotEqual(merging, vm.ApproveOp.Label);
worker.Gate.SetResult(new MergeResultDto("merged", new List<string>(), null)); worker.Gate.SetResult(new MergeResultDto("merged", new List<string>(), null));
@@ -220,7 +212,7 @@ public class DetailsIslandReviewActionsTests : IDisposable
} }
[Fact] [Fact]
public async Task ApproveReview_UnsubscribesFromMergeProgress_AfterTheCallCompletes() public async Task ApproveReview_UnsubscribesFromOperationProgress_AfterTheCallCompletes()
{ {
var worker = new BlockingApproveWorkerClient(); var worker = new BlockingApproveWorkerClient();
var vm = BuildVm(worker); var vm = BuildVm(worker);
@@ -228,12 +220,12 @@ public class DetailsIslandReviewActionsTests : IDisposable
vm.Monitor.ApplyState(TaskStatus.WaitingForReview); vm.Monitor.ApplyState(TaskStatus.WaitingForReview);
var approve = vm.ApproveReviewCommand.ExecuteAsync(null); var approve = vm.ApproveReviewCommand.ExecuteAsync(null);
Assert.Equal(1, MergeProgressSubscriberCount(worker)); Assert.Equal(1, OperationProgressSubscriberCount(worker));
worker.Gate.SetResult(new MergeResultDto("merged", new List<string>(), null)); worker.Gate.SetResult(new MergeResultDto("merged", new List<string>(), null));
await approve; await approve;
Assert.Equal(0, MergeProgressSubscriberCount(worker)); Assert.Equal(0, OperationProgressSubscriberCount(worker));
} }
private static int OperationProgressSubscriberCount(StubWorkerClient worker) private static int OperationProgressSubscriberCount(StubWorkerClient worker)
@@ -255,8 +247,7 @@ public class DetailsIslandReviewActionsTests : IDisposable
var approve = vm.ApproveReviewCommand.ExecuteAsync(null); var approve = vm.ApproveReviewCommand.ExecuteAsync(null);
var merging = vm.ApproveOp.Label; var merging = vm.ApproveOp.Label;
// The rebase phase needs the branch total, which only rides OperationProgress // The rebase phase needs the branch total — ignored for other tasks.
// (MergeProgressEvent forwards elapsed-seconds only) — ignored for other tasks.
worker.RaiseOperationProgress("some-other-task", "rebasing", 1, 3); worker.RaiseOperationProgress("some-other-task", "rebasing", 1, 3);
Assert.Equal(merging, vm.ApproveOp.Label); Assert.Equal(merging, vm.ApproveOp.Label);
@@ -79,7 +79,7 @@ public class MergeModalViewModelTests
// The verify gate starts — a distinct phase, so the user sees why this is taking minutes. // The verify gate starts — a distinct phase, so the user sees why this is taking minutes.
// (The elapsed time rides in a format argument, which the test localizer drops.) // (The elapsed time rides in a format argument, which the test localizer drops.)
worker.RaiseMergeProgress("task-1", "verifying", 90); worker.RaiseOperationProgress("task-1", "verifying", 90, 0);
Assert.NotEqual(merging, vm.ProgressMessage); Assert.NotEqual(merging, vm.ProgressMessage);
worker.MergeGate.SetResult(new MergeResultDto("merged", new List<string>(), null)); worker.MergeGate.SetResult(new MergeResultDto("merged", new List<string>(), null));
@@ -100,7 +100,7 @@ public class MergeModalViewModelTests
var submit = vm.SubmitCommand.ExecuteAsync(null); var submit = vm.SubmitCommand.ExecuteAsync(null);
var merging = vm.ProgressMessage; var merging = vm.ProgressMessage;
worker.RaiseMergeProgress("some-other-task", "verifying", 42); worker.RaiseOperationProgress("some-other-task", "verifying", 42, 0);
Assert.Equal(merging, vm.ProgressMessage); Assert.Equal(merging, vm.ProgressMessage);
@@ -115,7 +115,7 @@ public class MergeModalViewModelTests
await vm.InitializeAsync("task-1", "do the thing"); await vm.InitializeAsync("task-1", "do the thing");
await vm.SubmitCommand.ExecuteAsync(null); await vm.SubmitCommand.ExecuteAsync(null);
worker.RaiseMergeProgress("task-1", "verifying", 10); worker.RaiseOperationProgress("task-1", "verifying", 10, 0);
Assert.Null(vm.ProgressMessage); Assert.Null(vm.ProgressMessage);
} }
@@ -130,8 +130,7 @@ public class MergeModalViewModelTests
var submit = vm.SubmitCommand.ExecuteAsync(null); var submit = vm.SubmitCommand.ExecuteAsync(null);
var merging = vm.ProgressMessage; var merging = vm.ProgressMessage;
// Needs the branch total, which only rides OperationProgress (MergeProgressEvent // Needs the branch total, which only OperationProgress carries.
// forwards elapsed-seconds only) -- and must not be clobbered by that other forwarder.
worker.RaiseOperationProgress("task-1", "rebasing", 1, 2); worker.RaiseOperationProgress("task-1", "rebasing", 1, 2);
Assert.NotEqual(merging, vm.ProgressMessage); Assert.NotEqual(merging, vm.ProgressMessage);
Assert.Contains("(1/2)", vm.ProgressMessage); Assert.Contains("(1/2)", vm.ProgressMessage);