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.Data.Models; using ClaudeDo.Data.Repositories; 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 _dbFactory; private readonly IWorkerClient _worker; private readonly AppSettings? _settings; private readonly Action _onTaskStarted; private readonly Action _onTaskFinished; private readonly Action _onTaskUpdated; private readonly Action _onConnectionRestored; private readonly Action _onOperationStarted; private readonly Action _onOperationEnded; private readonly Action, string> _onHandoffRequested; private readonly Action> _onListHandlerStartRequested; private readonly Action _onHandlerUsageWarning; private readonly Action _onHandlerHandoffDeferred; // Embedded ConPTY sessions (task-based only) — a manual cockpit detached from the // review/merge/status machinery. public ObservableCollection 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 _pendingTaskOpens = new(); private readonly HashSet _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 Panes { get; } = new(); [ObservableProperty] private int _columnCount = 1; /// User-picked column count for the overview grid; 0 = auto (derived from the pane count). [ObservableProperty] private int _columnPreference; [ObservableProperty] [NotifyPropertyChangedFor(nameof(LayoutToggleLabel))] private bool _isFocusMode; [ObservableProperty] private ConPtyPaneViewModel? _focusedPane; public string LayoutToggleLabel => Loc.T(IsFocusMode ? "missionControl.overviewMode" : "missionControl.focusMode"); /// 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. public event Action? ErrorReported; public Action? OpenInApp { get; set; } // View-layer seam: open the app Settings modal from the Mission Control window. public Action? OpenSettingsRequested { get; set; } // Set by the shell: bring Mission Control on screen. A ConPTY pane only launches its process once // its view is attached, so a run started from outside the UI (start_list_handler MCP tool) or a // handoff arriving while Mission Control is closed would otherwise sit there without a process. public Action? OpenRequested { get; set; } public bool HasPanes => Panes.Count > 0; // Read-only activity strip. Beyond the queue itself (Queued/Running) it also carries the rows // that want the user's attention (Failed, WaitingForReview, roadblocked) — colour-coded only; // clicking a row opens the task in the main app, where the actual actions live. public ObservableCollection Queued { get; } = new(); public bool HasQueued => Queued.Count > 0; /// Worker operations running right now (verify, merge, rebase, worktree creation), oldest first. public ObservableCollection RunningOperations { get; } = new(); public bool HasRunningOperations => RunningOperations.Count > 0; /// The activity strip is shown while it has queue rows or running operations. public bool HasActivity => HasQueued || HasRunningOperations; // "New session" targets: one entry per list with a working dir, plus a browse fallback, so a // fresh session is two clicks instead of navigating a folder picker. public ObservableCollection SessionTargets { get; } = new(); /// "Recent sessions" flyout rows: each chain's origin session, its handoffs right below it. public ObservableCollection RecentSessions { get; } = new(); /// False shows the flyout's empty-state line. public bool HasRecentSessions => RecentSessions.Count > 0; // View-layer seam: the folder picker needs a TopLevel, so the view owns it (see // MissionControlView.OnNewSessionClicked). public Action? BrowseForSessionRequested { get; set; } // View-layer seam: confirms the "abandon run" action, naming the open-task count. Wired by the // view the same way TasksIslandViewModel/DetailsIslandViewModel are. public Func>? ConfirmAsync { get; set; } public UsagePillViewModel UsagePill { get; } public MissionControlViewModel(IDbContextFactory dbFactory, IWorkerClient worker, UsagePillViewModel usagePill, AppSettings? settings = null) { _dbFactory = dbFactory; _worker = worker; UsagePill = usagePill; _settings = settings; _columnPreference = Math.Clamp(settings?.MissionControlColumns ?? 0, 0, MaxColumns); 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(); _ = RefreshOperationsAsync(); }; _worker.ConnectionRestoredEvent += _onConnectionRestored; _onOperationStarted = AddOperation; _worker.OperationStartedEvent += _onOperationStarted; _onOperationEnded = RemoveOperation; _worker.OperationEndedEvent += _onOperationEnded; _onHandoffRequested = (taskId, survivingTaskIds, nextPhase) => { _ = OpenMergeHelperHandoffConPtySessionAsync(taskId, survivingTaskIds, nextPhase); }; _worker.HandoffRequestedEvent += _onHandoffRequested; _worker.SessionHandoffRequestedEvent += OpenSessionHandoff; _onListHandlerStartRequested = (listId, taskIds) => { OpenRequested?.Invoke(); _ = OpenMergeHelperConPtySessionAsync(listId, taskIds); }; _worker.ListHandlerStartRequestedEvent += _onListHandlerStartRequested; _onHandlerUsageWarning = (taskId, message) => { _ = WarnHandlerSessionAsync(taskId, message); }; _worker.HandlerUsageWarningEvent += _onHandlerUsageWarning; _onHandlerHandoffDeferred = (taskId, dueAt) => UpdateHandlerSessionTitleAsync(taskId, dueAt); _worker.HandlerHandoffDeferredEvent += _onHandlerHandoffDeferred; _ = RefreshQueueAsync(); _ = RefreshOperationsAsync(); _ = RefreshSessionTargetsAsync(); } /// Replaces the running-operations list with the Worker's snapshot (open and reconnect). internal async System.Threading.Tasks.Task RefreshOperationsAsync() { try { var snapshot = await _worker.GetActiveOperationsAsync(); var hadVerify = RunningOperations.Any(IsVerifyMerge); RunningOperations.Clear(); foreach (var op in snapshot.Where(o => !_endedOperationIds.Contains(o.Id)).OrderBy(o => o.StartedAt)) RunningOperations.Add(new RunningOperationViewModel(op)); NotifyOperationsChanged(); if (hadVerify || RunningOperations.Any(IsVerifyMerge)) _ = RefreshQueueAsync(); } catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.operationsRefreshFailed", ex.Message)); } } /// Ids already reported ended; guards against a late Started or a stale snapshot re-adding them. Op ids are unique, so it is never pruned (a few bytes per op). private readonly HashSet _endedOperationIds = new(); private void AddOperation(RunningOperationDto op) { if (_endedOperationIds.Contains(op.Id) || RunningOperations.Any(r => r.Id == op.Id)) return; var row = new RunningOperationViewModel(op); RunningOperations.Add(row); NotifyOperationsChanged(); if (IsVerifyMerge(row)) _ = RefreshQueueAsync(); } private void RemoveOperation(string id) { _endedOperationIds.Add(id); var row = RunningOperations.FirstOrDefault(r => r.Id == id); if (row is null) return; RunningOperations.Remove(row); NotifyOperationsChanged(); if (IsVerifyMerge(row)) _ = RefreshQueueAsync(); } /// A post-merge verify keeps its task in WaitingForReview, so only this op marks the activity row "Verifying". private static bool IsVerifyMerge(RunningOperationViewModel r) => r.Kind == OperationKinds.VerifyMerge; private void NotifyOperationsChanged() { OnPropertyChanged(nameof(HasRunningOperations)); OnPropertyChanged(nameof(HasActivity)); } /// Refreshes every row's elapsed text; the view calls this once a second while open. public void TickOperations(DateTime? nowUtc = null) { var now = nowUtc ?? DateTime.UtcNow; foreach (var r in RunningOperations) r.Tick(now); } 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 || t.Status == ClaudeDo.Data.Models.TaskStatus.WaitingForReview || t.Status == ClaudeDo.Data.Models.TaskStatus.Failed) // Attention first (failed, then review), then the queue itself. .OrderBy(t => t.Status == ClaudeDo.Data.Models.TaskStatus.Failed ? 0 : t.Status == ClaudeDo.Data.Models.TaskStatus.WaitingForReview ? 1 : t.Status == ClaudeDo.Data.Models.TaskStatus.Running ? 2 : 3) .ThenBy(t => t.SortOrder).ThenBy(t => t.CreatedAt) .Select(t => new { t.Id, t.Number, t.Title, t.BlockedByTaskId, t.Status, t.RoadblockCount }) .ToListAsync(); Queued.Clear(); foreach (var r in rows) { var id = r.Id; var running = r.Status == ClaudeDo.Data.Models.TaskStatus.Running; var (tint, colorKey, labelKey) = Decorate(r.Status, r.RoadblockCount, r.BlockedByTaskId != null, RunningOperations.Any(o => o.Kind == OperationKinds.VerifyMerge && o.TaskId == id)); Queued.Add(new QueuedTaskViewModel { Id = id, Number = r.Number, Title = r.Title ?? string.Empty, IsRunning = running, TintKey = tint, StatusColorKey = colorKey, StatusLabel = labelKey is null ? null : Loc.T(labelKey), OpenInAppCommand = new RelayCommand(() => OpenInApp?.Invoke(id)), OpenSessionCommand = new RelayCommand(() => _ = OpenConPtySessionAsync(id)), }); } OnPropertyChanged(nameof(HasQueued)); OnPropertyChanged(nameof(HasActivity)); } catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.queueRefreshFailed", ex.Message)); } } // Row tint + status label for the activity strip. Keys are resolved to brushes by // DotBrushConverter ("Brush"), so they must match tokens in Tokens.axaml. private static (string? Tint, string? ColorKey, string? LabelKey) Decorate( ClaudeDo.Data.Models.TaskStatus status, int roadblockCount, bool blocked, bool verifying) => status switch { ClaudeDo.Data.Models.TaskStatus.Failed => ("ErrorTint", "StatusError", "missionControl.failed"), ClaudeDo.Data.Models.TaskStatus.WaitingForReview when roadblockCount > 0 => ("RoadblockTint", "Amber", "missionControl.roadblock"), ClaudeDo.Data.Models.TaskStatus.WaitingForReview when verifying => ("RunningTint", "StatusRunning", "missionControl.verifying"), ClaudeDo.Data.Models.TaskStatus.WaitingForReview => ("DoneTint", "StatusReview", "missionControl.review"), ClaudeDo.Data.Models.TaskStatus.Running => ("RunningTint", "StatusRunning", "missionControl.running"), _ when blocked => (null, "Amber", "missionControl.blocked"), _ => (null, null, null), }; // Rebuilt on every flyout open — lists change rarely, but a stale target would launch a // session in a directory that is no longer configured. internal async System.Threading.Tasks.Task RefreshSessionTargetsAsync() { SessionTargets.Clear(); // Home + browse go in first so the menu stays usable even if the list query below fails. // Home is the target for quick general questions that belong to no project. var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); SessionTargets.Add(new SessionTargetViewModel { Label = Loc.T("missionControl.homeFolder"), StartCommand = new RelayCommand(() => _ = OpenAdHocConPtySessionAsync(home)), }); SessionTargets.Add(new SessionTargetViewModel { Label = Loc.T("missionControl.browseForFolder"), StartCommand = new RelayCommand(() => BrowseForSessionRequested?.Invoke()), }); try { await using var ctx = await _dbFactory.CreateDbContextAsync(); var lists = await ctx.Lists.AsNoTracking() .Where(l => l.WorkingDir != null && l.WorkingDir != "") .OrderBy(l => l.SortOrder).ThenBy(l => l.Name) .Select(l => new { l.Name, l.WorkingDir }) .ToListAsync(); foreach (var l in lists) { var dir = l.WorkingDir!; SessionTargets.Add(new SessionTargetViewModel { Label = l.Name, StartCommand = new RelayCommand(() => _ = OpenAdHocConPtySessionAsync(dir)), }); } } catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.queueRefreshFailed", ex.Message)); } } /// Rebuilds from the last 14 days (max 50 sessions); runs on /// every flyout open. internal async System.Threading.Tasks.Task RefreshRecentSessionsAsync() { try { await using var ctx = await _dbFactory.CreateDbContextAsync(); var sessions = await new MissionControlSessionRepository(ctx).ListRecentAsync(DateTime.UtcNow.AddDays(-14), 50); var ids = sessions.Select(s => s.SessionId).ToList(); // A task's current session resumes through its task tile, so it stays deduped by TaskId. var taskCurrent = (await ctx.Tasks.AsNoTracking() .Where(t => t.InteractiveSessionId != null && ids.Contains(t.InteractiveSessionId)) .Select(t => t.InteractiveSessionId!) .ToListAsync()).ToHashSet(); RecentSessions.Clear(); foreach (var (s, isHandoff) in NestHandoffs(sessions)) { var time = DateTime.SpecifyKind(s.StartedAt, DateTimeKind.Utc).ToLocalTime().ToString("g"); var briefPath = s.BriefPath; RecentSessionViewModel row = null!; row = new RecentSessionViewModel { SessionId = s.SessionId, Title = s.Title, Meta = isHandoff ? $"{Loc.T("missionControl.handoffSession")} · {time}" : $"{time} · {s.Cwd}", IsHandoff = isHandoff, ResumeCommand = new RelayCommand(() => ResumeSession(s, row.Title, taskCurrent.Contains(s.SessionId))), ShowBriefCommand = briefPath is null ? null : new RelayCommand(() => ShowBrief(briefPath)), CommitRenameCommand = new RelayCommand(() => _ = RenameSessionAsync(row)), }; RecentSessions.Add(row); } } catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.queueRefreshFailed", ex.Message)); } OnPropertyChanged(nameof(HasRecentSessions)); } /// Orders newest-first sessions into chains: each origin session (no recorded predecessor /// in the set) followed by every session handed off from it, directly or indirectly, oldest /// first. Chains are ordered by their newest member. internal static IEnumerable<(MissionControlSessionEntity Session, bool IsHandoff)> NestHandoffs( IReadOnlyList newestFirst) { var byId = newestFirst.ToDictionary(s => s.SessionId); string RootOf(MissionControlSessionEntity s) { var seen = new HashSet(); while (s.PredecessorSessionId is { } p && byId.TryGetValue(p, out var prev) && seen.Add(s.SessionId)) s = prev; return s.SessionId; } return newestFirst .GroupBy(RootOf) .SelectMany(chain => chain .OrderBy(s => s.SessionId != chain.Key) .ThenBy(s => s.StartedAt) .Select(s => (s, s.SessionId != chain.Key))); } // A task's current session reopens its task tile (deduped, same worktree guards); any other // session gets a fresh ad-hoc tile resuming it in its recorded cwd. private void ResumeSession(MissionControlSessionEntity session, string title, bool isTaskCurrent) { if (session.TaskId is { } taskId && isTaskCurrent) { _ = OpenConPtySessionAsync(taskId); return; } var paneId = Guid.NewGuid().ToString("n"); var sessionId = session.SessionId; AddConPtyPane(new ConPtyPaneViewModel(null, title, () => DescribeAsync(() => _worker.GetResumeSessionLaunchSpecAsync(sessionId, paneId))) { IdentityKey = paneId }); } /// Commits an inline rename: trims, ignores empty/unchanged input, persists the title. internal async System.Threading.Tasks.Task RenameSessionAsync(RecentSessionViewModel row) { if (!row.IsEditing) return; row.IsEditing = false; var title = row.EditText.Trim(); if (title.Length == 0 || title == row.Title) return; try { await using var ctx = await _dbFactory.CreateDbContextAsync(); await new MissionControlSessionRepository(ctx).RenameAsync(row.SessionId, title); row.Title = title; } catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.renameFailed", ex.Message)); } } private void ShowBrief(string briefPath) { var (ok, error) = ShellOpen.Path(briefPath); if (!ok) ErrorReported?.Invoke(Loc.T("missionControl.briefOpenFailed", error ?? briefPath)); } // 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; /// Upper bound of the column picker. public const int MaxColumns = 4; /// Sets the column preference from the header picker ("0" = auto) and persists it. [RelayCommand] private void SetColumns(string value) => ColumnPreference = Math.Clamp(int.Parse(value), 0, MaxColumns); partial void OnColumnPreferenceChanged(int value) { UpdateColumnCount(); if (_settings is null) return; _settings.MissionControlColumns = value; try { _settings.Save(); } catch (Exception ex) { ErrorReported?.Invoke(ex.Message); } } // Never more columns than panes, so a wide preference doesn't leave empty cells. private void UpdateColumnCount() => ColumnCount = ColumnPreference > 0 ? Math.Max(1, Math.Min(ColumnPreference, Panes.Count)) : Panes.Count switch { <= 1 => 1, <= 4 => 2, _ => 3, }; // 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; // The key is minted here, not in the pane, because the launch spec needs it as the paneId. var paneId = Guid.NewGuid().ToString("n"); AddConPtyPane(new ConPtyPaneViewModel(null, title, () => DescribeAsync(() => _worker.GetAdHocLaunchSpecAsync(directory, paneId))) { IdentityKey = paneId }); return System.Threading.Tasks.Task.CompletedTask; } /// A session called handoff_session: replaces its tile (same slot, same IdentityKey, so /// same colour) with a fresh session in the same cwd that starts from the brief. No matching tile /// (closed meanwhile, or another app instance) = nothing to replace. internal void OpenSessionHandoff(string paneId, string briefPath) { if (ConPtySessions.FirstOrDefault(s => s.IdentityKey == paneId) is not { Cwd: { } cwd } outgoing) return; OpenRequested?.Invoke(); var slot = ConPtySessions.IndexOf(outgoing); var taskId = outgoing.TaskId; var title = outgoing.DisplayTitle; CloseConPtySession(outgoing); AddConPtyPane(new ConPtyPaneViewModel(taskId, title, () => DescribeAsync(() => _worker.GetSessionHandoffLaunchSpecAsync(paneId, cwd, briefPath, taskId))) { IdentityKey = paneId }, slot); } // 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 System.Threading.Tasks.Task OpenMergeHelperConPtySessionAsync(string listId, IReadOnlyList taskIds, string? triageModel = null) => OpenListHandlerConPtySessionAsync(listId, taskIds, "missionControl.mergeHelperTitle", "missionControl.mergeHelperTaskTitle", taskId => _worker.GetMergeHelperLaunchSpecAsync(taskIds, listId, taskId, triageModel)); // Merge-only list-handler session over a list's WaitingForReview tasks (the review list's // "Let Claude merge the reviews"). Same host-task shape as the full run, but the session // starts directly at the merge phase via the existing handoff spec — no triage/wait first. public System.Threading.Tasks.Task OpenMergeReviewConPtySessionAsync(string listId, IReadOnlyList taskIds) => OpenListHandlerConPtySessionAsync(listId, taskIds, "missionControl.mergeReviewsTitle", "missionControl.mergeReviewsTaskTitle", taskId => _worker.GetMergeHelperHandoffLaunchSpecAsync(taskId, taskIds, "merge")); private async System.Threading.Tasks.Task OpenListHandlerConPtySessionAsync( string listId, IReadOnlyList taskIds, string titleKey, string taskTitleKey, Func> launchSpec) { 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(titleKey); 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(taskTitleKey, 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(() => launchSpec(taskId))) { IsListHandler = true }); } 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 survivingTaskIds, string nextPhase) { if (string.IsNullOrEmpty(taskId) || survivingTaskIds is not { Count: > 0 }) return; OpenRequested?.Invoke(); // Keep the tile where it was: the incoming phase re-enters at the outgoing pane's slot // instead of being appended, so a handoff doesn't reshuffle every other tile in the grid. var slot = ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is { } first ? ConPtySessions.IndexOf(first) : (int?)null; 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))) { IsListHandler = true }, slot); } // The 5h usage window is about to run out under this handler session: type the worker's warning // into its pane so the session can hand off cleanly instead of dying mid-merge. The UI process // owns the terminal, so this is the only place that can reach it at all. No pane (the tile was // closed, or this is a second app instance) = nothing to warn, and that is not an error. internal async System.Threading.Tasks.Task WarnHandlerSessionAsync(string taskId, string message) { if (string.IsNullOrEmpty(taskId) || string.IsNullOrEmpty(message)) return; if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is not { } pane) return; if (!await pane.SendLineAsync(message)) ErrorReported?.Invoke(Loc.T("missionControl.usageWarningNotDelivered")); } // A handler session's handoff is deferred due to usage limits: update the pane title to show // the reset time. No pane = nothing to update, not an error. private void UpdateHandlerSessionTitleAsync(string taskId, DateTimeOffset dueAt) { if (string.IsNullOrEmpty(taskId)) return; if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is not { } pane) return; var suffix = Loc.T("missionControl.deferredHandoffTitleSuffix", dueAt.LocalDateTime.ToString("HH:mm")); pane.DisplayTitle += $" — {suffix}"; } // 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, int? index = null) { pane.ErrorReported += OnConPtyPaneError; pane.CloseRequested += OnPaneCloseRequested; pane.SubmitForReviewRequested += OnPaneSubmitForReview; pane.SessionDiedAfterStartup += OnPaneSessionDied; pane.AbandonRunRequested += OnPaneAbandonRun; ConPtySessions.Insert(Math.Min(index ?? ConPtySessions.Count, ConPtySessions.Count), pane); pane.Start(); } // A ConPTY session that had been running died. The UI process owns the terminal, so this is the // only place the exit code exists -- report it and let the worker decide whether it was a list // handler killed by the usage limit and worth reopening after the reset // (HandlerRestartCoordinator). Every other pane is just a no-op there, so no filtering here. private async void OnPaneSessionDied(string taskId, int exitCode) { try { await _worker.ReportInteractiveSessionExitedAsync(taskId, exitCode); } catch (Exception ex) { // A worker that is down cannot restart anything anyway; the dead tile is already visible. ErrorReported?.Invoke(Loc.T("missionControl.conptyLaunchFailed", ex.Message)); } } private static async System.Threading.Tasks.Task DescribeAsync( Func> 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)); } } // Handler-run abort ("Not-Aus"): cancels every still-open (Queued/Running) task this handler // round queued or started, then closes the pane -- one action instead of "close tile" + "cancel // the tasks separately", which used to leave orphaned rows nobody merges. No open tasks (or no // round entry at all -- 7-day retention, or a run predating this feature) closes the pane // straight away, no confirmation needed. Guarded by IsAbandonPending like OnPaneSubmitForReview. private async void OnPaneAbandonRun(string taskId) { if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is not { } pane || pane.IsAbandonPending) return; pane.IsAbandonPending = true; try { var open = await _worker.GetHandlerOpenTasksAsync(taskId); if (open.Count > 0) { if (ConfirmAsync is null) return; if (!await ConfirmAsync(Loc.T("missionControl.abandonRunConfirm", open.Count))) return; foreach (var task in open) await _worker.CancelTaskAsync(task.Id); } CloseConPtySession(pane); } catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.abandonRunFailed", ex.Message)); } finally { pane.IsAbandonPending = false; } } // The tile's own X: a reflex click must not silently kill a live session the way it would a // regular window. Confirm only when the terminal is actually running -- a dead one (StartError, // or HasExited i.e. the #290 exited banner) is pure cleanup and closes straight away, same as it // always did. Every other close path (handoff replacing a pane, submit-for-review, abandon-run) // still calls CloseConPtySession directly and is unaffected. private async void OnPaneCloseRequested(ConPtyPaneViewModel pane) { if (pane.Terminal.IsRunning) { if (ConfirmAsync is null) return; if (!await ConfirmAsync(Loc.T("missionControl.closeSessionConfirm"))) return; } CloseConPtySession(pane); } private void CloseConPtySession(ConPtyPaneViewModel pane) { if (!ConPtySessions.Contains(pane)) return; pane.ErrorReported -= OnConPtyPaneError; pane.CloseRequested -= OnPaneCloseRequested; pane.SubmitForReviewRequested -= OnPaneSubmitForReview; pane.SessionDiedAfterStartup -= OnPaneSessionDied; pane.AbandonRunRequested -= OnPaneAbandonRun; ConPtySessions.Remove(pane); pane.Dispose(); } /// Swaps two tiles' slots (overview-grid drag and drop); also reorders the focus-mode tabs. public void SwapPanes(ConPtyPaneViewModel a, ConPtyPaneViewModel b) { var i = ConPtySessions.IndexOf(a); var j = ConPtySessions.IndexOf(b); if (i < 0 || j < 0 || i == j) return; if (i > j) (i, j) = (j, i); ConPtySessions.Move(i, j); ConPtySessions.Move(j - 1, i); } // Mirrors ConPtySessions' add/remove/move 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; case NotifyCollectionChangedAction.Move: Panes.Move(e.OldStartingIndex, e.NewStartingIndex); break; default: // Reset Panes.Clear(); break; } } private void OnPanesChanged(object? sender, NotifyCollectionChangedEventArgs e) { UpdateColumnCount(); OnPropertyChanged(nameof(HasPanes)); if (e.Action == NotifyCollectionChangedAction.Add && e.NewItems?[0] is ConPtyPaneViewModel 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.OperationStartedEvent -= _onOperationStarted; _worker.OperationEndedEvent -= _onOperationEnded; _worker.HandoffRequestedEvent -= _onHandoffRequested; _worker.SessionHandoffRequestedEvent -= OpenSessionHandoff; _worker.ListHandlerStartRequestedEvent -= _onListHandlerStartRequested; _worker.HandlerUsageWarningEvent -= _onHandlerUsageWarning; _worker.HandlerHandoffDeferredEvent -= _onHandlerHandoffDeferred; ConPtySessions.CollectionChanged -= OnConPtySessionsChanged; Panes.CollectionChanged -= OnPanesChanged; foreach (var c in ConPtySessions.ToList()) { c.ErrorReported -= OnConPtyPaneError; c.CloseRequested -= OnPaneCloseRequested; c.SubmitForReviewRequested -= OnPaneSubmitForReview; c.SessionDiedAfterStartup -= OnPaneSessionDied; c.AbandonRunRequested -= OnPaneAbandonRun; c.Dispose(); } ConPtySessions.Clear(); Panes.Clear(); } } /// Display row in the Mission Control activity strip (queued, running, or wanting /// attention). Read-only apart from opening the task in the app / in a session. public sealed class QueuedTaskViewModel { public required string Id { get; init; } /// Task number (#N); 0 for rows created outside TaskNumberAllocator. public int Number { get; init; } public bool ShowNumber => Number > 0; public required string Title { get; init; } public bool IsRunning { get; init; } /// Brush key of the row tint (e.g. "RunningTint"); null = untinted. public string? TintKey { get; init; } public string? TintBorderKey => TintKey is null ? null : TintKey + "Border"; public string? StatusLabel { get; init; } public string? StatusColorKey { get; init; } /// A running task's worktree belongs to the autonomous run — never open a second, /// interactive claude process into it. public bool CanOpenSession => !IsRunning; public IRelayCommand? OpenInAppCommand { get; init; } public IRelayCommand? OpenSessionCommand { get; init; } } /// One row in the "Running now" section: a Worker operation with a ticking elapsed time. public sealed partial class RunningOperationViewModel : ObservableObject { private readonly DateTime _startedAtUtc; public string Id { get; } /// Wire kind token (). public string Kind { get; } /// The task the operation runs for; null for a batch verify. public string? TaskId { get; } /// Localized operation kind ("Verifying after merge", "Rebasing", ...). public string KindLabel { get; } /// #Number Title for a task operation, the list name for a batch verify. public string Subject { get; } [ObservableProperty] private string _elapsed; public RunningOperationViewModel(RunningOperationDto op) { Id = op.Id; Kind = op.Kind; TaskId = op.TaskId; _startedAtUtc = op.StartedAt; KindLabel = op.Kind switch { OperationKinds.VerifyMerge => Loc.T("missionControl.opVerifyMerge"), OperationKinds.VerifyBatch => Loc.T("missionControl.opVerifyBatch"), OperationKinds.Merging => Loc.T("missionControl.opMerging"), OperationKinds.Rebasing => Loc.T("missionControl.opRebasing"), OperationKinds.CreatingWorktree => Loc.T("missionControl.opCreatingWorktree"), _ => op.Kind, }; Subject = op.TaskNumber is > 0 ? $"#{op.TaskNumber} {op.Title}" : op.Title; _elapsed = Format(DateTime.UtcNow - _startedAtUtc); } public void Tick(DateTime nowUtc) => Elapsed = Format(nowUtc - _startedAtUtc); private static string Format(TimeSpan t) { if (t < TimeSpan.Zero) t = TimeSpan.Zero; return t.TotalHours >= 1 ? t.ToString(@"h\:mm\:ss") : t.ToString(@"mm\:ss"); } } /// One row in the "Recent sessions" flyout. public sealed partial class RecentSessionViewModel : ObservableObject { public required string SessionId { get; init; } /// Display title; updated in place once a rename is persisted. [ObservableProperty] private string _title = ""; /// True while the inline title TextBox is shown. [ObservableProperty] private bool _isEditing; /// The TextBox's working value, kept apart from so Escape needs no restore. [ObservableProperty] private string _editText = ""; /// Commits as the new title. public required IRelayCommand CommitRenameCommand { get; init; } /// Switches the row into inline edit mode, seeded with the current title. public void BeginEdit() { EditText = Title; IsEditing = true; } /// Leaves edit mode without saving. public void CancelEdit() => IsEditing = false; /// Start time plus cwd (origin) or the handoff label (handoff). public required string Meta { get; init; } /// Indents the row under its chain's origin session. public bool IsHandoff { get; init; } public required IRelayCommand ResumeCommand { get; init; } /// Opens the session's brief.md; null for a session that did not start from one. public IRelayCommand? ShowBriefCommand { get; init; } public bool HasBrief => ShowBriefCommand is not null; } /// One entry in the "new session" menu: a configured list's working dir, or browse. public sealed class SessionTargetViewModel { public required string Label { get; init; } public required IRelayCommand StartCommand { get; init; } }