diff --git a/src/ClaudeDo.Data/Wire.cs b/src/ClaudeDo.Data/Wire.cs index 91a6474d..5d119d62 100644 --- a/src/ClaudeDo.Data/Wire.cs +++ b/src/ClaudeDo.Data/Wire.cs @@ -217,6 +217,10 @@ public sealed record LaunchSpec( IReadOnlyList Args, IReadOnlyDictionary Env); +// A handler round's still-open (Queued/Running) task, for the Mission Control "abandon run" +// action -- confirms with a count, then cancels each one before closing the pane. +public record HandlerOpenTaskDto(string Id, string Title); + public record TicketSettingsDto(string? ApiBaseUrl, bool TokenSet); public record TicketConnectionDto(bool Ok, string? UserName, IReadOnlyList Scopes, string? Error); public record TicketProjectDto(int Id, string Title, string DepartmentName); diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index 1246018e..c5f0498a 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -354,6 +354,10 @@ "enqueueAlreadyOpen": "Kann nicht in die Warteschlange gestellt werden — dieser Task hat eine offene interaktive Sitzung.", "enqueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "queueRefreshFailed": "Warteschlange konnte nicht aktualisiert werden: {0}", + "abandonRun": "Lauf abbrechen", + "abandonRunTip": "Die noch offenen Tasks dieses Laufs abbrechen und die Sitzung schließen", + "abandonRunConfirm": "{0} offene(n) Task(s) abbrechen und diese Sitzung schließen? Tasks im Review oder bereits erledigt bleiben unangetastet.", + "abandonRunFailed": "Offene Tasks konnten nicht abgebrochen werden: {0}", "question": { "title": "Claude fragt nach", "placeholder": "Antwort eingeben…", diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index bbd8f170..3b9b3c3e 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -354,6 +354,10 @@ "enqueueAlreadyOpen": "Can't queue — this task has an open interactive session.", "enqueueFailed": "Queue failed: {0}", "queueRefreshFailed": "Couldn't refresh the queue: {0}", + "abandonRun": "Abandon run", + "abandonRunTip": "Cancel this run's still-open tasks and close the session", + "abandonRunConfirm": "Cancel {0} open task(s) and close this session? Tasks already in review or done are not affected.", + "abandonRunFailed": "Couldn't cancel the open tasks: {0}", "question": { "title": "Claude is asking", "placeholder": "Type your answer…", diff --git a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs index 77e7a93c..ea823223 100644 --- a/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs @@ -158,6 +158,10 @@ public interface IWorkerClient : INotifyPropertyChanged /// running. The exit code only exists in this process; the worker decides whether it was a /// list-handler session killed by the usage limit and reopens the same phase after the 5h reset. Task ReportInteractiveSessionExitedAsync(string taskId, int exitCode, CancellationToken ct = default); + /// The handler round's still-open (Queued/Running) tasks, for the Mission Control + /// "abandon run" action. Empty when the round has no entry or nothing open -- the caller then + /// closes the pane without a confirmation dialog. + Task> GetHandlerOpenTasksAsync(string handlerTaskId, CancellationToken ct = default); /// Starts a planning session and returns the launch spec for an embedded ConPTY /// planning terminal (replaces StartPlanningSessionAsync's external wt window). Task GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default); diff --git a/src/ClaudeDo.Ui/Services/WorkerClient.cs b/src/ClaudeDo.Ui/Services/WorkerClient.cs index 85860146..b0230d65 100644 --- a/src/ClaudeDo.Ui/Services/WorkerClient.cs +++ b/src/ClaudeDo.Ui/Services/WorkerClient.cs @@ -641,6 +641,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC public Task ReportInteractiveSessionExitedAsync(string taskId, int exitCode, CancellationToken ct = default) => InvokeTimedAsync("ReportInteractiveSessionExited", () => _hub.InvokeAsync("ReportInteractiveSessionExited", taskId, exitCode, ct)); + public async Task> GetHandlerOpenTasksAsync(string handlerTaskId, CancellationToken ct = default) + => await InvokeTimedAsync>("GetHandlerOpenTasks", () => _hub.InvokeAsync>("GetHandlerOpenTasks", handlerTaskId, ct)); + public async Task GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default) => await InvokeTimedAsync("GetPlanningStartLaunchSpec", () => _hub.InvokeAsync("GetPlanningStartLaunchSpec", taskId, ct)); diff --git a/src/ClaudeDo.Ui/ViewModels/MissionControl/ConPtyPaneViewModel.cs b/src/ClaudeDo.Ui/ViewModels/MissionControl/ConPtyPaneViewModel.cs index e930850d..5737ccd9 100644 --- a/src/ClaudeDo.Ui/ViewModels/MissionControl/ConPtyPaneViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/MissionControl/ConPtyPaneViewModel.cs @@ -18,6 +18,12 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable // Only a task-based pane can be submitted for review (an ad-hoc directory session has no task). public bool IsTaskBased => TaskId is not null; + /// Set by the host for a list-handler run ("Let Claude handle it" / "Let Claude merge + /// the reviews") pane -- shows the "Abandon run" action. Plain mutable property, not + /// : the host sets it once right after construction, before the + /// pane is added to the visible collection, so no change notification is needed. + public bool IsListHandler { get; set; } + [ObservableProperty] private string _displayTitle; [ObservableProperty] private InteractiveTerminalViewModel _terminal = new(); @@ -26,6 +32,10 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable /// flight, so a rapid double-click can't race two submissions for the same task. [ObservableProperty] private bool _isSubmitPending; + /// Set by the host while an abandon-run round trip (query open tasks, confirm, + /// cancel each, close pane) is in flight, so a rapid double-click can't race two runs. + [ObservableProperty] private bool _isAbandonPending; + /// Raised when the terminal failed to start — the host surfaces this via the footer error strip. public event Action? ErrorReported; @@ -36,6 +46,12 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable /// commits the worktree and moves the task to WaitingForReview. public event Action? SubmitForReviewRequested; + /// Raised when the user abandons a list-handler run ( + /// only): the host looks up this round's still-open tasks, confirms with the user (naming the + /// count), cancels them, then closes this pane -- one action instead of closing the tile and + /// separately cancelling the queue. + public event Action? AbandonRunRequested; + /// Raised with (taskId, exitCode) when a task-based session that had been running /// dies non-zero. The host reports it to the worker, which decides whether this was a list /// handler killed by the usage limit and worth reopening. Ad-hoc panes never raise it — there is @@ -141,6 +157,16 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable partial void OnIsSubmitPendingChanged(bool value) => SubmitForReviewCommand.NotifyCanExecuteChanged(); + private bool CanAbandonRun() => IsListHandler && !IsAbandonPending; + + [RelayCommand(CanExecute = nameof(CanAbandonRun))] + private void AbandonRun() + { + if (TaskId is { } id) AbandonRunRequested?.Invoke(id); + } + + partial void OnIsAbandonPendingChanged(bool value) => AbandonRunCommand.NotifyCanExecuteChanged(); + // A launch failure permanently occupies the TaskId dedupe slot unless the user can retry — // re-opening the same task would otherwise just re-focus a dead tile. private bool CanRetry() => Terminal.HasExited && Terminal.StartError is not null; diff --git a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs index 12f453e8..02d6f7ce 100644 --- a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs @@ -79,6 +79,10 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable // 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) @@ -375,7 +379,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable } AddConPtyPane(new ConPtyPaneViewModel(taskId, title, - () => DescribeAsync(() => launchSpec(taskId)))); + () => DescribeAsync(() => launchSpec(taskId))) { IsListHandler = true }); } finally { @@ -416,7 +420,8 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable catch { /* best-effort title lookup */ } AddConPtyPane(new ConPtyPaneViewModel(taskId, title, - () => DescribeAsync(() => _worker.GetMergeHelperHandoffLaunchSpecAsync(taskId, survivingTaskIds, nextPhase)))); + () => DescribeAsync(() => _worker.GetMergeHelperHandoffLaunchSpecAsync(taskId, survivingTaskIds, nextPhase))) + { IsListHandler = true }); } // The 5h usage window is about to run out under this handler session: type the worker's warning @@ -440,6 +445,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable pane.CloseRequested += CloseConPtySession; pane.SubmitForReviewRequested += OnPaneSubmitForReview; pane.SessionDiedAfterStartup += OnPaneSessionDied; + pane.AbandonRunRequested += OnPaneAbandonRun; ConPtySessions.Add(pane); pane.Start(); } @@ -495,6 +501,41 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable } } + // 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; + } + } + private void CloseConPtySession(ConPtyPaneViewModel pane) { if (!ConPtySessions.Contains(pane)) return; @@ -502,6 +543,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable pane.CloseRequested -= CloseConPtySession; pane.SubmitForReviewRequested -= OnPaneSubmitForReview; pane.SessionDiedAfterStartup -= OnPaneSessionDied; + pane.AbandonRunRequested -= OnPaneAbandonRun; ConPtySessions.Remove(pane); pane.Dispose(); } @@ -555,6 +597,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable c.CloseRequested -= CloseConPtySession; c.SubmitForReviewRequested -= OnPaneSubmitForReview; c.SessionDiedAfterStartup -= OnPaneSessionDied; + c.AbandonRunRequested -= OnPaneAbandonRun; c.Dispose(); } ConPtySessions.Clear(); diff --git a/src/ClaudeDo.Ui/Views/MissionControl/ConPtyPaneView.axaml b/src/ClaudeDo.Ui/Views/MissionControl/ConPtyPaneView.axaml index 649f5d19..f6048afb 100644 --- a/src/ClaudeDo.Ui/Views/MissionControl/ConPtyPaneView.axaml +++ b/src/ClaudeDo.Ui/Views/MissionControl/ConPtyPaneView.axaml @@ -14,7 +14,7 @@ Background="{DynamicResource Surface2Brush}" BorderBrush="{DynamicResource LineBrush}" BorderThickness="0,0,0,1" Padding="8,3"> - + -