843 lines
40 KiB
C#
843 lines
40 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.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<ClaudeDoDbContext> _dbFactory;
|
|
private readonly IWorkerClient _worker;
|
|
private readonly AppSettings? _settings;
|
|
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;
|
|
private readonly Action<string, IReadOnlyList<string>> _onListHandlerStartRequested;
|
|
private readonly Action<string, string> _onHandlerUsageWarning;
|
|
private readonly Action<string, DateTimeOffset> _onHandlerHandoffDeferred;
|
|
|
|
// 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<ConPtyPaneViewModel> Panes { get; } = new();
|
|
|
|
[ObservableProperty] private int _columnCount = 1;
|
|
|
|
/// <summary>User-picked column count for the overview grid; 0 = auto (derived from the pane count).</summary>
|
|
[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");
|
|
|
|
/// <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; }
|
|
|
|
// 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<QueuedTaskViewModel> Queued { get; } = new();
|
|
public bool HasQueued => Queued.Count > 0;
|
|
|
|
// "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<SessionTargetViewModel> SessionTargets { get; } = new();
|
|
|
|
/// <summary>"Recent sessions" flyout rows: each chain's origin session, its handoffs right below it.</summary>
|
|
public ObservableCollection<RecentSessionViewModel> RecentSessions { get; } = new();
|
|
|
|
/// <summary>False shows the flyout's empty-state line.</summary>
|
|
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<string, System.Threading.Tasks.Task<bool>>? ConfirmAsync { get; set; }
|
|
|
|
public UsagePillViewModel UsagePill { get; }
|
|
|
|
public MissionControlViewModel(IDbContextFactory<ClaudeDoDbContext> 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(); };
|
|
_worker.ConnectionRestoredEvent += _onConnectionRestored;
|
|
|
|
_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();
|
|
_ = RefreshSessionTargetsAsync();
|
|
}
|
|
|
|
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);
|
|
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));
|
|
}
|
|
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 ("<key>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)
|
|
=> 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 => ("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)); }
|
|
}
|
|
|
|
/// <summary>Rebuilds <see cref="RecentSessions"/> from the last 14 days (max 50 sessions); runs on
|
|
/// every flyout open.</summary>
|
|
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;
|
|
RecentSessions.Add(new RecentSessionViewModel
|
|
{
|
|
Title = s.Title,
|
|
Meta = isHandoff ? $"{Loc.T("missionControl.handoffSession")} · {time}" : $"{time} · {s.Cwd}",
|
|
IsHandoff = isHandoff,
|
|
ResumeCommand = new RelayCommand(() => ResumeSession(s, taskCurrent.Contains(s.SessionId))),
|
|
ShowBriefCommand = briefPath is null ? null : new RelayCommand(() => ShowBrief(briefPath)),
|
|
});
|
|
}
|
|
}
|
|
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.queueRefreshFailed", ex.Message)); }
|
|
OnPropertyChanged(nameof(HasRecentSessions));
|
|
}
|
|
|
|
/// <summary>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.</summary>
|
|
internal static IEnumerable<(MissionControlSessionEntity Session, bool IsHandoff)> NestHandoffs(
|
|
IReadOnlyList<MissionControlSessionEntity> newestFirst)
|
|
{
|
|
var byId = newestFirst.ToDictionary(s => s.SessionId);
|
|
string RootOf(MissionControlSessionEntity s)
|
|
{
|
|
var seen = new HashSet<string>();
|
|
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, 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, session.Title,
|
|
() => DescribeAsync(() => _worker.GetResumeSessionLaunchSpecAsync(sessionId, paneId))) { IdentityKey = paneId });
|
|
}
|
|
|
|
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;
|
|
|
|
/// <summary>Upper bound of the column picker.</summary>
|
|
public const int MaxColumns = 4;
|
|
|
|
/// <summary>Sets the column preference from the header picker ("0" = auto) and persists it.</summary>
|
|
[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;
|
|
}
|
|
|
|
/// <summary>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.</summary>
|
|
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<string> 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<string> taskIds)
|
|
=> OpenListHandlerConPtySessionAsync(listId, taskIds,
|
|
"missionControl.mergeReviewsTitle", "missionControl.mergeReviewsTaskTitle",
|
|
taskId => _worker.GetMergeHelperHandoffLaunchSpecAsync(taskId, taskIds, "merge"));
|
|
|
|
private async System.Threading.Tasks.Task OpenListHandlerConPtySessionAsync(
|
|
string listId, IReadOnlyList<string> taskIds, string titleKey, string taskTitleKey,
|
|
Func<string, System.Threading.Tasks.Task<LaunchSpec>> 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<string> 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<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));
|
|
}
|
|
}
|
|
|
|
// 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();
|
|
}
|
|
|
|
/// <summary>Swaps two tiles' slots (overview-grid drag and drop); also reorders the focus-mode tabs.</summary>
|
|
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.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();
|
|
}
|
|
}
|
|
|
|
/// <summary>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.</summary>
|
|
public sealed class QueuedTaskViewModel
|
|
{
|
|
public required string Id { get; init; }
|
|
/// <summary>Task number (#N); 0 for rows created outside <c>TaskNumberAllocator</c>.</summary>
|
|
public int Number { get; init; }
|
|
public bool ShowNumber => Number > 0;
|
|
public required string Title { get; init; }
|
|
public bool IsRunning { get; init; }
|
|
/// <summary>Brush key of the row tint (e.g. "RunningTint"); null = untinted.</summary>
|
|
public string? TintKey { get; init; }
|
|
public string? TintBorderKey => TintKey is null ? null : TintKey + "Border";
|
|
public string? StatusLabel { get; init; }
|
|
public string? StatusColorKey { get; init; }
|
|
/// <summary>A running task's worktree belongs to the autonomous run — never open a second,
|
|
/// interactive claude process into it.</summary>
|
|
public bool CanOpenSession => !IsRunning;
|
|
public IRelayCommand? OpenInAppCommand { get; init; }
|
|
public IRelayCommand? OpenSessionCommand { get; init; }
|
|
}
|
|
|
|
/// <summary>One row in the "Recent sessions" flyout.</summary>
|
|
public sealed class RecentSessionViewModel
|
|
{
|
|
public required string Title { get; init; }
|
|
/// <summary>Start time plus cwd (origin) or the handoff label (handoff).</summary>
|
|
public required string Meta { get; init; }
|
|
/// <summary>Indents the row under its chain's origin session.</summary>
|
|
public bool IsHandoff { get; init; }
|
|
public required IRelayCommand ResumeCommand { get; init; }
|
|
/// <summary>Opens the session's brief.md; null for a session that did not start from one.</summary>
|
|
public IRelayCommand? ShowBriefCommand { get; init; }
|
|
public bool HasBrief => ShowBriefCommand is not null;
|
|
}
|
|
|
|
/// <summary>One entry in the "new session" menu: a configured list's working dir, or browse.</summary>
|
|
public sealed class SessionTargetViewModel
|
|
{
|
|
public required string Label { get; init; }
|
|
public required IRelayCommand StartCommand { get; init; }
|
|
}
|