Adds the IWorkerClient/WorkerClient usage surface (GetUsageSnapshot, GetModelUsage, GetTaskUsage, UsageUpdated event) and a shared UsagePillViewModel hosted once in IslandsShellViewModel (footer) and once in MissionControlViewModel (header), showing "5h X% · 7d Y%" with warn/blocked/stale states via existing design tokens. The OpenMonitorCommand is wired but currently a no-op, pending the usage monitor modal.
352 lines
14 KiB
C#
352 lines
14 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;
|
|
|
|
// Embedded ConPTY sessions (task-based only) — a manual cockpit detached from the
|
|
// review/merge/status machinery.
|
|
public ObservableCollection<ConPtyPaneViewModel> ConPtySessions { get; } = 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;
|
|
|
|
_ = 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 { /* 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();
|
|
}
|
|
|
|
[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;
|
|
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 */ }
|
|
|
|
AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
|
|
() => DescribeAsync(() => _worker.GetInteractiveLaunchSpecAsync(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;
|
|
}
|
|
|
|
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))));
|
|
}
|
|
|
|
// 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;
|
|
|
|
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))));
|
|
}
|
|
|
|
// 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.
|
|
private async void OnPaneSubmitForReview(string taskId)
|
|
{
|
|
try
|
|
{
|
|
await _worker.SubmitTaskForReviewAsync(taskId);
|
|
if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is { } pane)
|
|
CloseConPtySession(pane);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
ErrorReported?.Invoke(Loc.T("missionControl.submitForReviewFailed", ex.Message));
|
|
}
|
|
}
|
|
|
|
private void CloseConPtySession(ConPtyPaneViewModel pane)
|
|
{
|
|
if (!ConPtySessions.Contains(pane)) return;
|
|
pane.ErrorReported -= OnConPtyPaneError;
|
|
pane.CloseRequested -= CloseConPtySession;
|
|
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;
|
|
ConPtySessions.CollectionChanged -= OnConPtySessionsChanged;
|
|
Panes.CollectionChanged -= OnPanesChanged;
|
|
foreach (var c in ConPtySessions.ToList())
|
|
{
|
|
c.ErrorReported -= OnConPtyPaneError;
|
|
c.CloseRequested -= CloseConPtySession;
|
|
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; }
|
|
}
|