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()));