From c8244c67f3cd565e3b387799ed3d0fc0c63a997f Mon Sep 17 00:00:00 2001 From: mika kuns Date: Tue, 11 Aug 2026 09:40:14 +0200 Subject: [PATCH] feat(ui): wire list-handler nextPhase through the handoff hub event HandoffRequested now carries nextPhase end to end (IWorkerClient -> WorkerClient -> MissionControlViewModel -> GetMergeHelperHandoffLaunchSpecAsync), and the outgoing tile is left open on handoff instead of being closed -- lookups that need the active pane for a task now use LastOrDefault since a handler task's ConPtySessions can hold more than one pane. --- .../Services/Interfaces/IWorkerClient.cs | 14 ++++---- src/ClaudeDo.Ui/Services/WorkerClient.cs | 10 +++--- .../ViewModels/MissionControlViewModel.cs | 34 +++++++++++-------- tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs | 6 ++-- .../UiVm/TasksIslandViewModelPlanningTests.cs | 4 +-- 5 files changed, 37 insertions(+), 31 deletions(-) diff --git a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs index 4fafb035..a79cfa27 100644 --- a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs @@ -25,9 +25,10 @@ public interface IWorkerClient : INotifyPropertyChanged /// A pending question was answered, timed out, or the run ended: (taskId, questionId). event Action? TaskQuestionResolvedEvent; - /// A running list-handler session called handoff_list_handler at the end of Phase 2: - /// (handlerTaskId, survivingTaskIds). The UI opens a second ConPTY tile for the same task. - event Action>? HandoffRequestedEvent; + /// A running list-handler session called handoff_list_handler at the end of a phase: + /// (handlerTaskId, survivingTaskIds, nextPhase). The UI opens a second ConPTY tile for the same + /// task to carry out nextPhase ("wait" | "merge" | "wait_final" | "merge_final"). + event Action, string>? HandoffRequestedEvent; event Action? PrepStartedEvent; event Action? PrepLineEvent; @@ -109,10 +110,11 @@ public interface IWorkerClient : INotifyPropertyChanged /// never queued) so the ConPTY tile can be task-based instead of ad-hoc. Returns the new task id. Task CreateMergeHelperTaskAsync( IReadOnlyList taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default); - /// Launch spec for the fresh ConPTY session a merge-helper run hands off to once Phase 2 - /// is done -- reuses the SAME handler task id (no new task created). + /// Launch spec for the fresh ConPTY session a merge-helper run hands off to once a phase + /// is done -- reuses the SAME handler task id (no new task created). nextPhase picks the next + /// session's role: "wait" | "merge" | "wait_final" | "merge_final". Task GetMergeHelperHandoffLaunchSpecAsync( - string taskId, IReadOnlyList survivingTaskIds, CancellationToken ct = default); + string taskId, IReadOnlyList survivingTaskIds, string nextPhase, CancellationToken ct = default); /// Starts a planning session and returns the launch spec for an embedded ConPTY /// planning terminal (replaces StartPlanningSessionAsync's external wt window). Task GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default); diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index cf5453e4..e7afa6da 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -49,7 +49,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC public event Action? TaskUpdatedEvent; public event Action? TaskQuestionAskedEvent; public event Action? TaskQuestionResolvedEvent; - public event Action>? HandoffRequestedEvent; + public event Action, string>? HandoffRequestedEvent; public event Action? ConnectionRestoredEvent; public event Action? WorktreeUpdatedEvent; public event Action? ListUpdatedEvent; @@ -151,9 +151,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC Dispatcher.UIThread.Post(() => TaskQuestionResolvedEvent?.Invoke(taskId, questionId)); }); - _hub.On>("HandoffRequested", (taskId, survivingTaskIds) => + _hub.On, string>("HandoffRequested", (taskId, survivingTaskIds, nextPhase) => { - Dispatcher.UIThread.Post(() => HandoffRequestedEvent?.Invoke(taskId, survivingTaskIds)); + Dispatcher.UIThread.Post(() => HandoffRequestedEvent?.Invoke(taskId, survivingTaskIds, nextPhase)); }); _hub.On("WorktreeUpdated", taskId => @@ -566,8 +566,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC => await _hub.InvokeAsync("CreateMergeHelperTask", taskIds, listId, title, descriptionHeader, ct); public async Task GetMergeHelperHandoffLaunchSpecAsync( - string taskId, IReadOnlyList survivingTaskIds, CancellationToken ct = default) - => await _hub.InvokeAsync("GetMergeHelperHandoffLaunchSpec", taskId, survivingTaskIds, ct); + string taskId, IReadOnlyList survivingTaskIds, string nextPhase, CancellationToken ct = default) + => await _hub.InvokeAsync("GetMergeHelperHandoffLaunchSpec", taskId, survivingTaskIds, nextPhase, ct); public async Task GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default) => await _hub.InvokeAsync("GetPlanningStartLaunchSpec", taskId, ct); diff --git a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs index b8b5da2e..acc18da4 100644 --- a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs @@ -20,7 +20,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable private readonly Action _onTaskFinished; private readonly Action _onTaskUpdated; private readonly Action _onConnectionRestored; - private readonly Action> _onHandoffRequested; + private readonly Action, string> _onHandoffRequested; // Embedded ConPTY sessions (task-based only) — a manual cockpit detached from the // review/merge/status machinery. @@ -85,7 +85,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable _onConnectionRestored = () => { _ = RefreshQueueAsync(); }; _worker.ConnectionRestoredEvent += _onConnectionRestored; - _onHandoffRequested = (taskId, survivingTaskIds) => { _ = OpenMergeHelperHandoffConPtySessionAsync(taskId, survivingTaskIds); }; + _onHandoffRequested = (taskId, survivingTaskIds, nextPhase) => { _ = OpenMergeHelperHandoffConPtySessionAsync(taskId, survivingTaskIds, nextPhase); }; _worker.HandoffRequestedEvent += _onHandoffRequested; _ = RefreshQueueAsync(); @@ -153,7 +153,9 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable public async System.Threading.Tasks.Task OpenConPtySessionAsync(string taskId) { if (string.IsNullOrEmpty(taskId)) return; - if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is { } existing) + // A merge-helper handoff can leave more than one pane for this TaskId (the outgoing phase's + // frozen tile plus the active one) -- LastOrDefault resolves to the newest/active pane. + if (ConPtySessions.LastOrDefault(s => s.TaskId == taskId) is { } existing) { FocusedPane = existing; return; @@ -281,19 +283,19 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable } } - // List-handler handoff: the running session called handoff_list_handler at the end of Phase 2. - // Replaces the Phase 1-2 tile with a fresh one for the SAME handler task id, which carries out - // Phases 3-5. The old tile used to be left open so its last message could still be read, but - // its process is gone by then and the terminal renders empty — it was only ever a dead - // placeholder to close by hand. No new task is created here; see - // InteractiveLaunchSpecService.BuildForMergeHelperHandoffAsync. - public async System.Threading.Tasks.Task OpenMergeHelperHandoffConPtySessionAsync(string taskId, IReadOnlyList survivingTaskIds) + // List-handler handoff: the running session called handoff_list_handler at the end of a phase. + // Opens a NEW tile for the SAME handler task id to carry out nextPhase, and deliberately leaves + // the outgoing tile open -- it still shows that phase's finished output, which the wait/merge + // chain can run through several times for one handler task. ConPtySessions can therefore hold + // more than one pane per TaskId from here on; lookups that mean "the current/active session for + // this task" (OpenConPtySessionAsync's dedupe, OnPaneSubmitForReview) use LastOrDefault so they + // resolve to the newest tile rather than a frozen earlier phase. No new task is created here; + // see InteractiveLaunchSpecService.BuildForMergeHelperHandoffAsync. + public async System.Threading.Tasks.Task OpenMergeHelperHandoffConPtySessionAsync( + string taskId, IReadOnlyList survivingTaskIds, string nextPhase) { if (string.IsNullOrEmpty(taskId) || survivingTaskIds is not { Count: > 0 }) return; - foreach (var stale in ConPtySessions.Where(s => s.TaskId == taskId).ToList()) - CloseConPtySession(stale); - var baseTitle = Loc.T("missionControl.mergeHelperTitle"); var title = baseTitle + Loc.T("missionControl.mergeHelperHandoffTitleSuffix"); try @@ -310,7 +312,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable catch { /* best-effort title lookup */ } AddConPtyPane(new ConPtyPaneViewModel(taskId, title, - () => DescribeAsync(() => _worker.GetMergeHelperHandoffLaunchSpecAsync(taskId, survivingTaskIds)))); + () => DescribeAsync(() => _worker.GetMergeHelperHandoffLaunchSpecAsync(taskId, survivingTaskIds, nextPhase)))); } // Wires a freshly built pane and shows it immediately — the pane resolves its own launch spec, @@ -340,7 +342,9 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable // SubmitTaskForReviewAsync calls, with the loser flashing a spurious footer error. private async void OnPaneSubmitForReview(string taskId) { - if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is not { } pane || pane.IsSubmitPending) + // See OpenConPtySessionAsync -- LastOrDefault resolves to the newest/active pane when a + // merge-helper handoff has left an earlier phase's frozen tile in place for this TaskId. + if (ConPtySessions.LastOrDefault(s => s.TaskId == taskId) is not { } pane || pane.IsSubmitPending) return; pane.IsSubmitPending = true; diff --git a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs index 689601e6..4ee0b9a5 100644 --- a/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs +++ b/tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs @@ -25,7 +25,7 @@ public abstract class StubWorkerClient : IWorkerClient public event Action? WorkerLogReceivedEvent; public event Action? TaskQuestionAskedEvent; public event Action? TaskQuestionResolvedEvent; - public event Action>? HandoffRequestedEvent; + public event Action, string>? HandoffRequestedEvent; public event Action? PrepStartedEvent; public event Action? PrepLineEvent; public event Action? PrepFinishedEvent; @@ -52,7 +52,7 @@ public abstract class StubWorkerClient : IWorkerClient public void RaiseConnectionRestored() => ConnectionRestoredEvent?.Invoke(); public void RaiseTaskQuestionAsked(string taskId, string questionId, string question) => TaskQuestionAskedEvent?.Invoke(taskId, questionId, question); public void RaiseTaskQuestionResolved(string taskId, string questionId) => TaskQuestionResolvedEvent?.Invoke(taskId, questionId); - public void RaiseHandoffRequested(string taskId, IReadOnlyList survivingTaskIds) => HandoffRequestedEvent?.Invoke(taskId, survivingTaskIds); + public void RaiseHandoffRequested(string taskId, IReadOnlyList survivingTaskIds, string nextPhase = "wait") => HandoffRequestedEvent?.Invoke(taskId, survivingTaskIds, nextPhase); public void RaisePlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList files, bool externallyDriven) => PlanningMergeConflictEvent?.Invoke(planningTaskId, subtaskId, files, externallyDriven); public void RaisePlanningMergeStarted(string planningTaskId, string targetBranch) => PlanningMergeStartedEvent?.Invoke(planningTaskId, targetBranch); @@ -116,7 +116,7 @@ public abstract class StubWorkerClient : IWorkerClient IReadOnlyList taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default) => Task.FromResult(Guid.NewGuid().ToString()); public virtual Task GetMergeHelperHandoffLaunchSpecAsync( - string taskId, IReadOnlyList survivingTaskIds, CancellationToken ct = default) + string taskId, IReadOnlyList survivingTaskIds, string nextPhase, CancellationToken ct = default) => Task.FromResult(new LaunchSpec(".", "claude", Array.Empty(), new Dictionary())); public virtual Task GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default) => Task.FromResult(new LaunchSpec(".", "claude", Array.Empty(), new Dictionary())); diff --git a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs index e3a9c9c5..de56ecf1 100644 --- a/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs +++ b/tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs @@ -37,7 +37,7 @@ sealed class FakeWorkerClient : IWorkerClient public event Action? WorkerLogReceivedEvent; public event Action? TaskQuestionAskedEvent; public event Action? TaskQuestionResolvedEvent; - public event Action>? HandoffRequestedEvent; + public event Action, string>? HandoffRequestedEvent; public void RaiseTaskUpdated(string taskId) => TaskUpdatedEvent?.Invoke(taskId); public void RaiseWorktreeUpdated(string taskId) => WorktreeUpdatedEvent?.Invoke(taskId); public void RaiseTaskMessage(string taskId, string line) => TaskMessageEvent?.Invoke(taskId, line); @@ -87,7 +87,7 @@ sealed class FakeWorkerClient : IWorkerClient IReadOnlyList taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default) => Task.FromResult(Guid.NewGuid().ToString()); public Task GetMergeHelperHandoffLaunchSpecAsync( - string taskId, IReadOnlyList survivingTaskIds, CancellationToken ct = default) + string taskId, IReadOnlyList survivingTaskIds, string nextPhase, CancellationToken ct = default) => Task.FromResult(new LaunchSpec(".", "claude", Array.Empty(), new Dictionary())); public Task GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default) => Task.FromResult(new LaunchSpec(directory, "claude", Array.Empty(), new Dictionary()));