Compare commits

..
24 Commits
Author SHA1 Message Date
mika kuns 4dc4fe27e3 Merge claudedo/f4d2d2f8eef340ca82540a189f9e299a 2026-07-29 13:29:18 +02:00
mika kuns 0ee30bee03 Merge claudedo/3832008a0b5147bf8308407ed4bd1ded 2026-07-29 13:26:15 +02:00
mika kuns b9e0721875 Merge claudedo/113934cda24a4ae99e15d77fca29507e 2026-07-29 13:25:08 +02:00
mika kuns 92eb654f9b Merge claudedo/50a6027eba294dde8b18e4082dfc1e9b 2026-07-29 13:24:50 +02:00
mika kuns 724814f770 Merge claudedo/9c97a8ca2e134729bf96ca282cfbf346 2026-07-29 13:24:26 +02:00
mika kuns b5464fc533 fix(worker-client): stop swallowing mutating hub call failures
TryInvokeAsync catches every exception and returns null, which is fine
for read-only calls but hid real HubException reasons behind a generic
"offline" message for the 7 mutating call sites (RestoreDefaultAgents,
UpsertPrimeSchedule, AddDailyNote, CleanupFinishedWorktrees,
ResetAllWorktrees, ForceRemoveWorktree, BuildPlanningIntegrationBranch)
— the same bug class fixed for ApproveReview in e1807fd. Each of the 22
TryInvokeAsync call sites was audited; the 15 read-only ones are left
unchanged (empty/offline is the right display). For the 7 switched to
a direct hub invoke, every caller was checked and, where it had no
catch, one was added so the exception surfaces (StatusMessage,
ShowErrorAsync/CombinedWarning) instead of crashing.
2026-07-29 13:19:38 +02:00
mika kuns 44cdad386c feat(mission-control): sidebar shows queued+running, no auto-monitor seeding
- TaskStarted no longer creates a monitor pane; Panes stays empty unless a
  ConPTY session is explicitly opened
- SeedActive no longer called on construction or ConnectionRestored
- EnsureMonitor / SeedActive kept compiling and functional (internal), just unused
- RefreshQueueAsync now loads Queued + Running tasks, Running sorted first
- QueuedTaskViewModel gains IsRunning + OpenInAppCommand (IRelayCommand)
- Sidebar row is a Button; running rows get RunningTint overlay + "Running" label
- HasQueued is true whenever any queued or running task exists
- New localization keys: missionControl.running (en + de)
- Tests updated: 12 monitor-machinery tests use EnsureMonitor directly;
  3 new acceptance-criteria tests (no-auto-pane, running-first sort, row click)
2026-07-29 12:12:34 +02:00
mika kuns 58f8b11dbb fix(worker-tests): pin LogRingBuffer clock in Does_not_throw_when_detached
The test built the buffer with the real DateTime.UtcNow clock but emitted
an event with the file's fixed EvtTime timestamp (2026-06-23). As real
time drifted more than the 1h window past that fixed timestamp, the
just-appended record was evicted inside the same Append call, before
Snapshot() ran. BroadcastLogSink.Emit itself buffers unconditionally
regardless of attach state, so this was a test bug, not a sink bug. Pin
the buffer's clock to EvtTime, matching every sibling test's NewSink setup.
2026-07-29 12:10:58 +02:00
mika kuns e653677487 feat(worker): expose maxParallelExecutions in get_app_settings
The list-handler prompt (MergeHelperDefault) tells the handler to read
maxParallelExecutions via get_app_settings, but the DTO never carried
the field. Handler had to fall back to reading app_settings directly
from the DB on the 2026-07-29 E2E run.
2026-07-29 12:06:52 +02:00
mika kuns 994e94c2af fix(claude-do): List-Handler-Session mit --permission-mode auto starten
## Problem
Die "Let Claude handle it"-Session (List-Handler) startet mit `--permission-mode default` und fragt
dadurch bei jedem Tool-Aufruf nach Bestätigung. Sie soll autonom durchlaufen können — der User
überwacht die ConPTY-Kachel, statt jede Aktion einzeln freizugeben.

## Ist-Zustand
`src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs`, `BuildForMergeHelperAsync`
(Zeile ~183-249). Die

ClaudeDo-Task: 50a6027eba294dde8b18e4082dfc1e9b
2026-07-29 12:03:30 +02:00
mika kuns db447f36da Merge remote-tracking branch 'origin/main' 2026-07-29 09:29:32 +02:00
mika kuns df2fcd8def Merge claudedo/9a0fd00eaf164f3883e4a9e7e28ac99f 2026-07-29 09:15:06 +02:00
mika kuns 17ef99bc9b Merge claudedo/c90fe66442cb4b72b0476777c2225c65 2026-07-29 09:13:53 +02:00
mika kuns c4425d6499 Merge claudedo/5af6ac9acb4447929e3e550c3c233a25 2026-07-29 09:13:06 +02:00
mika kuns be6ccb2c17 Merge claudedo/f6226a2ec97d41fe8906f1f86ed33e41 2026-07-29 09:12:51 +02:00
mika kuns 57d433276e Merge claudedo/c0630785eb8e47c8bf1da4108028a3c4 2026-07-29 09:12:36 +02:00
mika kuns 785ebe55e4 Merge claudedo/85e48360c061409a9ffd9c772450cf73 2026-07-29 09:12:24 +02:00
mika kuns e1807fd53b fix(review): propagate HubException from ApproveReviewAsync so blocked merges surface errors
TryInvokeAsync swallowed all exceptions including HubException, so a
blocked merge (uncommitted changes in target, mid-merge state, inactive
worktree) returned null silently — the task stayed WaitingForReview with
no feedback shown.  Switch to a direct _hub.InvokeAsync so both VM
catch blocks (TasksIsland ErrorReported, DetailsIsland ShowErrorAsync)
actually fire.

Add regression tests for both call sites verifying that a throwing
worker causes the error to be reported.
2026-07-29 09:08:19 +02:00
mika kuns 149e2adadb feat(ui): accent color presets in Settings → General
Adds Moss / Peat / Sea preset swatches to the General settings tab.
Selecting a preset mutates the live SolidColorBrush objects in the
Application resource dictionary so all StaticResource consumers update
instantly; the choice is persisted to ui.config.json and re-applied
at startup. Missing or unknown saved value falls back to Moss.
2026-07-29 09:08:13 +02:00
mika kuns d569313598 feat(mcp): allow Done via update_task_status with worktree guard
External tasks finished outside a ClaudeDo run had no way to close out
their tracking task; update_task_status now permits Done alongside
Idle/Queued/Cancelled, refusing it when the task has an active
worktree so review_task stays the only path that merges.
2026-07-29 08:58:34 +02:00
mika kuns 0a3c25840f feat(claude-do): Neu angelegter Task soll direkt ausgewählt werden (Detailpan
## Problem
Nach "Neuen Task hinzufügen" (Eingabefeld im Tasks-Header, `Ctrl+N`) wird der Task angelegt und in die
Liste eingefügt, aber **nicht ausgewählt**. Um Beschreibung/Config zu ergänzen, muss man ihn erst
manuell anklicken.

## Ist-Zustand
`src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs`, `AddAsync()` (ab Zeile ~424):
legt die `TaskEntity` an, speichert über `TaskRepository.AddA

ClaudeDo-Task: 5af6ac9acb4447929e3e550c3c233a25
2026-07-29 08:57:55 +02:00
mika kuns 7d6cb2bd3e feat(ui): add Let Claude handle it broom button to tasks header 2026-07-29 08:55:26 +02:00
mika kuns 7c8a9dd61b refactor(claude-do): Task-Kontextmenü: "Als manuell markieren" in das "Markieren
## Problem
Im Task-Kontextmenü gibt es bereits ein Untermenü "Markieren als …" (`tasks.ctxMarkAs`) mit
"Erledigt" und "Abgebrochen". Die beiden Manuell-Einträge hängen dagegen ganz unten flach im
Hauptmenü — inkonsistent, obwohl sie semantisch dasselbe "markieren als"-Verb sind.

## Ist-Zustand
`src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`:
- Zeile ~47-50: `<MenuItem Header="{loc:Tr tasks.ctxM

ClaudeDo-Task: c0630785eb8e47c8bf1da4108028a3c4
2026-07-29 08:53:05 +02:00
ClaudeDo CI 3fbbd7ab93 docs(changelog): update for v2.4.0 2026-07-27 13:07:19 +00:00
43 changed files with 963 additions and 111 deletions
+30
View File
@@ -1,5 +1,35 @@
# Changelog # Changelog
## v2.4.0 — 2026-07-27
### Features
- mark tasks and lists as manual (3a648b7)
- per-model effort and turn presets (fde9615)
- Interactive chip for tasks with an open ConPTY session (c93a20f)
- spinners for ConPTY session start and task refine (1466d0f)
- five-phase list-handler prompt with dedupe and enhance (e3bacc3)
- allow update_task_status to set Cancelled (cc823ec)
### Fixes
- bind search focus to Ctrl+K instead of OemQuestion (7f9f0ca)
- persist title edits from the detail pane (edd2774)
### Refactoring
- make the list-handler launch spec single-list and single-repo (4877802)
- scope "Let Claude handle it" to a single list (40eb979)
### Documentation
- record the manual-task, effort-preset and chip/spinner changes (24f999f)
- document the list-handler selection modal (62fc5aa)
- describe the list-scoped five-phase handler (023e136)
- implementation plan for the per-list task handler (ec10b06)
- per-list task handler with read/dedupe/enhance/run/merge (81cf941)
- update for v2.3.1 (538b4ed)
## v2.3.1 — 2026-07-24 ## v2.3.1 — 2026-07-24
### Fixes ### Fixes
+3
View File
@@ -3,6 +3,7 @@ using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using ClaudeDo.Ui;
using ClaudeDo.Ui.Services; using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels; using ClaudeDo.Ui.ViewModels;
using ClaudeDo.Ui.Views; using ClaudeDo.Ui.Views;
@@ -22,6 +23,8 @@ public partial class App : Application
public override void Initialize() public override void Initialize()
{ {
AvaloniaXamlLoader.Load(this); AvaloniaXamlLoader.Load(this);
if (_services?.GetService<AppSettings>() is { } settings)
AccentPresetService.Apply(AccentPresets.Find(settings.AccentPreset));
} }
public override void OnFrameworkInitializationCompleted() public override void OnFrameworkInitializationCompleted()
+10 -4
View File
@@ -31,7 +31,11 @@
"weekdayFriday": "Freitag", "weekdayFriday": "Freitag",
"weekdaySaturday": "Samstag", "weekdaySaturday": "Samstag",
"sessionSkills": "Session-Skills", "sessionSkills": "Session-Skills",
"sessionSkillsHint": "Gilt für jede Aufgabe. Kombiniert sich mit Listen- und Aufgaben-Auswahl." "sessionSkillsHint": "Gilt für jede Aufgabe. Kombiniert sich mit Listen- und Aufgaben-Auswahl.",
"accentPreset": "Akzentfarbe",
"accentPresetMoss": "Moos",
"accentPresetPeat": "Torf",
"accentPresetSea": "Meer"
}, },
"worktrees": { "worktrees": {
"strategy": "Strategie", "strategy": "Strategie",
@@ -117,6 +121,7 @@
}, },
"tasks": { "tasks": {
"showCompletedTip": "Abgeschlossene anzeigen", "showCompletedTip": "Abgeschlossene anzeigen",
"letClaudeTip": "Claude machen lassen",
"listSettingsTip": "Listeneinstellungen", "listSettingsTip": "Listeneinstellungen",
"addPlaceholder": "Aufgabe hinzufügen…", "addPlaceholder": "Aufgabe hinzufügen…",
"enterKey": "ENTER", "enterKey": "ENTER",
@@ -272,6 +277,7 @@
"settings": "Einstellungen", "settings": "Einstellungen",
"queue": "Warteschlange", "queue": "Warteschlange",
"blocked": "Blockiert", "blocked": "Blockiert",
"running": "Läuft",
"focusMode": "Fokus", "focusMode": "Fokus",
"overviewMode": "Übersicht", "overviewMode": "Übersicht",
"closeSession": "Sitzung schließen", "closeSession": "Sitzung schließen",
@@ -538,7 +544,7 @@
"taskRow": { "createdPrefix": "Erstellt {0}", "stepsText": "{0}/{1} Schritte" }, "taskRow": { "createdPrefix": "Erstellt {0}", "stepsText": "{0}/{1} Schritte" },
"tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "planningOpenFailed": "Planungssitzung konnte nicht geöffnet werden: {0}", "planningResumeFailed": "Planungssitzung konnte nicht fortgesetzt werden: {0}", "pickUpInTerminalFailed": "Im Terminal fortsetzen fehlgeschlagen: {0}", "approveFailed": "Genehmigen & Mergen fehlgeschlagen: {0}" }, "tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "planningOpenFailed": "Planungssitzung konnte nicht geöffnet werden: {0}", "planningResumeFailed": "Planungssitzung konnte nicht fortgesetzt werden: {0}", "pickUpInTerminalFailed": "Im Terminal fortsetzen fehlgeschlagen: {0}", "approveFailed": "Genehmigen & Mergen fehlgeschlagen: {0}" },
"diff": { "loadFailed": "Diff konnte nicht geladen werden: {0}", "noChanges": "Keine Änderungen anzuzeigen.", "unavailable": "Diff nicht mehr verfügbar — Commit-Bereich unvollständig." }, "diff": { "loadFailed": "Diff konnte nicht geladen werden: {0}", "noChanges": "Keine Änderungen anzuzeigen.", "unavailable": "Diff nicht mehr verfügbar — Commit-Bereich unvollständig." },
"planningDiff": { "hubError": "Kombinierte Vorschau konnte nicht erstellt werden (Hub-Fehler).", "conflict": "Kombinierte Vorschau nicht möglich: Teilaufgabe {0} steht im Konflikt mit einer früheren Teilaufgabe ({1} Dateien)." }, "planningDiff": { "hubError": "Kombinierte Vorschau konnte nicht erstellt werden (Hub-Fehler).", "conflict": "Kombinierte Vorschau nicht möglich: Teilaufgabe {0} steht im Konflikt mit einer früheren Teilaufgabe ({1} Dateien).", "buildFailed": "Kombinierte Vorschau konnte nicht erstellt werden: {0}" },
"merge": { "commitMessage": "Merge-Aufgabe: {0}", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "conflict": "Merge-Konflikt — Ziel-Branch wiederhergestellt. Manuell oder über Fortsetzen lösen, dann erneut versuchen.", "blocked": "Blockiert: {0}", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" }, "merge": { "commitMessage": "Merge-Aufgabe: {0}", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "conflict": "Merge-Konflikt — Ziel-Branch wiederhergestellt. Manuell oder über Fortsetzen lösen, dann erneut versuchen.", "blocked": "Blockiert: {0}", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" },
"conflictResolution": { "vsCodeError": "VS Code konnte nicht gestartet werden: {0}. Die Pfade sind oben aufgeführt — kopiere sie manuell.", "subtaskPrefix": "Konflikte in Teilaufgabe: {0}", "targetPrefix": "Zusammenführen in: {0}" }, "conflictResolution": { "vsCodeError": "VS Code konnte nicht gestartet werden: {0}. Die Pfade sind oben aufgeführt — kopiere sie manuell.", "subtaskPrefix": "Konflikte in Teilaufgabe: {0}", "targetPrefix": "Zusammenführen in: {0}" },
"settingsModal": { "workerOffline": "Worker offline — Einstellungen schreibgeschützt.", "saveFailed": "Speichern fehlgeschlagen: {0}" }, "settingsModal": { "workerOffline": "Worker offline — Einstellungen schreibgeschützt.", "saveFailed": "Speichern fehlgeschlagen: {0}" },
@@ -546,8 +552,8 @@
"weeklyReport": { "invalidRange": "Ungültiger Datumsbereich.", "generating": "Bericht wird erstellt…", "error": "Fehler: {0}" }, "weeklyReport": { "invalidRange": "Ungültiger Datumsbereich.", "generating": "Bericht wird erstellt…", "error": "Fehler: {0}" },
"filesTab": { "workerOffline": "Worker offline.", "noneBundled": "Keine Standard-Agenten mitgeliefert.", "allPresent": "Alle Standard-Agenten bereits vorhanden.", "restored": "{0} Standard-Agent(en) wiederhergestellt.", "restoreFailed": "Wiederherstellung fehlgeschlagen: {0}", "openFailed": "Öffnen fehlgeschlagen: {0}" }, "filesTab": { "workerOffline": "Worker offline.", "noneBundled": "Keine Standard-Agenten mitgeliefert.", "allPresent": "Alle Standard-Agenten bereits vorhanden.", "restored": "{0} Standard-Agent(en) wiederhergestellt.", "restoreFailed": "Wiederherstellung fehlgeschlagen: {0}", "openFailed": "Öffnen fehlgeschlagen: {0}" },
"sessionSkillsTab": { "installed": "Installiert: {0}", "installFailed": "Installation fehlgeschlagen: {0}", "updated": "Aktualisiert.", "updateFailed": "Aktualisierung fehlgeschlagen: {0}", "removed": "Entfernt.", "removeFailed": "Entfernen fehlgeschlagen: {0}" }, "sessionSkillsTab": { "installed": "Installiert: {0}", "installFailed": "Installation fehlgeschlagen: {0}", "updated": "Aktualisiert.", "updateFailed": "Aktualisierung fehlgeschlagen: {0}", "removed": "Entfernt.", "removeFailed": "Entfernen fehlgeschlagen: {0}" },
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "{0} Worktree(s) entfernt.", "blocked": "Zwangsentfernung nicht möglich: {0} Aufgabe(n) laufen noch. Brich sie zuerst ab.", "removedFrom": "{0} Worktree(s) von {1} Aufgabe(n) entfernt." }, "worktreesTab": { "workerOffline": "Worker offline.", "removed": "{0} Worktree(s) entfernt.", "blocked": "Zwangsentfernung nicht möglich: {0} Aufgabe(n) laufen noch. Brich sie zuerst ab.", "removedFrom": "{0} Worktree(s) von {1} Aufgabe(n) entfernt.", "cleanupFailed": "Aufräumen fehlgeschlagen: {0}", "resetFailed": "Zurücksetzen fehlgeschlagen: {0}" },
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "Liste", "cleanupFailed": "Aufräumen fehlgeschlagen.", "removed": "{0} Worktree(s) entfernt.", "discardFailed": "Worktree konnte nicht verworfen werden.", "keepFailed": "Worktree konnte nicht behalten werden.", "cannotForceRunning": "Eine laufende Aufgabe kann nicht zwangsweise entfernt werden.", "forceRemoveFailed": "Zwangsentfernung fehlgeschlagen.", "batchProgress": "Merge {0}/{1}…", "batchDone": "{0} gemergt, {1} zu lösen." }, "worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "Liste", "cleanupFailed": "Aufräumen fehlgeschlagen.", "cleanupFailedDetailed": "Aufräumen fehlgeschlagen: {0}", "removed": "{0} Worktree(s) entfernt.", "discardFailed": "Worktree konnte nicht verworfen werden.", "keepFailed": "Worktree konnte nicht behalten werden.", "cannotForceRunning": "Eine laufende Aufgabe kann nicht zwangsweise entfernt werden.", "forceRemoveFailed": "Zwangsentfernung fehlgeschlagen.", "forceRemoveFailedDetailed": "Zwangsentfernung fehlgeschlagen: {0}", "batchProgress": "Merge {0}/{1}…", "batchDone": "{0} gemergt, {1} zu lösen." },
"listSettings": { "untitled": "Unbenannt" }, "listSettings": { "untitled": "Unbenannt" },
"lists": { "localSuffix": "{0} / lokal", "smartMyDay": "Mein Tag", "smartImportant": "Wichtig", "smartPlanned": "Geplant", "virtualQueue": "Warteschlange", "virtualRunning": "Läuft", "virtualReview": "Prüfung", "newList": "Neue Liste" } "lists": { "localSuffix": "{0} / lokal", "smartMyDay": "Mein Tag", "smartImportant": "Wichtig", "smartPlanned": "Geplant", "virtualQueue": "Warteschlange", "virtualRunning": "Läuft", "virtualReview": "Prüfung", "newList": "Neue Liste" }
} }
+10 -4
View File
@@ -31,7 +31,11 @@
"weekdayFriday": "Friday", "weekdayFriday": "Friday",
"weekdaySaturday": "Saturday", "weekdaySaturday": "Saturday",
"sessionSkills": "Session skills", "sessionSkills": "Session skills",
"sessionSkillsHint": "Applied to every task. Combines with list- and task-level selections." "sessionSkillsHint": "Applied to every task. Combines with list- and task-level selections.",
"accentPreset": "Accent color",
"accentPresetMoss": "Moss",
"accentPresetPeat": "Peat",
"accentPresetSea": "Sea"
}, },
"worktrees": { "worktrees": {
"strategy": "Strategy", "strategy": "Strategy",
@@ -117,6 +121,7 @@
}, },
"tasks": { "tasks": {
"showCompletedTip": "Show completed", "showCompletedTip": "Show completed",
"letClaudeTip": "Let Claude handle it",
"listSettingsTip": "List settings", "listSettingsTip": "List settings",
"addPlaceholder": "Add a task…", "addPlaceholder": "Add a task…",
"enterKey": "ENTER", "enterKey": "ENTER",
@@ -272,6 +277,7 @@
"settings": "Settings", "settings": "Settings",
"queue": "Queue", "queue": "Queue",
"blocked": "Blocked", "blocked": "Blocked",
"running": "Running",
"focusMode": "Focus", "focusMode": "Focus",
"overviewMode": "Overview", "overviewMode": "Overview",
"closeSession": "Close session", "closeSession": "Close session",
@@ -538,7 +544,7 @@
"taskRow": { "createdPrefix": "Created {0}", "stepsText": "{0}/{1} steps" }, "taskRow": { "createdPrefix": "Created {0}", "stepsText": "{0}/{1} steps" },
"tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "planningOpenFailed": "Couldn't open planning session: {0}", "planningResumeFailed": "Couldn't resume planning session: {0}", "pickUpInTerminalFailed": "Pick up in terminal failed: {0}", "approveFailed": "Approve & merge failed: {0}" }, "tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "planningOpenFailed": "Couldn't open planning session: {0}", "planningResumeFailed": "Couldn't resume planning session: {0}", "pickUpInTerminalFailed": "Pick up in terminal failed: {0}", "approveFailed": "Approve & merge failed: {0}" },
"diff": { "loadFailed": "Failed to load diff: {0}", "noChanges": "No changes to show.", "unavailable": "Diff no longer available — commit range incomplete." }, "diff": { "loadFailed": "Failed to load diff: {0}", "noChanges": "No changes to show.", "unavailable": "Diff no longer available — commit range incomplete." },
"planningDiff": { "hubError": "Could not build combined preview (hub error).", "conflict": "Cannot build combined preview: subtask {0} conflicts with an earlier subtask ({1} files)." }, "planningDiff": { "hubError": "Could not build combined preview (hub error).", "conflict": "Cannot build combined preview: subtask {0} conflicts with an earlier subtask ({1} files).", "buildFailed": "Could not build combined preview: {0}" },
"merge": { "commitMessage": "Merge task: {0}", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "conflict": "Merge conflict — target branch restored. Resolve manually or via Continue, then retry.", "blocked": "Blocked: {0}", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" }, "merge": { "commitMessage": "Merge task: {0}", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "conflict": "Merge conflict — target branch restored. Resolve manually or via Continue, then retry.", "blocked": "Blocked: {0}", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" },
"conflictResolution": { "vsCodeError": "Could not launch VS Code: {0}. Paths are listed above — copy them manually.", "subtaskPrefix": "Conflicts in subtask: {0}", "targetPrefix": "Merging into: {0}" }, "conflictResolution": { "vsCodeError": "Could not launch VS Code: {0}. Paths are listed above — copy them manually.", "subtaskPrefix": "Conflicts in subtask: {0}", "targetPrefix": "Merging into: {0}" },
"settingsModal": { "workerOffline": "Worker offline — settings read-only.", "saveFailed": "Save failed: {0}" }, "settingsModal": { "workerOffline": "Worker offline — settings read-only.", "saveFailed": "Save failed: {0}" },
@@ -546,8 +552,8 @@
"weeklyReport": { "invalidRange": "Invalid date range.", "generating": "Generating report…", "error": "Error: {0}" }, "weeklyReport": { "invalidRange": "Invalid date range.", "generating": "Generating report…", "error": "Error: {0}" },
"filesTab": { "workerOffline": "Worker offline.", "noneBundled": "No default agents bundled.", "allPresent": "All default agents already present.", "restored": "Restored {0} default agent(s).", "restoreFailed": "Restore failed: {0}", "openFailed": "Open failed: {0}" }, "filesTab": { "workerOffline": "Worker offline.", "noneBundled": "No default agents bundled.", "allPresent": "All default agents already present.", "restored": "Restored {0} default agent(s).", "restoreFailed": "Restore failed: {0}", "openFailed": "Open failed: {0}" },
"sessionSkillsTab": { "installed": "Installed: {0}", "installFailed": "Install failed: {0}", "updated": "Updated.", "updateFailed": "Update failed: {0}", "removed": "Removed.", "removeFailed": "Remove failed: {0}" }, "sessionSkillsTab": { "installed": "Installed: {0}", "installFailed": "Install failed: {0}", "updated": "Updated.", "updateFailed": "Update failed: {0}", "removed": "Removed.", "removeFailed": "Remove failed: {0}" },
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "Removed {0} worktree(s).", "blocked": "Cannot force-remove: {0} task(s) still running. Cancel them first.", "removedFrom": "Removed {0} worktree(s) from {1} task(s)." }, "worktreesTab": { "workerOffline": "Worker offline.", "removed": "Removed {0} worktree(s).", "blocked": "Cannot force-remove: {0} task(s) still running. Cancel them first.", "removedFrom": "Removed {0} worktree(s) from {1} task(s).", "cleanupFailed": "Cleanup failed: {0}", "resetFailed": "Reset failed: {0}" },
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "list", "cleanupFailed": "Cleanup failed.", "removed": "Removed {0} worktree(s).", "discardFailed": "Failed to discard worktree.", "keepFailed": "Failed to keep worktree.", "cannotForceRunning": "Cannot force-remove a running task.", "forceRemoveFailed": "Force remove failed.", "batchProgress": "Merging {0}/{1}…", "batchDone": "Merged {0}, {1} need resolution." }, "worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "list", "cleanupFailed": "Cleanup failed.", "cleanupFailedDetailed": "Cleanup failed: {0}", "removed": "Removed {0} worktree(s).", "discardFailed": "Failed to discard worktree.", "keepFailed": "Failed to keep worktree.", "cannotForceRunning": "Cannot force-remove a running task.", "forceRemoveFailed": "Force remove failed.", "forceRemoveFailedDetailed": "Force remove failed: {0}", "batchProgress": "Merging {0}/{1}…", "batchDone": "Merged {0}, {1} need resolution." },
"listSettings": { "untitled": "Untitled" }, "listSettings": { "untitled": "Untitled" },
"lists": { "localSuffix": "{0} / local", "smartMyDay": "My Day", "smartImportant": "Important", "smartPlanned": "Planned", "virtualQueue": "Queue", "virtualRunning": "Running", "virtualReview": "Review", "newList": "New list" } "lists": { "localSuffix": "{0} / local", "smartMyDay": "My Day", "smartImportant": "Important", "smartPlanned": "Planned", "virtualQueue": "Queue", "virtualRunning": "Running", "virtualReview": "Review", "newList": "New list" }
} }
+43
View File
@@ -0,0 +1,43 @@
using Avalonia.Media;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.ViewModels;
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClaudeDo.Ui;
public sealed record AccentPreset(string Name, string Accent, string Dim, string Soft, string Glow);
public static class AccentPresets
{
// Hue 88 — moss/sage green (original)
public static readonly AccentPreset Moss = new("moss", "#FF7C9166", "#FF64785A", "#FF3E4B39", "#387C9166");
// Hue ~40 — warm earthy brown/terra
public static readonly AccentPreset Peat = new("peat", "#FF9A7B5C", "#FF7F6449", "#FF4D3C2C", "#389A7B5C");
// Hue ~180 — cool teal/sea-green
public static readonly AccentPreset Sea = new("sea", "#FF5B8F8C", "#FF4A7573", "#FF263D3C", "#385B8F8C");
public static readonly IReadOnlyList<AccentPreset> All = [Moss, Peat, Sea];
public static AccentPreset Default => Moss;
public static AccentPreset Find(string? name) =>
All.FirstOrDefault(p => p.Name == name) ?? Default;
}
public sealed partial class AccentPresetSwatchViewModel : ViewModelBase
{
public AccentPreset Preset { get; }
[ObservableProperty] private bool _isSelected;
public Color DisplayColor { get; }
public string DisplayName =>
Loc.T($"settings.general.accentPreset{char.ToUpperInvariant(Preset.Name[0])}{Preset.Name.Substring(1)}");
public AccentPresetSwatchViewModel(AccentPreset preset, bool selected)
{
Preset = preset;
_isSelected = selected;
DisplayColor = Color.Parse(preset.Accent);
}
}
+1
View File
@@ -8,6 +8,7 @@ public sealed class AppSettings
public string DbPath { get; set; } = "~/.todo-app/todo.db"; public string DbPath { get; set; } = "~/.todo-app/todo.db";
public string SignalRUrl { get; set; } = "http://127.0.0.1:47821/hub"; public string SignalRUrl { get; set; } = "http://127.0.0.1:47821/hub";
public string Language { get; set; } = ""; public string Language { get; set; } = "";
public string AccentPreset { get; set; } = "";
private static readonly string ConfigPath = Paths.Expand("~/.todo-app/ui.config.json"); private static readonly string ConfigPath = Paths.Expand("~/.todo-app/ui.config.json");
@@ -0,0 +1,24 @@
using Avalonia;
using Avalonia.Media;
namespace ClaudeDo.Ui.Services;
public static class AccentPresetService
{
public static void Apply(AccentPreset preset)
{
if (Application.Current is not { } app) return;
SetBrushColor(app, "AccentBrush", preset.Accent);
SetBrushColor(app, "AccentDimBrush", preset.Dim);
SetBrushColor(app, "AccentSoftBrush", preset.Soft);
SetBrushColor(app, "AccentGlowBrush", preset.Glow);
SetBrushColor(app, "MossBrush", preset.Accent);
}
private static void SetBrushColor(Application app, string key, string hex)
{
if (app.TryGetResource(key, null, out var value) && value is SolidColorBrush brush)
brush.Color = Color.Parse(hex);
}
}
+16 -16
View File
@@ -329,8 +329,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
await _hub.InvokeAsync("RefreshAgents"); await _hub.InvokeAsync("RefreshAgents");
} }
public Task<SeedResultDto?> RestoreDefaultAgentsAsync() public async Task<SeedResultDto?> RestoreDefaultAgentsAsync()
=> TryInvokeAsync<SeedResultDto>("RestoreDefaultAgents"); => await _hub.InvokeAsync<SeedResultDto>("RestoreDefaultAgents");
private async Task SeedActiveTasksAsync() private async Task SeedActiveTasksAsync()
{ {
@@ -372,8 +372,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task<List<PrimeScheduleDto>> GetPrimeSchedulesAsync() public async Task<List<PrimeScheduleDto>> GetPrimeSchedulesAsync()
=> await TryInvokeAsync<List<PrimeScheduleDto>>("ListPrimeSchedules") ?? new List<PrimeScheduleDto>(); => await TryInvokeAsync<List<PrimeScheduleDto>>("ListPrimeSchedules") ?? new List<PrimeScheduleDto>();
public Task<PrimeScheduleDto?> UpsertPrimeScheduleAsync(PrimeScheduleDto dto) public async Task<PrimeScheduleDto?> UpsertPrimeScheduleAsync(PrimeScheduleDto dto)
=> TryInvokeAsync<PrimeScheduleDto>("UpsertPrimeSchedule", dto); => await _hub.InvokeAsync<PrimeScheduleDto>("UpsertPrimeSchedule", dto);
public async Task DeletePrimeScheduleAsync(Guid id) public async Task DeletePrimeScheduleAsync(Guid id)
{ {
@@ -400,8 +400,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task<List<DailyNoteDto>> GetDailyNotesAsync(DateOnly day) public async Task<List<DailyNoteDto>> GetDailyNotesAsync(DateOnly day)
=> await TryInvokeAsync<List<DailyNoteDto>>("GetDailyNotes", IsoDay(day)) ?? new List<DailyNoteDto>(); => await TryInvokeAsync<List<DailyNoteDto>>("GetDailyNotes", IsoDay(day)) ?? new List<DailyNoteDto>();
public Task<DailyNoteDto?> AddDailyNoteAsync(DateOnly day, string text) public async Task<DailyNoteDto?> AddDailyNoteAsync(DateOnly day, string text)
=> TryInvokeAsync<DailyNoteDto>("AddDailyNote", IsoDay(day), text); => await _hub.InvokeAsync<DailyNoteDto>("AddDailyNote", IsoDay(day), text);
public async Task UpdateDailyNoteAsync(string id, string text) public async Task UpdateDailyNoteAsync(string id, string text)
=> await _hub.InvokeAsync("UpdateDailyNote", id, text); => await _hub.InvokeAsync("UpdateDailyNote", id, text);
@@ -450,10 +450,10 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
await _hub.InvokeAsync("SetTaskStatus", taskId, status.ToString()); await _hub.InvokeAsync("SetTaskStatus", taskId, status.ToString());
} }
public Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) public async Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch)
{ {
LastApproveTarget = targetBranch; LastApproveTarget = targetBranch;
return TryInvokeAsync<MergeResultDto>("ApproveReview", taskId, targetBranch); return await _hub.InvokeAsync<MergeResultDto>("ApproveReview", taskId, targetBranch);
} }
public Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch) public Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch)
@@ -474,11 +474,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
await _hub.InvokeAsync("CancelReview", taskId); await _hub.InvokeAsync("CancelReview", taskId);
} }
public Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync(string? listId = null) public async Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync(string? listId = null)
=> TryInvokeAsync<WorktreeCleanupDto>("CleanupFinishedWorktrees", listId); => await _hub.InvokeAsync<WorktreeCleanupDto>("CleanupFinishedWorktrees", listId);
public Task<WorktreeResetDto?> ResetAllWorktreesAsync() public async Task<WorktreeResetDto?> ResetAllWorktreesAsync()
=> TryInvokeAsync<WorktreeResetDto>("ResetAllWorktrees"); => await _hub.InvokeAsync<WorktreeResetDto>("ResetAllWorktrees");
public async Task<List<WorktreeOverviewDto>> GetWorktreesOverviewAsync(string? listId) public async Task<List<WorktreeOverviewDto>> GetWorktreesOverviewAsync(string? listId)
=> await TryInvokeAsync<List<WorktreeOverviewDto>>("GetWorktreesOverview", listId) => await TryInvokeAsync<List<WorktreeOverviewDto>>("GetWorktreesOverview", listId)
@@ -501,8 +501,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
} }
} }
public Task<ForceRemoveResultDto?> ForceRemoveWorktreeAsync(string taskId) public async Task<ForceRemoveResultDto?> ForceRemoveWorktreeAsync(string taskId)
=> TryInvokeAsync<ForceRemoveResultDto>("ForceRemoveWorktree", taskId); => await _hub.InvokeAsync<ForceRemoveResultDto>("ForceRemoveWorktree", taskId);
public async Task<PlanningSessionStartInfo> StartPlanningSessionAsync(string taskId, CancellationToken ct = default) public async Task<PlanningSessionStartInfo> StartPlanningSessionAsync(string taskId, CancellationToken ct = default)
=> await _hub.InvokeAsync<PlanningSessionStartInfo>("StartPlanningSessionAsync", taskId, ct); => await _hub.InvokeAsync<PlanningSessionStartInfo>("StartPlanningSessionAsync", taskId, ct);
@@ -543,8 +543,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregateAsync(string planningTaskId) public async Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregateAsync(string planningTaskId)
=> await TryInvokeAsync<List<SubtaskDiffDto>>("GetPlanningAggregate", planningTaskId) ?? []; => await TryInvokeAsync<List<SubtaskDiffDto>>("GetPlanningAggregate", planningTaskId) ?? [];
public Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch) public async Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch)
=> TryInvokeAsync<CombinedDiffResultDto>("BuildPlanningIntegrationBranch", planningTaskId, targetBranch); => await _hub.InvokeAsync<CombinedDiffResultDto>("BuildPlanningIntegrationBranch", planningTaskId, targetBranch);
public async Task ContinuePlanningMergeAsync(string planningTaskId) public async Task ContinuePlanningMergeAsync(string planningTaskId)
{ {
@@ -311,6 +311,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
Prep = new PrepPanelViewModel(worker); Prep = new PrepPanelViewModel(worker);
Notes = new NotesEditorViewModel(_notesApi); Notes = new NotesEditorViewModel(_notesApi);
Notes.ErrorReported += msg => { if (ShowErrorAsync is not null) _ = ShowErrorAsync(msg); };
Subtasks.CollectionChanged += (_, _) => NotifyStepsChanged(); Subtasks.CollectionChanged += (_, _) => NotifyStepsChanged();
Subtasks.CollectionChanged += (_, _) => Merge.SyncChildOutcomes(HasChildOutcomes, Subtasks.Count); Subtasks.CollectionChanged += (_, _) => Merge.SyncChildOutcomes(HasChildOutcomes, Subtasks.Count);
Attachments.CollectionChanged += (_, _) => OnPropertyChanged(nameof(FilesBadge)); Attachments.CollectionChanged += (_, _) => OnPropertyChanged(nameof(FilesBadge));
@@ -24,6 +24,9 @@ public sealed partial class NotesEditorViewModel : ViewModelBase
public NotesEditorViewModel(INotesApi api) => _api = api; public NotesEditorViewModel(INotesApi api) => _api = api;
// Raised when a worker call fails so the host VM can surface it (e.g. via ShowErrorAsync).
public event Action<string>? ErrorReported;
public ObservableCollection<NoteBulletViewModel> Bullets { get; } = new(); public ObservableCollection<NoteBulletViewModel> Bullets { get; } = new();
[ObservableProperty] private DateOnly _currentDay = DateOnly.FromDateTime(DateTime.Today); [ObservableProperty] private DateOnly _currentDay = DateOnly.FromDateTime(DateTime.Today);
@@ -55,9 +58,16 @@ public sealed partial class NotesEditorViewModel : ViewModelBase
{ {
var text = NewBulletText.Trim(); var text = NewBulletText.Trim();
if (text.Length == 0) return; if (text.Length == 0) return;
var dto = await _api.AddAsync(CurrentDay, text); try
if (dto is not null) Bullets.Add(MakeBullet(dto.Id, dto.Text)); {
NewBulletText = ""; var dto = await _api.AddAsync(CurrentDay, text);
if (dto is not null) Bullets.Add(MakeBullet(dto.Id, dto.Text));
NewBulletText = "";
}
catch (Exception ex)
{
ErrorReported?.Invoke(ex.Message);
}
} }
[RelayCommand] private Task PrevDay() => LoadDayAsync(CurrentDay.AddDays(-1)); [RelayCommand] private Task PrevDay() => LoadDayAsync(CurrentDay.AddDays(-1));
@@ -72,6 +72,12 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
[ObservableProperty] private string _completedHeader = ""; [ObservableProperty] private string _completedHeader = "";
[ObservableProperty] private bool _showNotesRow; [ObservableProperty] private bool _showNotesRow;
[ObservableProperty] private bool _isMyDayList; [ObservableProperty] private bool _isMyDayList;
[ObservableProperty] private bool _isLetClaudeVisible;
public event EventHandler? LetClaudeHandleRequested;
[RelayCommand]
private void LetClaudeHandle() => LetClaudeHandleRequested?.Invoke(this, EventArgs.Empty);
internal Task? LoadTask { get; private set; } internal Task? LoadTask { get; private set; }
@@ -211,8 +217,11 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
private void OnCurrentListPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) private void OnCurrentListPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{ {
if (e.PropertyName == nameof(ListNavItemViewModel.Name) && sender is ListNavItemViewModel vm) if (sender is not ListNavItemViewModel vm) return;
if (e.PropertyName == nameof(ListNavItemViewModel.Name))
HeaderTitle = vm.Name; HeaderTitle = vm.Name;
else if (e.PropertyName == nameof(ListNavItemViewModel.WorkingDir))
IsLetClaudeVisible = vm.Kind == ListKind.User && !string.IsNullOrWhiteSpace(vm.WorkingDir);
} }
public void LoadForList(ListNavItemViewModel? list) public void LoadForList(ListNavItemViewModel? list)
@@ -237,12 +246,13 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
HasCompleted = false; HasCompleted = false;
ShowOpenLabel = false; ShowOpenLabel = false;
ShowNotesRow = false; ShowNotesRow = false;
if (list is null) { LoadTask = Task.CompletedTask; return; } if (list is null) { IsLetClaudeVisible = false; LoadTask = Task.CompletedTask; return; }
HeaderTitle = list.Name; HeaderTitle = list.Name;
HeaderEyebrow = DateTime.Now.ToString("dddd · MMM dd", CultureInfo.InvariantCulture).ToUpperInvariant(); HeaderEyebrow = DateTime.Now.ToString("dddd · MMM dd", CultureInfo.InvariantCulture).ToUpperInvariant();
ShowNotesRow = list.Id == "smart:my-day"; ShowNotesRow = list.Id == "smart:my-day";
IsMyDayList = list.Id == "smart:my-day"; IsMyDayList = list.Id == "smart:my-day";
IsLetClaudeVisible = list.Kind == ListKind.User && !string.IsNullOrWhiteSpace(list.WorkingDir);
LoadTask = LoadForListAsync(list, ct); LoadTask = LoadForListAsync(list, ct);
} }
@@ -443,6 +453,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
row.ShowListChip = _currentList?.Kind == ListKind.Virtual; row.ShowListChip = _currentList?.Kind == ListKind.Virtual;
Items.Add(row); Items.Add(row);
Regroup(); Regroup();
SelectedTask = row;
NewTaskTitle = ""; NewTaskTitle = "";
UpdateSubtitle(); UpdateSubtitle();
TasksChanged?.Invoke(this, EventArgs.Empty); TasksChanged?.Invoke(this, EventArgs.Empty);
@@ -251,6 +251,11 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
if (Lists.SelectedList is { } row) if (Lists.SelectedList is { } row)
Lists.OpenListSettingsCommand.Execute(row); Lists.OpenListSettingsCommand.Execute(row);
}; };
Tasks.LetClaudeHandleRequested += (_, _) =>
{
if (Lists.SelectedList is { } row)
Lists.LetClaudeHandleListCommand.Execute(row);
};
Details.CloseDetail = () => Tasks.SelectedTask = null; Details.CloseDetail = () => Tasks.SelectedTask = null;
Details.DeleteFromList = row => Details.DeleteFromList = row =>
{ {
@@ -81,7 +81,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
ConPtySessions.CollectionChanged += OnConPtySessionsChanged; ConPtySessions.CollectionChanged += OnConPtySessionsChanged;
Panes.CollectionChanged += OnPanesChanged; Panes.CollectionChanged += OnPanesChanged;
_onTaskStarted = (slot, taskId, startedAt) => { EnsureMonitor(taskId); _ = RefreshQueueAsync(); }; _onTaskStarted = (slot, taskId, startedAt) => { _ = RefreshQueueAsync(); };
_worker.TaskStartedEvent += _onTaskStarted; _worker.TaskStartedEvent += _onTaskStarted;
_onTaskFinished = (slot, taskId, status, finishedAt) => _ = RefreshQueueAsync(); _onTaskFinished = (slot, taskId, status, finishedAt) => _ = RefreshQueueAsync();
@@ -90,10 +90,9 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
_onTaskUpdated = taskId => _ = RefreshQueueAsync(); _onTaskUpdated = taskId => _ = RefreshQueueAsync();
_worker.TaskUpdatedEvent += _onTaskUpdated; _worker.TaskUpdatedEvent += _onTaskUpdated;
_onConnectionRestored = () => { SeedActive(); _ = RefreshQueueAsync(); }; _onConnectionRestored = () => { _ = RefreshQueueAsync(); };
_worker.ConnectionRestoredEvent += _onConnectionRestored; _worker.ConnectionRestoredEvent += _onConnectionRestored;
SeedActive();
_ = RefreshQueueAsync(); _ = RefreshQueueAsync();
} }
@@ -103,19 +102,26 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
{ {
await using var ctx = await _dbFactory.CreateDbContextAsync(); await using var ctx = await _dbFactory.CreateDbContextAsync();
var rows = await ctx.Tasks.AsNoTracking() var rows = await ctx.Tasks.AsNoTracking()
.Where(t => t.Status == ClaudeDo.Data.Models.TaskStatus.Queued) .Where(t => t.Status == ClaudeDo.Data.Models.TaskStatus.Queued
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt) || t.Status == ClaudeDo.Data.Models.TaskStatus.Running)
.Select(t => new { t.Id, t.Title, t.BlockedByTaskId }) .OrderBy(t => t.Status == ClaudeDo.Data.Models.TaskStatus.Running ? 0 : 1)
.ThenBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
.Select(t => new { t.Id, t.Title, t.BlockedByTaskId, t.Status })
.ToListAsync(); .ToListAsync();
Queued.Clear(); Queued.Clear();
foreach (var r in rows) foreach (var r in rows)
{
var id = r.Id;
Queued.Add(new QueuedTaskViewModel Queued.Add(new QueuedTaskViewModel
{ {
Id = r.Id, Id = id,
Title = r.Title ?? string.Empty, Title = r.Title ?? string.Empty,
IsBlocked = r.BlockedByTaskId != null, IsBlocked = r.BlockedByTaskId != null,
IsRunning = r.Status == ClaudeDo.Data.Models.TaskStatus.Running,
OpenInAppCommand = new RelayCommand(() => _openInApp?.Invoke(id)),
}); });
}
OnPropertyChanged(nameof(HasQueued)); OnPropertyChanged(nameof(HasQueued));
} }
catch { /* best-effort queue refresh */ } catch { /* best-effort queue refresh */ }
@@ -141,13 +147,15 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
await RefreshQueueAsync(); await RefreshQueueAsync();
} }
private void SeedActive() // Kept intentionally — unused since auto-seeding was disabled (2026-07-29).
internal void SeedActive()
{ {
foreach (var a in _worker.GetActiveTasks()) foreach (var a in _worker.GetActiveTasks())
EnsureMonitor(a.TaskId); EnsureMonitor(a.TaskId);
} }
private void EnsureMonitor(string taskId) // Kept intentionally — unused since auto-seeding was disabled (2026-07-29).
internal void EnsureMonitor(string taskId)
{ {
if (string.IsNullOrEmpty(taskId)) return; if (string.IsNullOrEmpty(taskId)) return;
if (Monitors.Any(m => m.SubscribedTaskId == taskId)) return; if (Monitors.Any(m => m.SubscribedTaskId == taskId)) return;
@@ -428,10 +436,12 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
} }
} }
/// <summary>Read-only display row for a queued task in the Mission Control side strip.</summary> /// <summary>Read-only display row for a queued or running task in the Mission Control side strip.</summary>
public sealed class QueuedTaskViewModel public sealed class QueuedTaskViewModel
{ {
public required string Id { get; init; } public required string Id { get; init; }
public required string Title { get; init; } public required string Title { get; init; }
public bool IsBlocked { get; init; } public bool IsBlocked { get; init; }
public bool IsRunning { get; init; }
public IRelayCommand? OpenInAppCommand { get; init; }
} }
@@ -197,6 +197,11 @@ public sealed partial class DiffViewerViewModel : ViewModelBase
DisplayedDiff = ""; DisplayedDiff = "";
} }
} }
catch (Exception ex)
{
DisplayedDiff = "";
CombinedWarning = Loc.T("vm.planningDiff.buildFailed", ex.Message);
}
finally finally
{ {
IsLoadingCombined = false; IsLoadingCombined = false;
@@ -4,6 +4,7 @@ using ClaudeDo.Localization;
using ClaudeDo.Ui.Services; using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Agent; using ClaudeDo.Ui.ViewModels.Agent;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClaudeDo.Ui.ViewModels.Modals.Settings; namespace ClaudeDo.Ui.ViewModels.Modals.Settings;
@@ -27,6 +28,27 @@ public sealed partial class GeneralSettingsTabViewModel : ViewModelBase
public ObservableCollection<SelectableSkillViewModel> SessionSkills { get; } = new(); public ObservableCollection<SelectableSkillViewModel> SessionSkills { get; } = new();
public ObservableCollection<AccentPresetSwatchViewModel> AccentPresetSwatches { get; } = new();
private Action<string>? _persistAccent;
public void InitAccentPresets(string saved, Action<string> persist)
{
_persistAccent = persist;
var current = AccentPresets.Find(saved);
AccentPresetSwatches.Clear();
foreach (var p in AccentPresets.All)
AccentPresetSwatches.Add(new AccentPresetSwatchViewModel(p, p.Name == current.Name));
}
[RelayCommand]
private void SelectAccentPreset(AccentPreset preset)
{
foreach (var s in AccentPresetSwatches)
s.IsSelected = s.Preset.Name == preset.Name;
AccentPresetService.Apply(preset);
_persistAccent?.Invoke(preset.Name);
}
/// <summary>One editable row per model alias: the effort and turn budget a run gets under that /// <summary>One editable row per model alias: the effort and turn budget a run gets under that
/// model. Supplies the global defaults; list- and task-level max-turns overrides still win.</summary> /// model. Supplies the global defaults; list- and task-level max-turns overrides still win.</summary>
public ObservableCollection<ModelPresetRowViewModel> ModelPresets { get; } = new(); public ObservableCollection<ModelPresetRowViewModel> ModelPresets { get; } = new();
@@ -46,6 +46,7 @@ public sealed partial class WorktreesSettingsTabViewModel : ViewModelBase
var r = await _worker.CleanupFinishedWorktreesAsync(); var r = await _worker.CleanupFinishedWorktreesAsync();
StatusMessage = r is null ? Loc.T("vm.worktreesTab.workerOffline") : Loc.T("vm.worktreesTab.removed", r.Removed); StatusMessage = r is null ? Loc.T("vm.worktreesTab.workerOffline") : Loc.T("vm.worktreesTab.removed", r.Removed);
} }
catch (Exception ex) { StatusMessage = Loc.T("vm.worktreesTab.cleanupFailed", ex.Message); }
finally { IsBusy = false; } finally { IsBusy = false; }
} }
@@ -63,6 +64,7 @@ public sealed partial class WorktreesSettingsTabViewModel : ViewModelBase
else if (r.Blocked) StatusMessage = Loc.T("vm.worktreesTab.blocked", r.RunningTasks); else if (r.Blocked) StatusMessage = Loc.T("vm.worktreesTab.blocked", r.RunningTasks);
else StatusMessage = Loc.T("vm.worktreesTab.removedFrom", r.Removed, r.TasksAffected); else StatusMessage = Loc.T("vm.worktreesTab.removedFrom", r.Removed, r.TasksAffected);
} }
catch (Exception ex) { StatusMessage = Loc.T("vm.worktreesTab.resetFailed", ex.Message); }
finally { IsBusy = false; } finally { IsBusy = false; }
} }
} }
@@ -36,6 +36,11 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
appSettings.Language = code; appSettings.Language = code;
appSettings.Save(); appSettings.Save();
}); });
General.InitAccentPresets(appSettings.AccentPreset, preset =>
{
appSettings.AccentPreset = preset;
appSettings.Save();
});
Worktrees = new WorktreesSettingsTabViewModel(worker); Worktrees = new WorktreesSettingsTabViewModel(worker);
Files = new FilesSettingsTabViewModel(worker); Files = new FilesSettingsTabViewModel(worker);
Prime = prime; Prime = prime;
@@ -174,6 +174,7 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
StatusMessage = result is null ? Loc.T("vm.worktreesOverview.cleanupFailed") : Loc.T("vm.worktreesOverview.removed", result.Removed); StatusMessage = result is null ? Loc.T("vm.worktreesOverview.cleanupFailed") : Loc.T("vm.worktreesOverview.removed", result.Removed);
await LoadAsync(); await LoadAsync();
} }
catch (Exception ex) { StatusMessage = Loc.T("vm.worktreesOverview.cleanupFailedDetailed", ex.Message); }
finally { IsBusy = false; } finally { IsBusy = false; }
} }
@@ -241,7 +242,16 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
if (row.IsRunning) { StatusMessage = Loc.T("vm.worktreesOverview.cannotForceRunning"); return; } if (row.IsRunning) { StatusMessage = Loc.T("vm.worktreesOverview.cannotForceRunning"); return; }
if (ConfirmAction is not null && !await ConfirmAction($"Force remove worktree for '{row.TaskTitle}'? This deletes the directory and branch.")) return; if (ConfirmAction is not null && !await ConfirmAction($"Force remove worktree for '{row.TaskTitle}'? This deletes the directory and branch.")) return;
var result = await _worker.ForceRemoveWorktreeAsync(row.TaskId); ForceRemoveResultDto? result;
try
{
result = await _worker.ForceRemoveWorktreeAsync(row.TaskId);
}
catch (Exception ex)
{
StatusMessage = Loc.T("vm.worktreesOverview.forceRemoveFailedDetailed", ex.Message);
return;
}
if (result is null || !result.Removed) if (result is null || !result.Removed)
{ {
StatusMessage = result?.Reason ?? Loc.T("vm.worktreesOverview.forceRemoveFailed"); StatusMessage = result?.Reason ?? Loc.T("vm.worktreesOverview.forceRemoveFailed");
@@ -47,6 +47,13 @@
<MenuItem Header="{loc:Tr tasks.ctxMarkAs}"> <MenuItem Header="{loc:Tr tasks.ctxMarkAs}">
<MenuItem Header="{loc:Tr tasks.ctxMarkDone}" Tag="Done" Click="OnSetStatusClick"/> <MenuItem Header="{loc:Tr tasks.ctxMarkDone}" Tag="Done" Click="OnSetStatusClick"/>
<MenuItem Header="{loc:Tr tasks.ctxMarkCancelled}" Tag="Cancelled" Click="OnSetStatusClick"/> <MenuItem Header="{loc:Tr tasks.ctxMarkCancelled}" Tag="Cancelled" Click="OnSetStatusClick"/>
<Separator/>
<MenuItem Header="{loc:Tr tasks.ctxMarkManual}"
IsVisible="{Binding !IsManual}"
Click="OnToggleManualClick"/>
<MenuItem Header="{loc:Tr tasks.ctxMarkClaudeTask}"
IsVisible="{Binding IsManual}"
Click="OnToggleManualClick"/>
</MenuItem> </MenuItem>
<Separator/> <Separator/>
<MenuItem Header="{loc:Tr tasks.ctxOpenConPtySession}" <MenuItem Header="{loc:Tr tasks.ctxOpenConPtySession}"
@@ -77,13 +84,6 @@
<MenuItem Header="{loc:Tr tasks.ctxRemoveFromMyDay}" <MenuItem Header="{loc:Tr tasks.ctxRemoveFromMyDay}"
IsVisible="{Binding IsMyDay}" IsVisible="{Binding IsMyDay}"
Click="OnRemoveFromMyDayClick"/> Click="OnRemoveFromMyDayClick"/>
<Separator/>
<MenuItem Header="{loc:Tr tasks.ctxMarkManual}"
IsVisible="{Binding !IsManual}"
Click="OnToggleManualClick"/>
<MenuItem Header="{loc:Tr tasks.ctxMarkClaudeTask}"
IsVisible="{Binding IsManual}"
Click="OnToggleManualClick"/>
</ContextMenu> </ContextMenu>
</Border.ContextMenu> </Border.ContextMenu>
<Grid ColumnDefinitions="0,18,32,*,Auto,Auto,32" Margin="6,8,10,8"> <Grid ColumnDefinitions="0,18,32,*,Auto,Auto,32" Margin="6,8,10,8">
@@ -33,6 +33,10 @@
ToolTip.Tip="{loc:Tr tasks.showCompletedTip}"> ToolTip.Tip="{loc:Tr tasks.showCompletedTip}">
<PathIcon Width="15" Height="15" Data="{StaticResource Icon.Eye}"/> <PathIcon Width="15" Height="15" Data="{StaticResource Icon.Eye}"/>
</Button> </Button>
<Button Classes="icon-btn" IsVisible="{Binding IsLetClaudeVisible}"
Command="{Binding LetClaudeHandleCommand}" ToolTip.Tip="{loc:Tr tasks.letClaudeTip}">
<PathIcon Width="15" Height="15" Data="{StaticResource Icon.Broom}"/>
</Button>
<Button Classes="icon-btn" IsVisible="{Binding IsMyDayList}" <Button Classes="icon-btn" IsVisible="{Binding IsMyDayList}"
Command="{Binding ClearDayCommand}" ToolTip.Tip="{loc:Tr tasks.clearDayTip}"> Command="{Binding ClearDayCommand}" ToolTip.Tip="{loc:Tr tasks.clearDayTip}">
<PathIcon Width="15" Height="15" Data="{StaticResource Icon.Broom}"/> <PathIcon Width="15" Height="15" Data="{StaticResource Icon.Broom}"/>
@@ -73,21 +73,35 @@
<ItemsControl ItemsSource="{Binding Queued}"> <ItemsControl ItemsSource="{Binding Queued}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:QueuedTaskViewModel"> <DataTemplate x:DataType="vm:QueuedTaskViewModel">
<Border Margin="0,0,0,4" Padding="8,6" <Button Command="{Binding OpenInAppCommand}"
Background="{DynamicResource SurfaceBrush}" Padding="0" Margin="0,0,0,4"
BorderBrush="{DynamicResource LineBrush}" Background="Transparent" BorderThickness="0"
BorderThickness="1" CornerRadius="6"> HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch"
<StackPanel Spacing="2"> Cursor="Hand">
<TextBlock Text="{Binding Title}" <Panel>
TextTrimming="CharacterEllipsis" <Border Background="{DynamicResource SurfaceBrush}"
ToolTip.Tip="{Binding Title}" BorderBrush="{DynamicResource LineBrush}"
Foreground="{DynamicResource TextDimBrush}" /> BorderThickness="1" CornerRadius="6" />
<TextBlock Classes="meta" <Border Background="{DynamicResource RunningTintBrush}"
Text="{loc:Tr missionControl.blocked}" BorderBrush="{DynamicResource RunningTintBorderBrush}"
IsVisible="{Binding IsBlocked}" BorderThickness="1" CornerRadius="6"
Foreground="{DynamicResource AmberBrush}" /> IsVisible="{Binding IsRunning}" />
</StackPanel> <StackPanel Margin="8,6" Spacing="2">
</Border> <TextBlock Text="{Binding Title}"
TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding Title}"
Foreground="{DynamicResource TextDimBrush}" />
<TextBlock Classes="meta"
Text="{loc:Tr missionControl.blocked}"
IsVisible="{Binding IsBlocked}"
Foreground="{DynamicResource AmberBrush}" />
<TextBlock Classes="meta"
Text="{loc:Tr missionControl.running}"
IsVisible="{Binding IsRunning}"
Foreground="{DynamicResource StatusRunningBrush}" />
</StackPanel>
</Panel>
</Button>
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
@@ -2,6 +2,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals" xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
xmlns:settings="using:ClaudeDo.Ui.ViewModels.Modals.Settings" xmlns:settings="using:ClaudeDo.Ui.ViewModels.Modals.Settings"
xmlns:ui="using:ClaudeDo.Ui"
xmlns:agent="using:ClaudeDo.Ui.ViewModels.Agent" xmlns:agent="using:ClaudeDo.Ui.ViewModels.Agent"
xmlns:services="using:ClaudeDo.Ui.Services" xmlns:services="using:ClaudeDo.Ui.Services"
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls" xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
@@ -61,6 +62,39 @@
</ComboBox.ItemTemplate> </ComboBox.ItemTemplate>
</ComboBox> </ComboBox>
</StackPanel> </StackPanel>
<StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="{loc:Tr settings.general.accentPreset}"/>
<ItemsControl ItemsSource="{Binding General.AccentPresetSwatches}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" Spacing="8"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="ui:AccentPresetSwatchViewModel">
<Button Padding="6,4"
Command="{Binding $parent[ItemsControl].((vm:SettingsModalViewModel)DataContext).General.SelectAccentPresetCommand}"
CommandParameter="{Binding Preset}">
<StackPanel Spacing="4">
<Grid Width="32" Height="32">
<Border Width="32" Height="32" CornerRadius="16"
BorderBrush="{DynamicResource AccentBrush}" BorderThickness="2"
IsVisible="{Binding IsSelected}"/>
<Ellipse Width="22" Height="22"
HorizontalAlignment="Center" VerticalAlignment="Center">
<Ellipse.Fill>
<SolidColorBrush Color="{Binding DisplayColor}"/>
</Ellipse.Fill>
</Ellipse>
</Grid>
<TextBlock Text="{Binding DisplayName}"
HorizontalAlignment="Center" FontSize="11"/>
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<StackPanel Spacing="4"> <StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="{loc:Tr settings.general.defaultInstructions}"/> <TextBlock Classes="field-label" Text="{loc:Tr settings.general.defaultInstructions}"/>
<TextBox AcceptsReturn="True" TextWrapping="Wrap" Height="110" <TextBox AcceptsReturn="True" TextWrapping="Wrap" Height="110"
+3 -3
View File
@@ -33,14 +33,14 @@ Interfaces (e.g. `IQueueWaker`, `IPrimeClock`, `ITaskStateService`) live in an `
- **OverrideSlotService** — owns `RunNow` / `ContinueTask`; goes through `TaskStateService.StartRunningAsync` (caller-driven, serialized by slot lock). - **OverrideSlotService** — owns `RunNow` / `ContinueTask`; goes through `TaskStateService.StartRunningAsync` (caller-driven, serialized by slot lock).
- **StaleTaskRecovery** — startup-only service; calls `TaskStateService.RecoverStaleRunningAsync` to flip orphaned `Running` rows to `Failed`. - **StaleTaskRecovery** — startup-only service; calls `TaskStateService.RecoverStaleRunningAsync` to flip orphaned `Running` rows to `Failed`.
- **External/*** — always-on MCP tools for general Claude sessions, scoped to *starting* and *observing* sessions (no worktree/merge, multi-turn, planning, or app-settings writes). Auth via optional `X-ClaudeDo-Key` header. Registered explicitly in `Program.cs`'s external app via `.WithTools<T>()`. Every optional/filter parameter across these tools must carry a C# default value (e.g. `string? status = null`) — the MCP schema only marks a parameter optional when it has one; nullability alone doesn't do it (`ExternalMcpToolSchemaTests` guards this by reflection). `ExternalMcpExceptionFilter.Wrap` is registered as a call-tool filter so `InvalidOperationException`/`ArgumentException` messages survive as `McpException` — otherwise the SDK's own catch-all replaces any non-`McpException` with a generic "An error occurred invoking 'X'." Organized by concern: - **External/*** — always-on MCP tools for general Claude sessions, scoped to *starting* and *observing* sessions (no worktree/merge, multi-turn, planning, or app-settings writes). Auth via optional `X-ClaudeDo-Key` header. Registered explicitly in `Program.cs`'s external app via `.WithTools<T>()`. Every optional/filter parameter across these tools must carry a C# default value (e.g. `string? status = null`) — the MCP schema only marks a parameter optional when it has one; nullability alone doesn't do it (`ExternalMcpToolSchemaTests` guards this by reflection). `ExternalMcpExceptionFilter.Wrap` is registered as a call-tool filter so `InvalidOperationException`/`ArgumentException` messages survive as `McpException` — otherwise the SDK's own catch-all replaces any non-`McpException` with a generic "An error occurred invoking 'X'." Organized by concern:
- `ExternalMcpService` — task CRUD + execution: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `AddSubtask`, `UpdateTask`, `UpdateTaskStatus` (`Idle` / `Queued` / `Cancelled``Cancelled` goes through `TaskStateService.CancelAsync(..., allowFromIdle: true)`, the only caller that opts into cancelling from `Idle`; `PlanningChainCoordinator` relies on `Idle` staying a no-op there by default, since a child parked back to `Idle` mid-chain is a manual opt-out signal), `GetTaskStatusValues`, `ReviewTask` (`approve` / `reject_rerun` / `reject_park` / `cancel` for a WaitingForReview task; approve is review+merge exactly like the hub's `ApproveReview` — unit merge for parents, worktree merge into optional `targetBranch` for childless tasks, conflicts reported in the `ReviewTaskResult`), `RunTaskNow`, `ContinueTask`, `CancelTask`, `DeleteTask`; worktree/git: `GetTaskWorktree`, `GetTaskDiff`, `MergeTask`, `ListWorktrees`, `CleanupTaskWorktree` - `ExternalMcpService` — task CRUD + execution: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `AddSubtask`, `UpdateTask`, `UpdateTaskStatus` (`Idle` / `Queued` / `Cancelled` / `Done``Cancelled` goes through `TaskStateService.CancelAsync(..., allowFromIdle: true)`, the only caller that opts into cancelling from `Idle`; `PlanningChainCoordinator` relies on `Idle` staying a no-op there by default, since a child parked back to `Idle` mid-chain is a manual opt-out signal; `Done` goes through `TaskStateService.ForceSetStatusAsync` — same unconditional write the UI's "set status freely" affordance uses — but is refused here with an active-worktree task, since that would skip `review_task`'s merge), `GetTaskStatusValues`, `ReviewTask` (`approve` / `reject_rerun` / `reject_park` / `cancel` for a WaitingForReview task; approve is review+merge exactly like the hub's `ApproveReview` — unit merge for parents, worktree merge into optional `targetBranch` for childless tasks, conflicts reported in the `ReviewTaskResult`), `RunTaskNow`, `ContinueTask`, `CancelTask`, `DeleteTask`; worktree/git: `GetTaskWorktree`, `GetTaskDiff`, `MergeTask`, `ListWorktrees`, `CleanupTaskWorktree`
- `BatchMcpTools` — best-effort batch variants that loop the `ExternalMcpService` single-entity methods (sequential — the scoped DbContext is not thread-safe; merge/review stay single-task): `BatchGetTasks`, `BatchAddTasks`, `BatchUpdateTaskStatus`, `BatchCancelTasks`, `BatchDeleteTasks`, `BatchSetMyDay`, `BatchCleanupTaskWorktrees`. Every tool returns a per-item result array ({ id/index, ok, error?, … }) — a failing item never aborts the rest — and rejects batches over 100 items. - `BatchMcpTools` — best-effort batch variants that loop the `ExternalMcpService` single-entity methods (sequential — the scoped DbContext is not thread-safe; merge/review stay single-task): `BatchGetTasks`, `BatchAddTasks`, `BatchUpdateTaskStatus`, `BatchCancelTasks`, `BatchDeleteTasks`, `BatchSetMyDay`, `BatchCleanupTaskWorktrees`. Every tool returns a per-item result array ({ id/index, ok, error?, … }) — a failing item never aborts the rest — and rejects batches over 100 items.
- `ListMcpTools``CreateList`, `UpdateList`, `DeleteList` - `ListMcpTools``CreateList`, `UpdateList`, `DeleteList`
- `ConfigMcpTools``GetListConfig`, `SetListConfig`, `GetTaskConfig`, `SetTaskConfig` - `ConfigMcpTools``GetListConfig`, `SetListConfig`, `GetTaskConfig`, `SetTaskConfig`
- `RunHistoryMcpTools``ListRuns`, `GetRun`, `GetTaskLog` (latest run's log, tail-capped at 256 KB) - `RunHistoryMcpTools``ListRuns`, `GetRun`, `GetTaskLog` (latest run's log, tail-capped at 256 KB)
- `AgentMcpTools``ListAgents` - `AgentMcpTools``ListAgents`
- `LifecycleMcpTools``ResetFailedTask` - `LifecycleMcpTools``ResetFailedTask`
- `AppSettingsMcpTools``GetAppSettings` (read-only) - `AppSettingsMcpTools``GetAppSettings` (read-only; includes `MaxParallelExecutions`)
- `AttachmentMcpTools``AddTaskAttachment(taskId, fileName, textContent?|base64Content?)`, `ListTaskAttachments`, `RemoveTaskAttachment`. Re-attaching the same fileName overwrites; add/remove refuse on a Running task. - `AttachmentMcpTools``AddTaskAttachment(taskId, fileName, textContent?|base64Content?)`, `ListTaskAttachments`, `RemoveTaskAttachment`. Re-attaching the same fileName overwrites; add/remove refuse on a Running task.
- `ExternalMcpService` also exposes two daily-prep tools: - `ExternalMcpService` also exposes two daily-prep tools:
- `GetDailyPrepCandidates` — returns Idle, non-blocked tasks in a git repo NOT excluded by `AppSettings.ReportExcludedPaths` and not already `IsMyDay`, plus the current Idle MyDay tasks and `maxTasks` (= `DailyPrepMaxTasks`). Repo-exclusion logic lives in the `DailyPrepFilter` helper (same file). - `GetDailyPrepCandidates` — returns Idle, non-blocked tasks in a git repo NOT excluded by `AppSettings.ReportExcludedPaths` and not already `IsMyDay`, plus the current Idle MyDay tasks and `maxTasks` (= `DailyPrepMaxTasks`). Repo-exclusion logic lives in the `DailyPrepFilter` helper (same file).
@@ -156,7 +156,7 @@ Each CLI invocation is recorded in the `task_runs` table via `TaskRunRepository`
- Review/merge: `ApproveReview(taskId, targetBranch) -> MergeResultDto` (childless task: merges its worktree then Done, conflict stays WaitingForReview; task with children: drives `PlanningMergeOrchestrator` to merge the whole unit), `ContinuePlanningMerge` / `AbortPlanningMerge` (resolve a unit-merge conflict), `PreviewMerge(taskId, targetBranch) -> MergePreviewDto` (non-destructive mergeability check), `RejectReviewToQueue`, `RejectReviewToIdle`, `CancelReview`, `MergeTask`, `GetMergeTargets` - Review/merge: `ApproveReview(taskId, targetBranch) -> MergeResultDto` (childless task: merges its worktree then Done, conflict stays WaitingForReview; task with children: drives `PlanningMergeOrchestrator` to merge the whole unit), `ContinuePlanningMerge` / `AbortPlanningMerge` (resolve a unit-merge conflict), `PreviewMerge(taskId, targetBranch) -> MergePreviewDto` (non-destructive mergeability check), `RejectReviewToQueue`, `RejectReviewToIdle`, `CancelReview`, `MergeTask`, `GetMergeTargets`
- Single-task conflict resolver (Layer C): `StartConflictMerge`, `GetMergeConflictDocuments` (segments), `WriteConflictResolution`, `ContinueConflictMerge`, `AbortConflictMerge` (service-level `TaskMergeService.ContinueMergeAsync`/`AbortMergeAsync` keep their names) - Single-task conflict resolver (Layer C): `StartConflictMerge`, `GetMergeConflictDocuments` (segments), `WriteConflictResolution`, `ContinueConflictMerge`, `AbortConflictMerge` (service-level `TaskMergeService.ContinueMergeAsync`/`AbortMergeAsync` keep their names)
- Planning sessions: `StartPlanningSession`, `ResumePlanningSession`, `DiscardPlanningSession`, `FinalizePlanningSession`, `QueuePlanningSubtasks`, `GetPendingDraftCount`, `GetPlanningAggregate` (per-subtask diffs), `BuildPlanningIntegrationBranch` (combined diff) - Planning sessions: `StartPlanningSession`, `ResumePlanningSession`, `DiscardPlanningSession`, `FinalizePlanningSession`, `QueuePlanningSubtasks`, `GetPendingDraftCount`, `GetPlanningAggregate` (per-subtask diffs), `BuildPlanningIntegrationBranch` (combined diff)
- Interactive sessions (embedded ConPTY, UI process): `ResumeTaskInTerminal` (pick-up-in-terminal), `GetInteractiveLaunchSpec`, `GetAdHocLaunchSpec`. **Every** ConPTY spec that `InteractiveLaunchSpecService` builds leads with `--effort <level>` from the relevant model's preset (task/list model for a task session, `PlanningAlias` for planning, list config for the list handler, global default for ad-hoc). `--model` is deliberately NOT forced on an interactive session — the user can still switch models in the TUI. - Interactive sessions (embedded ConPTY, UI process): `ResumeTaskInTerminal` (pick-up-in-terminal), `GetInteractiveLaunchSpec`, `GetAdHocLaunchSpec`. **Every** ConPTY spec that `InteractiveLaunchSpecService` builds leads with `--effort <level>` from the relevant model's preset (task/list model for a task session, `PlanningAlias` for planning, list config for the list handler, global default for ad-hoc). `--model` is deliberately NOT forced on an interactive session — the user can still switch models in the TUI. The list-handler spec (`BuildForMergeHelperAsync`) uses `--permission-mode auto` so it runs unattended; the `--allowedTools` allowlist (`mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill`) remains the security boundary.
- Worktrees: `CleanupFinishedWorktrees`, `ResetAllWorktrees`, `GetWorktreesOverview`, `SetWorktreeState`, `ForceRemoveWorktree` - Worktrees: `CleanupFinishedWorktrees`, `ResetAllWorktrees`, `GetWorktreesOverview`, `SetWorktreeState`, `ForceRemoveWorktree`
- Agents/settings/lists: `GetAgents`, `RefreshAgents`, `RestoreDefaultAgents`, `GetAppSettings`, `UpdateAppSettings`, `UpdateList`, `UpdateListConfig`, `GetListConfig`, `UpdateTaskAgentSettings` - Agents/settings/lists: `GetAgents`, `RefreshAgents`, `RestoreDefaultAgents`, `GetAppSettings`, `UpdateAppSettings`, `UpdateList`, `UpdateListConfig`, `GetListConfig`, `UpdateTaskAgentSettings`
- Reports/notes/prep: `GetWeekReport`, `GenerateWeekReport`, `GetDailyNotes`, `AddDailyNote`, `UpdateDailyNote`, `DeleteDailyNote`, `RunDailyPrepNow`, `ClearMyDay`, `GetLastPrepLog`, `ListPrimeSchedules`, `UpsertPrimeSchedule`, `DeletePrimeSchedule` - Reports/notes/prep: `GetWeekReport`, `GenerateWeekReport`, `GetDailyNotes`, `AddDailyNote`, `UpdateDailyNote`, `DeleteDailyNote`, `RunDailyPrepNow`, `ClearMyDay`, `GetLastPrepLog`, `ListPrimeSchedules`, `UpsertPrimeSchedule`, `DeletePrimeSchedule`
+3 -1
View File
@@ -8,6 +8,7 @@ namespace ClaudeDo.Worker.External;
public sealed record AppSettingsReadDto( public sealed record AppSettingsReadDto(
string DefaultModel, int DefaultMaxTurns, string DefaultPermissionMode, string DefaultModel, int DefaultMaxTurns, string DefaultPermissionMode,
int MaxParallelExecutions,
string WorktreeStrategy, string? CentralWorktreeRoot, string WorktreeStrategy, string? CentralWorktreeRoot,
bool WorktreeAutoCleanupEnabled, int WorktreeAutoCleanupDays); bool WorktreeAutoCleanupEnabled, int WorktreeAutoCleanupDays);
@@ -18,13 +19,14 @@ public sealed class AppSettingsMcpTools
public AppSettingsMcpTools(IDbContextFactory<ClaudeDoDbContext> dbFactory) => _dbFactory = dbFactory; public AppSettingsMcpTools(IDbContextFactory<ClaudeDoDbContext> dbFactory) => _dbFactory = dbFactory;
[McpServerTool, Description("Read the worker's app-level defaults (model, max turns, permission mode, worktree strategy). Read-only.")] [McpServerTool, Description("Read the worker's app-level defaults (model, max turns, permission mode, max parallel execution slots, worktree strategy). Read-only.")]
public async Task<AppSettingsReadDto> GetAppSettings(CancellationToken cancellationToken) public async Task<AppSettingsReadDto> GetAppSettings(CancellationToken cancellationToken)
{ {
using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken); using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
var row = await new AppSettingsRepository(ctx).GetAsync(cancellationToken); var row = await new AppSettingsRepository(ctx).GetAsync(cancellationToken);
return new AppSettingsReadDto( return new AppSettingsReadDto(
row.DefaultModel, row.DefaultMaxTurns, row.DefaultPermissionMode, row.DefaultModel, row.DefaultMaxTurns, row.DefaultPermissionMode,
row.MaxParallelExecutions,
row.WorktreeStrategy, row.CentralWorktreeRoot, row.WorktreeStrategy, row.CentralWorktreeRoot,
row.WorktreeAutoCleanupEnabled, row.WorktreeAutoCleanupDays); row.WorktreeAutoCleanupEnabled, row.WorktreeAutoCleanupDays);
} }
+2 -2
View File
@@ -93,8 +93,8 @@ public sealed class BatchMcpTools
} }
[McpServerTool, Description( [McpServerTool, Description(
"Set the status of many tasks at once. status is 'Idle' (reset to editable) or " + "Set the status of many tasks at once. status is 'Idle', 'Queued', 'Cancelled' or 'Done' only — " +
"'Queued' (enqueue for execution) only — same rule as update_task_status. " + "same rule as update_task_status ('Done' is refused per-item for a task with an active worktree). " +
"Returns one result per id: { taskId, ok, error }. Max 100 ids.")] "Returns one result per id: { taskId, ok, error }. Max 100 ids.")]
public async Task<IReadOnlyList<BatchTaskResult>> BatchUpdateTaskStatus( public async Task<IReadOnlyList<BatchTaskResult>> BatchUpdateTaskStatus(
string[] taskIds, string status, CancellationToken cancellationToken) string[] taskIds, string status, CancellationToken cancellationToken)
+18 -2
View File
@@ -264,10 +264,12 @@ public sealed class ExternalMcpService
} }
[McpServerTool, Description( [McpServerTool, Description(
"Update a task's status. Only 'Idle', 'Queued' and 'Cancelled' are permitted externally — " + "Update a task's status. Only 'Idle', 'Queued', 'Cancelled' and 'Done' are permitted externally — " +
"use run_task_now for execution control, and review_task to act on a WaitingForReview task. " + "use run_task_now for execution control, and review_task to act on a WaitingForReview task. " +
"Settable: Idle (reset to editable), Queued (enqueue for execution), " + "Settable: Idle (reset to editable), Queued (enqueue for execution), " +
"Cancelled (retire the task without deleting it; it can be reset to Idle later). " + "Cancelled (retire the task without deleting it; it can be reset to Idle later), " +
"Done (mark complete; refused if the task has an active worktree — use review_task to approve " +
"and merge that worktree instead). " +
"Full lifecycle: Idle → Queued → Running → WaitingForReview → Done | Failed | Cancelled.")] "Full lifecycle: Idle → Queued → Running → WaitingForReview → Done | Failed | Cancelled.")]
public async Task<TaskDto> UpdateTaskStatus( public async Task<TaskDto> UpdateTaskStatus(
string taskId, string taskId,
@@ -300,6 +302,20 @@ public sealed class ExternalMcpService
throw new InvalidOperationException(cancelResult.Reason ?? "Cannot cancel task."); throw new InvalidOperationException(cancelResult.Reason ?? "Cannot cancel task.");
break; break;
case TaskStatus.Done:
await using (var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken))
{
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, cancellationToken);
if (wt is not null && wt.State == WorktreeState.Active)
throw new InvalidOperationException(
"Task has an active worktree — use review_task to approve and merge instead.");
}
var doneResult = await _state.ForceSetStatusAsync(taskId, TaskStatus.Done, cancellationToken);
if (!doneResult.Ok)
throw new InvalidOperationException(doneResult.Reason ?? "Cannot set task to Done.");
break;
default: default:
throw new InvalidOperationException( throw new InvalidOperationException(
$"Status '{target}' is not settable externally. Use run_task_now or review_task."); $"Status '{target}' is not settable externally. Use run_task_now or review_task.");
@@ -232,7 +232,7 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
var args = new List<string> var args = new List<string>
{ {
"--effort", EffortFor(settings, listConfig?.Model), "--effort", EffortFor(settings, listConfig?.Model),
"--permission-mode", "default", "--permission-mode", "auto",
"--allowedTools", MergeHelperAllowedTools, "--allowedTools", MergeHelperAllowedTools,
"--add-dir", sessionDir, repoDir, "--add-dir", sessionDir, repoDir,
"--append-system-prompt-file", systemPromptPath, "--append-system-prompt-file", systemPromptPath,
@@ -329,9 +329,15 @@ public sealed class TaskStateService : ITaskStateService
public async Task<TransitionResult> ForceSetStatusAsync(string taskId, TaskStatus status, CancellationToken ct) public async Task<TransitionResult> ForceSetStatusAsync(string taskId, TaskStatus status, CancellationToken ct)
{ {
await using var ctx = await _dbFactory.CreateDbContextAsync(ct); await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var affected = await ctx.Tasks var affected = status == TaskStatus.Done
.Where(t => t.Id == taskId) ? await ctx.Tasks
.ExecuteUpdateAsync(s => s.SetProperty(t => t.Status, status), ct); .Where(t => t.Id == taskId)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, status)
.SetProperty(t => t.FinishedAt, DateTime.UtcNow), ct)
: await ctx.Tasks
.Where(t => t.Id == taskId)
.ExecuteUpdateAsync(s => s.SetProperty(t => t.Status, status), ct);
if (affected == 0) if (affected == 0)
return new TransitionResult(false, "Task not found."); return new TransitionResult(false, "Task not found.");
@@ -137,4 +137,28 @@ public class DetailsIslandReviewActionsTests : IDisposable
Assert.True(vm.ShowReviewDiffHint); Assert.True(vm.ShowReviewDiffHint);
Assert.False(vm.ApproveReviewCommand.CanExecute(null)); Assert.False(vm.ApproveReviewCommand.CanExecute(null));
} }
private sealed class ThrowingWorkerClient : StubWorkerClient
{
public override bool IsConnected => true;
public string ExceptionMessage { get; init; } = "blocked: target working tree has uncommitted changes";
public override Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) =>
throw new Exception(ExceptionMessage);
}
[Fact]
public async Task ApproveReview_WhenWorkerThrows_CallsShowErrorAsync()
{
var worker = new ThrowingWorkerClient();
var vm = BuildVm(worker);
vm.Bind(new TaskRowViewModel { Id = "task-err-1", Status = TaskStatus.WaitingForReview });
vm.Monitor.ApplyState(TaskStatus.WaitingForReview);
string? reportedError = null;
vm.ShowErrorAsync = msg => { reportedError = msg; return Task.CompletedTask; };
await vm.ApproveReviewCommand.ExecuteAsync(null);
Assert.Equal(worker.ExceptionMessage, reportedError);
}
} }
@@ -21,11 +21,14 @@ public class DiffViewerViewModelTests
{ {
public IReadOnlyList<SubtaskDiffDto> AggregateResult { get; set; } = Array.Empty<SubtaskDiffDto>(); public IReadOnlyList<SubtaskDiffDto> AggregateResult { get; set; } = Array.Empty<SubtaskDiffDto>();
public CombinedDiffResultDto? CombinedResult { get; set; } public CombinedDiffResultDto? CombinedResult { get; set; }
public string? CombinedException { get; set; }
public override Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregateAsync(string planningTaskId) => public override Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregateAsync(string planningTaskId) =>
Task.FromResult(AggregateResult); Task.FromResult(AggregateResult);
public override Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch) => public override Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch) =>
Task.FromResult(CombinedResult); CombinedException is not null
? throw new Exception(CombinedException)
: Task.FromResult(CombinedResult);
} }
// ── Files mode: commit-range guards (ported from DiffModal) ── // ── Files mode: commit-range guards (ported from DiffModal) ──
@@ -193,4 +196,26 @@ public class DiffViewerViewModelTests
Assert.NotNull(vm.CombinedWarning); Assert.NotNull(vm.CombinedWarning);
Assert.NotEmpty(vm.CombinedWarning!); Assert.NotEmpty(vm.CombinedWarning!);
} }
[Fact]
public async Task Planning_ToggleCombined_WhenWorkerThrows_ShowsExceptionMessage()
{
var fake = new FakePlanningWorker
{
AggregateResult = new[] { new SubtaskDiffDto("s1", "First", "b1", "base1", "head1", null, "DIFF-A") },
CombinedException = "planning task not found",
};
var vm = new DiffViewerViewModel(null!, fake);
vm.ConfigurePlanning("plan-1", "main");
await vm.LoadAsync();
vm.IsCombinedMode = true;
var deadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < deadline && vm.IsLoadingCombined) await Task.Delay(10);
Assert.NotNull(vm.CombinedWarning);
Assert.Contains("planning task not found", vm.CombinedWarning);
Assert.Equal("", vm.DisplayedDiff);
}
} }
@@ -0,0 +1,38 @@
using System.IO;
using ClaudeDo.Localization;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Modals.Settings;
namespace ClaudeDo.Ui.Tests.ViewModels;
public class FilesSettingsTabViewModelTests
{
public FilesSettingsTabViewModelTests()
{
var dir = AppContext.BaseDirectory;
while (dir is not null && !Directory.Exists(Path.Combine(dir, "src", "ClaudeDo.Localization", "locales")))
dir = Path.GetDirectoryName(dir);
Loc.Current = new Localizer(
LocaleStore.Load(Path.Combine(dir!, "src", "ClaudeDo.Localization", "locales")), "en");
}
private sealed class ThrowingWorker : StubWorkerClient
{
public string ExceptionMessage { get; init; } = "permission denied copying agent files";
public override Task<SeedResultDto?> RestoreDefaultAgentsAsync() =>
throw new Exception(ExceptionMessage);
}
[Fact]
public async Task RestoreDefaultAgents_WhenWorkerThrows_ShowsExceptionMessage_NotGenericOffline()
{
var worker = new ThrowingWorker();
var vm = new FilesSettingsTabViewModel(worker);
await vm.RestoreDefaultAgentsCommand.ExecuteAsync(null);
Assert.Contains(worker.ExceptionMessage, vm.StatusMessage);
Assert.False(vm.IsBusy);
}
}
@@ -49,8 +49,10 @@ public class MissionControlViewModelTests : IDisposable
private MissionControlViewModel BuildVm(StubWorkerClient worker) private MissionControlViewModel BuildVm(StubWorkerClient worker)
=> new MissionControlViewModel(new TestDbFactory(NewContext), worker); => new MissionControlViewModel(new TestDbFactory(NewContext), worker);
// ── acceptance criterion (a): TaskStarted must NOT add a pane ──────────────
[Fact] [Fact]
public void TwoStarts_CreateTwoMonitors_ColumnCountTwo() public void TaskStarted_DoesNotAddPane()
{ {
var worker = new FakeWorker(); var worker = new FakeWorker();
using var vm = BuildVm(worker); using var vm = BuildVm(worker);
@@ -58,18 +60,140 @@ public class MissionControlViewModelTests : IDisposable
worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow); worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow);
worker.RaiseTaskStarted("slot-2", "t2", DateTime.UtcNow); worker.RaiseTaskStarted("slot-2", "t2", DateTime.UtcNow);
Assert.Empty(vm.Monitors);
Assert.Empty(vm.Panes);
}
[Fact]
public void Construction_DoesNotSeedMonitorsEvenWithActiveTasks()
{
var worker = new SeededFakeWorker();
using var vm = BuildVm(worker);
Assert.Empty(vm.Monitors);
Assert.Empty(vm.Panes);
}
private sealed class SeededFakeWorker : StubWorkerClient
{
public override IReadOnlyList<ActiveTask> GetActiveTasks()
=> new[] { new ActiveTask("slot-1", "seed1", DateTime.UtcNow) };
}
// ── acceptance criterion (b): RefreshQueueAsync returns Running rows first ─
[Fact]
public async Task RefreshQueueAsync_RunningFirst_WithIsRunningSet()
{
await using (var db = NewContext())
{
db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow });
// Running task has higher SortOrder — must still appear first
db.Tasks.Add(new TaskEntity { Id = "r1", ListId = "L1", Title = "running-task", Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow, SortOrder = 10 });
db.Tasks.Add(new TaskEntity { Id = "q1", ListId = "L1", Title = "queued-task", Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow, SortOrder = 0 });
await db.SaveChangesAsync();
}
var worker = new FakeWorker();
using var vm = BuildVm(worker);
await vm.RefreshQueueAsync();
Assert.True(vm.HasQueued);
Assert.Equal(2, vm.Queued.Count);
Assert.Equal("r1", vm.Queued[0].Id);
Assert.True(vm.Queued[0].IsRunning);
Assert.Equal("q1", vm.Queued[1].Id);
Assert.False(vm.Queued[1].IsRunning);
}
[Fact]
public async Task HasQueued_TrueWhenOnlyRunningTasksExist()
{
await using (var db = NewContext())
{
db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow });
db.Tasks.Add(new TaskEntity { Id = "r1", ListId = "L1", Title = "running", Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow, SortOrder = 0 });
await db.SaveChangesAsync();
}
var worker = new FakeWorker();
using var vm = BuildVm(worker);
await vm.RefreshQueueAsync();
Assert.True(vm.HasQueued);
}
// ── acceptance criterion (c): row click invokes OpenInApp ──────────────────
[Fact]
public async Task Row_Click_InvokesOpenInApp()
{
await using (var db = NewContext())
{
db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow });
db.Tasks.Add(new TaskEntity { Id = "q1", ListId = "L1", Title = "clickable", Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow, SortOrder = 0 });
await db.SaveChangesAsync();
}
var worker = new FakeWorker();
using var vm = BuildVm(worker);
string? opened = null;
vm.OpenInApp = id => opened = id;
await vm.RefreshQueueAsync();
vm.Queued[0].OpenInAppCommand!.Execute(null);
Assert.Equal("q1", opened);
}
[Fact]
public async Task Row_Click_RunningTask_InvokesOpenInApp()
{
await using (var db = NewContext())
{
db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow });
db.Tasks.Add(new TaskEntity { Id = "r1", ListId = "L1", Title = "running", Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow, SortOrder = 0 });
await db.SaveChangesAsync();
}
var worker = new FakeWorker();
using var vm = BuildVm(worker);
string? opened = null;
vm.OpenInApp = id => opened = id;
await vm.RefreshQueueAsync();
vm.Queued[0].OpenInAppCommand!.Execute(null);
Assert.Equal("r1", opened);
}
// ── monitor machinery tests (EnsureMonitor still works when called directly) ─
[Fact]
public void EnsureMonitor_TwoCalls_CreateTwoMonitors_ColumnCountTwo()
{
var worker = new FakeWorker();
using var vm = BuildVm(worker);
vm.EnsureMonitor("t1");
vm.EnsureMonitor("t2");
Assert.Equal(2, vm.Monitors.Count); Assert.Equal(2, vm.Monitors.Count);
Assert.Equal(2, vm.ColumnCount); Assert.Equal(2, vm.ColumnCount);
} }
[Fact] [Fact]
public void DuplicateStart_DoesNotAddSecondMonitor() public void EnsureMonitor_DuplicateCall_DoesNotAddSecondMonitor()
{ {
var worker = new FakeWorker(); var worker = new FakeWorker();
using var vm = BuildVm(worker); using var vm = BuildVm(worker);
worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow); vm.EnsureMonitor("t1");
worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow); vm.EnsureMonitor("t1");
Assert.Equal(1, vm.Monitors.Count); Assert.Equal(1, vm.Monitors.Count);
} }
@@ -80,7 +204,7 @@ public class MissionControlViewModelTests : IDisposable
var worker = new FakeWorker(); var worker = new FakeWorker();
using var vm = BuildVm(worker); using var vm = BuildVm(worker);
worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow); vm.EnsureMonitor("t1");
worker.RaiseTaskFinished("slot-1", "t1", "done", DateTime.UtcNow); worker.RaiseTaskFinished("slot-1", "t1", "done", DateTime.UtcNow);
Assert.Equal(1, vm.Monitors.Count); Assert.Equal(1, vm.Monitors.Count);
@@ -93,8 +217,8 @@ public class MissionControlViewModelTests : IDisposable
var worker = new FakeWorker(); var worker = new FakeWorker();
using var vm = BuildVm(worker); using var vm = BuildVm(worker);
worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow); vm.EnsureMonitor("t1");
worker.RaiseTaskStarted("slot-2", "t2", DateTime.UtcNow); vm.EnsureMonitor("t2");
worker.RaiseTaskFinished("slot-1", "t1", "done", DateTime.UtcNow); worker.RaiseTaskFinished("slot-1", "t1", "done", DateTime.UtcNow);
vm.ClearFinishedCommand.Execute(null); vm.ClearFinishedCommand.Execute(null);
@@ -104,22 +228,6 @@ public class MissionControlViewModelTests : IDisposable
Assert.Equal(1, vm.ColumnCount); Assert.Equal(1, vm.ColumnCount);
} }
[Fact]
public void SeedsFromActiveTasksOnConstruction()
{
var worker = new SeededFakeWorker();
using var vm = BuildVm(worker);
Assert.Equal(1, vm.Monitors.Count);
Assert.Equal("seed1", vm.Monitors[0].SubscribedTaskId);
}
private sealed class SeededFakeWorker : StubWorkerClient
{
public override IReadOnlyList<ActiveTask> GetActiveTasks()
=> new[] { new ActiveTask("slot-1", "seed1", DateTime.UtcNow) };
}
[Fact] [Fact]
public void OpenInApp_PropagatesToMonitors_AndCommandInvokesHook() public void OpenInApp_PropagatesToMonitors_AndCommandInvokesHook()
{ {
@@ -129,7 +237,7 @@ public class MissionControlViewModelTests : IDisposable
string? revealed = null; string? revealed = null;
vm.OpenInApp = id => revealed = id; vm.OpenInApp = id => revealed = id;
worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow); vm.EnsureMonitor("t1");
vm.Monitors[0].OpenInAppCommand.Execute(null); vm.Monitors[0].OpenInAppCommand.Execute(null);
@@ -145,7 +253,7 @@ public class MissionControlViewModelTests : IDisposable
Action? reDock = null; Action? reDock = null;
vm.ShowDetached = (m, rd) => { detached = m; reDock = rd; }; vm.ShowDetached = (m, rd) => { detached = m; reDock = rd; };
worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow); vm.EnsureMonitor("t1");
var monitor = vm.Monitors[0]; var monitor = vm.Monitors[0];
monitor.DetachCommand.Execute(null); monitor.DetachCommand.Execute(null);
@@ -162,7 +270,7 @@ public class MissionControlViewModelTests : IDisposable
{ {
var worker = new FakeWorker(); var worker = new FakeWorker();
using var vm = BuildVm(worker); using var vm = BuildVm(worker);
worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow); vm.EnsureMonitor("t1");
worker.RaiseTaskFinished("slot-1", "t1", "waiting_for_review", DateTime.UtcNow); worker.RaiseTaskFinished("slot-1", "t1", "waiting_for_review", DateTime.UtcNow);
Assert.True(vm.Monitors[0].IsWaitingForReview); Assert.True(vm.Monitors[0].IsWaitingForReview);
@@ -177,7 +285,7 @@ public class MissionControlViewModelTests : IDisposable
using var vm = BuildVm(worker); using var vm = BuildVm(worker);
Action? reDock = null; Action? reDock = null;
vm.ShowDetached = (m, rd) => reDock = rd; vm.ShowDetached = (m, rd) => reDock = rd;
worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow); vm.EnsureMonitor("t1");
var monitor = vm.Monitors[0]; var monitor = vm.Monitors[0];
monitor.DetachCommand.Execute(null); monitor.DetachCommand.Execute(null);
@@ -195,7 +303,7 @@ public class MissionControlViewModelTests : IDisposable
var worker = new FakeWorker(); var worker = new FakeWorker();
using var vm = BuildVm(worker); using var vm = BuildVm(worker);
vm.ShowDetached = (m, rd) => { }; vm.ShowDetached = (m, rd) => { };
worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow); vm.EnsureMonitor("t1");
var monitor = vm.Monitors[0]; var monitor = vm.Monitors[0];
var closeRequested = false; var closeRequested = false;
@@ -210,9 +318,9 @@ public class MissionControlViewModelTests : IDisposable
{ {
var worker = new FakeWorker(); var worker = new FakeWorker();
using var vm = BuildVm(worker); using var vm = BuildVm(worker);
worker.RaiseTaskStarted("s1", "t1", DateTime.UtcNow); vm.EnsureMonitor("t1");
worker.RaiseTaskStarted("s2", "t2", DateTime.UtcNow); vm.EnsureMonitor("t2");
worker.RaiseTaskStarted("s3", "t3", DateTime.UtcNow); vm.EnsureMonitor("t3");
vm.MoveMonitor(vm.Monitors[0], vm.Monitors[2]); // move t1 to t3's slot vm.MoveMonitor(vm.Monitors[0], vm.Monitors[2]); // move t1 to t3's slot
Assert.Equal(new[] { "t2", "t3", "t1" }, vm.Monitors.Select(m => m.SubscribedTaskId).ToArray()); Assert.Equal(new[] { "t2", "t3", "t1" }, vm.Monitors.Select(m => m.SubscribedTaskId).ToArray());
@@ -320,7 +428,7 @@ public class MissionControlViewModelTests : IDisposable
var worker = new FakeWorker(); var worker = new FakeWorker();
using var vm = BuildVm(worker); using var vm = BuildVm(worker);
worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow); vm.EnsureMonitor("t1");
await vm.OpenConPtySessionAsync("t2"); await vm.OpenConPtySessionAsync("t2");
Assert.Equal(2, vm.Panes.Count); Assert.Equal(2, vm.Panes.Count);
@@ -27,6 +27,33 @@ public class NotesEditorViewModelTests
public Task DeleteAsync(string id) { Store.RemoveAll(n => n.Id == id); return Task.CompletedTask; } public Task DeleteAsync(string id) { Store.RemoveAll(n => n.Id == id); return Task.CompletedTask; }
} }
private sealed class ThrowingNotes : INotesApi
{
public string ExceptionMessage { get; init; } = "worker offline";
public Task<List<DailyNoteDto>> ListAsync(DateOnly day) => Task.FromResult(new List<DailyNoteDto>());
public Task<DailyNoteDto?> AddAsync(DateOnly day, string text) => throw new Exception(ExceptionMessage);
public Task UpdateAsync(string id, string text) => Task.CompletedTask;
public Task DeleteAsync(string id) => Task.CompletedTask;
}
[Fact]
public async Task AddBullet_WhenApiThrows_RaisesErrorReported_AndKeepsDraftText()
{
var api = new ThrowingNotes();
var vm = new NotesEditorViewModel(api);
await vm.LoadDayAsync(new DateOnly(2026, 6, 1));
string? reportedError = null;
vm.ErrorReported += msg => reportedError = msg;
vm.NewBulletText = "Standup vorbereitet";
await vm.AddBulletCommand.ExecuteAsync(null);
Assert.Equal(api.ExceptionMessage, reportedError);
Assert.Empty(vm.Bullets);
Assert.Equal("Standup vorbereitet", vm.NewBulletText);
}
[Fact] [Fact]
public async Task AddBullet_PersistsAndAppears_ForCurrentDay() public async Task AddBullet_PersistsAndAppears_ForCurrentDay()
{ {
@@ -19,6 +19,14 @@ public class PrimeClaudeTabViewModelTests
public Task DeleteAsync(Guid id) { Deletes.Add(id); return Task.CompletedTask; } public Task DeleteAsync(Guid id) { Deletes.Add(id); return Task.CompletedTask; }
} }
private sealed class ThrowingApi : IPrimeScheduleApi
{
public string ExceptionMessage { get; init; } = "worker offline";
public Task<List<PrimeScheduleDto>> ListAsync() => Task.FromResult(new List<PrimeScheduleDto>());
public Task<PrimeScheduleDto?> UpsertAsync(PrimeScheduleDto dto) => throw new Exception(ExceptionMessage);
public Task DeleteAsync(Guid id) => Task.CompletedTask;
}
private static PrimeScheduleDto Dto(Guid id, int days, TimeSpan time) => private static PrimeScheduleDto Dto(Guid id, int days, TimeSpan time) =>
new(id, days, time, true, null, null); new(id, days, time, true, null, null);
@@ -93,4 +101,19 @@ public class PrimeClaudeTabViewModelTests
vm.AddScheduleCommand.Execute(null); vm.AddScheduleCommand.Execute(null);
Assert.Null(vm.Validate()); Assert.Null(vm.Validate());
} }
// SettingsModalViewModel.Save() is the only place that catches Prime.SaveAsync's
// failures (via a try/catch around the whole settings save) and surfaces them as
// "Save failed: {message}". For that to work, SaveAsync must propagate the worker's
// exception rather than swallow it.
[Fact]
public async Task Save_WhenApiThrows_PropagatesException()
{
var api = new ThrowingApi();
var vm = new PrimeClaudeTabViewModel(api);
vm.AddScheduleCommand.Execute(null);
var ex = await Assert.ThrowsAsync<Exception>(() => vm.SaveAsync());
Assert.Equal(api.ExceptionMessage, ex.Message);
}
} }
@@ -0,0 +1,76 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.ViewModels.Islands;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Ui.Tests.ViewModels;
public class TasksIslandAddAndSelectTests : IDisposable
{
private readonly string _dbPath;
public TasksIslandAddAndSelectTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_ui_addselect_{Guid.NewGuid():N}.db");
using var ctx = NewContext();
ctx.Database.EnsureCreated();
}
public void Dispose()
{
try { File.Delete(_dbPath); } catch { }
try { File.Delete(_dbPath + "-wal"); } catch { }
try { File.Delete(_dbPath + "-shm"); } catch { }
}
private ClaudeDoDbContext NewContext()
{
var opts = new DbContextOptionsBuilder<ClaudeDoDbContext>()
.UseSqlite($"Data Source={_dbPath}")
.Options;
return new ClaudeDoDbContext(opts);
}
private sealed class TestDbFactory : IDbContextFactory<ClaudeDoDbContext>
{
private readonly Func<ClaudeDoDbContext> _create;
public TestDbFactory(Func<ClaudeDoDbContext> create) => _create = create;
public ClaudeDoDbContext CreateDbContext() => _create();
}
private async Task SeedListAsync()
{
await using var db = NewContext();
db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow });
await db.SaveChangesAsync();
}
[Fact]
public async Task AddAsync_SelectsNewTask()
{
await SeedListAsync();
var vm = new TasksIslandViewModel(new TestDbFactory(NewContext), worker: null);
vm.LoadForList(new ListNavItemViewModel { Id = "user:L1", Name = "Work", Kind = ListKind.User });
await vm.LoadTask!;
vm.NewTaskTitle = "My new task";
await vm.AddCommand.ExecuteAsync(null);
Assert.NotNull(vm.SelectedTask);
Assert.Equal("My new task", vm.SelectedTask!.Title);
}
[Fact]
public async Task AddAsync_EmptyTitle_DoesNotChangeSelection()
{
await SeedListAsync();
var vm = new TasksIslandViewModel(new TestDbFactory(NewContext), worker: null);
vm.LoadForList(new ListNavItemViewModel { Id = "user:L1", Name = "Work", Kind = ListKind.User });
await vm.LoadTask!;
vm.NewTaskTitle = "";
await vm.AddCommand.ExecuteAsync(null);
Assert.Null(vm.SelectedTask);
}
}
@@ -0,0 +1,66 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Islands;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Ui.Tests.ViewModels;
public class TasksIslandApproveReviewTests : IDisposable
{
private readonly string _dbPath;
public TasksIslandApproveReviewTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_approve_test_{Guid.NewGuid():N}.db");
using var ctx = NewContext();
ctx.Database.EnsureCreated();
}
public void Dispose()
{
try { File.Delete(_dbPath); } catch { }
try { File.Delete(_dbPath + "-wal"); } catch { }
try { File.Delete(_dbPath + "-shm"); } catch { }
}
private ClaudeDoDbContext NewContext()
{
var opts = new DbContextOptionsBuilder<ClaudeDoDbContext>()
.UseSqlite($"Data Source={_dbPath}")
.Options;
return new ClaudeDoDbContext(opts);
}
private sealed class TestDbFactory : IDbContextFactory<ClaudeDoDbContext>
{
private readonly Func<ClaudeDoDbContext> _create;
public TestDbFactory(Func<ClaudeDoDbContext> create) => _create = create;
public ClaudeDoDbContext CreateDbContext() => _create();
}
private sealed class ThrowingWorkerClient : StubWorkerClient
{
public string ExceptionMessage { get; init; } = "blocked: target working tree has uncommitted changes";
public override Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) =>
throw new Exception(ExceptionMessage);
}
[Fact]
public async Task ApproveReview_WhenWorkerThrows_RaisesErrorReported()
{
var worker = new ThrowingWorkerClient();
var factory = new TestDbFactory(NewContext);
var vm = new TasksIslandViewModel(factory, worker);
string? reportedError = null;
vm.ErrorReported += msg => reportedError = msg;
var row = new TaskRowViewModel { Id = "task-err-2", Status = TaskStatus.WaitingForReview };
await vm.ApproveReviewCommand.ExecuteAsync(row);
Assert.NotNull(reportedError);
Assert.Contains(worker.ExceptionMessage, reportedError);
}
}
@@ -0,0 +1,65 @@
using System.IO;
using ClaudeDo.Localization;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Modals;
namespace ClaudeDo.Ui.Tests.ViewModels;
public class WorktreesOverviewModalErrorTests
{
public WorktreesOverviewModalErrorTests()
{
var dir = AppContext.BaseDirectory;
while (dir is not null && !Directory.Exists(Path.Combine(dir, "src", "ClaudeDo.Localization", "locales")))
dir = Path.GetDirectoryName(dir);
Loc.Current = new Localizer(
LocaleStore.Load(Path.Combine(dir!, "src", "ClaudeDo.Localization", "locales")), "en");
}
private sealed class ThrowingWorker : StubWorkerClient
{
public string ExceptionMessage { get; init; } = "worktree is locked by another process";
public override Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync(string? listId = null) =>
throw new Exception(ExceptionMessage);
public override Task<ForceRemoveResultDto?> ForceRemoveWorktreeAsync(string taskId) =>
throw new Exception(ExceptionMessage);
}
private static WorktreesOverviewModalViewModel NewVm(ThrowingWorker worker) =>
new(worker, () => null!, new MergeCoordinator());
[Fact]
public async Task CleanupFinished_WhenWorkerThrows_ShowsExceptionMessage()
{
var worker = new ThrowingWorker();
var vm = NewVm(worker);
await vm.CleanupFinishedCommand.ExecuteAsync(null);
Assert.NotNull(vm.StatusMessage);
Assert.Contains(worker.ExceptionMessage, vm.StatusMessage);
Assert.False(vm.IsBusy);
}
[Fact]
public async Task ForceRemove_WhenWorkerThrows_ShowsExceptionMessage_AndKeepsRow()
{
var worker = new ThrowingWorker();
var vm = NewVm(worker);
var row = new WorktreeOverviewRowViewModel
{
TaskId = "task-1",
TaskTitle = "Task 1",
TaskStatus = ClaudeDo.Data.Models.TaskStatus.Idle,
State = ClaudeDo.Data.Models.WorktreeState.Active,
};
vm.AddRowForTest(row);
await vm.ForceRemoveCommand.ExecuteAsync(row);
Assert.NotNull(vm.StatusMessage);
Assert.Contains(worker.ExceptionMessage, vm.StatusMessage);
Assert.Contains(row, vm.Rows);
}
}
@@ -0,0 +1,53 @@
using System.IO;
using ClaudeDo.Localization;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Modals.Settings;
namespace ClaudeDo.Ui.Tests.ViewModels;
public class WorktreesSettingsTabViewModelTests
{
public WorktreesSettingsTabViewModelTests()
{
var dir = AppContext.BaseDirectory;
while (dir is not null && !Directory.Exists(Path.Combine(dir, "src", "ClaudeDo.Localization", "locales")))
dir = Path.GetDirectoryName(dir);
Loc.Current = new Localizer(
LocaleStore.Load(Path.Combine(dir!, "src", "ClaudeDo.Localization", "locales")), "en");
}
private sealed class ThrowingWorker : StubWorkerClient
{
public string ExceptionMessage { get; init; } = "disk full";
public override Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync(string? listId = null) =>
throw new Exception(ExceptionMessage);
public override Task<WorktreeResetDto?> ResetAllWorktreesAsync() =>
throw new Exception(ExceptionMessage);
}
[Fact]
public async Task CleanupWorktrees_WhenWorkerThrows_ShowsExceptionMessage()
{
var worker = new ThrowingWorker();
var vm = new WorktreesSettingsTabViewModel(worker);
await vm.CleanupWorktreesCommand.ExecuteAsync(null);
Assert.Contains(worker.ExceptionMessage, vm.StatusMessage);
Assert.False(vm.IsBusy);
}
[Fact]
public async Task ConfirmResetAll_WhenWorkerThrows_ShowsExceptionMessage()
{
var worker = new ThrowingWorker();
var vm = new WorktreesSettingsTabViewModel(worker);
await vm.ConfirmResetAllCommand.ExecuteAsync(null);
Assert.Contains(worker.ExceptionMessage, vm.StatusMessage);
Assert.False(vm.IsBusy);
Assert.False(vm.ShowResetConfirm);
}
}
@@ -0,0 +1,31 @@
using ClaudeDo.Data.Models;
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Worker.Tests.External;
public sealed class AppSettingsMcpToolsTests : IDisposable
{
private readonly DbFixture _db = new();
public void Dispose() => _db.Dispose();
[Fact]
public async Task GetAppSettings_ReturnsMaxParallelExecutionsFromDb()
{
using (var ctx = _db.CreateContext())
{
var row = await ctx.AppSettings.FirstOrDefaultAsync(s => s.Id == AppSettingsEntity.SingletonId)
?? throw new InvalidOperationException("Expected seeded app_settings row.");
row.MaxParallelExecutions = 3;
await ctx.SaveChangesAsync();
}
var sut = new AppSettingsMcpTools(_db.CreateFactory());
var result = await sut.GetAppSettings(CancellationToken.None);
Assert.Equal(3, result.MaxParallelExecutions);
}
}
@@ -189,6 +189,22 @@ public sealed class BatchMcpToolsTests : IDisposable
Assert.Equal(TaskStatus.Queued, (await _tasks.GetByIdAsync(t2.Id))!.Status); Assert.Equal(TaskStatus.Queued, (await _tasks.GetByIdAsync(t2.Id))!.Status);
} }
[Fact]
public async Task BatchUpdateTaskStatus_Done_MixedWorktreeState_ReportsPerItemAndDoesNotAbort()
{
var listId = await SeedListAsync();
var noWorktree = await SeedTaskAsync(listId, "no-wt", TaskStatus.Idle);
var missing = "does-not-exist";
var sut = BuildSut();
var results = await sut.BatchUpdateTaskStatus(
new[] { noWorktree.Id, missing }, "Done", CancellationToken.None);
Assert.True(results.Single(r => r.TaskId == noWorktree.Id).Ok);
Assert.False(results.Single(r => r.TaskId == missing).Ok);
Assert.Equal(TaskStatus.Done, (await _tasks.GetByIdAsync(noWorktree.Id))!.Status);
}
[Fact] [Fact]
public async Task BatchTools_RejectEmptyBatch() public async Task BatchTools_RejectEmptyBatch()
{ {
@@ -395,17 +395,47 @@ public sealed class ExternalMcpServiceTests : IDisposable
} }
[Fact] [Fact]
public async Task UpdateTaskStatus_Done_StillRejected() public async Task UpdateTaskStatus_WaitingForReview_StillRejected()
{ {
var listId = await SeedListAsync(); var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId, status: TaskStatus.Idle); var task = await SeedTaskAsync(listId, status: TaskStatus.Idle);
var sut = BuildSut(CreateQueue()); var sut = BuildSut(CreateQueue());
var ex = await Assert.ThrowsAsync<InvalidOperationException>( var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => sut.UpdateTaskStatus(task.Id, "Done", CancellationToken.None)); () => sut.UpdateTaskStatus(task.Id, "WaitingForReview", CancellationToken.None));
Assert.Contains("not settable externally", ex.Message); Assert.Contains("not settable externally", ex.Message);
} }
[Fact]
public async Task UpdateTaskStatus_Done_NoWorktree_SetsDoneAndFinishedAt()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId, status: TaskStatus.Idle);
var sut = BuildSut(CreateQueue());
var dto = await sut.UpdateTaskStatus(task.Id, "Done", CancellationToken.None);
Assert.Equal("Done", dto.Status);
var loaded = await _tasks.GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.Done, loaded!.Status);
Assert.NotNull(loaded.FinishedAt);
}
[Fact]
public async Task UpdateTaskStatus_Done_WithActiveWorktree_Throws()
{
if (!GitAvailable) return;
var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
var sut = BuildSut(CreateQueue());
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => sut.UpdateTaskStatus(task.Id, "Done", CancellationToken.None));
Assert.Contains("active worktree", ex.Message, StringComparison.OrdinalIgnoreCase);
var loaded = await _tasks.GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.WaitingForReview, loaded!.Status);
}
private ExternalMcpService NewService() => BuildSut(CreateQueue()); private ExternalMcpService NewService() => BuildSut(CreateQueue());
private async Task<string> SeedIdleTask(string title = "t") private async Task<string> SeedIdleTask(string title = "t")
@@ -98,7 +98,7 @@ public class BroadcastLogSinkTests
[Fact] [Fact]
public void Does_not_throw_when_detached() public void Does_not_throw_when_detached()
{ {
var buffer = new LogRingBuffer(TimeSpan.FromHours(1)); var buffer = new LogRingBuffer(TimeSpan.FromHours(1), utcNow: () => EvtTime.UtcDateTime);
var sink = new BroadcastLogSink(buffer); var sink = new BroadcastLogSink(buffer);
sink.Emit(Evt(LogEventLevel.Error, "no subscriber")); sink.Emit(Evt(LogEventLevel.Error, "no subscriber"));
@@ -428,7 +428,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
var pmIdx = args.IndexOf("--permission-mode"); var pmIdx = args.IndexOf("--permission-mode");
Assert.True(pmIdx >= 0); Assert.True(pmIdx >= 0);
Assert.Equal("default", args[pmIdx + 1]); Assert.Equal("auto", args[pmIdx + 1]);
var atIdx = args.IndexOf("--allowedTools"); var atIdx = args.IndexOf("--allowedTools");
Assert.Equal("mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill", args[atIdx + 1]); Assert.Equal("mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill", args[atIdx + 1]);