From 0892883a825f014163bcf123593d2af4c6ca1d6f Mon Sep 17 00:00:00 2001 From: mika kuns Date: Fri, 21 Aug 2026 17:53:39 +0200 Subject: [PATCH] feat(ui): confirm main-window close when Mission Control sessions are open Closing the main window used to silently kill every open Mission Control ConPTY session (App's ShutdownMode.OnMainWindowClose tears the process down without warning). Intercept Window.Closing, and when at least one pane is open, ask via the existing ConfirmAsync pattern before proceeding; cancelling leaves the window and sessions alive, confirming force-closes as before. --- src/ClaudeDo.Localization/locales/de.json | 1 + src/ClaudeDo.Localization/locales/en.json | 1 + .../ViewModels/IslandsShellViewModel.cs | 10 +++++++ src/ClaudeDo.Ui/Views/MainWindow.axaml.cs | 28 +++++++++++++++++++ .../MainWindowCloseConfirmDecisionTests.cs | 25 +++++++++++++++++ 5 files changed, 65 insertions(+) create mode 100644 tests/ClaudeDo.Ui.Tests/MainWindowCloseConfirmDecisionTests.cs diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index 4436139a..3031f6bd 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -714,6 +714,7 @@ "vm": { "connection": { "online": "Online", "connecting": "Verbinden…", "offline": "Offline" }, "shell": { "restartingWorker": "Worker wird neu gestartet…", "unexpectedError": "Unerwarteter Fehler: {0}" }, + "mainWindow": { "closeConfirm": { "message": "{0} Mission-Control-Sitzung(en) laufen noch. Beim Beenden werden sie abgebrochen. Trotzdem schließen?" } }, "agentStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "review": "Prüfung", "children": "Wartet auf Teilaufgaben", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen" }, "taskStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "waitingForReview": "Wartet auf Prüfung", "waitingForChildren": "Wartet auf Teilaufgaben", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen", "parked": "Geparkt", "interactive": "Interaktiv" }, "failureReason": { "maxTurns": "Turn-Limit erreicht", "timeout": "Zeitüberschreitung", "error": "Fehler", "cancelled": "Abgebrochen", "unknown": "Grund unbekannt" }, diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index bbe5aecb..ca29d581 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -714,6 +714,7 @@ "vm": { "connection": { "online": "Online", "connecting": "Connecting…", "offline": "Offline" }, "shell": { "restartingWorker": "Restarting worker…", "unexpectedError": "Unexpected error: {0}" }, + "mainWindow": { "closeConfirm": { "message": "{0} Mission Control session(s) are still running. Closing the app will end them. Close anyway?" } }, "agentStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "review": "Review", "children": "Waiting for Subtasks", "done": "Done", "failed": "Failed", "cancelled": "Cancelled" }, "taskStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "waitingForReview": "Waiting for Review", "waitingForChildren": "Waiting for Subtasks", "done": "Done", "failed": "Failed", "cancelled": "Cancelled", "parked": "Parked", "interactive": "Interactive" }, "failureReason": { "maxTurns": "Turn limit reached", "timeout": "Timed out", "error": "Error", "cancelled": "Cancelled", "unknown": "Unknown reason" }, diff --git a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs index 2df99684..aa85bccd 100644 --- a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs @@ -520,6 +520,16 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable Dialogs.ShowMissionControl(MissionControl); } + /// Number of Mission Control ConPTY panes currently open — the main window's + /// close guard asks for confirmation whenever this is greater than zero. + public int OpenMissionControlSessionCount => MissionControl?.ConPtySessions.Count ?? 0; + + // Pure decision extracted for testability — closing the main window kills every open + // Mission Control pane's `claude` process (ConPtyPaneViewModel.Dispose), so ask first + // rather than let it happen silently. + internal static bool RequiresCloseConfirmation(int openMissionControlSessionCount) => + openMissionControlSessionCount > 0; + private void SyncInteractiveSessionChips() { if (MissionControl is null || Tasks is null) return; diff --git a/src/ClaudeDo.Ui/Views/MainWindow.axaml.cs b/src/ClaudeDo.Ui/Views/MainWindow.axaml.cs index d506f287..b1633f86 100644 --- a/src/ClaudeDo.Ui/Views/MainWindow.axaml.cs +++ b/src/ClaudeDo.Ui/Views/MainWindow.axaml.cs @@ -1,9 +1,12 @@ using System.Linq; +using System.Threading.Tasks; using Avalonia; using Avalonia.Controls; using Avalonia.Input; using Avalonia.Layout; using Avalonia.Media; +using ClaudeDo.Ui.Localization; +using ClaudeDo.Ui.Services; using ClaudeDo.Ui.ViewModels; using ClaudeDo.Ui.ViewModels.Islands; using ClaudeDo.Ui.ViewModels.Modals; @@ -13,6 +16,8 @@ namespace ClaudeDo.Ui.Views; public partial class MainWindow : Window { + private bool _forceClose; + public MainWindow() { InitializeComponent(); @@ -21,6 +26,29 @@ public partial class MainWindow : Window UpdateMaxIcon(); } + // Closing the main window kills every open Mission Control ConPTY session without + // warning (App.axaml.cs: ShutdownMode.OnMainWindowClose). Ask first — never silently. + protected override void OnClosing(WindowClosingEventArgs e) + { + if (!_forceClose + && DataContext is IslandsShellViewModel { Dialogs: { } dialogs } vm + && IslandsShellViewModel.RequiresCloseConfirmation(vm.OpenMissionControlSessionCount)) + { + e.Cancel = true; + _ = ConfirmCloseWithOpenSessionsAsync(dialogs, vm.OpenMissionControlSessionCount); + return; + } + base.OnClosing(e); + } + + private async Task ConfirmCloseWithOpenSessionsAsync(IDialogService dialogs, int openSessionCount) + { + var confirmed = await dialogs.ConfirmAsync(Loc.T("vm.mainWindow.closeConfirm.message", openSessionCount)); + if (!confirmed) return; + _forceClose = true; + Close(); + } + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) { base.OnPropertyChanged(change); diff --git a/tests/ClaudeDo.Ui.Tests/MainWindowCloseConfirmDecisionTests.cs b/tests/ClaudeDo.Ui.Tests/MainWindowCloseConfirmDecisionTests.cs new file mode 100644 index 00000000..03d2a729 --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/MainWindowCloseConfirmDecisionTests.cs @@ -0,0 +1,25 @@ +using ClaudeDo.Ui.ViewModels; +using Xunit; + +namespace ClaudeDo.Ui.Tests; + +public class MainWindowCloseConfirmDecisionTests +{ + [Fact] + public void True_when_at_least_one_mission_control_session_is_open() + { + Assert.True(IslandsShellViewModel.RequiresCloseConfirmation(1)); + } + + [Fact] + public void True_when_multiple_mission_control_sessions_are_open() + { + Assert.True(IslandsShellViewModel.RequiresCloseConfirmation(3)); + } + + [Fact] + public void False_when_no_mission_control_sessions_are_open() + { + Assert.False(IslandsShellViewModel.RequiresCloseConfirmation(0)); + } +}