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
@@ -138,6 +138,8 @@ public abstract class StubWorkerClient : IWorkerClient
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task ReportInteractiveSessionExitedAsync(string taskId, int exitCode, CancellationToken ct = default)
=> Task.CompletedTask;
public virtual Task<IReadOnlyList<HandlerOpenTaskDto>> GetHandlerOpenTasksAsync(string handlerTaskId, CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<HandlerOpenTaskDto>>(Array.Empty<HandlerOpenTaskDto>());
public virtual Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<LaunchSpec> GetPlanningResumeLaunchSpecAsync(string taskId, CancellationToken ct = default)
@@ -966,4 +966,110 @@ public class MissionControlViewModelTests : IDisposable
vm.ToggleLayoutCommand.Execute(null);
Assert.False(vm.IsFocusMode);
}
// ── Abandon run: single-action "stop session + cancel its still-open tasks" ────────────────
private sealed class RecordingAbandonWorker : StubWorkerClient
{
public IReadOnlyList<HandlerOpenTaskDto> OpenTasks { get; set; } = Array.Empty<HandlerOpenTaskDto>();
public readonly List<string> Cancelled = new();
public override Task<IReadOnlyList<HandlerOpenTaskDto>> GetHandlerOpenTasksAsync(string handlerTaskId, CancellationToken ct = default)
=> Task.FromResult(OpenTasks);
public override Task CancelTaskAsync(string taskId)
{
Cancelled.Add(taskId);
return Task.CompletedTask;
}
}
// A plain OpenConPtySessionAsync pane (not a list handler) never shows/allows the action --
// acceptance criterion 5 ("only at handler panes").
[Fact]
public async Task AbandonRun_OnNonHandlerPane_IsNotAvailable()
{
var worker = new FakeWorker();
using var vm = BuildVm(worker);
await vm.OpenConPtySessionAsync("t1");
var pane = vm.ConPtySessions[0];
Assert.False(pane.IsListHandler);
Assert.False(pane.AbandonRunCommand.CanExecute(null));
}
[Fact]
public async Task AbandonRun_ListHandlerPane_IsListHandlerTrue_CommandAvailable()
{
var worker = new RecordingAbandonWorker();
using var vm = BuildVm(worker);
await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" });
var pane = Assert.Single(vm.ConPtySessions);
Assert.True(pane.IsListHandler);
Assert.True(pane.AbandonRunCommand.CanExecute(null));
}
// Acceptance criterion 4: no open tasks (or no round entry, which the fake models the same
// way) closes the pane straight away -- no confirmation dialog.
[Fact]
public async Task AbandonRun_NoOpenTasks_ClosesPaneWithoutConfirming()
{
var worker = new RecordingAbandonWorker { OpenTasks = Array.Empty<HandlerOpenTaskDto>() };
using var vm = BuildVm(worker);
await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" });
var pane = Assert.Single(vm.ConPtySessions);
var confirmCalled = false;
vm.ConfirmAsync = _ => { confirmCalled = true; return Task.FromResult(true); };
pane.AbandonRunCommand.Execute(null);
await Task.Delay(20);
Assert.False(confirmCalled);
Assert.Empty(vm.ConPtySessions);
}
// Acceptance criterion 3 (decline branch): declining leaves the session and its tasks
// untouched.
[Fact]
public async Task AbandonRun_OpenTasks_ConfirmDeclined_LeavesSessionAndTasksUntouched()
{
var worker = new RecordingAbandonWorker
{
OpenTasks = new[] { new HandlerOpenTaskDto("t1", "First"), new HandlerOpenTaskDto("t2", "Second") },
};
using var vm = BuildVm(worker);
await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1", "t2" });
var pane = Assert.Single(vm.ConPtySessions);
string? confirmMessage = null;
vm.ConfirmAsync = msg => { confirmMessage = msg; return Task.FromResult(false); };
pane.AbandonRunCommand.Execute(null);
await Task.Delay(20);
Assert.NotNull(confirmMessage);
Assert.Empty(worker.Cancelled);
Assert.Single(vm.ConPtySessions);
}
// Acceptance criteria 1-3 (accept branch): the count is confirmed, every open task is
// cancelled, then the pane closes -- one action instead of two.
[Fact]
public async Task AbandonRun_OpenTasks_ConfirmAccepted_CancelsEachThenClosesPane()
{
var worker = new RecordingAbandonWorker
{
OpenTasks = new[] { new HandlerOpenTaskDto("t1", "First"), new HandlerOpenTaskDto("t2", "Second") },
};
using var vm = BuildVm(worker);
await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1", "t2" });
var pane = Assert.Single(vm.ConPtySessions);
vm.ConfirmAsync = _ => Task.FromResult(true);
pane.AbandonRunCommand.Execute(null);
await Task.Delay(20);
Assert.Equal(new[] { "t1", "t2" }, worker.Cancelled);
Assert.Empty(vm.ConPtySessions);
}
}