diff --git a/src/ClaudeDo.Localization/locales/de.json b/src/ClaudeDo.Localization/locales/de.json index 2d079d85..bd3283a9 100644 --- a/src/ClaudeDo.Localization/locales/de.json +++ b/src/ClaudeDo.Localization/locales/de.json @@ -785,6 +785,7 @@ "checkForUpdates": "Nach Updates suchen", "restartWorker": "Worker neu starten", "updateClaudeCli": "Claude CLI aktualisieren", + "shutdown": "Beenden", "worktrees": "Worktrees…", "weeklyReport": "Wochenbericht…", "usageMonitor": "Usage Monitor…", @@ -810,7 +811,7 @@ }, "vm": { "connection": { "online": "Online", "connecting": "Verbinden…", "offline": "Offline" }, - "shell": { "unexpectedError": "Unerwarteter Fehler: {0}" }, + "shell": { "unexpectedError": "Unerwarteter Fehler: {0}", "shutdownConfirm": "Dadurch wird der ClaudeDo-Worker beendet (laufende Task-Runs und interaktive Sitzungen brechen ab) und die App geschlossen. Fortfahren?" }, "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", "waiting": "Wartet" }, diff --git a/src/ClaudeDo.Localization/locales/en.json b/src/ClaudeDo.Localization/locales/en.json index 8f71e486..0205a724 100644 --- a/src/ClaudeDo.Localization/locales/en.json +++ b/src/ClaudeDo.Localization/locales/en.json @@ -785,6 +785,7 @@ "checkForUpdates": "Check for updates", "restartWorker": "Restart worker", "updateClaudeCli": "Update Claude CLI", + "shutdown": "Shutdown", "worktrees": "Worktrees…", "weeklyReport": "Weekly Report…", "usageMonitor": "Usage Monitor…", @@ -810,7 +811,7 @@ }, "vm": { "connection": { "online": "Online", "connecting": "Connecting…", "offline": "Offline" }, - "shell": { "unexpectedError": "Unexpected error: {0}" }, + "shell": { "unexpectedError": "Unexpected error: {0}", "shutdownConfirm": "This stops the ClaudeDo worker (any running task or interactive session is interrupted) and closes the app. Continue?" }, "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", "waiting": "Waiting" }, diff --git a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs index 1ead9b8e..e879fd4e 100644 --- a/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs +++ b/src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs @@ -658,8 +658,17 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable var exe = _workerLocator.Find(); if (exe is null) throw new InvalidOperationException("Worker executable not found."); - // Only kill the worker belonging to THIS installation — not any other - // ClaudeDo.Worker on the machine (e.g. a second install). + KillMatchingWorkerProcesses(exe); + + var started = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(exe) { UseShellExecute = true }); + if (started is not null && started.WaitForExit(RestartSurvivalCheckMs)) + throw new InvalidOperationException($"worker process exited immediately (code {started.ExitCode})"); + } + + // Only kill the worker belonging to THIS installation — not any other + // ClaudeDo.Worker on the machine (e.g. a second install). + private static void KillMatchingWorkerProcesses(string exe) + { var exeFull = System.IO.Path.GetFullPath(exe); foreach (var p in System.Diagnostics.Process.GetProcessesByName("ClaudeDo.Worker")) { @@ -675,10 +684,29 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable catch { /* may have exited or be inaccessible */ } finally { p.Dispose(); } } + } - var started = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(exe) { UseShellExecute = true }); - if (started is not null && started.WaitForExit(RestartSurvivalCheckMs)) - throw new InvalidOperationException($"worker process exited immediately (code {started.ExitCode})"); + // Set by MainWindow: force-closes the window without re-triggering its own close + // confirmation (the shutdown dialog above already confirmed). + public Action? CloseWindow { get; set; } + + // Test seam: overrides the real kill-worker + close-window body. + internal Action? ShutdownOperation { get; set; } + + [RelayCommand] + private async Task ShutdownAsync() + { + if (Dialogs is null) return; + var confirmed = await Dialogs.ConfirmAsync(Loc.T("vm.shell.shutdownConfirm")); + if (!confirmed) return; + (ShutdownOperation ?? ShutdownWorkerAndCloseApp)(); + } + + private void ShutdownWorkerAndCloseApp() + { + var exe = _workerLocator.Find(); + if (exe is not null) KillMatchingWorkerProcesses(exe); + CloseWindow?.Invoke(); } private bool _updatingClaudeCli; diff --git a/src/ClaudeDo.Ui/Views/MainWindow.axaml b/src/ClaudeDo.Ui/Views/MainWindow.axaml index e5d54798..9aad5ade 100644 --- a/src/ClaudeDo.Ui/Views/MainWindow.axaml +++ b/src/ClaudeDo.Ui/Views/MainWindow.axaml @@ -68,6 +68,9 @@ Command="{Binding OpenUsageMonitorCommand}"/> + + + { + _forceClose = true; + Close(); + }; if (vm.Tasks is not null) vm.Tasks.IsWindowVisible = WindowState != WindowState.Minimized; } diff --git a/tests/ClaudeDo.Ui.Tests/IslandsShellViewModelShutdownTests.cs b/tests/ClaudeDo.Ui.Tests/IslandsShellViewModelShutdownTests.cs new file mode 100644 index 00000000..7b292fc2 --- /dev/null +++ b/tests/ClaudeDo.Ui.Tests/IslandsShellViewModelShutdownTests.cs @@ -0,0 +1,64 @@ +using System; +using System.Threading.Tasks; +using ClaudeDo.Ui.Services; +using ClaudeDo.Ui.ViewModels; +using ClaudeDo.Ui.ViewModels.Conflicts; +using ClaudeDo.Ui.ViewModels.Modals; + +namespace ClaudeDo.Ui.Tests; + +// Covers the worker-menu "Shutdown" command: a confirm dialog gates the actual kill-worker + +// close-app action. ShutdownOperation is a test seam standing in for the real body, so these +// tests only assert the confirm gate, never a real process kill. +public class IslandsShellViewModelShutdownTests +{ + [Fact] + public async Task Declined_DoesNotInvokeShutdownOperation() + { + var invoked = false; + var vm = new IslandsShellViewModel + { + Dialogs = new FakeDialogService(confirmResult: false), + ShutdownOperation = () => invoked = true, + }; + + await vm.ShutdownCommand.ExecuteAsync(null); + + Assert.False(invoked); + } + + [Fact] + public async Task Confirmed_InvokesShutdownOperation() + { + var invoked = false; + var vm = new IslandsShellViewModel + { + Dialogs = new FakeDialogService(confirmResult: true), + ShutdownOperation = () => invoked = true, + }; + + await vm.ShutdownCommand.ExecuteAsync(null); + + Assert.True(invoked); + } + + private sealed class FakeDialogService(bool confirmResult) : IDialogService + { + public Task ConfirmAsync(string message) => Task.FromResult(confirmResult); + + public Task ShowAboutAsync(AboutModalViewModel vm) => throw new NotImplementedException(); + public Task ShowWeeklyReportAsync(WeeklyReportModalViewModel vm) => throw new NotImplementedException(); + public Task ShowUsageMonitorAsync(UsageMonitorModalViewModel vm) => throw new NotImplementedException(); + public Task ShowSettingsAsync(SettingsModalViewModel vm) => throw new NotImplementedException(); + public Task ShowListSettingsAsync(ListSettingsModalViewModel vm) => throw new NotImplementedException(); + public Task ShowRepoImportAsync(RepoImportModalViewModel vm) => throw new NotImplementedException(); + public Task ShowWorktreesOverviewAsync(WorktreesOverviewModalViewModel vm) => throw new NotImplementedException(); + public Task?> ShowMergeHelperSelectionAsync(MergeHelperSelectionModalViewModel vm) => throw new NotImplementedException(); + public Task ShowWorkerConnectionAsync(WorkerConnectionModalViewModel vm) => throw new NotImplementedException(); + public Task ShowConflictResolverAsync(ConflictResolverViewModel vm) => throw new NotImplementedException(); + public Task ShowLogVisualizerAsync(LogVisualizerViewModel vm) => throw new NotImplementedException(); + public Task ShowErrorAsync(string message) => Task.CompletedTask; + public void ShowMissionControl(MissionControlViewModel vm) => throw new NotImplementedException(); + public void ShowCheatSheets(CheatSheetsViewModel vm) => throw new NotImplementedException(); + } +}