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)); + } +}