feat(ui): worker-menu Shutdown ends worker + app after confirm

Adds a Shutdown entry after the Worker menu's Restart/Update Claude CLI
items. Confirms via IDialogService.ConfirmAsync, then kills this
installation's ClaudeDo.Worker process(es) (shared kill loop, extracted
from RestartWorkerService) and force-closes the window through the same
_forceClose path MainWindow already uses for the Mission-Control close
confirm, so no second confirmation dialog shows.
This commit is contained in:
mika kuns
2026-09-10 13:48:50 +02:00
parent 7eba0affbe
commit 1bdf20272d
6 changed files with 109 additions and 7 deletions
+2 -1
View File
@@ -785,6 +785,7 @@
"checkForUpdates": "Nach Updates suchen", "checkForUpdates": "Nach Updates suchen",
"restartWorker": "Worker neu starten", "restartWorker": "Worker neu starten",
"updateClaudeCli": "Claude CLI aktualisieren", "updateClaudeCli": "Claude CLI aktualisieren",
"shutdown": "Beenden",
"worktrees": "Worktrees…", "worktrees": "Worktrees…",
"weeklyReport": "Wochenbericht…", "weeklyReport": "Wochenbericht…",
"usageMonitor": "Usage Monitor…", "usageMonitor": "Usage Monitor…",
@@ -810,7 +811,7 @@
}, },
"vm": { "vm": {
"connection": { "online": "Online", "connecting": "Verbinden…", "offline": "Offline" }, "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?" } }, "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" }, "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" }, "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" },
+2 -1
View File
@@ -785,6 +785,7 @@
"checkForUpdates": "Check for updates", "checkForUpdates": "Check for updates",
"restartWorker": "Restart worker", "restartWorker": "Restart worker",
"updateClaudeCli": "Update Claude CLI", "updateClaudeCli": "Update Claude CLI",
"shutdown": "Shutdown",
"worktrees": "Worktrees…", "worktrees": "Worktrees…",
"weeklyReport": "Weekly Report…", "weeklyReport": "Weekly Report…",
"usageMonitor": "Usage Monitor…", "usageMonitor": "Usage Monitor…",
@@ -810,7 +811,7 @@
}, },
"vm": { "vm": {
"connection": { "online": "Online", "connecting": "Connecting…", "offline": "Offline" }, "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?" } }, "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" }, "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" }, "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" },
@@ -658,8 +658,17 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
var exe = _workerLocator.Find(); var exe = _workerLocator.Find();
if (exe is null) throw new InvalidOperationException("Worker executable not found."); if (exe is null) throw new InvalidOperationException("Worker executable not found.");
// Only kill the worker belonging to THIS installation — not any other KillMatchingWorkerProcesses(exe);
// ClaudeDo.Worker on the machine (e.g. a second install).
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); var exeFull = System.IO.Path.GetFullPath(exe);
foreach (var p in System.Diagnostics.Process.GetProcessesByName("ClaudeDo.Worker")) 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 */ } catch { /* may have exited or be inaccessible */ }
finally { p.Dispose(); } finally { p.Dispose(); }
} }
}
var started = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(exe) { UseShellExecute = true }); // Set by MainWindow: force-closes the window without re-triggering its own close
if (started is not null && started.WaitForExit(RestartSurvivalCheckMs)) // confirmation (the shutdown dialog above already confirmed).
throw new InvalidOperationException($"worker process exited immediately (code {started.ExitCode})"); 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; private bool _updatingClaudeCli;
+3
View File
@@ -68,6 +68,9 @@
Command="{Binding OpenUsageMonitorCommand}"/> Command="{Binding OpenUsageMonitorCommand}"/>
<MenuItem Header="{loc:Tr shell.menu.updateClaudeCli}" <MenuItem Header="{loc:Tr shell.menu.updateClaudeCli}"
Command="{Binding UpdateClaudeCliCommand}"/> Command="{Binding UpdateClaudeCliCommand}"/>
<Separator/>
<MenuItem Header="{loc:Tr shell.menu.shutdown}"
Command="{Binding ShutdownCommand}"/>
</MenuItem> </MenuItem>
<MenuItem Header="{loc:Tr shell.menu.repositories}" <MenuItem Header="{loc:Tr shell.menu.repositories}"
FontSize="{StaticResource FontSizeMono}" FontSize="{StaticResource FontSizeMono}"
@@ -80,6 +80,11 @@ public partial class MainWindow : Window
WindowState = WindowState.Normal; WindowState = WindowState.Normal;
Activate(); Activate();
}; };
vm.CloseWindow = () =>
{
_forceClose = true;
Close();
};
if (vm.Tasks is not null) if (vm.Tasks is not null)
vm.Tasks.IsWindowVisible = WindowState != WindowState.Minimized; vm.Tasks.IsWindowVisible = WindowState != WindowState.Minimized;
} }
@@ -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<bool> 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<IReadOnlyList<string>?> 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();
}
}