feat(mission-control): Handler-Run per Knopf abbrechen

Ein "Abandon run"-Knopf an Handler-Panes fragt HandlerRoundState.Get(handlerTaskId)
nach den noch offenen (Queued/Running) Tasks des Laufs, bestaetigt mit Anzahl,
cancelt jeden ueber den bestehenden CancelTaskAsync-Pfad und schliesst danach die
Session -- ein Schritt statt Tile schliessen + Queue separat abbrechen. Ohne
offene Tasks (oder ohne Round-Eintrag) schliesst die Aktion direkt, ohne Dialog.
Nur an den beiden Handler-Panes sichtbar (IsListHandler), normale Task-/Ad-hoc-
Panes bleiben unveraendert.
This commit is contained in:
mika kuns
2026-09-02 22:39:12 +02:00
parent b298594a2c
commit bd5aa265d1
13 changed files with 417 additions and 5 deletions
@@ -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.</summary>
Task ReportInteractiveSessionExitedAsync(string taskId, int exitCode, CancellationToken ct = default);
/// <summary>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.</summary>
Task<IReadOnlyList<HandlerOpenTaskDto>> GetHandlerOpenTasksAsync(string handlerTaskId, CancellationToken ct = default);
/// <summary>Starts a planning session and returns the launch spec for an embedded ConPTY
/// planning terminal (replaces StartPlanningSessionAsync's external wt window).</summary>
Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default);
+3
View File
@@ -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<IReadOnlyList<HandlerOpenTaskDto>> GetHandlerOpenTasksAsync(string handlerTaskId, CancellationToken ct = default)
=> await InvokeTimedAsync<List<HandlerOpenTaskDto>>("GetHandlerOpenTasks", () => _hub.InvokeAsync<List<HandlerOpenTaskDto>>("GetHandlerOpenTasks", handlerTaskId, ct));
public async Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default)
=> await InvokeTimedAsync<LaunchSpec>("GetPlanningStartLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetPlanningStartLaunchSpec", taskId, ct));
@@ -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;
/// <summary>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
/// <see cref="ObservableProperty"/>: the host sets it once right after construction, before the
/// pane is added to the visible collection, so no change notification is needed.</summary>
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.</summary>
[ObservableProperty] private bool _isSubmitPending;
/// <summary>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.</summary>
[ObservableProperty] private bool _isAbandonPending;
/// <summary>Raised when the terminal failed to start — the host surfaces this via the footer error strip.</summary>
public event Action<string>? ErrorReported;
@@ -36,6 +46,12 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
/// commits the worktree and moves the task to WaitingForReview.</summary>
public event Action<string>? SubmitForReviewRequested;
/// <summary>Raised when the user abandons a list-handler run (<see cref="IsListHandler"/>
/// 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.</summary>
public event Action<string>? AbandonRunRequested;
/// <summary>Raised with <c>(taskId, exitCode)</c> 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;
@@ -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<string, System.Threading.Tasks.Task<bool>>? ConfirmAsync { get; set; }
public UsagePillViewModel UsagePill { get; }
public MissionControlViewModel(IDbContextFactory<ClaudeDoDbContext> 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();
@@ -14,7 +14,7 @@
Background="{DynamicResource Surface2Brush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,0,0,1" Padding="8,3">
<Grid ColumnDefinitions="*,Auto,Auto">
<Grid ColumnDefinitions="*,Auto,Auto,Auto">
<TextBlock Grid.Column="0" Classes="meta" Text="{Binding DisplayTitle}"
TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding DisplayTitle}"
@@ -25,7 +25,12 @@
Content="{loc:Tr missionControl.submitForReview}"
ToolTip.Tip="{loc:Tr missionControl.submitForReviewTip}"
Command="{Binding SubmitForReviewCommand}" />
<Button Grid.Column="2" Classes="title-ctrl"
<Button Grid.Column="2" Classes="btn" Margin="0,0,6,0"
IsVisible="{Binding IsListHandler}"
Content="{loc:Tr missionControl.abandonRun}"
ToolTip.Tip="{loc:Tr missionControl.abandonRunTip}"
Command="{Binding AbandonRunCommand}" />
<Button Grid.Column="3" Classes="title-ctrl"
Command="{Binding CloseCommand}"
ToolTip.Tip="{loc:Tr missionControl.closeSession}">
<PathIcon Data="{StaticResource Icon.WinClose}" Width="12" Height="12"/>
@@ -2,6 +2,7 @@ using System;
using System.Collections.Specialized;
using System.ComponentModel;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
@@ -55,6 +56,7 @@ public partial class MissionControlView : UserControl
_vm.Panes.CollectionChanged -= OnPanesChanged;
_vm.PropertyChanged -= OnViewModelPropertyChanged;
_vm.BrowseForSessionRequested = null;
_vm.ConfirmAsync = null;
}
_vm = DataContext as MissionControlViewModel;
@@ -64,12 +66,59 @@ public partial class MissionControlView : UserControl
_vm.Panes.CollectionChanged += OnPanesChanged;
_vm.PropertyChanged += OnViewModelPropertyChanged;
_vm.BrowseForSessionRequested = BrowseForSessionAsync;
_vm.ConfirmAsync = ShowConfirmAsync;
}
RebuildOverviewGrid();
UpdateQueueColumn();
}
// Abandon-run confirmation -- same TaskCompletionSource-backed plain Window as
// TasksIslandView.ShowConfirmAsync (no shared control for this shape yet).
private async Task<bool> ShowConfirmAsync(string message)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return false;
var tcs = new TaskCompletionSource<bool>();
var cancel = new Button { Content = "Cancel", MinWidth = 90 };
var confirm = new Button { Content = "Confirm", MinWidth = 90, Classes = { "danger" } };
var dialog = new Window
{
Title = "Confirm",
Width = 380,
SizeToContent = SizeToContent.Height,
CanResize = false,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
ShowInTaskbar = false,
Background = this.FindResource("SurfaceBrush") as IBrush,
Content = new StackPanel
{
Margin = new Thickness(20),
Spacing = 16,
Children =
{
new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },
new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
Spacing = 8,
Children = { cancel, confirm },
},
},
},
};
cancel.Click += (_, _) => { tcs.TrySetResult(false); dialog.Close(); };
confirm.Click += (_, _) => { tcs.TrySetResult(true); dialog.Close(); };
dialog.Closed += (_, _) => tcs.TrySetResult(false);
_ = dialog.ShowDialog(owner);
return await tcs.Task;
}
private void OnPanesChanged(object? sender, NotifyCollectionChangedEventArgs e) => RebuildOverviewGrid();
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)