Neither the initial nor the handoff kickoff ever told a list-handler
session its own handler task id, so handoff_list_handler(taskId, ...)
was unrenderable -- the handoff chain broke exactly where it was needed
(#200/#201 on 2026-08-21).
Add {handlerTaskId} to both MergeHelperInitialDefault and
MergeHelperHandoffDefault, thread a handlerTaskId parameter through
BuildForMergeHelperAsync (interface, WorkerHub.GetMergeHelperLaunchSpec,
IWorkerClient/WorkerClient, and the MissionControlViewModel call site,
which already had the id from CreateMergeHelperTaskAsync but never
passed it on), and render it in BuildForMergeHelperHandoffAsync from the
taskId parameter it already receives.
RenderTemplate leaves unknown/missing tokens untouched, so a
user-edited override without the new token still renders fine -- no
forced migration for override users.
442 lines
19 KiB
C#
442 lines
19 KiB
C#
using System.Collections.ObjectModel;
|
|
using System.Collections.Specialized;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using ClaudeDo.Data;
|
|
using ClaudeDo.Ui.Localization;
|
|
using ClaudeDo.Ui.Services;
|
|
using ClaudeDo.Ui.ViewModels.MissionControl;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace ClaudeDo.Ui.ViewModels;
|
|
|
|
public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|
{
|
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
|
private readonly IWorkerClient _worker;
|
|
private readonly Action<string, string, DateTime> _onTaskStarted;
|
|
private readonly Action<string, string, string, DateTime> _onTaskFinished;
|
|
private readonly Action<string> _onTaskUpdated;
|
|
private readonly Action _onConnectionRestored;
|
|
private readonly Action<string, IReadOnlyList<string>, string> _onHandoffRequested;
|
|
|
|
// Embedded ConPTY sessions (task-based only) — a manual cockpit detached from the
|
|
// review/merge/status machinery.
|
|
public ObservableCollection<ConPtyPaneViewModel> ConPtySessions { get; } = new();
|
|
|
|
// Claims a TaskId/listId for the duration of an Open*ConPtySessionAsync call so a second,
|
|
// overlapping invocation for the same key (e.g. a double-click) bails out instead of racing
|
|
// past the ConPtySessions dedupe check before the first call has added its pane. Checked and
|
|
// claimed synchronously at method entry — before any await — and released in a finally.
|
|
private readonly HashSet<string> _pendingTaskOpens = new();
|
|
private readonly HashSet<string> _pendingMergeHelperLists = new();
|
|
|
|
// Mirror of ConPtySessions typed as the pane abstraction so the layout toggle (grid/tabs)
|
|
// binds one contract rather than a ConPTY-specific type.
|
|
public ObservableCollection<IMissionControlPane> Panes { get; } = new();
|
|
|
|
[ObservableProperty] private int _columnCount = 1;
|
|
|
|
[ObservableProperty]
|
|
[NotifyPropertyChangedFor(nameof(LayoutToggleLabel))]
|
|
private bool _isFocusMode;
|
|
|
|
[ObservableProperty] private IMissionControlPane? _focusedPane;
|
|
|
|
public string LayoutToggleLabel => Loc.T(IsFocusMode ? "missionControl.overviewMode" : "missionControl.focusMode");
|
|
|
|
/// <summary>Surfaces a Command Center failure (e.g. a ConPTY launch spec fetch) — the shell
|
|
/// wires this into the footer error strip, same as the island view models' ErrorReported.</summary>
|
|
public event Action<string>? ErrorReported;
|
|
|
|
public Action<string>? OpenInApp { get; set; }
|
|
|
|
// View-layer seam: open the app Settings modal from the Mission Control window.
|
|
public Action? OpenSettingsRequested { get; set; }
|
|
|
|
public bool HasPanes => Panes.Count > 0;
|
|
|
|
// Read-only view of the worker queue (tasks waiting to run), shown as a side strip.
|
|
public ObservableCollection<QueuedTaskViewModel> Queued { get; } = new();
|
|
public bool HasQueued => Queued.Count > 0;
|
|
|
|
public UsagePillViewModel UsagePill { get; }
|
|
|
|
public MissionControlViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient worker, UsagePillViewModel usagePill)
|
|
{
|
|
_dbFactory = dbFactory;
|
|
_worker = worker;
|
|
UsagePill = usagePill;
|
|
|
|
ConPtySessions.CollectionChanged += OnConPtySessionsChanged;
|
|
Panes.CollectionChanged += OnPanesChanged;
|
|
|
|
_onTaskStarted = (slot, taskId, startedAt) => { _ = RefreshQueueAsync(); };
|
|
_worker.TaskStartedEvent += _onTaskStarted;
|
|
|
|
_onTaskFinished = (slot, taskId, status, finishedAt) => _ = RefreshQueueAsync();
|
|
_worker.TaskFinishedEvent += _onTaskFinished;
|
|
|
|
_onTaskUpdated = taskId => _ = RefreshQueueAsync();
|
|
_worker.TaskUpdatedEvent += _onTaskUpdated;
|
|
|
|
_onConnectionRestored = () => { _ = RefreshQueueAsync(); };
|
|
_worker.ConnectionRestoredEvent += _onConnectionRestored;
|
|
|
|
_onHandoffRequested = (taskId, survivingTaskIds, nextPhase) => { _ = OpenMergeHelperHandoffConPtySessionAsync(taskId, survivingTaskIds, nextPhase); };
|
|
_worker.HandoffRequestedEvent += _onHandoffRequested;
|
|
|
|
_ = RefreshQueueAsync();
|
|
}
|
|
|
|
internal async System.Threading.Tasks.Task RefreshQueueAsync()
|
|
{
|
|
try
|
|
{
|
|
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
|
var rows = await ctx.Tasks.AsNoTracking()
|
|
.Where(t => t.Status == ClaudeDo.Data.Models.TaskStatus.Queued
|
|
|| t.Status == ClaudeDo.Data.Models.TaskStatus.Running)
|
|
.OrderBy(t => t.Status == ClaudeDo.Data.Models.TaskStatus.Running ? 0 : 1)
|
|
.ThenBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
|
.Select(t => new { t.Id, t.Title, t.BlockedByTaskId, t.Status })
|
|
.ToListAsync();
|
|
|
|
Queued.Clear();
|
|
foreach (var r in rows)
|
|
{
|
|
var id = r.Id;
|
|
Queued.Add(new QueuedTaskViewModel
|
|
{
|
|
Id = id,
|
|
Title = r.Title ?? string.Empty,
|
|
IsBlocked = r.BlockedByTaskId != null,
|
|
IsRunning = r.Status == ClaudeDo.Data.Models.TaskStatus.Running,
|
|
OpenInAppCommand = new RelayCommand(() => OpenInApp?.Invoke(id)),
|
|
});
|
|
}
|
|
OnPropertyChanged(nameof(HasQueued));
|
|
}
|
|
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.queueRefreshFailed", ex.Message)); }
|
|
}
|
|
|
|
// Drop-to-queue: a task dragged from the main app onto Mission Control gets queued. Goes
|
|
// through the worker hub (TaskStateService.EnqueueAsync) rather than a raw EF write so the
|
|
// manual/draft-child guards apply here too. The interactive-session check has to live here
|
|
// rather than on the worker side: the worker never touches task status for a UI-hosted ConPTY
|
|
// session, so it has no way to know one is open — only Mission Control's own pane list does.
|
|
public async System.Threading.Tasks.Task EnqueueTaskAsync(string taskId)
|
|
{
|
|
if (string.IsNullOrEmpty(taskId)) return;
|
|
if (ConPtySessions.Any(s => s.TaskId == taskId))
|
|
{
|
|
ErrorReported?.Invoke(Loc.T("missionControl.enqueueAlreadyOpen"));
|
|
return;
|
|
}
|
|
try
|
|
{
|
|
var baseDirty = await _worker.SetTaskStatusAsync(taskId, ClaudeDo.Data.Models.TaskStatus.Queued);
|
|
if (baseDirty is not null)
|
|
ErrorReported?.Invoke(Loc.T(
|
|
"vm.queue.baseDirtyWarning", baseDirty.ModifiedCount, baseDirty.UntrackedCount));
|
|
}
|
|
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.enqueueFailed", ex.Message)); }
|
|
await RefreshQueueAsync();
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void OpenSettings() => OpenSettingsRequested?.Invoke();
|
|
|
|
[RelayCommand]
|
|
private void ToggleLayout() => IsFocusMode = !IsFocusMode;
|
|
|
|
// Fetches the launch spec for a task's worktree and hosts an embedded ConPTY session as a
|
|
// Command Center pane (task-based only).
|
|
public async System.Threading.Tasks.Task OpenConPtySessionAsync(string taskId)
|
|
{
|
|
if (string.IsNullOrEmpty(taskId)) return;
|
|
// One pane per TaskId is an invariant -- a merge-helper handoff closes its outgoing pane
|
|
// before opening the next phase's (see OpenMergeHelperHandoffConPtySessionAsync).
|
|
if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is { } existing)
|
|
{
|
|
FocusedPane = existing;
|
|
return;
|
|
}
|
|
if (!_pendingTaskOpens.Add(taskId)) return;
|
|
|
|
try
|
|
{
|
|
var title = taskId;
|
|
try
|
|
{
|
|
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
|
var entity = await ctx.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId);
|
|
if (entity?.Title is { Length: > 0 } t) title = t;
|
|
}
|
|
catch { /* best-effort title lookup */ }
|
|
|
|
AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
|
|
() => DescribeAsync(() => _worker.GetInteractiveLaunchSpecAsync(taskId))));
|
|
}
|
|
finally
|
|
{
|
|
_pendingTaskOpens.Remove(taskId);
|
|
}
|
|
}
|
|
|
|
// Starts (or resumes) a planning session and hosts it as an embedded ConPTY Command Center
|
|
// pane — the ConPTY replacement for the old external wt planning window. Deduped by TaskId
|
|
// like OpenConPtySessionAsync.
|
|
public async System.Threading.Tasks.Task OpenPlanningConPtySessionAsync(string taskId, bool resume)
|
|
{
|
|
if (string.IsNullOrEmpty(taskId)) return;
|
|
if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is { } existing)
|
|
{
|
|
FocusedPane = existing;
|
|
return;
|
|
}
|
|
if (!_pendingTaskOpens.Add(taskId)) return;
|
|
|
|
try
|
|
{
|
|
var title = taskId;
|
|
try
|
|
{
|
|
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
|
var entity = await ctx.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId);
|
|
if (entity?.Title is { Length: > 0 } t) title = t;
|
|
}
|
|
catch { /* best-effort title lookup */ }
|
|
title += Loc.T("missionControl.planningTitleSuffix");
|
|
|
|
AddConPtyPane(new ConPtyPaneViewModel(taskId, title, () => DescribeAsync(() => resume
|
|
? _worker.GetPlanningResumeLaunchSpecAsync(taskId)
|
|
: _worker.GetPlanningStartLaunchSpecAsync(taskId))));
|
|
}
|
|
finally
|
|
{
|
|
_pendingTaskOpens.Remove(taskId);
|
|
}
|
|
}
|
|
|
|
// Ad-hoc (task-less) ConPTY session in a user-chosen directory. Never deduped — every call
|
|
// opens a fresh pane, unlike the task-based OpenConPtySessionAsync above.
|
|
public System.Threading.Tasks.Task OpenAdHocConPtySessionAsync(string directory)
|
|
{
|
|
if (string.IsNullOrEmpty(directory)) return System.Threading.Tasks.Task.CompletedTask;
|
|
|
|
var title = Path.GetFileName(directory.TrimEnd('\\', '/'));
|
|
if (string.IsNullOrEmpty(title)) title = directory;
|
|
|
|
AddConPtyPane(ConPtyPaneViewModel.CreateAdHoc(title,
|
|
() => DescribeAsync(() => _worker.GetAdHocLaunchSpecAsync(directory))));
|
|
return System.Threading.Tasks.Task.CompletedTask;
|
|
}
|
|
|
|
// List-handler session over a hand-picked set of tasks ("Let Claude handle it"). Task-based:
|
|
// creates one new ClaudeDo task per run to own the session (title/diff/result), deduped by
|
|
// TaskId like OpenConPtySessionAsync. The handler still merges the tasks it handles itself
|
|
// (no worktree of its own) — see TaskEntity.HandlerBaseCommit/HandlerHeadCommit.
|
|
public async System.Threading.Tasks.Task OpenMergeHelperConPtySessionAsync(string listId, IReadOnlyList<string> taskIds)
|
|
{
|
|
if (taskIds is not { Count: > 0 }) return;
|
|
// The TaskId dedupe below can't help here — CreateMergeHelperTaskAsync mints a brand-new
|
|
// task id every call, so a double-trigger for the same list would always mint two host
|
|
// tasks before either pane exists to dedupe against. Guard the whole method per listId.
|
|
if (!_pendingMergeHelperLists.Add(listId)) return;
|
|
|
|
try
|
|
{
|
|
var title = Loc.T("missionControl.mergeHelperTitle");
|
|
var listName = listId;
|
|
try
|
|
{
|
|
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
|
var list = await ctx.Lists.AsNoTracking().FirstOrDefaultAsync(l => l.Id == listId);
|
|
if (list?.Name is { Length: > 0 } name) { listName = name; title = $"{title} — {name}"; }
|
|
}
|
|
catch { /* best-effort title lookup */ }
|
|
|
|
string taskId;
|
|
try
|
|
{
|
|
taskId = await _worker.CreateMergeHelperTaskAsync(taskIds, listId,
|
|
Loc.T("missionControl.mergeHelperTaskTitle", listName),
|
|
Loc.T("missionControl.mergeHelperTaskDescriptionHeader"));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
ErrorReported?.Invoke(Loc.T("missionControl.conptyLaunchFailed", ex.Message));
|
|
return;
|
|
}
|
|
|
|
if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is { } existing)
|
|
{
|
|
FocusedPane = existing;
|
|
return;
|
|
}
|
|
|
|
AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
|
|
() => DescribeAsync(() => _worker.GetMergeHelperLaunchSpecAsync(taskIds, listId, taskId))));
|
|
}
|
|
finally
|
|
{
|
|
_pendingMergeHelperLists.Remove(listId);
|
|
}
|
|
}
|
|
|
|
// 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. Each tile is a live
|
|
// `claude` process with the full mcp__claudedo__* surface, so the outgoing phase's pane is
|
|
// closed first -- it was only ever told to end its turn, never to exit, and a multi-phase run
|
|
// would otherwise leave one live process per phase behind. This keeps the one-pane-per-TaskId
|
|
// invariant intact, so OpenConPtySessionAsync's dedupe and OnPaneSubmitForReview can keep using
|
|
// FirstOrDefault. No new task is created here; see
|
|
// InteractiveLaunchSpecService.BuildForMergeHelperHandoffAsync.
|
|
public async System.Threading.Tasks.Task OpenMergeHelperHandoffConPtySessionAsync(
|
|
string taskId, IReadOnlyList<string> 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
|
|
{
|
|
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
|
var task = await ctx.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId);
|
|
if (task is not null)
|
|
{
|
|
var list = await ctx.Lists.AsNoTracking().FirstOrDefaultAsync(l => l.Id == task.ListId);
|
|
if (list?.Name is { Length: > 0 } name)
|
|
title = $"{baseTitle} — {name}{Loc.T("missionControl.mergeHelperHandoffTitleSuffix")}";
|
|
}
|
|
}
|
|
catch { /* best-effort title lookup */ }
|
|
|
|
AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
|
|
() => DescribeAsync(() => _worker.GetMergeHelperHandoffLaunchSpecAsync(taskId, survivingTaskIds, nextPhase))));
|
|
}
|
|
|
|
// Wires a freshly built pane and shows it immediately — the pane resolves its own launch spec,
|
|
// so the tile is on screen (spinner running) while the worker is still preparing the worktree.
|
|
private void AddConPtyPane(ConPtyPaneViewModel pane)
|
|
{
|
|
pane.ErrorReported += OnConPtyPaneError;
|
|
pane.CloseRequested += CloseConPtySession;
|
|
pane.SubmitForReviewRequested += OnPaneSubmitForReview;
|
|
ConPtySessions.Add(pane);
|
|
pane.Start();
|
|
}
|
|
|
|
private static async System.Threading.Tasks.Task<TerminalLaunchDescriptor> DescribeAsync(
|
|
Func<System.Threading.Tasks.Task<LaunchSpec>> fetch)
|
|
{
|
|
var spec = await fetch();
|
|
return new TerminalLaunchDescriptor(spec.Cwd, spec.Exe, spec.Args, spec.Env);
|
|
}
|
|
|
|
private void OnConPtyPaneError(string message)
|
|
=> ErrorReported?.Invoke(Loc.T("missionControl.conptyLaunchFailed", message));
|
|
|
|
// Submit a task's hand-driven ConPTY work for review, then close the pane (the interactive
|
|
// session is finished). The worker commits the worktree and moves the task to WaitingForReview.
|
|
// Guarded by the pane's IsSubmitPending flag — a rapid double-click would otherwise race two
|
|
// SubmitTaskForReviewAsync calls, with the loser flashing a spurious footer error.
|
|
private async void OnPaneSubmitForReview(string taskId)
|
|
{
|
|
// See OpenConPtySessionAsync -- one pane per TaskId is an invariant, so FirstOrDefault is
|
|
// always the current/active pane for this task.
|
|
if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is not { } pane || pane.IsSubmitPending)
|
|
return;
|
|
|
|
pane.IsSubmitPending = true;
|
|
try
|
|
{
|
|
await _worker.SubmitTaskForReviewAsync(taskId);
|
|
CloseConPtySession(pane);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
pane.IsSubmitPending = false;
|
|
ErrorReported?.Invoke(Loc.T("missionControl.submitForReviewFailed", ex.Message));
|
|
}
|
|
}
|
|
|
|
private void CloseConPtySession(ConPtyPaneViewModel pane)
|
|
{
|
|
if (!ConPtySessions.Contains(pane)) return;
|
|
pane.ErrorReported -= OnConPtyPaneError;
|
|
pane.CloseRequested -= CloseConPtySession;
|
|
pane.SubmitForReviewRequested -= OnPaneSubmitForReview;
|
|
ConPtySessions.Remove(pane);
|
|
pane.Dispose();
|
|
}
|
|
|
|
// Mirrors ConPtySessions' add/remove 1:1 into Panes (typed as the pane abstraction).
|
|
private void OnConPtySessionsChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
|
{
|
|
switch (e.Action)
|
|
{
|
|
case NotifyCollectionChangedAction.Add:
|
|
Panes.Insert(e.NewStartingIndex, (ConPtyPaneViewModel)e.NewItems![0]!);
|
|
break;
|
|
case NotifyCollectionChangedAction.Remove:
|
|
Panes.RemoveAt(e.OldStartingIndex);
|
|
break;
|
|
default: // Reset
|
|
Panes.Clear();
|
|
break;
|
|
}
|
|
}
|
|
|
|
private void OnPanesChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
|
{
|
|
ColumnCount = Panes.Count switch
|
|
{
|
|
<= 1 => 1,
|
|
<= 4 => 2,
|
|
_ => 3,
|
|
};
|
|
OnPropertyChanged(nameof(HasPanes));
|
|
if (e.Action == NotifyCollectionChangedAction.Add && e.NewItems?[0] is IMissionControlPane added)
|
|
FocusedPane = added;
|
|
else if (e.Action == NotifyCollectionChangedAction.Remove && ReferenceEquals(FocusedPane, e.OldItems?[0]))
|
|
FocusedPane = Panes.LastOrDefault();
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
_worker.TaskStartedEvent -= _onTaskStarted;
|
|
_worker.TaskFinishedEvent -= _onTaskFinished;
|
|
_worker.TaskUpdatedEvent -= _onTaskUpdated;
|
|
_worker.ConnectionRestoredEvent -= _onConnectionRestored;
|
|
_worker.HandoffRequestedEvent -= _onHandoffRequested;
|
|
ConPtySessions.CollectionChanged -= OnConPtySessionsChanged;
|
|
Panes.CollectionChanged -= OnPanesChanged;
|
|
foreach (var c in ConPtySessions.ToList())
|
|
{
|
|
c.ErrorReported -= OnConPtyPaneError;
|
|
c.CloseRequested -= CloseConPtySession;
|
|
c.SubmitForReviewRequested -= OnPaneSubmitForReview;
|
|
c.Dispose();
|
|
}
|
|
ConPtySessions.Clear();
|
|
Panes.Clear();
|
|
}
|
|
}
|
|
|
|
/// <summary>Read-only display row for a queued or running task in the Mission Control side strip.</summary>
|
|
public sealed class QueuedTaskViewModel
|
|
{
|
|
public required string Id { get; init; }
|
|
public required string Title { get; init; }
|
|
public bool IsBlocked { get; init; }
|
|
public bool IsRunning { get; init; }
|
|
public IRelayCommand? OpenInAppCommand { get; init; }
|
|
}
|