using System.Collections.ObjectModel; using System.Collections.Specialized; using System.Linq; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using ClaudeDo.Data; using ClaudeDo.Data.Repositories; using ClaudeDo.Ui.Localization; using ClaudeDo.Ui.Services; using ClaudeDo.Ui.ViewModels.Islands; 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 Action _onTaskStarted; private readonly Action _onTaskFinished; private readonly Action _onTaskUpdated; private readonly Action _onConnectionRestored; private readonly Action _onInteractiveStarted; public ObservableCollection Monitors { get; } = new(); // Embedded ConPTY sessions (task-based only) — a manual cockpit detached from the // review/merge/status machinery. Mirrored into Panes alongside the streamed-log Monitors. public ObservableCollection ConPtySessions { get; } = new(); // Unified view of Monitors ++ ConPtySessions (in that order) so the layout toggle can // present one heterogeneous collection as either a grid or tabs. public ObservableCollection 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"); /// 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; private Action? _openInApp; public Action? OpenInApp { get => _openInApp; set { _openInApp = value; foreach (var m in Monitors) m.OpenInAppRequested = value; } } // View-layer seam: show a detached monitor in its own window. Second arg is the re-dock callback // invoked when that window closes. public Action? ShowDetached { get; set; } // View-layer seam: open the app Settings modal from the Mission Control window. public Action? OpenSettingsRequested { get; set; } public bool HasMonitors => Monitors.Count > 0; public bool HasPanes => Panes.Count > 0; // Read-only view of the worker queue (tasks waiting to run), shown as a side strip. public ObservableCollection Queued { get; } = new(); public bool HasQueued => Queued.Count > 0; public MissionControlViewModel(IDbContextFactory dbFactory, IWorkerClient worker) { _dbFactory = dbFactory; _worker = worker; Monitors.CollectionChanged += OnMonitorsChanged; ConPtySessions.CollectionChanged += OnConPtySessionsChanged; Panes.CollectionChanged += OnPanesChanged; _onTaskStarted = (slot, taskId, startedAt) => { EnsureMonitor(taskId); _ = RefreshQueueAsync(); }; _worker.TaskStartedEvent += _onTaskStarted; _onTaskFinished = (slot, taskId, status, finishedAt) => _ = RefreshQueueAsync(); _worker.TaskFinishedEvent += _onTaskFinished; _onTaskUpdated = taskId => _ = RefreshQueueAsync(); _worker.TaskUpdatedEvent += _onTaskUpdated; _onConnectionRestored = () => { SeedActive(); _ = RefreshQueueAsync(); }; _worker.ConnectionRestoredEvent += _onConnectionRestored; _onInteractiveStarted = taskId => { EnsureMonitor(taskId); var m = Monitors.FirstOrDefault(x => x.SubscribedTaskId == taskId); m?.SetInteractiveLive(true); }; _worker.InteractiveSessionStartedEvent += _onInteractiveStarted; SeedActive(); _ = 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) .OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt) .Select(t => new { t.Id, t.Title, t.BlockedByTaskId }) .ToListAsync(); Queued.Clear(); foreach (var r in rows) Queued.Add(new QueuedTaskViewModel { Id = r.Id, Title = r.Title ?? string.Empty, IsBlocked = r.BlockedByTaskId != null, }); OnPropertyChanged(nameof(HasQueued)); } catch { /* best-effort queue refresh */ } } // Drop-to-queue: a task dragged from the main app onto Mission Control gets queued. public async System.Threading.Tasks.Task EnqueueTaskAsync(string taskId) { if (string.IsNullOrEmpty(taskId)) return; try { await using var db = await _dbFactory.CreateDbContextAsync(); var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == taskId); if (entity is null || entity.Status == ClaudeDo.Data.Models.TaskStatus.Running || entity.Status == ClaudeDo.Data.Models.TaskStatus.Queued) return; entity.Status = ClaudeDo.Data.Models.TaskStatus.Queued; await db.SaveChangesAsync(); await _worker.WakeQueueAsync(); } catch { /* best-effort enqueue */ } await RefreshQueueAsync(); } private void SeedActive() { foreach (var a in _worker.GetActiveTasks()) EnsureMonitor(a.TaskId); } private void EnsureMonitor(string taskId) { if (string.IsNullOrEmpty(taskId)) return; if (Monitors.Any(m => m.SubscribedTaskId == taskId)) return; var monitor = new TaskMonitorViewModel(_dbFactory, _worker); monitor.SetTaskId(taskId); monitor.OpenInAppRequested = _openInApp; monitor.DetachRequested = Detach; Monitors.Add(monitor); _ = HydrateAsync(monitor, taskId); } private void Detach(TaskMonitorViewModel monitor) { if (!Monitors.Contains(monitor)) return; monitor.IsDetached = true; Monitors.Remove(monitor); // drop from grid — do NOT dispose; it keeps streaming ShowDetached?.Invoke(monitor, () => ReDock(monitor)); } private void ReDock(TaskMonitorViewModel monitor) { monitor.IsDetached = false; if (!Monitors.Contains(monitor) && monitor.SubscribedTaskId is not null) Monitors.Add(monitor); // back into the grid } private async System.Threading.Tasks.Task HydrateAsync(TaskMonitorViewModel monitor, string taskId) { try { await using var ctx = await _dbFactory.CreateDbContextAsync(); var entity = await ctx.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId); if (entity is null || monitor.SubscribedTaskId != taskId) return; monitor.ApplyState(entity.Status); monitor.Title = entity.Title; var latestRun = await new TaskRunRepository(ctx).GetLatestByTaskIdAsync(taskId); monitor.ApplyOutcome(entity.Result, latestRun?.ErrorMarkdown); await monitor.ReplayLogFileAsync(entity.LogPath, CancellationToken.None); // Re-attach: if the task is blocked on an AskUser question right now, surface it. var pending = await _worker.GetPendingQuestionAsync(taskId); if (pending is not null && monitor.SubscribedTaskId == taskId) monitor.SetPendingQuestion(pending.QuestionId, pending.Question); } catch { /* best-effort hydrate */ } } [RelayCommand] private void ClearFinished() { foreach (var m in Monitors.Where(m => m.IsDone || m.IsFailed || m.IsCancelled || m.IsWaitingForReview).ToList()) { Monitors.Remove(m); m.Dispose(); } } [RelayCommand] private void OpenSettings() => OpenSettingsRequested?.Invoke(); [RelayCommand] private void ToggleLayout() => IsFocusMode = !IsFocusMode; public void MoveMonitor(TaskMonitorViewModel dragged, TaskMonitorViewModel target) { if (ReferenceEquals(dragged, target)) return; var from = Monitors.IndexOf(dragged); var to = Monitors.IndexOf(target); if (from < 0 || to < 0) return; Monitors.Move(from, to); } // Fetches the launch spec for a task's worktree and hosts an embedded ConPTY session as a // Command Center pane (task-based only). A distinct entry point from RunInteractivelyAsync's // streaming session — the two coexist until the streaming stack is removed. public async System.Threading.Tasks.Task OpenConPtySessionAsync(string taskId) { if (string.IsNullOrEmpty(taskId)) return; if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is { } existing) { FocusedPane = existing; return; } 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 */ } try { var spec = await _worker.GetInteractiveLaunchSpecAsync(taskId); var descriptor = new TerminalLaunchDescriptor(spec.Cwd, spec.Exe, spec.Args, spec.Env); var pane = new ConPtyPaneViewModel(taskId, title, descriptor); pane.ErrorReported += OnConPtyPaneError; pane.CloseRequested += CloseConPtySession; ConPtySessions.Add(pane); } catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.conptyLaunchFailed", ex.Message)); } } private void OnConPtyPaneError(string message) => ErrorReported?.Invoke(message); private void CloseConPtySession(ConPtyPaneViewModel pane) { if (!ConPtySessions.Contains(pane)) return; pane.ErrorReported -= OnConPtyPaneError; pane.CloseRequested -= CloseConPtySession; ConPtySessions.Remove(pane); pane.Dispose(); } // Mirrors Monitors' add/remove/move into the front (Monitors-prefix) section of Panes. private void OnMonitorsChanged(object? sender, NotifyCollectionChangedEventArgs e) { switch (e.Action) { case NotifyCollectionChangedAction.Add: Panes.Insert(e.NewStartingIndex, (TaskMonitorViewModel)e.NewItems![0]!); break; case NotifyCollectionChangedAction.Remove: Panes.RemoveAt(e.OldStartingIndex); break; case NotifyCollectionChangedAction.Move: Panes.Move(e.OldStartingIndex, e.NewStartingIndex); break; default: // Reset (Dispose's Monitors.Clear()) foreach (var p in Panes.OfType().ToList()) Panes.Remove(p); break; } OnPropertyChanged(nameof(HasMonitors)); } // Mirrors ConPtySessions' add/remove into the tail (ConPtySessions-suffix) section of Panes. private void OnConPtySessionsChanged(object? sender, NotifyCollectionChangedEventArgs e) { switch (e.Action) { case NotifyCollectionChangedAction.Add: Panes.Insert(Monitors.Count + e.NewStartingIndex, (ConPtyPaneViewModel)e.NewItems![0]!); break; case NotifyCollectionChangedAction.Remove: Panes.RemoveAt(Monitors.Count + e.OldStartingIndex); break; default: // Reset foreach (var p in Panes.OfType().ToList()) Panes.Remove(p); 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.InteractiveSessionStartedEvent -= _onInteractiveStarted; Monitors.CollectionChanged -= OnMonitorsChanged; ConPtySessions.CollectionChanged -= OnConPtySessionsChanged; Panes.CollectionChanged -= OnPanesChanged; foreach (var m in Monitors) m.Dispose(); Monitors.Clear(); foreach (var c in ConPtySessions.ToList()) { c.ErrorReported -= OnConPtyPaneError; c.CloseRequested -= CloseConPtySession; c.Dispose(); } ConPtySessions.Clear(); Panes.Clear(); } } /// Read-only display row for a queued task in the Mission Control side strip. public sealed class QueuedTaskViewModel { public required string Id { get; init; } public required string Title { get; init; } public bool IsBlocked { get; init; } }