feat(claude-do): merge feat(ui): Worker-Menü "Shutdown" beendet nach Bestätigung Wo

ClaudeDo-Task: f97295c1e25a44db9db23df07a38062a
This commit is contained in:
mika kuns
2026-09-10 13:52:30 +02:00
6 changed files with 109 additions and 7 deletions
+2 -1
View File
@@ -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" },
+2 -1
View File
@@ -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" },
@@ -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;
+3
View File
@@ -68,6 +68,9 @@
Command="{Binding OpenUsageMonitorCommand}"/>
<MenuItem Header="{loc:Tr shell.menu.updateClaudeCli}"
Command="{Binding UpdateClaudeCliCommand}"/>
<Separator/>
<MenuItem Header="{loc:Tr shell.menu.shutdown}"
Command="{Binding ShutdownCommand}"/>
</MenuItem>
<MenuItem Header="{loc:Tr shell.menu.repositories}"
FontSize="{StaticResource FontSizeMono}"
@@ -80,6 +80,11 @@ public partial class MainWindow : Window
WindowState = WindowState.Normal;
Activate();
};
vm.CloseWindow = () =>
{
_forceClose = true;
Close();
};
if (vm.Tasks is not null)
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();
}
}