From eb6237143003f56131cb06dba4a7320bd19637f0 Mon Sep 17 00:00:00 2001 From: mika kuns Date: Thu, 10 Sep 2026 13:49:35 +0200 Subject: [PATCH] feat(ui): confirm before closing a running ConPTY session The tile's own X killed a live Claude session with no confirmation, unlike the semantically similar Abandon-run action. Gate the close behind the same ConfirmAsync mechanism, but only when Terminal.IsRunning -- a dead session (StartError or exited-after-startup) still closes immediately since the X there is pure cleanup. --- src/ClaudeDo.Localization/locales/de.json | 1 + src/ClaudeDo.Localization/locales/en.json | 1 + .../ViewModels/MissionControlViewModel.cs | 22 +++++++- .../MissionControlViewModelTests.cs | 55 +++++++++++++++++++ 4 files changed, 76 insertions(+), 3 deletions(-) diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index bd3283a9..a0ac36f0 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -380,6 +380,7 @@ "focusMode": "Fokus", "overviewMode": "Übersicht", "closeSession": "Sitzung beenden", + "closeSessionConfirm": "Laufende Sitzung beenden? Der Claude-Prozess wird abgebrochen.", "conptyLaunchFailed": "Interaktive Sitzung konnte nicht geöffnet werden: {0}", "conptyStarting": "Sitzung wird gestartet…", "conptyExited": "Sitzung beendet (Code {0}).", diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index 0205a724..1e511775 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -380,6 +380,7 @@ "focusMode": "Focus", "overviewMode": "Overview", "closeSession": "Kill session", + "closeSessionConfirm": "End the running session? The Claude process will be aborted.", "conptyLaunchFailed": "Couldn't open interactive session: {0}", "conptyStarting": "Starting session…", "conptyExited": "Session ended (code {0}).", diff --git a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs index 11ec8819..91ef739c 100644 --- a/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs @@ -449,7 +449,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable private void AddConPtyPane(ConPtyPaneViewModel pane) { pane.ErrorReported += OnConPtyPaneError; - pane.CloseRequested += CloseConPtySession; + pane.CloseRequested += OnPaneCloseRequested; pane.SubmitForReviewRequested += OnPaneSubmitForReview; pane.SessionDiedAfterStartup += OnPaneSessionDied; pane.AbandonRunRequested += OnPaneAbandonRun; @@ -543,11 +543,27 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable } } + // 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 -= CloseConPtySession; + pane.CloseRequested -= OnPaneCloseRequested; pane.SubmitForReviewRequested -= OnPaneSubmitForReview; pane.SessionDiedAfterStartup -= OnPaneSessionDied; pane.AbandonRunRequested -= OnPaneAbandonRun; @@ -601,7 +617,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable foreach (var c in ConPtySessions.ToList()) { c.ErrorReported -= OnConPtyPaneError; - c.CloseRequested -= CloseConPtySession; + c.CloseRequested -= OnPaneCloseRequested; c.SubmitForReviewRequested -= OnPaneSubmitForReview; c.SessionDiedAfterStartup -= OnPaneSessionDied; c.AbandonRunRequested -= OnPaneAbandonRun; diff --git a/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs b/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs index a3d181d1..67be7570 100644 --- a/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs +++ b/tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs @@ -1072,4 +1072,59 @@ public class MissionControlViewModelTests : IDisposable Assert.Equal(new[] { "t1", "t2" }, worker.Cancelled); Assert.Empty(vm.ConPtySessions); } + + // The tile's own X must not silently kill a live session -- a reflex click on a running pane + // has to go through the same confirm mechanism as Abandon Run. + [Fact] + public async Task Close_RunningSession_ConfirmDeclined_LeavesSessionOpen() + { + var worker = new FakeWorker(); + using var vm = BuildVm(worker); + await vm.OpenConPtySessionAsync("t1"); + var pane = vm.ConPtySessions[0]; + pane.Terminal.IsRunning = true; + string? confirmMessage = null; + vm.ConfirmAsync = msg => { confirmMessage = msg; return Task.FromResult(false); }; + + pane.CloseCommand.Execute(null); + await Task.Delay(20); + + Assert.NotNull(confirmMessage); + Assert.Single(vm.ConPtySessions); + } + + [Fact] + public async Task Close_RunningSession_ConfirmAccepted_ClosesPane() + { + var worker = new FakeWorker(); + using var vm = BuildVm(worker); + await vm.OpenConPtySessionAsync("t1"); + var pane = vm.ConPtySessions[0]; + pane.Terminal.IsRunning = true; + vm.ConfirmAsync = _ => Task.FromResult(true); + + pane.CloseCommand.Execute(null); + await Task.Delay(20); + + Assert.Empty(vm.ConPtySessions); + } + + // A dead session (start failure or exited-after-startup) is pure cleanup -- no confirm needed. + [Fact] + public async Task Close_DeadSession_ClosesWithoutConfirming() + { + var worker = new FakeWorker(); + using var vm = BuildVm(worker); + await vm.OpenConPtySessionAsync("t1"); + var pane = vm.ConPtySessions[0]; + pane.Terminal.ReportStartFailure("boom"); + var confirmCalled = false; + vm.ConfirmAsync = _ => { confirmCalled = true; return Task.FromResult(true); }; + + pane.CloseCommand.Execute(null); + await Task.Delay(20); + + Assert.False(confirmCalled); + Assert.Empty(vm.ConPtySessions); + } }