Eine PowerUse-Handler-Session soll am 5h-Limit nicht mehr mitten im Merge sterben, sondern vorher selbst sauber uebergeben: - HandlerUsageGuard (neu) warnt die laufende Session genau einmal pro Runde, sobald FiveHour.Utilization den Schwellwert PowerUseHandoffPct erreicht. Die Warnung wird per HandlerUsageWarning-Broadcast an Mission Control geschickt und dort in das Pane der Handler-TaskId getippt. - handoff_list_handler broadcastet den Handoff dann nicht sofort, sondern parkt ihn bis ResetsAt + 60 s; das Ergebnis traegt deferredUntil. Ohne ResetsAt wird wie bisher sofort gesendet. - Der geparkte Handoff liegt im HandlerRoundState (handler-rounds.json) und wird vom Guard-Tick ausgeloest, also auch nach einem Worker-Neustart wieder eingeplant bzw. sofort gesendet, wenn er ueberfaellig ist. - Review-Befund aus #272: HandlerRestartCoordinator wartet nicht mehr selbst bis ResetsAt, sondern parkt den Neustart im selben Slot. AutoRestartUsed wird erst gesetzt, wenn der Broadcast wirklich rausging - ein Worker-Absturz waehrend der Wartezeit laesst die Runde nicht mehr dauerhaft tot zurueck. Zurueckgehaltener Handoff und Auto-Neustart teilen sich einen Slot, also oeffnet nie beides dieselbe Runde. UsageLimitProofPct entfaellt zugunsten von PowerUseHandoffPct. - PtyTerminalSession.SendLineAsync schreibt in die laufende Session. Gegen eine echte claude-Session verifiziert: Text und Enter muessen zwei getrennte Writes sein, die Enter-Bytes kommen von Terminal.GenerateKeyInput.
580 lines
27 KiB
C#
580 lines
27 KiB
C#
using System.Collections.ObjectModel;
|
|
using System.Collections.Specialized;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using CommunityToolkit.Mvvm.ComponentModel;
|
|
using CommunityToolkit.Mvvm.Input;
|
|
using ClaudeDo.Data;
|
|
using ClaudeDo.Ui.Localization;
|
|
using ClaudeDo.Ui.Services;
|
|
using ClaudeDo.Ui.ViewModels.MissionControl;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace ClaudeDo.Ui.ViewModels;
|
|
|
|
public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
|
{
|
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
|
private readonly IWorkerClient _worker;
|
|
private readonly Action<string, string, DateTime> _onTaskStarted;
|
|
private readonly Action<string, string, string, DateTime> _onTaskFinished;
|
|
private readonly Action<string> _onTaskUpdated;
|
|
private readonly Action _onConnectionRestored;
|
|
private readonly Action<string, IReadOnlyList<string>, string> _onHandoffRequested;
|
|
private readonly Action<string, string> _onHandlerUsageWarning;
|
|
|
|
// 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;
|
|
|
|
[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; }
|
|
|
|
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();
|
|
|
|
// View-layer seam: the folder picker needs a TopLevel, so the view owns it (see
|
|
// MissionControlView.OnNewSessionClicked).
|
|
public Action? BrowseForSessionRequested { get; set; }
|
|
|
|
public UsagePillViewModel UsagePill { get; }
|
|
|
|
public MissionControlViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient worker, UsagePillViewModel usagePill)
|
|
{
|
|
_dbFactory = dbFactory;
|
|
_worker = worker;
|
|
UsagePill = usagePill;
|
|
|
|
ConPtySessions.CollectionChanged += OnConPtySessionsChanged;
|
|
Panes.CollectionChanged += OnPanesChanged;
|
|
|
|
_onTaskStarted = (slot, taskId, startedAt) => { _ = RefreshQueueAsync(); };
|
|
_worker.TaskStartedEvent += _onTaskStarted;
|
|
|
|
_onTaskFinished = (slot, taskId, status, finishedAt) => _ = RefreshQueueAsync();
|
|
_worker.TaskFinishedEvent += _onTaskFinished;
|
|
|
|
_onTaskUpdated = taskId => _ = RefreshQueueAsync();
|
|
_worker.TaskUpdatedEvent += _onTaskUpdated;
|
|
|
|
_onConnectionRestored = () => { _ = RefreshQueueAsync(); };
|
|
_worker.ConnectionRestoredEvent += _onConnectionRestored;
|
|
|
|
_onHandoffRequested = (taskId, survivingTaskIds, nextPhase) => { _ = OpenMergeHelperHandoffConPtySessionAsync(taskId, survivingTaskIds, nextPhase); };
|
|
_worker.HandoffRequestedEvent += _onHandoffRequested;
|
|
|
|
_onHandlerUsageWarning = (taskId, message) => { _ = WarnHandlerSessionAsync(taskId, message); };
|
|
_worker.HandlerUsageWarningEvent += _onHandlerUsageWarning;
|
|
|
|
_ = 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.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,
|
|
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();
|
|
// Browse goes in first so the menu stays usable even if the list query below fails.
|
|
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)); }
|
|
}
|
|
|
|
// Drop-to-queue: a task dragged from the main app onto Mission Control gets queued. Goes
|
|
// through the worker hub (TaskStateService.EnqueueAsync) rather than a raw EF write so the
|
|
// manual/draft-child guards apply here too. The interactive-session check has to live here
|
|
// rather than on the worker side: the worker never touches task status for a UI-hosted ConPTY
|
|
// session, so it has no way to know one is open — only Mission Control's own pane list does.
|
|
public async System.Threading.Tasks.Task EnqueueTaskAsync(string taskId)
|
|
{
|
|
if (string.IsNullOrEmpty(taskId)) return;
|
|
if (ConPtySessions.Any(s => s.TaskId == taskId))
|
|
{
|
|
ErrorReported?.Invoke(Loc.T("missionControl.enqueueAlreadyOpen"));
|
|
return;
|
|
}
|
|
try
|
|
{
|
|
var baseDirty = await _worker.SetTaskStatusAsync(taskId, ClaudeDo.Data.Models.TaskStatus.Queued);
|
|
if (baseDirty is not null)
|
|
ErrorReported?.Invoke(Loc.T(
|
|
"vm.queue.baseDirtyWarning", baseDirty.ModifiedCount, baseDirty.UntrackedCount));
|
|
}
|
|
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.enqueueFailed", ex.Message)); }
|
|
await RefreshQueueAsync();
|
|
}
|
|
|
|
[RelayCommand]
|
|
private void OpenSettings() => OpenSettingsRequested?.Invoke();
|
|
|
|
[RelayCommand]
|
|
private void ToggleLayout() => IsFocusMode = !IsFocusMode;
|
|
|
|
// Fetches the launch spec for a task's worktree and hosts an embedded ConPTY session as a
|
|
// Command Center pane (task-based only).
|
|
public async System.Threading.Tasks.Task OpenConPtySessionAsync(string taskId)
|
|
{
|
|
if (string.IsNullOrEmpty(taskId)) return;
|
|
// One pane per TaskId is an invariant -- a merge-helper handoff closes its outgoing pane
|
|
// before opening the next phase's (see OpenMergeHelperHandoffConPtySessionAsync).
|
|
if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is { } existing)
|
|
{
|
|
FocusedPane = existing;
|
|
return;
|
|
}
|
|
if (!_pendingTaskOpens.Add(taskId)) return;
|
|
|
|
try
|
|
{
|
|
var title = taskId;
|
|
try
|
|
{
|
|
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
|
var entity = await ctx.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId);
|
|
if (entity?.Title is { Length: > 0 } t) title = t;
|
|
}
|
|
catch { /* best-effort title lookup */ }
|
|
|
|
AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
|
|
() => DescribeAsync(() => _worker.GetInteractiveLaunchSpecAsync(taskId))));
|
|
}
|
|
finally
|
|
{
|
|
_pendingTaskOpens.Remove(taskId);
|
|
}
|
|
}
|
|
|
|
// Starts (or resumes) a planning session and hosts it as an embedded ConPTY Command Center
|
|
// pane — the ConPTY replacement for the old external wt planning window. Deduped by TaskId
|
|
// like OpenConPtySessionAsync.
|
|
public async System.Threading.Tasks.Task OpenPlanningConPtySessionAsync(string taskId, bool resume)
|
|
{
|
|
if (string.IsNullOrEmpty(taskId)) return;
|
|
if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is { } existing)
|
|
{
|
|
FocusedPane = existing;
|
|
return;
|
|
}
|
|
if (!_pendingTaskOpens.Add(taskId)) return;
|
|
|
|
try
|
|
{
|
|
var title = taskId;
|
|
try
|
|
{
|
|
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
|
var entity = await ctx.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId);
|
|
if (entity?.Title is { Length: > 0 } t) title = t;
|
|
}
|
|
catch { /* best-effort title lookup */ }
|
|
title += Loc.T("missionControl.planningTitleSuffix");
|
|
|
|
AddConPtyPane(new ConPtyPaneViewModel(taskId, title, () => DescribeAsync(() => resume
|
|
? _worker.GetPlanningResumeLaunchSpecAsync(taskId)
|
|
: _worker.GetPlanningStartLaunchSpecAsync(taskId))));
|
|
}
|
|
finally
|
|
{
|
|
_pendingTaskOpens.Remove(taskId);
|
|
}
|
|
}
|
|
|
|
// Ad-hoc (task-less) ConPTY session in a user-chosen directory. Never deduped — every call
|
|
// opens a fresh pane, unlike the task-based OpenConPtySessionAsync above.
|
|
public System.Threading.Tasks.Task OpenAdHocConPtySessionAsync(string directory)
|
|
{
|
|
if (string.IsNullOrEmpty(directory)) return System.Threading.Tasks.Task.CompletedTask;
|
|
|
|
var title = Path.GetFileName(directory.TrimEnd('\\', '/'));
|
|
if (string.IsNullOrEmpty(title)) title = directory;
|
|
|
|
AddConPtyPane(ConPtyPaneViewModel.CreateAdHoc(title,
|
|
() => DescribeAsync(() => _worker.GetAdHocLaunchSpecAsync(directory))));
|
|
return System.Threading.Tasks.Task.CompletedTask;
|
|
}
|
|
|
|
// List-handler session over a hand-picked set of tasks ("Let Claude handle it"). Task-based:
|
|
// creates one new ClaudeDo task per run to own the session (title/diff/result), deduped by
|
|
// TaskId like OpenConPtySessionAsync. The handler still merges the tasks it handles itself
|
|
// (no worktree of its own) — see TaskEntity.HandlerBaseCommit/HandlerHeadCommit.
|
|
public System.Threading.Tasks.Task OpenMergeHelperConPtySessionAsync(string listId, IReadOnlyList<string> taskIds)
|
|
=> OpenListHandlerConPtySessionAsync(listId, taskIds,
|
|
"missionControl.mergeHelperTitle", "missionControl.mergeHelperTaskTitle",
|
|
taskId => _worker.GetMergeHelperLaunchSpecAsync(taskIds, listId, taskId));
|
|
|
|
// 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))));
|
|
}
|
|
finally
|
|
{
|
|
_pendingMergeHelperLists.Remove(listId);
|
|
}
|
|
}
|
|
|
|
// List-handler handoff: the running session called handoff_list_handler at the end of a phase.
|
|
// Opens a NEW tile for the SAME handler task id to carry out nextPhase. Each tile is a live
|
|
// `claude` process with the full mcp__claudedo__* surface, so the outgoing phase's pane is
|
|
// closed first -- it was only ever told to end its turn, never to exit, and a multi-phase run
|
|
// would otherwise leave one live process per phase behind. This keeps the one-pane-per-TaskId
|
|
// invariant intact, so OpenConPtySessionAsync's dedupe and OnPaneSubmitForReview can keep using
|
|
// FirstOrDefault. No new task is created here; see
|
|
// InteractiveLaunchSpecService.BuildForMergeHelperHandoffAsync.
|
|
public async System.Threading.Tasks.Task OpenMergeHelperHandoffConPtySessionAsync(
|
|
string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase)
|
|
{
|
|
if (string.IsNullOrEmpty(taskId) || survivingTaskIds is not { Count: > 0 }) return;
|
|
|
|
foreach (var stale in ConPtySessions.Where(s => s.TaskId == taskId).ToList())
|
|
CloseConPtySession(stale);
|
|
|
|
var baseTitle = Loc.T("missionControl.mergeHelperTitle");
|
|
var title = baseTitle + Loc.T("missionControl.mergeHelperHandoffTitleSuffix");
|
|
try
|
|
{
|
|
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
|
var task = await ctx.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId);
|
|
if (task is not null)
|
|
{
|
|
var list = await ctx.Lists.AsNoTracking().FirstOrDefaultAsync(l => l.Id == task.ListId);
|
|
if (list?.Name is { Length: > 0 } name)
|
|
title = $"{baseTitle} — {name}{Loc.T("missionControl.mergeHelperHandoffTitleSuffix")}";
|
|
}
|
|
}
|
|
catch { /* best-effort title lookup */ }
|
|
|
|
AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
|
|
() => DescribeAsync(() => _worker.GetMergeHelperHandoffLaunchSpecAsync(taskId, survivingTaskIds, nextPhase))));
|
|
}
|
|
|
|
// 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"));
|
|
}
|
|
|
|
// 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;
|
|
pane.SessionDiedAfterStartup += OnPaneSessionDied;
|
|
ConPtySessions.Add(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));
|
|
}
|
|
}
|
|
|
|
private void CloseConPtySession(ConPtyPaneViewModel pane)
|
|
{
|
|
if (!ConPtySessions.Contains(pane)) return;
|
|
pane.ErrorReported -= OnConPtyPaneError;
|
|
pane.CloseRequested -= CloseConPtySession;
|
|
pane.SubmitForReviewRequested -= OnPaneSubmitForReview;
|
|
pane.SessionDiedAfterStartup -= OnPaneSessionDied;
|
|
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 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.HandlerUsageWarningEvent -= _onHandlerUsageWarning;
|
|
ConPtySessions.CollectionChanged -= OnConPtySessionsChanged;
|
|
Panes.CollectionChanged -= OnPanesChanged;
|
|
foreach (var c in ConPtySessions.ToList())
|
|
{
|
|
c.ErrorReported -= OnConPtyPaneError;
|
|
c.CloseRequested -= CloseConPtySession;
|
|
c.SubmitForReviewRequested -= OnPaneSubmitForReview;
|
|
c.SessionDiedAfterStartup -= OnPaneSessionDied;
|
|
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; }
|
|
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 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; }
|
|
}
|