Compare commits

..
27 Commits
Author SHA1 Message Date
Mika Kuns 87dbb117d7 fix: Interactive sessions no longer Instantly start working
Changelog / changelog (push) Successful in 2s
Release / release (push) Successful in 45s
2026-09-28 14:33:06 +02:00
Mika Kuns 0f82fa67e1 feat(claude-do): merge UI-Prozess loggt auf Platte (ui-.log) + fehlgeschlagene Hub-
ClaudeDo-Task: d2ae3831-733f-4d40-93d7-70c93da11590
2026-09-28 13:39:18 +02:00
Mika Kuns 9f2e63d337 feat(claude-do): merge Ordner in Ordner erlauben (beliebig tief, nur per Drag & Dro
ClaudeDo-Task: dc3d5f8cf9e74821ba99b18e443fe5f5
2026-09-28 13:37:19 +02:00
Mika Kuns 3009ff0f8f fix(claude-do): snake_case Serilog tokens + no Warning-log on cancelled hub calls
Message-template tokens must be snake_case (repo rule) -- fixes 4 PascalCase
tokens introduced in 57dd6766. TryInvokeAsync's swallowing catch also no
longer logs a Warning for OperationCanceledException, which is routine
(e.g. worker shutdown/reconnect), not a failure worth a trace.

ClaudeDo-Task: d2ae3831-733f-4d40-93d7-70c93da11590
2026-09-28 13:32:38 +02:00
Mika KunsandClaude Sonnet 5 0dac047c46 feat(lists): allow nested sidebar folders via drag and drop
Folders can now contain other folders, to arbitrary depth, via a
self-referencing parent_folder_id (ON DELETE SET NULL, same pattern as
lists.folder_id). Nesting/un-nesting is drag-and-drop only: dropping a
folder onto another folder's header middle moves it in, the outer
quarters or any top-level/in-folder drop insert it beside a target.
Dropping a folder into itself or one of its own subfolders is silently
ignored. Deleting a folder releases its lists and subfolders back to
the top level, unchanged from the existing list behavior.

ListFolderNavItemViewModel.Lists became Children (object, mixing lists
and subfolders, mirroring TopLevel) so the existing implicit, type-matched
DataTemplates render nested folders recursively without new XAML, and each
nesting level gets its indent for free from the folder template's own
margin. Folders/RootLists/ContainerOf/PersistOrderAsync/RecomputeFolderCounts
now walk the tree instead of assuming one level.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-28 13:31:26 +02:00
Mika Kuns 57dd676672 feat(claude-do): UI-Prozess loggt auf Platte (ui-.log) + fehlgeschlagene Hub-
## Problem
Beim DeleteTask-Bug (Fix 352aaa52) gab es null Spuren: Der UI-Prozess (ClaudeDo.App) hat gar kein File-Logging (weder Serilog noch `Microsoft.Extensions.Logging` sind in App/Ui referenziert), und im Worker loggt SignalR fehlgeschlagene Hub-Invocations nur auf Debug (`Microsoft.AspNetCore` steht in `src/ClaudeDo.Worker/Program.cs` Z. 60 auf Warning). Die Ursache ließ sich nur finden, ind

ClaudeDo-Task: d2ae3831-733f-4d40-93d7-70c93da11590
2026-09-28 13:27:21 +02:00
mika kuns 352aaa525b fix(worker): DeleteTask aus der UI schlug immer mit generischem Serverfehler fehl
Der CancellationToken-Parameter auf der nicht-streamenden Hub-Methode liess SignalR jede Invocation vor dem Methodenrumpf abbrechen. Die Tests riefen den Hub direkt auf und sahen das nicht; neuer Reflection-Guard verbietet CancellationToken auf allen nicht-streamenden Hub-Methoden.
2026-09-28 13:02:47 +02:00
Mika Kuns c8b2ad046a chore(claude-do): merge Mission Control: Recent Sessions mit verschachtelten Handoff
ClaudeDo-Task: e51c2b8a-614b-4e4f-8e93-6f90a7ba9960
2026-09-28 12:24:39 +02:00
Mika Kuns d3db4455fa feat(ui): Mission Control Recent Sessions mit verschachtelten Handoffs
Neue Tabelle mission_control_sessions (Migration AddMissionControlSessions), befuellt vom
InteractiveLaunchSpecService bei Task-, Ad-hoc- und Handoff-Kacheln. Flyout im Mission-Control-Header
listet die letzten 14 Tage / 50 Sessions, Handoffs eingerueckt unter ihrer Ursprungs-Session, mit
Fortsetzen (--resume im gespeicherten cwd) und Brief anzeigen.
2026-09-28 12:11:21 +02:00
Mika Kuns 216620c52e chore(claude-do): merge Mission Control: allgemeiner Session-Handoff an eine frische
ClaudeDo-Task: 8b846deb-ad10-4243-9272-9aea2a01ec93
2026-09-28 11:58:14 +02:00
Mika Kuns 08d1797ead chore(claude-do): Mission Control: allgemeiner Session-Handoff an eine frische
## Ziel

Jede Mission-Control-Session (Ad-hoc UND Task-Session) soll ihre Arbeit an eine frische Session
uebergeben koennen: "gib das ab an eine neue Session". Die alte Kachel wird geschlossen, an
derselben Position oeffnet eine frische Session im selben cwd, die mit einem Brief startet, den
die abgebende Session selbst geschrieben hat.

Motivation: eine lange Session mit vollem/verrottetem Kontex

ClaudeDo-Task: 8b846deb-ad10-4243-9272-9aea2a01ec93
2026-09-28 11:57:37 +02:00
mika kuns e0a1448f44 feat(ui): Mission-Control-Kacheln behalten Position und Identitaetsfarbe ueber Handoffs
Ein Handoff schloss die alte Pane und haengte die neue hinten an -- die Kachel
sprang ans Ende und das ganze Grid ordnete sich neu. Die neue Pane wird jetzt am
Index der alten eingesetzt.

Dazu bekommt jede Kachel eine aus IdentityKey (Default: TaskId) abgeleitete Farbe:
3px-Streifen links am Tile, Punkt im Focus-Mode-Tab. Kein State, keine Persistenz --
derselbe Task faerbt nach App-Neustart gleich, und ein Handoff erbt die Farbe, weil
er dieselbe TaskId behaelt. Nur kuehle Hues, damit nichts als Status gelesen wird.
2026-09-28 08:56:50 +02:00
ClaudeDo CI c9dd885fe0 docs(changelog): update for v2.16.0 2026-09-16 13:44:19 +00:00
mika kunsandClaude Opus 5 31dfaffc8b docs(changelog): update for v2.16.0
Changelog / changelog (push) Successful in 2s
Release / release (push) Successful in 41s
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 15:43:58 +02:00
Mika Kuns 09bcd696dd feat(ui): dirty Ziel-Verzeichnis beim Oeffnen des Merge-Dialogs melden statt nach dem Fehlschlag
MergeTargetsDto/MergeTargets bekommen TargetDirty (via HasChangesAsync,
includeUntracked: false). Das Merge-Modal zeigt Warnung + Stash-Retry
sofort in InitializeAsync statt erst nach dem dirty_target-Fehlschlag;
der REVIEW-Block in der Detail-Pane zeigt denselben Hinweis neben
Approve. Der dirty_target-Rueckgabepfad bleibt als Absicherung bestehen.

Behebt Befund F6 aus docs/test-session-2026-09-16.md.
2026-09-16 15:43:39 +02:00
Mika Kuns 86cc309ba9 fix(ui): duplizierter Confirm-Dialog mit "Delete"-Knopf – zusammenfuehren und lokalisieren
F4: DetailsIslandView/TasksIslandView hatten je eine view-lokale Confirm/Error-Dialog-Kopie,
teils mit hartkodiertem "Delete"-Knopf fuer jede Aktion (inkl. der Stash+Merge-Bestaetigung,
die nichts loescht). Beide Kopien entfernt; DetailsIslandViewModel/TasksIslandViewModel routen
Confirm/Error jetzt durch den geteilten Dialogs-Seam (IDialogService, propagiert vom Shell wie
schon bei Lists/Tasks) statt ueber eine vom Code-behind gesetzte Func-Property.

IDialogService.ConfirmAsync bekommt einen optionalen confirmLabel-Parameter (Default: lokalisiertes
"Confirm"); nur die echten Delete-Aktionen (Task loeschen, abgeschlossene Tasks leeren) uebergeben
das lokalisierte "Delete"-Label.

F5: "Cancel"/"Confirm"/"Delete"/"OK" und die Titel "Confirm"/"Error" durch Loc.T mit neuen Keys
unter "dialog.*" ersetzt (en.json + de.json). Mission Controls eigene Dialog-Kopie bleibt bestehen
(eigenes Fenster, WindowDialogService ist an den Haupt-Owner gebunden) — nur ihre Labels wurden
lokalisiert.

Out of scope (siehe Task-Beschreibung): die Nachrichtentexte der Dialoge bleiben unlokalisiert.
2026-09-16 15:43:38 +02:00
Mika KunsandClaude Sonnet 5 684b0115e5 feat(worker): preview_merge_set flags removed symbols and locale-key overlaps
Overlap detection was file-name-only: 9/15 tasks touching en.json/de.json in
run #316 all got flagged even though only one pair actually collided on a
key, and a removed method with no textual file conflict (#297 vs #286) went
unflagged entirely.

- removedSymbols per task entry: a line-pattern scan of `-` diff lines for
  C# method/property/field/type/event declarations (skips .axaml/.json).
- symbolWarnings at the set level: cross-references those names against
  every other task's `+` lines, catching same-symbol collisions across
  files that overlaps/subsets (both purely file-based) can't see.
- overlaps narrows to key-path level for .json files (base commit vs. task
  branch head, both sides parsed and flattened) so two tasks adding
  different locale keys to the same file no longer collide; unparsable
  JSON on either side falls back to the old file-level check.
- GitService.ShowFileAsync: `git show ref:path`, used to read a task
  branch's json content at its base/head commit.

Out of scope per the task: no Roslyn/parser, no AXAML analysis, and the
merge-helper prompt in PromptFiles.cs doesn't mention the new fields yet.

Build: dotnet build src/ClaudeDo.App (pulls in Ui+Data) + src/ClaudeDo.Worker, both clean.
Tests: dotnet test tests/ClaudeDo.Worker.Tests --filter "FullyQualifiedName~MergeMcpTools|FullyQualifiedName~ExternalMcpToolSchema" -- 51 passed, 0 failed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 15:43:38 +02:00
Mika Kuns 1325ab9438 fix(ui): Worktrees-Uebersicht zeigt Task-Nummern, DIFF-Spalte trimmt statt abzuschneiden
Befund F7 aus der Sichtpruefung 2026-09-16. TaskNumber war bereits im
WorktreeMaintenanceService-Join und in WorktreeOverviewRow vorhanden, fehlte
aber im Wire-DTO und in der UI-Kette; jetzt bis zur Row-/Conflict-Anzeige
durchgereicht (#<number> dimmed vor dem Titel, ausgeblendet bei 0). DIFF-Spalte
bekommt CharacterEllipsis-Trimming + Tooltip statt hartem Cut, Spalte von 80
auf 110px verbreitert (Header und Row synchron).
2026-09-16 15:43:38 +02:00
Mika Kuns 56161e4e9b feat(ui): Bilder aus der Zwischenablage ins interaktive Terminal einfuegen
Ctrl+V mit einem Bild in der Zwischenablage schreibt es als temporaeres
PNG unter ~/.claudeDo/tmp/ und fuegt den Pfad in den Composer ein, genau
wie ein Datei-Drop -- Claude liest die Datei selbst. Text-Paste laeuft
unveraendert ueber den bestehenden bracketed-paste-Pfad.

Avalonia 12.0.4 liest Clipboard-Bitmaps bereits selbst (IClipboard.
TryGetDataAsync + AsyncDataTransferExtensions.TryGetBitmapAsync), kein
Win32-P/Invoke noetig. Alte Tmp-Dateien (>24h) werden beim App-Start
aufgeraeumt (ClaudeDo.App/Program.cs), keine eigene Aufraeum-Infrastruktur.
2026-09-16 15:43:38 +02:00
Mika Kuns 4ff5383b35 fix(ui): Diff-Viewer scrollt jetzt auch horizontal synchron
VerticalScrollSync mirrort nur noch Y, X blieb pro Pane unabhaengig. Beide
Panes nutzen dieselbe Schrift/Breite, also X 1:1 mitspiegeln statt wie bei
Y ueber TargetOffset umzurechnen. Die Echo-Abbruchbedingung prueft jetzt
beide Achsen (vorher nur Y), sonst wuerde der neue X-Schreibvorgang seine
eigene Echo-Bedingung nie erfuellen und endlos weiterlaufen.

Klasse in ScrollSync umbenannt (DiffTextView + ConflictResolverView
mitgezogen), da sie nicht mehr nur vertikal synchronisiert.

Kein Headless-Test hinzugefuegt: DiffDispatcherGuardTests.cs dokumentiert
bereits, dass ein bootstrapptes Avalonia Application/Dispatcher in
ClaudeDo.Ui.Tests nach drei eskalierenden Versuchen als Sackgasse verifiziert
wurde (Dispatcher-Thread-Affinitaet kollidiert mit xUnits Worker-Pool ohne
Thread-Garantie; Avalonia.Headless.XUnit kollidiert mit xunit 2.9.3). Als
Finding avaloniaedit-ui-test-dead-end festgehalten. Sichtpruefung bleibt
offen (Side-by-side + Merge-Editor mit ueberbreiten Zeilen).
2026-09-16 15:43:37 +02:00
Mika Kuns 5cdfb14ff1 fix(ui): Ordner-Toggle in der Sidebar schluckt schnelle Klicks
ToggleFolderAsync war ein async [RelayCommand]; AsyncRelayCommand sperrt
sich waehrend des DB-Writes selbst, schnelle Klicks gingen verloren
(Befund F1, docs/test-session-2026-09-16.md).

Variante A: Toggle ist jetzt synchron (reiner UI-State), Persistierung
fire-and-forget mit einem CancellationTokenSource pro Ordner, damit ein
zweiter Klick vor dem ersten Schreibvorgang diesen abbricht - die DB
landet immer beim zuletzt sichtbaren Zustand, nie bei einem veralteten.
Gleiches Muster wie SaveTitleAsync/SaveDescriptionAsync in
DetailsIslandViewModel.

Gegenprueft: RemoveFromFolderAsync/CreateListAsync haben denselben
async-RelayCommand-Schnitt, sind aber Einzelklick-Aktionen (Kontextmenue/
Button), kein Rapid-Click-Ziel wie der Ordner-Toggle - bewusst nicht
umgebaut. Die Modal-oeffnenden Commands sind unkritisch, ein Modal
blockiert ohnehin.
2026-09-16 15:43:37 +02:00
Mika KunsandClaude Sonnet 5 7452f6cfe4 fix(worker): preview_merge braucht einen runVerify-Parameter wie preview_merge_set
preview_merge baute bisher immer, sobald die Liste ein Verify-Kommando
konfiguriert hatte -- anders als das Geschwister-Tool preview_merge_set,
dessen runVerify-Parameter per Default false ist. Ein einzelnes preview_merge
im Merge-Helper-Recheck stiess dadurch einen ungewollten Scratch-Build an.

runVerify (Default false) jetzt symmetrisch bei preview_merge ergaenzt,
Description-Text und die Merge-Helper-Prompt-Doku angepasst.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 15:43:37 +02:00
Mika KunsandClaude Opus 5 c874bf7e52 fix(worker): depends_on-Nachfolger schon beim gelandeten Merge freigeben
Der Queue-Picker gatete eine task_dependencies-Kante auf status='done'.
review_task(skipVerify) laesst einen erfolgreich gemergten Task aber
bewusst auf WaitingForReview stehen, bis verify_merges einmal zentral
laeuft -- damit blockierte jeder Nachfolger ueber den gesamten Batch plus
Full-Suite-Verify hinweg, bei freien Slots.

Eine Kante gilt jetzt als erfuellt, wenn der Vorgaenger 'done' ist ODER
sein Merge gelandet ist (worktrees.state = 'merged'). TaskBlocking
(get_task/list_tasks/wait_for_task_change) und der UI-Wartehinweis folgen
derselben Regel, damit "blocked" nicht meldet, was der Picker claimt.

Abgeleitet: revert_merge/abort_merge setzen den Worktree auf Active und
blockieren wieder; reject_rerun/reject_park haben nie gemergt und bleiben
unerfuellt; ein Task ohne Worktree wird nur ueber 'done' erfuellt; ein
fehlgeschlagener verify_merges gibt Nachfolger frei, weil der Code so
oder so auf main liegt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 15:43:37 +02:00
Mika KunsandClaude Sonnet 5 50b6b56f87 feat(usage): poll the OAuth usage endpoint as fast as its measured rate limit allows
Measured 2026-09-16 against the live endpoint: a clean burst allows exactly 5
requests before a 429, with Retry-After: 300 (a flat 5-req/5-min quota, no
ratelimit-* headers on either the 200s or the 429s). Lowered
UsagePollIntervalActiveSeconds/UsagePollIntervalIdleSeconds default from
300/900 to 120 each (clamp floor 90, up from 60) — leaves ~2.5 requests of
headroom per window for manual refreshes and any other consumer of the same
OAuth token, unlike 60s which consumes the whole quota by itself. The quota
is a flat per-account limit, not tied to ClaudeDo's own running/idle state,
so both keys share the same default now (kept separate for config
compatibility).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 15:43:37 +02:00
Mika Kuns 9a41bed0f5 fix(ui): Worker-Neustart trifft nun den tatsaechlich laufenden Prozess
RestartWorkerService verliess sich auf WorkerLocator.Find(), das bei einem
Debug-Build ohne install.json in der Verzeichniskette auf die Registry
zurueckfaellt und den Pfad der *installierten* Worker.exe liefert. Der
passt nicht zum laufenden Debug-Prozess, also ueberspringt
KillMatchingWorkerProcesses ihn stillschweigend (Pfadvergleich) - der neu
gestartete Worker startet vom falschen Pfad und stirbt sofort am bereits
belegten Port, waehrend der echte Worker unberuehrt weiterlaeuft. Der
Neustart nimmt jetzt den Pfad des tatsaechlich laufenden
ClaudeDo.Worker-Prozesses als Quelle, mit dem Locator nur noch als
Fallback, wenn keiner laeuft.
2026-09-16 15:43:36 +02:00
Mika Kuns 103eb9a687 fix(ui): Shutdown terminates the app even with secondary windows open
ShutdownWorkerAndCloseApp only closed MainWindow via CloseWindow, relying
on ShutdownMode.OnMainWindowClose to bring the process down. That mode
backs off if any window cancels its own Closing event, and
MissionControlWindow always does (it hides instead of closing) - so the
process never actually exited while Mission Control, Cheat Sheets, or the
diff viewer were still open. Call IClassicDesktopStyleApplicationLifetime
.Shutdown() directly, which force-closes every window regardless of
per-window cancellation.
2026-09-16 15:43:36 +02:00
Mika Kuns 7ecb1fa0b5 Fix: some Reactive stuff 2026-09-16 15:43:36 +02:00
96 changed files with 5262 additions and 480 deletions
+25
View File
@@ -1,5 +1,30 @@
# Changelog
## v2.16.0 — 2026-09-16
### Features
- dirty Ziel-Verzeichnis beim Oeffnen des Merge-Dialogs melden statt nach dem Fehlschlag (09bcd696)
- preview_merge_set flags removed symbols and locale-key overlaps (684b0115)
- Bilder aus der Zwischenablage ins interaktive Terminal einfuegen (56161e4e)
- poll the OAuth usage endpoint as fast as its measured rate limit allows (50b6b56f)
### Fixes
- duplizierter Confirm-Dialog mit "Delete"-Knopf – zusammenfuehren und lokalisieren (86cc309b)
- Worktrees-Uebersicht zeigt Task-Nummern, DIFF-Spalte trimmt statt abzuschneiden (1325ab94)
- Diff-Viewer scrollt jetzt auch horizontal synchron (4ff5383b)
- Ordner-Toggle in der Sidebar schluckt schnelle Klicks (5cdfb14f)
- preview_merge braucht einen runVerify-Parameter wie preview_merge_set (7452f6cf)
- depends_on-Nachfolger schon beim gelandeten Merge freigeben (c874bf7e)
- Worker-Neustart trifft nun den tatsaechlich laufenden Prozess (9a41bed0)
- Shutdown terminates the app even with secondary windows open (103eb9a6)
### Documentation
- update for v2.16.0 (31dfaffc)
- update for v2.15.0 (18394dd6)
## v2.15.0 — 2026-09-11
### Features
+42
View File
@@ -97,6 +97,48 @@ Net effect: reopening an interactive session for a task (pane closed, process ki
resumes the same claude conversation, because the id was committed to the DB before the previous
launch even started.
## Session handoff (`handoff_session`)
Task and ad-hoc tiles can hand off to a fresh session in the same tile. The **paneId is the pane's
`IdentityKey`**: the task id for a task tile, a GUID minted by `OpenAdHocConPtySessionAsync` for an
ad-hoc one.
**The model never passes an id** (it guesses GUIDs). Instead, every such spec gets
`--mcp-config ~/.claudeDo/pane-sessions/<paneId>/mcp.json`, which adds a second entry,
`claudedo_pane`. It points at the same external MCP, with headers `X-ClaudeDo-Pane` and
`X-ClaudeDo-Session` (plus `X-ClaudeDo-Key` when one is configured). An `AddListToolsFilter`
(`SessionHandoffMcpTools.FilterByPane`) splits the tool list by that header:
- `claudedo_pane` lists only `handoff_session`;
- the plain `claudedo` entry lists everything except `handoff_session`.
The hint (`handoff-hint.md`, via `--append-system-prompt-file`) says when to call
`mcp__claudedo_pane__handoff_session(brief)` and what goes into the brief, and contains no ids. Ad-hoc
tiles get a pre-assigned `--session-id` so the header has one. With `ExternalMcpPort = 0` the tile
gets neither the entry nor the hint.
`handoff_session(brief)` writes `pane-sessions/<paneId>/handoffs/<guid>/brief.md` (first line:
`Handed off from session <id>`) and broadcasts `SessionHandoffRequested(paneId, briefPath)`. The
worker has no pane registry: the UI looks up the pane by `IdentityKey`, takes its `Cwd` (from the
resolved launch spec) and its `TaskId`, and closes it. It then reopens the tile at the same slot
with the same `IdentityKey` via `GetSessionHandoffLaunchSpec` → `BuildHandoffAsync`. For a task
tile, the new session id replaces `InteractiveSessionId`, so reopening the task resumes the
handed-off session. The old transcript remains under `~/.claude/projects/`.
## Recent sessions
Every task, ad-hoc and handoff tile launch (only those — `paneId != null`) records its session in
`mission_control_sessions` from inside `InteractiveLaunchSpecService`, which is where the session id
is known. A handoff's predecessor comes from the brief's first line
(`SessionHandoffMcpTools.ReadHandedOffFrom`), and it inherits the predecessor's title and task.
The clock button in the Mission Control header opens a flyout (`RefreshRecentSessionsAsync`, last 14
days / 50 rows, read straight from the DB). `NestHandoffs` groups each chain under its origin.
- **Resume**: a task's *current* `InteractiveSessionId` reopens the task tile (deduped by TaskId).
Any other session gets a fresh ad-hoc tile via `GetResumeSessionLaunchSpec` → `BuildResumeAsync`
(`--resume <id>` in the recorded cwd, handoff hint included).
- **Brief**: `ShellOpen.Path(brief.md)`, which opens the file in the default editor.
- Not guarded: resuming a session that is still live in another ad-hoc tile, since panes don't
track their session id.
## List handler ("Let Claude handle it")
`BuildForMergeHelperAsync`/`BuildForMergeHelperHandoffAsync` resolve `--permission-mode` via
+3 -3
View File
@@ -184,9 +184,9 @@ detached-HEAD scratch worktree under the OS temp dir (`GitService.WorktreeAddDet
is always removed afterward (`finally`, non-cancellable cleanup). No verify command configured, or
`runVerify=false`, reproduces the pre-existing preview exactly — no delay, no scratch worktree.
Wired into `ExternalMcpService`: `preview_merge` (single task) always requests a verify run when the
list has a command configured; `preview_merge_set` only does when its `runVerify` parameter is
explicitly set (default `false`) — a set preview never starts N builds unasked. The Hub's
Wired into `ExternalMcpService`: both `preview_merge` (single task) and `preview_merge_set` only
request a verify run when their `runVerify` parameter is explicitly set (default `false`) — a
preview never starts a build unasked. The Hub's
`PreviewMerge` (the UI's live mergeability indicator) always passes `runVerify: false`, since
running a build on every poll would be a bad regression; `MergePreviewDto` carries the
verify fields anyway so a future UI entry point can opt in. Result fields: `VerifyExitCode` (null =
+6 -1
View File
@@ -21,6 +21,11 @@ authenticated with the Bearer access token Claude Code itself keeps fresh at
Because the endpoint is undocumented and can change without notice, **every consumer
fails open**. That is the single most important invariant here.
**Measured 2026-09-16** (clean burst against the live endpoint, Worker otherwise idle): exactly
5 requests succeed, the 6th 429s with `Retry-After: 300` — a flat 5-requests-per-5-minute quota,
no `ratelimit-*`/`x-ratelimit-*` headers on either the 200s or the 429. The quota is per-account,
not per-process, so a manual refresh or another consumer of the same OAuth token shares it.
## Components (`Usage/`)
| Type | Role |
@@ -28,7 +33,7 @@ fails open**. That is the single most important invariant here.
| `UsageModels` | `UsageBucket` / `UsageLimitRow` / `UsageSnapshot`. `UsageBucket.Utilization` is already a 0–100 percent — compare directly against thresholds, don't rescale. |
| `ClaudeOAuthUsageClient` | Reads the token, calls the endpoint. Defensive parsing: missing/null buckets → null, missing `limits` → empty list. |
| `UsageState` | Threadsafe singleton. A failed poll **never** overwrites the last good snapshot — it only sets `LastError`. |
| `UsageMonitorService` | `BackgroundService`; polls on `usage_poll_interval_seconds` (default 60, clamped to min 15 on config load), one poll at startup. Logs a failure at most once per distinct error message. Broadcasts `HubBroadcaster.UsageUpdated` after **every** tick, success or failure. |
| `UsageMonitorService` | `BackgroundService`; polls on `usage_poll_interval_active_seconds` / `usage_poll_interval_idle_seconds` (default 120 each, clamped to min 90 on config load), one poll at startup. Logs a failure at most once per distinct error message. Broadcasts `HubBroadcaster.UsageUpdated` after **every** tick, success or failure. |
| `UsageSnapshotBuilder` | Builds the Hub-facing `UsageSnapshotDto` from `UsageState` + `IUsageGate` + `AppSettings`. The one shared place for stale/threshold/gate logic — `WorkerHub.GetUsageSnapshot` and `UsageMonitorService` must not diverge. |
| `UsageGate` | Hard pause decision → `UsageGateDecision(IsBlocked, Reason)`. |
| `UsageThrottle` | Pure static staging of parallelism ahead of the gate. |
+1
View File
@@ -52,6 +52,7 @@ public partial class App : Application
Dispatcher.UIThread.UnhandledException += (_, e) =>
{
e.Handled = true;
Serilog.Log.Error(e.Exception, "Unhandled exception on the UI dispatcher");
shell.FlashFooterError(Loc.T("vm.shell.unexpectedError", e.Exception.Message));
};
+2
View File
@@ -26,6 +26,8 @@
</PackageReference>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageReference Include="Serilog" Version="4.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
</ItemGroup>
<ItemGroup>
+35
View File
@@ -13,6 +13,7 @@ using ClaudeDo.Ui.ViewModels.Modals;
using ClaudeDo.Ui.ViewModels.Modals.Settings;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Serilog;
using System;
using System.Globalization;
using System.IO;
@@ -20,6 +21,7 @@ using System.Linq;
using System.Net.Http;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
namespace ClaudeDo.App;
@@ -34,6 +36,35 @@ sealed class Program
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
SetCurrentProcessExplicitAppUserModelID("ClaudeDo.App");
var logRoot = Path.Combine(Paths.AppDataRoot(), "logs");
Directory.CreateDirectory(logRoot);
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Information()
.WriteTo.File(
Path.Combine(logRoot, "ui-.log"),
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 7,
shared: true)
.CreateLogger();
var version = Assembly.GetEntryAssembly()?
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
?? "0.0.0";
Log.Information("ClaudeDo.App starting (version: {version})", version);
// Last-resort net: any exception that misses every other handler still gets one
// Fatal line in the log before the process dies.
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
{
Log.Fatal(e.ExceptionObject as Exception, "Unhandled exception, terminating (is_terminating: {is_terminating})", e.IsTerminating);
Log.CloseAndFlush();
};
TaskScheduler.UnobservedTaskException += (_, e) =>
{
Log.Error(e.Exception, "Unobserved task exception");
e.SetObserved();
};
var services = BuildServices();
using (var scope = services.CreateScope())
@@ -42,6 +73,8 @@ sealed class Program
ClaudeDoDbContext.MigrateAndConfigure(db);
}
ClipboardImageTempStore.DeleteOlderThan(TimeSpan.FromHours(24), DateTime.UtcNow);
try
{
ConfigureAppBuilder(AppBuilder.Configure(() => new App(services)))
@@ -60,6 +93,8 @@ sealed class Program
{
/* best effort on shutdown */
}
Log.CloseAndFlush();
}
}
+3 -1
View File
@@ -14,7 +14,7 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
- Legacy status values `Manual`/`Planning`/`Planned`/`Draft`/`Waiting` were retired. The backfill migration was squashed away (2026-08-26) — a database still holding those values can't be upgraded, see Schema below.
- **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt, IsManual (reminder list — tasks created here default to `IsManual`), Icon, FolderId
- `FolderId` (`folder_id`, nullable FK → `list_folders.id`, `ON DELETE SET NULL`) — the sidebar folder the list sits in; null = top level. Written only by the UI (`ListsIslandViewModel` drag/context menu), never by MCP.
- **ListFolderEntity** — Id, Name, Icon, SortOrder, IsCollapsed, CreatedAt → `list_folders`. A pure sidebar grouping: no tasks, no config, no working dir, **one level** (folders never nest). Deliberately its own table rather than a flag on `lists`, so every caller that enumerates lists (MCP `list_task_lists`, "move to list", the queue picker, repo import) needs no folder filtering. Folder CRUD lives on `ListRepository`.
- **ListFolderEntity** — Id, Name, Icon, SortOrder, IsCollapsed, ParentFolderId, CreatedAt → `list_folders`. A pure sidebar grouping: no tasks, no config, no working dir. `ParentFolderId` (self-FK, `ON DELETE SET NULL`) lets folders nest arbitrarily deep — drag-and-drop only, no UI to pick a parent otherwise. Deliberately its own table rather than a flag on `lists`, so every caller that enumerates lists (MCP `list_task_lists`, "move to list", the queue picker, repo import) needs no folder filtering. Folder CRUD lives on `ListRepository`.
- **ListConfigEntity** — ListId (PK, 1:1), Model, SystemPrompt, AgentPath, MaxTurns, SessionSkills, VerifyCommand, PermissionMode, TicketProjectId (all nullable). `VerifyCommand` is an optional post-merge gate; null/blank = no gate → [review-merge](../../docs/explore-notes/review-merge.md). `PermissionMode` is null = inherit the global default; resolution order task → list → global lives in `EffectiveRunConfigResolver`. `TicketProjectId` (`ticket_project_id`) links the list to a project in the external ticket system; null = not linked → `ClaudeDo.Worker/CLAUDE.md` Tickets section.
- ⚠️ `ListRepository.SetConfigAsync` copies the entity **verbatim**, so every writer must carry the fields it doesn't own (`SessionSkills`, `PermissionMode`, `TicketProjectId`) or they silently reset. `UpdateListConfigDto.TicketProjectId` is tri-state (`null` = keep stored) for exactly that reason — only the list-settings modal sends an explicit value. This bit `PermissionMode` for real: its update-branch copy was missing from `SetConfigAsync` until `f0a3a186`, so a list's permission-mode override only ever stuck on the list's *first* save, never on a later one.
- **WorktreeEntity** — TaskId (PK, 1:1), Path, BranchName, BaseCommit, HeadCommit, DiffStat, MergeCommit (nullable — SHA of the merge commit this branch produced; the only thing making `revert_merge` possible without searching `git log`), State (`Active|Merged|Discarded|Kept`)
@@ -24,6 +24,7 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
- **WeekReportEntity** — Id, StartDate/EndDate (DateOnly), Markdown, GeneratedAt → `week_reports`, unique index on (start_date, end_date)
- **TaskAttachmentEntity** — Id, TaskId (FK, ON DELETE CASCADE), FileName, ByteSize, CreatedAt → `task_attachments`
- **TaskDependencyEntity** — one declared "run after" edge: TaskId + DependsOnTaskId, composite PK, table `task_dependencies`. Both FKs are `ON DELETE CASCADE`, which is what the old single `depends_on_task_id` column got from `ON DELETE SET NULL`: deleting a predecessor drops the edge so the dependent becomes pickable instead of blocked on a task that's gone. Written **only** by `TaskStateService.SetDependsOnAsync`.
- **MissionControlSessionEntity** — SessionId (PK), Cwd, TaskId (no FK), Title, StartedAt, PredecessorSessionId, BriefPath → `mission_control_sessions`. Index behind Mission Control's "Recent sessions" flyout; written only by `InteractiveLaunchSpecService` (task/ad-hoc/handoff tile launches), insert-if-absent → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md).
- **SubtaskEntity**, **AgentInfo** — subtasks / record for scanned agent files
### AppSettingsEntity
@@ -120,6 +121,7 @@ Tables (one per line so parallel migrations don't collide on the same line):
- `task_attachments`
- `task_dependencies`
- `list_folders`
- `mission_control_sessions`
Managed by EF Core migrations in `Migrations/`. **The 40 incremental migrations were squashed into
a single `20260826094154_InitialCreate` on 2026-08-26** — the schema history is git history now,
+1
View File
@@ -54,6 +54,7 @@ public class ClaudeDoDbContext : DbContext
public DbSet<DailyNoteEntity> DailyNotes => Set<DailyNoteEntity>();
public DbSet<WeekReportEntity> WeekReports => Set<WeekReportEntity>();
public DbSet<SessionSkillEntity> SessionSkills => Set<SessionSkillEntity>();
public DbSet<MissionControlSessionEntity> MissionControlSessions => Set<MissionControlSessionEntity>();
private static readonly ValueConverter<DateTime, DateTime> UtcConverter =
new(v => v, v => DateTime.SpecifyKind(v, DateTimeKind.Utc));
@@ -17,7 +17,14 @@ public class ListFolderEntityConfiguration : IEntityTypeConfiguration<ListFolder
builder.Property(f => f.Icon).HasColumnName("icon");
builder.Property(f => f.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0);
builder.Property(f => f.IsCollapsed).HasColumnName("is_collapsed").IsRequired().HasDefaultValue(false);
builder.Property(f => f.ParentFolderId).HasColumnName("parent_folder_id");
builder.HasIndex(f => f.SortOrder).HasDatabaseName("idx_list_folders_sort");
builder.HasIndex(f => f.ParentFolderId).HasDatabaseName("idx_list_folders_parent");
builder.HasOne<ListFolderEntity>()
.WithMany()
.HasForeignKey(f => f.ParentFolderId)
.OnDelete(DeleteBehavior.SetNull);
}
}
@@ -0,0 +1,22 @@
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ClaudeDo.Data.Configuration;
public class MissionControlSessionEntityConfiguration : IEntityTypeConfiguration<MissionControlSessionEntity>
{
public void Configure(EntityTypeBuilder<MissionControlSessionEntity> builder)
{
builder.ToTable("mission_control_sessions");
builder.HasKey(s => s.SessionId);
builder.Property(s => s.SessionId).HasColumnName("session_id").ValueGeneratedNever();
builder.Property(s => s.Cwd).HasColumnName("cwd").IsRequired();
builder.Property(s => s.TaskId).HasColumnName("task_id");
builder.Property(s => s.Title).HasColumnName("title").IsRequired();
builder.Property(s => s.StartedAt).HasColumnName("started_at").IsRequired();
builder.Property(s => s.PredecessorSessionId).HasColumnName("predecessor_session_id");
builder.Property(s => s.BriefPath).HasColumnName("brief_path");
builder.HasIndex(s => s.StartedAt);
}
}
+10
View File
@@ -558,6 +558,16 @@ public sealed class GitService
return exitCode == 0 ? stdout : null;
}
/// <summary>
/// Content of <paramref name="path"/> at <paramref name="revision"/> via `git show revision:path`.
/// Null when the path doesn't exist at that revision (new, deleted, or renamed file).
/// </summary>
public async Task<string?> ShowFileAsync(string repoDir, string revision, string path, CancellationToken ct = default)
{
var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["show", $"{revision}:{path}"], ct, trimOutput: false);
return exitCode == 0 ? stdout : null;
}
/// <summary>
/// Non-destructive mergeability probe via `git merge-tree --write-tree`. Writes only
/// loose objects — the working tree, index, and refs are left untouched.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,44 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddMissionControlSessions : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "mission_control_sessions",
columns: table => new
{
session_id = table.Column<string>(type: "TEXT", nullable: false),
cwd = table.Column<string>(type: "TEXT", nullable: false),
task_id = table.Column<string>(type: "TEXT", nullable: true),
title = table.Column<string>(type: "TEXT", nullable: false),
started_at = table.Column<DateTime>(type: "TEXT", nullable: false),
predecessor_session_id = table.Column<string>(type: "TEXT", nullable: true),
brief_path = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_mission_control_sessions", x => x.session_id);
});
migrationBuilder.CreateIndex(
name: "IX_mission_control_sessions_started_at",
table: "mission_control_sessions",
column: "started_at");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "mission_control_sessions");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,49 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddNestedListFolders : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "parent_folder_id",
table: "list_folders",
type: "TEXT",
nullable: true);
migrationBuilder.CreateIndex(
name: "idx_list_folders_parent",
table: "list_folders",
column: "parent_folder_id");
migrationBuilder.AddForeignKey(
name: "FK_list_folders_list_folders_parent_folder_id",
table: "list_folders",
column: "parent_folder_id",
principalTable: "list_folders",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_list_folders_list_folders_parent_folder_id",
table: "list_folders");
migrationBuilder.DropIndex(
name: "idx_list_folders_parent",
table: "list_folders");
migrationBuilder.DropColumn(
name: "parent_folder_id",
table: "list_folders");
}
}
}
@@ -389,6 +389,10 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<string>("ParentFolderId")
.HasColumnType("TEXT")
.HasColumnName("parent_folder_id");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
@@ -397,12 +401,54 @@ namespace ClaudeDo.Data.Migrations
b.HasKey("Id");
b.HasIndex("ParentFolderId")
.HasDatabaseName("idx_list_folders_parent");
b.HasIndex("SortOrder")
.HasDatabaseName("idx_list_folders_sort");
b.ToTable("list_folders", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.MissionControlSessionEntity", b =>
{
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<string>("BriefPath")
.HasColumnType("TEXT")
.HasColumnName("brief_path");
b.Property<string>("Cwd")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("cwd");
b.Property<string>("PredecessorSessionId")
.HasColumnType("TEXT")
.HasColumnName("predecessor_session_id");
b.Property<DateTime>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("SessionId");
b.HasIndex("StartedAt");
b.ToTable("mission_control_sessions", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{
b.Property<Guid>("Id")
@@ -988,6 +1034,14 @@ namespace ClaudeDo.Data.Migrations
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListFolderEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListFolderEntity", null)
.WithMany()
.HasForeignKey("ParentFolderId")
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
+4 -1
View File
@@ -4,7 +4,7 @@ namespace ClaudeDo.Data.Models;
/// A sidebar folder grouping user lists. Deliberately its own table rather than a flag on
/// <see cref="ListEntity"/>: every place that enumerates lists (MCP list_task_lists, "move to
/// list", the queue picker, repo import) would otherwise have to filter folders out.
/// One level only — a folder holds lists, never other folders.
/// Folders nest arbitrarily deep via <see cref="ParentFolderId"/>.
/// </summary>
public sealed class ListFolderEntity
{
@@ -17,4 +17,7 @@ public sealed class ListFolderEntity
// Sidebar expand/collapse state. Lives here rather than in ui.config.json because the row is
// already being read for the folder itself.
public bool IsCollapsed { get; set; }
// Parent folder, or null for a top-level folder. Self-FK, ON DELETE SET NULL — deleting a
// folder releases its subfolders to the top level, same as it does for its lists.
public string? ParentFolderId { get; set; }
}
@@ -0,0 +1,27 @@
namespace ClaudeDo.Data.Models;
/// <summary>One claude session started in a Mission Control tile (ad-hoc, task or handoff) — the
/// index behind "Recent sessions". The transcript itself stays with Claude Code.</summary>
public sealed class MissionControlSessionEntity
{
/// <summary>The claude session id (`--session-id` / `--resume`).</summary>
public string SessionId { get; init; } = string.Empty;
/// <summary>Working directory the session ran in — a resume must start there.</summary>
public string Cwd { get; set; } = string.Empty;
/// <summary>Task the tile belonged to; null for an ad-hoc tile. No FK: the row outlives the task.</summary>
public string? TaskId { get; set; }
/// <summary>Tile title at start (task title or folder name; a handoff inherits its predecessor's).</summary>
public string Title { get; set; } = string.Empty;
/// <summary>UTC time the session was first launched.</summary>
public DateTime StartedAt { get; set; } = DateTime.UtcNow;
/// <summary>Session this one was handed off from; null unless it is a handoff session.</summary>
public string? PredecessorSessionId { get; set; }
/// <summary>brief.md the session started from; non-null exactly for handoff sessions.</summary>
public string? BriefPath { get; set; }
}
+1 -1
View File
@@ -523,7 +523,7 @@ public static class PromptFiles
- Clean merge → mergeStatus "merged_verify_pending" (expected: the task deliberately stays WaitingForReview until verify_merges passes) or "merged" (list has no verify command; the task is Done). Either way, move on.
- Conflict (markers left in the working tree, repoPath returned) → resolve it below.
After each merge, main has moved — a clean preview_merge_set result from before this merge is now stale for the remaining tasks. If you are unsure whether an earlier overlap warning still matters, call preview_merge for the next task again before merging it.
After each merge, main has moved — a clean preview_merge_set result from before this merge is now stale for the remaining tasks. If you are unsure whether an earlier overlap warning still matters, call preview_merge for the next task again before merging it (default runVerify=false — this is just a conflict recheck, not a build; the verify command still runs once via verify_merges after the last merge).
Every branch in this run forked from the same base, so conflicts between them are the NORMAL case, not a failure. Resolve them and keep going; do not abandon the run because a merge conflicted.
@@ -135,6 +135,14 @@ public sealed class ListRepository
.ExecuteUpdateAsync(s => s.SetProperty(f => f.IsCollapsed, collapsed), ct);
}
/// <summary>Moves a folder under <paramref name="parentFolderId"/>, or to the top level when null.</summary>
public async Task SetFolderParentAsync(string folderId, string? parentFolderId, CancellationToken ct = default)
{
await _context.ListFolders
.Where(f => f.Id == folderId)
.ExecuteUpdateAsync(s => s.SetProperty(f => f.ParentFolderId, parentFolderId), ct);
}
/// <summary>Moves a list into <paramref name="folderId"/>, or back to the top level when null.</summary>
public async Task SetListFolderAsync(string listId, string? folderId, CancellationToken ct = default)
{
@@ -0,0 +1,34 @@
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Data.Repositories;
/// <summary>Reads and writes <c>mission_control_sessions</c>.</summary>
public sealed class MissionControlSessionRepository
{
private readonly ClaudeDoDbContext _context;
public MissionControlSessionRepository(ClaudeDoDbContext context) => _context = context;
/// <summary>Inserts the session unless it is already recorded — a resumed task session keeps
/// its original row and start time.</summary>
public async Task RecordAsync(MissionControlSessionEntity session, CancellationToken ct = default)
{
if (await _context.MissionControlSessions.AnyAsync(s => s.SessionId == session.SessionId, ct)) return;
_context.MissionControlSessions.Add(session);
await _context.SaveChangesAsync(ct);
}
/// <summary>The recorded session, or null.</summary>
public async Task<MissionControlSessionEntity?> GetAsync(string sessionId, CancellationToken ct = default) =>
await _context.MissionControlSessions.AsNoTracking().FirstOrDefaultAsync(s => s.SessionId == sessionId, ct);
/// <summary>Sessions started since <paramref name="sinceUtc"/>, newest first, at most <paramref name="max"/>.</summary>
public async Task<IReadOnlyList<MissionControlSessionEntity>> ListRecentAsync(
DateTime sinceUtc, int max, CancellationToken ct = default) =>
await _context.MissionControlSessions.AsNoTracking()
.Where(s => s.StartedAt >= sinceUtc)
.OrderByDescending(s => s.StartedAt)
.Take(max)
.ToListAsync(ct);
}
+4 -2
View File
@@ -72,7 +72,8 @@ public record WorktreeOverviewDto(
WorktreeState State,
string? DiffStat,
DateTime CreatedAt,
bool PathExistsOnDisk);
bool PathExistsOnDisk,
int TaskNumber = 0);
public record ForceRemoveResultDto(bool Removed, string? Reason);
@@ -91,7 +92,8 @@ public record MergePreviewDto(
int? VerifyExitCode = null, long? VerifyDurationMs = null, string? VerifyOutputTail = null);
public record MergeTargetsDto(
string DefaultBranch, IReadOnlyList<string> LocalBranches, string DefaultCommitMessage);
string DefaultBranch, IReadOnlyList<string> LocalBranches, string DefaultCommitMessage,
bool TargetDirty = false);
public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDocumentDto> Files);
+17 -2
View File
@@ -328,6 +328,7 @@
"worktree": "Worktree",
"reviewCombinedDiff": "Kombinierten Diff prüfen",
"openDiffHint": "Diff öffnen, um Mergen freizuschalten",
"targetDirty": "Ziel-Arbeitsverzeichnis hat uncommittete Änderungen — beim Mergen werden sie gestasht und danach wiederhergestellt.",
"approveMerge": "Genehmigen & Mergen",
"park": "Parken",
"parkTip": "Zurückstellen — zurück auf Leerlauf, Worktree bleibt erhalten",
@@ -377,6 +378,12 @@
"openSession": "Sitzung öffnen",
"homeFolder": "Home (Schnellsitzung)",
"browseForFolder": "Ordner wählen…",
"recentSessions": "Letzte Sitzungen",
"recentSessionsEmpty": "Keine Sitzungen in den letzten 14 Tagen",
"resumeSession": "Fortsetzen",
"showBrief": "Brief",
"handoffSession": "Übergabe",
"briefOpenFailed": "Brief konnte nicht geöffnet werden: {0}",
"focusMode": "Fokus",
"overviewMode": "Übersicht",
"closeSession": "Sitzung hart beenden",
@@ -805,6 +812,14 @@
"done": "Fertig"
}
},
"dialog": {
"cancel": "Abbrechen",
"confirm": "Bestätigen",
"delete": "Löschen",
"ok": "OK",
"confirmTitle": "Bestätigen",
"errorTitle": "Fehler"
},
"shell": {
"menu": {
"help": "Hilfe",
@@ -851,7 +866,7 @@
"tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "statusPill": "{0} laufend · {1} Prüfung", "openTaskOne": "1 offene Aufgabe", "openTasks": "{0} offene Aufgaben", "planningResumeFailed": "Planungssitzung konnte nicht fortgesetzt werden: {0}", "approveFailed": "Genehmigen & Mergen fehlgeschlagen: {0}", "cancelReviewFailed": "Prüfung abbrechen fehlgeschlagen: {0}", "sendToQueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "queuePlanBlockedInteractive": "Plan kann nicht in die Warteschlange gestellt werden — {0} hat eine offene interaktive Sitzung und muss zuerst geschlossen werden.", "moveRunningRejected": "Ein laufender Task kann nicht in eine andere Liste verschoben werden.", "moveWorktreeRejected": "Verschieben nicht möglich — dieser Task hat einen aktiven Worktree, der auf sein aktuelles Repo zeigt.", "moveRepoConfirm": "Unterschiedliche Repos — {0} → {1}. Task trotzdem verschieben?", "moveConfirmUnavailable": "Verschieben nicht möglich — der Bestätigungsdialog ist nicht verfügbar.", "quickClaudeNoWorkingDir": "Für diese Liste ist kein Arbeitsverzeichnis konfiguriert.", "quickClaudeDirMissing": "Arbeitsverzeichnis existiert nicht mehr: {0}", "setStatusFailed": "Status konnte nicht aktualisiert werden: {0}", "cancelFailed": "Abbrechen fehlgeschlagen: {0}", "rejectToQueueFailed": "Zurückweisen in die Warteschlange fehlgeschlagen: {0}", "rejectToIdleFailed": "Zurückweisen fehlgeschlagen: {0}", "deleteTaskConfirm": "\"{0}\" löschen? Dies kann nicht rückgängig gemacht werden.", "deleteTaskFailed": "Löschen fehlgeschlagen: {0}", "toggleDoneFailed": "Erledigt-Status konnte nicht aktualisiert werden: {0}", "removeFromQueueFailed": "Aus der Warteschlange entfernen fehlgeschlagen: {0}", "openWorktreeFailed": "Worktree konnte nicht geöffnet werden: {0}" },
"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).", "buildFailed": "Kombinierte Vorschau konnte nicht erstellt werden: {0}" },
"merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Wird zusammengeführt…", "progressVerifying": "Verify-Kommando der Liste läuft… ({0})", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "blocked": "Blockiert: {0}", "verifyFailed": "Merge ist gelandet, aber das Verify-Kommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf Erledigt gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "dirtyTarget": "Merge abgelehnt — das Ziel-Arbeitsverzeichnis hat uncommittete Änderungen.", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" },
"merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Wird zusammengeführt…", "progressVerifying": "Verify-Kommando der Liste läuft… ({0})", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "blocked": "Blockiert: {0}", "verifyFailed": "Merge ist gelandet, aber das Verify-Kommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf Erledigt gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "dirtyTarget": "Merge abgelehnt — das Ziel-Arbeitsverzeichnis hat uncommittete Änderungen.", "targetDirty": "Das Ziel-Arbeitsverzeichnis hat bereits uncommittete Änderungen — stashen und mergen?", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" },
"settingsModal": { "workerOffline": "Worker offline — Einstellungen schreibgeschützt.", "saveFailed": "Speichern fehlgeschlagen: {0}" },
"onlineInbox": { "workerOffline": "Worker offline — Konfiguration kann nicht geladen werden.", "saved": "Konfiguration gespeichert.", "saveFailed": "Speichern fehlgeschlagen: {0}", "signedIn": "Erfolgreich angemeldet.", "signedInNoRole": "Angemeldet, aber diesem Konto fehlt die Rolle 'user' in Zitadel — die Online-Synchronisierung wird abgelehnt, bis die Rolle im ClaudeDo-Projekt zugewiesen wird.", "signInFailed": "Anmeldung fehlgeschlagen: {0}", "signedOut": "Abgemeldet.", "signOutFailed": "Abmeldung fehlgeschlagen: {0}" },
"tickets": { "connectionOk": "Verbunden als {0}.", "connectionMissingScopes": "Verbunden als {0}, aber es fehlen Scopes: {1}", "tokenCleared": "Token entfernt.", "workerOffline": "Worker nicht erreichbar." },
@@ -870,7 +885,7 @@
"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.", "cleanupFailed": "Aufräumen fehlgeschlagen: {0}", "resetFailed": "Zurücksetzen fehlgeschlagen: {0}" },
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "Liste", "cleanupFailed": "Aufräumen fehlgeschlagen.", "cleanupFailedDetailed": "Aufräumen fehlgeschlagen: {0}", "removed": "{0} Worktree(s) entfernt.", "discardConfirm": "Worktree für \"{0}\" verwerfen? Nicht committete Arbeit geht verloren.", "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}", "batchDone": "{0} gemergt, {1} zu lösen.", "openInExplorerFailed": "Pfad konnte nicht geöffnet werden: {0}" },
"listSettings": { "untitled": "Unbenannt", "ticketProjectNone": "— keins —", "loadFailed": "Listeneinstellungen konnten nicht geladen werden: {0}", "ticketProjectsLoadFailed": "Ticket-Projekte konnten nicht geladen werden: {0}", "confirmDeleteFolder": "Ordner \"{0}\" löschen? Die Listen darin bleiben erhalten und wandern auf die oberste Ebene.", "confirmDeleteList": "Liste \"{0}\" und alle ihre Aufgaben löschen? Das kann nicht rückgängig gemacht werden.", "deleteBlockedPlanning": "Diese Liste hat Planungssitzungen mit Kind-Aufgaben. Erst diese verwerfen, dann die Liste löschen.", "workingDirMissing": "Dieser Ordner existiert nicht.", "workingDirNotRepo": "Dieser Ordner ist noch kein Git-Repository. Führe darin zuerst `git init` aus, oder leere das Arbeitsverzeichnis, um dies als manuelle Liste zu speichern." },
"listSettings": { "untitled": "Unbenannt", "ticketProjectNone": "— keins —", "loadFailed": "Listeneinstellungen konnten nicht geladen werden: {0}", "ticketProjectsLoadFailed": "Ticket-Projekte konnten nicht geladen werden: {0}", "confirmDeleteFolder": "Ordner \"{0}\" löschen? Die Listen und Unterordner darin bleiben erhalten und wandern auf die oberste Ebene.", "confirmDeleteList": "Liste \"{0}\" und alle ihre Aufgaben löschen? Das kann nicht rückgängig gemacht werden.", "deleteBlockedPlanning": "Diese Liste hat Planungssitzungen mit Kind-Aufgaben. Erst diese verwerfen, dann die Liste löschen.", "workingDirMissing": "Dieser Ordner existiert nicht.", "workingDirNotRepo": "Dieser Ordner ist noch kein Git-Repository. Führe darin zuerst `git init` aus, oder leere das Arbeitsverzeichnis, um dies als manuelle Liste zu speichern." },
"detailsIsland": { "verifyFailed": "Merge ist erfolgt, aber das Verifikationskommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf 'Erledigt' gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "dirtyTarget": "Merge abgelehnt — das Ziel-Arbeitsverzeichnis hat uncommittete Änderungen.", "stashAndMergeConfirm": "Das Ziel-Arbeitsverzeichnis hat uncommittete Änderungen.\nStashen, mergen und danach wiederherstellen?", "stopOffline": "Worker offline — Task kann nicht gestoppt werden.", "stopFailed": "Stoppen fehlgeschlagen: {0}", "enqueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "dequeueFailed": "Aus der Warteschlange entfernen fehlgeschlagen: {0}", "resetAndRetryFailed": "Zurücksetzen & erneut versuchen fehlgeschlagen: {0}", "toggleDoneFailed": "Erledigt-Status konnte nicht aktualisiert werden: {0}" },
"mergeSection": { "openWorktreeFailed": "Worktree konnte nicht geöffnet werden: {0}" },
"about": { "openPathFailed": "Pfad konnte nicht geöffnet werden: {0}" },
+17 -2
View File
@@ -328,6 +328,7 @@
"worktree": "Worktree",
"reviewCombinedDiff": "Review Combined Diff",
"openDiffHint": "Open the diff to enable merge",
"targetDirty": "Target working directory has uncommitted changes — merging will stash and restore them.",
"approveMerge": "Approve & Merge",
"park": "Park",
"parkTip": "Set aside — back to Idle, keeps the worktree",
@@ -377,6 +378,12 @@
"openSession": "Open session",
"homeFolder": "Home (quick session)",
"browseForFolder": "Choose folder…",
"recentSessions": "Recent sessions",
"recentSessionsEmpty": "No sessions in the last 14 days",
"resumeSession": "Resume",
"showBrief": "Brief",
"handoffSession": "Handoff",
"briefOpenFailed": "Couldn't open the brief: {0}",
"focusMode": "Focus",
"overviewMode": "Overview",
"closeSession": "Kill session",
@@ -805,6 +812,14 @@
"done": "Done"
}
},
"dialog": {
"cancel": "Cancel",
"confirm": "Confirm",
"delete": "Delete",
"ok": "OK",
"confirmTitle": "Confirm",
"errorTitle": "Error"
},
"shell": {
"menu": {
"help": "Help",
@@ -851,7 +866,7 @@
"tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "statusPill": "{0} running · {1} review", "openTaskOne": "1 open task", "openTasks": "{0} open tasks", "planningResumeFailed": "Couldn't resume planning session: {0}", "approveFailed": "Approve & merge failed: {0}", "cancelReviewFailed": "Cancel review failed: {0}", "sendToQueueFailed": "Send to queue failed: {0}", "queuePlanBlockedInteractive": "Can't queue the plan — {0} has an open interactive session and must be closed first.", "moveRunningRejected": "Can't move a running task to another list.", "moveWorktreeRejected": "Can't move — this task has an active worktree pointing at its current repo.", "moveRepoConfirm": "Different repos — {0} → {1}. Move the task anyway?", "moveConfirmUnavailable": "Can't move — the confirmation dialog isn't available.", "quickClaudeNoWorkingDir": "This list has no working directory configured.", "quickClaudeDirMissing": "Working directory no longer exists: {0}", "setStatusFailed": "Failed to update status: {0}", "cancelFailed": "Cancel failed: {0}", "rejectToQueueFailed": "Reject to queue failed: {0}", "rejectToIdleFailed": "Reject failed: {0}", "deleteTaskConfirm": "Delete \"{0}\"? This cannot be undone.", "deleteTaskFailed": "Delete failed: {0}", "toggleDoneFailed": "Couldn't update done state: {0}", "removeFromQueueFailed": "Remove from queue failed: {0}", "openWorktreeFailed": "Couldn't open worktree: {0}" },
"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).", "buildFailed": "Could not build combined preview: {0}" },
"merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Merging…", "progressVerifying": "Running the list's verify command… ({0})", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "blocked": "Blocked: {0}", "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "dirtyTarget": "Merge refused — the target working directory has uncommitted changes.", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" },
"merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Merging…", "progressVerifying": "Running the list's verify command… ({0})", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "blocked": "Blocked: {0}", "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "dirtyTarget": "Merge refused — the target working directory has uncommitted changes.", "targetDirty": "The target working directory already has uncommitted changes — stash them and merge?", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" },
"settingsModal": { "workerOffline": "Worker offline — settings read-only.", "saveFailed": "Save failed: {0}" },
"onlineInbox": { "workerOffline": "Worker offline — cannot load config.", "saved": "Config saved.", "saveFailed": "Save failed: {0}", "signedIn": "Signed in successfully.", "signedInNoRole": "Signed in, but this account is missing the 'user' role in Zitadel — online sync will be rejected until the role is granted in the ClaudeDo project.", "signInFailed": "Sign-in failed: {0}", "signedOut": "Signed out.", "signOutFailed": "Sign-out failed: {0}" },
"tickets": { "connectionOk": "Connected as {0}.", "connectionMissingScopes": "Connected as {0}, but missing scopes: {1}", "tokenCleared": "Token removed.", "workerOffline": "Worker not reachable." },
@@ -870,7 +885,7 @@
"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).", "cleanupFailed": "Cleanup failed: {0}", "resetFailed": "Reset failed: {0}" },
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "list", "cleanupFailed": "Cleanup failed.", "cleanupFailedDetailed": "Cleanup failed: {0}", "removed": "Removed {0} worktree(s).", "discardConfirm": "Discard the worktree for \"{0}\"? Uncommitted work will be lost.", "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}", "batchDone": "Merged {0}, {1} need resolution.", "openInExplorerFailed": "Couldn't open path: {0}" },
"listSettings": { "untitled": "Untitled", "ticketProjectNone": "— none —", "loadFailed": "Couldn't load list settings: {0}", "ticketProjectsLoadFailed": "Couldn't load ticket projects: {0}", "confirmDeleteFolder": "Delete folder \"{0}\"? The lists inside it are kept and move back to the top level.", "confirmDeleteList": "Delete list \"{0}\" and all its tasks? This cannot be undone.", "deleteBlockedPlanning": "This list has planning sessions with child tasks. Discard those first, then delete the list.", "workingDirMissing": "This folder doesn't exist.", "workingDirNotRepo": "This folder isn't a git repository yet. Run `git init` in it first, or clear the working directory to save this as a manual list." },
"listSettings": { "untitled": "Untitled", "ticketProjectNone": "— none —", "loadFailed": "Couldn't load list settings: {0}", "ticketProjectsLoadFailed": "Couldn't load ticket projects: {0}", "confirmDeleteFolder": "Delete folder \"{0}\"? The lists and subfolders inside it are kept and move back to the top level.", "confirmDeleteList": "Delete list \"{0}\" and all its tasks? This cannot be undone.", "deleteBlockedPlanning": "This list has planning sessions with child tasks. Discard those first, then delete the list.", "workingDirMissing": "This folder doesn't exist.", "workingDirNotRepo": "This folder isn't a git repository yet. Run `git init` in it first, or clear the working directory to save this as a manual list." },
"detailsIsland": { "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "dirtyTarget": "Merge refused — the target working directory has uncommitted changes.", "stashAndMergeConfirm": "The target working directory has uncommitted changes.\nStash them, merge, and restore them afterwards?", "stopOffline": "Worker offline — can't stop the task.", "stopFailed": "Stop failed: {0}", "enqueueFailed": "Queue failed: {0}", "dequeueFailed": "Remove from queue failed: {0}", "resetAndRetryFailed": "Reset & retry failed: {0}", "toggleDoneFailed": "Couldn't update done state: {0}" },
"mergeSection": { "openWorktreeFailed": "Couldn't open worktree: {0}" },
"about": { "openPathFailed": "Couldn't open path: {0}" },
+2 -2
View File
@@ -41,7 +41,7 @@ Design/ — Tokens.axaml (design tokens; merged before styles)
- **IslandsShellViewModel** — root coordinator. Owns the three island VMs and the `WorkerClient`, wires cross-island events (selection, notes/prep mode, conflict resolution), connection state, the update banner, the inline worker-log strip (clickable → Log Visualizer overlay; `FlashFooterError` surfaces UI-action failures + the worker's Warn/Error there), responsive-layout flags (`ShowLists`/`ShowDetails` by window width), `PrimeStatus` flash, the modal openers, and `RestartWorkerAsync`/`CheckForUpdatesAsync`. Hosts `UpdateCheckService`.
- **ListsIslandViewModel** — smart lists (My Day, Important, Planned, virtual queued/running/review), user lists, selection, list CRUD, drag-reorder, badge counts, opens list settings / repo import / worktrees overview, `OpenInExplorer`/`OpenInTerminal`.
- **Folders:** the sidebar's top level is **one mixed collection**, `TopLevel` (`ObservableCollection<object>`: `ListFolderNavItemViewModel` rows, each with its own `Lists`, interleaved with folder-less `ListNavItemViewModel` rows) — that's what lets a list sit *above* a folder. `Folders`/`RootLists` are `OfType` **projections** of it, not collections to keep in sync. `UserLists` stays the **flat superset** every count refresh, row refresh and the no-repo banner work over — a row lives in both. `ContainerOf(row)` resolves which collection renders it (non-generic `IList`, since the top level is mixed). The view is a single `ItemsControl` over `TopLevel` with **implicit, type-matched `DataTemplate`s in `UserControl.DataTemplates`** — no keyed row template, and the folder's child `ItemsControl` resolves the same list template by walking up to the UserControl. A folder is never selectable: tapping the header runs `ToggleFolderCommand` (persisted to `list_folders.is_collapsed`), and its badge is the sum of its children. `DropAsync(source, target, placeBelow)` handles all four source/target combinations and doubles as the move: a drop position implies a container, so dropping a list onto a row in another folder — or next to *anything* at the top level, folder headers included — reassigns `FolderId`. A folder dropped onto a row inside a folder is ignored; folders never nest. `MoveToFolderAsync` is the drop-into-the-header path (reaches collapsed/empty folders, bound to the middle half of the header — the outer quarters insert *next to* the folder instead); `RemoveFromFolderCommand` is its context-menu counterpart. Both end in `PersistOrderAsync` → `ListRepository.ReorderSidebarAsync`, one `sort_order` sequence spanning **both tables**. ⚠️ A **folder** drag starts from `PointerMoved` past a 4px threshold using the *stored* `PointerPressedEventArgs` (`DoDragDropAsync` only accepts those) — starting it on press would capture the pointer and swallow the `Tapped` that folds the folder.
- **Folders:** the sidebar's top level is **one mixed collection**, `TopLevel` (`ObservableCollection<object>`: `ListFolderNavItemViewModel` rows, each with its own `Children`, interleaved with folder-less `ListNavItemViewModel` rows) — that's what lets a list sit *above* a folder. Folders nest arbitrarily deep — a folder's `Children` mixes lists and subfolders the same way `TopLevel` mixes folders and folder-less lists, via `ParentFolderId`. `Folders` is a **recursive** `OfType` projection over the whole tree (not just the top level); `RootLists` stays a `TopLevel`-only projection. `UserLists` stays the **flat superset** every count refresh, row refresh and the no-repo banner work over — a row lives in both. `ContainerOf(row-or-folder)` resolves which collection renders it (non-generic `IList`, since every container is mixed). The view is a single `ItemsControl` over `TopLevel` with **implicit, type-matched `DataTemplate`s in `UserControl.DataTemplates`** — no keyed row template, and the folder's child `ItemsControl` resolves the same templates recursively by walking up to the UserControl (which is also what indents nested folders: each level adds its template's fixed margin). A folder is never selectable: tapping the header runs `ToggleFolderCommand` (persisted to `list_folders.is_collapsed`), and its badge is the sum of all descendant lists, recursive. `DropAsync(source, target, placeBelow)` handles list and folder sources against any target and doubles as the move: a drop position implies a container, so dropping a row onto a row in another folder — or next to *anything* at the top level, folder headers included — reassigns `FolderId`/`ParentFolderId`. A folder dropped onto or next to a target inside its own subtree is silently ignored (self/descendant guard), never nested into itself. `MoveToFolderAsync` is the drop-into-the-header path (reaches collapsed/empty folders, bound to the middle half of the header — the outer quarters insert *next to* the folder instead; also takes a folder source now); `RemoveFromFolderCommand` is its context-menu counterpart. Both end in `PersistOrderAsync` → `ListRepository.ReorderSidebarAsync`/`SetFolderParentAsync`, one `sort_order` sequence spanning **both tables**, walked depth-first. ⚠️ A **folder** drag starts from `PointerMoved` past a 4px threshold using the *stored* `PointerPressedEventArgs` (`DoDragDropAsync` only accepts those) — starting it on press would capture the pointer and swallow the `Tapped` that folds the folder.
- **TasksIslandViewModel** — open/overdue/completed groups for the selected list with hierarchy-aware regrouping; task CRUD, drag-reorder, toggle done/star, schedule, enqueue/dequeue, cancel; review actions; planning session lifecycle; `RefineTask`, `OpenConPtySessionRequested`, `ToggleManual`, `SyncInteractiveSessions`; MyDay extras (`IsMyDayList`, `ClearDayCommand`, `ShowPrepLogCommand`) and the pinned Notes pseudo-row. Raises `NotesRequested`/`PrepRequested` for the shell.
- **DetailsIslandViewModel** — the detail pane for a bound `TaskRowViewModel`. Owns live-log streaming (`Log` via `StreamLineFormatter`), debounced title/description editing, subtasks, the session-outcome/roadblock split, the three-tab work console (`output`/`git`/`session`), child surfacing (`ChildOutcomes` + `ChildrenNeedingAttention` drive an attention band on the Session tab), attachments, and the modes `IsNotesMode`/`IsPrepMode`/computed `IsTaskDetailVisible`. Failures raise `ErrorReported`, wired by the shell into `FlashFooterError`.
- Three concerns are extracted into section VMs exposed as properties: `AgentSettings` (`AgentConfigEditorViewModel`, scope=Task), `MergeSectionViewModel`, `PrepPanelViewModel`. Helper rows live in the same file.
@@ -92,7 +92,7 @@ single-task and planning unit-merge conflicts. Full detail →
The two surfaces are **not** variants of one control (read-only 2-way renderer over aligned rows
vs. 3-way editor over a writable document), but they share their AvaloniaEdit host plumbing via
`Views/Controls/DiffEditorSetup.cs`: the process-wide TextMate `Registry`, `InstallHighlighting`,
`ApplyGrammar`, the `Brush` resource fallback, the `Seg` segment, and `VerticalScrollSync`. Put
`ApplyGrammar`, the `Brush` resource fallback, the `Seg` segment, and `ScrollSync`. Put
new editor boilerplate there rather than copying it a third time.
## Services
+1
View File
@@ -17,6 +17,7 @@
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
<PackageReference Include="Duende.IdentityModel.OidcClient" Version="7.1.0" />
<PackageReference Include="Iciclecreek.Avalonia.Terminal" Version="2.0.3" />
<PackageReference Include="Serilog" Version="4.0.0" />
</ItemGroup>
<ItemGroup>
+11
View File
@@ -104,6 +104,16 @@
<SolidColorBrush x:Key="RoadblockTintBrush" Color="#1FD4A574" />
<SolidColorBrush x:Key="RoadblockTintBorderBrush" Color="#4CD4A574" />
<!-- Mission Control tile identity colours. Cool hues only, on purpose: the warm/green half of
the wheel is taken by the status semantics above, and an identity strip must never read as
"running"/"review"/"failed". -->
<SolidColorBrush x:Key="Identity1Brush" Color="#5FA8C8" />
<SolidColorBrush x:Key="Identity2Brush" Color="#4FA396" />
<SolidColorBrush x:Key="Identity3Brush" Color="#7E88CC" />
<SolidColorBrush x:Key="Identity4Brush" Color="#A57BB5" />
<SolidColorBrush x:Key="Identity5Brush" Color="#C77FA0" />
<SolidColorBrush x:Key="Identity6Brush" Color="#5C78B8" />
<!-- Merge editor (3-pane conflict resolver) block tints -->
<SolidColorBrush x:Key="MergeOursTintBrush" Color="#1F7C9166" /> <!-- ours side (moss) -->
<SolidColorBrush x:Key="MergeTheirsTintBrush" Color="#1FD4A574" /> <!-- theirs side (amber) -->
@@ -148,6 +158,7 @@
<Thickness x:Key="IslandHeaderPadding">18,16,18,12</Thickness>
<Thickness x:Key="IslandBodyPadding">14</Thickness>
<Thickness x:Key="WindowBodyPadding">14</Thickness>
<Thickness x:Key="NestedRowIndent">18,0,0,0</Thickness> <!-- SpaceXl: a child row under its parent -->
<!-- ============================================================ -->
<!-- CORNERS & BORDERS -->
@@ -0,0 +1,45 @@
using System;
using System.IO;
using ClaudeDo.Data;
namespace ClaudeDo.Ui.Services;
/// <summary>
/// Temp PNGs written for clipboard-image paste into the interactive terminal (see
/// <see cref="ClaudeDo.Ui.Views.InteractiveTerminalView"/>) — dropped into the composer as a path,
/// the same way a file drop is, then read by Claude like any other file.
/// </summary>
public static class ClipboardImageTempStore
{
public static string Directory => Path.Combine(Paths.AppDataRoot(), "tmp");
/// <summary>A fresh, collision-free path for one pasted image.</summary>
public static string NewPngPath(DateTime utcNow) => NewPngPath(Directory, utcNow);
/// <summary>Deletes files older than <paramref name="maxAge"/> under the tmp dir — called once at
/// app startup, not a running cleanup service.</summary>
public static void DeleteOlderThan(TimeSpan maxAge, DateTime utcNow) => DeleteOlderThan(Directory, maxAge, utcNow);
/// <summary>Test seam for <see cref="NewPngPath(DateTime)"/> — takes the target dir explicitly
/// instead of the real app data root.</summary>
internal static string NewPngPath(string dir, DateTime utcNow) =>
Path.Combine(dir, $"paste-{utcNow:yyyyMMdd-HHmmssfff}-{Guid.NewGuid():N}.png");
/// <summary>Test seam for <see cref="DeleteOlderThan(TimeSpan,DateTime)"/> — takes the target dir
/// explicitly instead of the real app data root.</summary>
internal static void DeleteOlderThan(string dir, TimeSpan maxAge, DateTime utcNow)
{
if (!System.IO.Directory.Exists(dir)) return;
foreach (var file in System.IO.Directory.EnumerateFiles(dir))
{
try
{
if (utcNow - File.GetLastWriteTimeUtc(file) > maxAge)
File.Delete(file);
}
catch (IOException) { /* best effort */ }
catch (UnauthorizedAccessException) { /* best effort */ }
}
}
}
+3 -2
View File
@@ -28,8 +28,9 @@ public interface IDialogService
Task ShowConflictResolverAsync(ConflictResolverViewModel vm);
Task ShowLogVisualizerAsync(LogVisualizerViewModel vm);
/// <summary>Modal yes/no confirmation. Returns true only when confirmed.</summary>
Task<bool> ConfirmAsync(string message);
/// <summary>Modal yes/no confirmation. Returns true only when confirmed. <paramref name="confirmLabel"/>
/// overrides the default localized "Confirm" button text (e.g. a localized "Delete" for destructive actions).</summary>
Task<bool> ConfirmAsync(string message, string? confirmLabel = null);
/// <summary>Modal error notice with a single dismiss button.</summary>
Task ShowErrorAsync(string message);
@@ -30,6 +30,10 @@ public interface IWorkerClient : INotifyPropertyChanged
/// task to carry out nextPhase ("wait" | "merge" | "wait_final" | "merge_final").</summary>
event Action<string, IReadOnlyList<string>, string>? HandoffRequestedEvent;
/// <summary>A Mission Control session called handoff_session: (paneId, briefPath). The UI replaces
/// the tile whose IdentityKey is paneId with a fresh session starting from the brief.</summary>
event Action<string, string>? SessionHandoffRequestedEvent;
/// <summary>start_list_handler was called through the external MCP: (listId, taskIds). The UI
/// starts a list-handler run for them exactly like the "Let Claude handle it" button.</summary>
event Action<string, IReadOnlyList<string>>? ListHandlerStartRequestedEvent;
@@ -133,8 +137,12 @@ public interface IWorkerClient : INotifyPropertyChanged
/// in a task's worktree (same worktree prep as an autonomous run).</summary>
Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default);
/// <summary>Launch spec for an ad-hoc interactive session in an arbitrary directory --
/// no task, no worktree.</summary>
Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default);
/// no task, no worktree. paneId (the tile's IdentityKey) is rendered into the handoff hint.</summary>
Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, string paneId, CancellationToken ct = default);
/// <summary>Launch spec for the fresh session a tile hands off to after handoff_session.</summary>
Task<LaunchSpec> GetSessionHandoffLaunchSpecAsync(string paneId, string cwd, string briefPath, string? taskId, CancellationToken ct = default);
/// <summary>Launch spec resuming a recorded Mission Control session in its cwd, as tile paneId.</summary>
Task<LaunchSpec> GetResumeSessionLaunchSpecAsync(string sessionId, string paneId, CancellationToken ct = default);
/// <summary>Launch spec for an embedded ConPTY "merge helper" session that drives the given
/// tasks to a merged/Done state. listId scopes the session (and cwd) to that list.
/// handlerTaskId (from CreateMergeHelperTaskAsync) is rendered into the brief so the session
+29 -2
View File
@@ -49,6 +49,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public event Action<string, string, string>? TaskQuestionAskedEvent;
public event Action<string, string>? TaskQuestionResolvedEvent;
public event Action<string, IReadOnlyList<string>, string>? HandoffRequestedEvent;
public event Action<string, string>? SessionHandoffRequestedEvent;
public event Action<string, IReadOnlyList<string>>? ListHandlerStartRequestedEvent;
public event Action<string, string>? HandlerUsageWarningEvent;
public event Action? ConnectionRestoredEvent;
@@ -160,6 +161,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
Dispatcher.UIThread.Post(() => HandoffRequestedEvent?.Invoke(taskId, survivingTaskIds, nextPhase));
});
_hub.On<string, string>("SessionHandoffRequested", (paneId, briefPath) =>
{
Dispatcher.UIThread.Post(() => SessionHandoffRequestedEvent?.Invoke(paneId, briefPath));
});
_hub.On<string, IReadOnlyList<string>>("ListHandlerStartRequested", (listId, taskIds) =>
{
Dispatcher.UIThread.Post(() => ListHandlerStartRequestedEvent?.Invoke(listId, taskIds));
@@ -298,6 +304,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
ok = true;
return result;
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
Serilog.Log.Warning(ex, "Hub call {method_name} failed", method);
return default;
}
catch { return default; }
finally { OperationTiming.Shared.Record("hub", method, sw.Elapsed, ok); }
}
@@ -312,6 +323,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
await invoke();
ok = true;
}
catch (Exception ex)
{
Serilog.Log.Warning(ex, "Hub call {method_name} failed", method);
throw;
}
finally { OperationTiming.Shared.Record("hub", method, sw.Elapsed, ok); }
}
@@ -326,6 +342,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
ok = true;
return result;
}
catch (Exception ex)
{
Serilog.Log.Warning(ex, "Hub call {method_name} failed", method);
throw;
}
finally { OperationTiming.Shared.Record("hub", method, sw.Elapsed, ok); }
}
@@ -620,8 +641,14 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default)
=> await InvokeTimedAsync<LaunchSpec>("GetInteractiveLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetInteractiveLaunchSpec", taskId, ct));
public async Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
=> await InvokeTimedAsync<LaunchSpec>("GetAdHocLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetAdHocLaunchSpec", directory, ct));
public async Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, string paneId, CancellationToken ct = default)
=> await InvokeTimedAsync<LaunchSpec>("GetAdHocLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetAdHocLaunchSpec", directory, paneId, ct));
public async Task<LaunchSpec> GetSessionHandoffLaunchSpecAsync(string paneId, string cwd, string briefPath, string? taskId, CancellationToken ct = default)
=> await InvokeTimedAsync<LaunchSpec>("GetSessionHandoffLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetSessionHandoffLaunchSpec", paneId, cwd, briefPath, taskId, ct));
public async Task<LaunchSpec> GetResumeSessionLaunchSpecAsync(string sessionId, string paneId, CancellationToken ct = default)
=> await InvokeTimedAsync<LaunchSpec>("GetResumeSessionLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetResumeSessionLaunchSpec", sessionId, paneId, ct));
public async Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, string? model = null, CancellationToken ct = default)
=> await InvokeTimedAsync<LaunchSpec>("GetMergeHelperLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetMergeHelperLaunchSpec", taskIds, listId, handlerTaskId, model, ct));
@@ -291,11 +291,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
// Set by shell so DeleteTaskCommand can remove from list
public Func<TaskRowViewModel, System.Threading.Tasks.Task>? DeleteFromList { get; set; }
// Set by the view so DeleteTaskCommand can prompt yes/no before deleting
public Func<string, System.Threading.Tasks.Task<bool>>? ConfirmAsync { get; set; }
// Set by the view so DeleteTaskCommand can show an error message
public Func<string, System.Threading.Tasks.Task>? ShowErrorAsync { get; set; }
// Set by the shell (mirrors ListsIslandViewModel.Dialogs) so confirm/error prompts go
// through the one shared dialog implementation instead of a view-local copy.
public IDialogService? Dialogs { get; set; }
// ── Review ──────────────────────────────────────────────────────────────
[ObservableProperty]
@@ -728,6 +726,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
Merge.MergeTargetBranches.Clear();
foreach (var b in targets.LocalBranches) Merge.MergeTargetBranches.Add(b);
Merge.SelectedMergeTarget = targets.DefaultBranch;
Merge.TargetDirty = targets.TargetDirty;
}
}
await Merge.RefreshMergePreviewAsync();
@@ -778,6 +777,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
foreach (var b in targets.LocalBranches)
Merge.MergeTargetBranches.Add(b);
Merge.SelectedMergeTarget = targets.DefaultBranch;
Merge.TargetDirty = targets.TargetDirty;
}
}
}
@@ -824,6 +824,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
foreach (var b in targets.LocalBranches)
Merge.MergeTargetBranches.Add(b);
Merge.SelectedMergeTarget = targets.DefaultBranch;
Merge.TargetDirty = targets.TargetDirty;
}
}
}
@@ -1042,9 +1043,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
{
if (Task == null) return;
var row = Task;
if (ConfirmAsync != null)
if (Dialogs != null)
{
var ok = await ConfirmAsync($"Delete \"{row.Title}\"? This cannot be undone.");
var ok = await Dialogs.ConfirmAsync($"Delete \"{row.Title}\"? This cannot be undone.", Loc.T("dialog.delete"));
if (!ok) return;
}
// Routed through the worker (mirrors the MCP delete_task tool) so a deleted child
@@ -1060,14 +1061,14 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
{
// Belt and braces: the connection can drop between the CanExecute check and
// this call, so a stray throw here must surface, not vanish silently.
if (ShowErrorAsync != null)
await ShowErrorAsync(ex.Message);
if (Dialogs != null)
await Dialogs.ShowErrorAsync(ex.Message);
return;
}
if (!deleted)
{
if (ShowErrorAsync != null)
await ShowErrorAsync(error ?? "Delete failed.");
if (Dialogs != null)
await Dialogs.ShowErrorAsync(error ?? "Delete failed.");
return;
}
if (DeleteFromList != null)
@@ -1216,10 +1217,10 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
private async System.Threading.Tasks.Task ResetAndRetryAsync()
{
if (Task == null) return;
if (ConfirmAsync == null) return;
if (Dialogs == null) return;
var branchName = $"claudedo/{Task.Id.Replace("-", "")}";
var ok = await ConfirmAsync(
var ok = await Dialogs.ConfirmAsync(
$"Reset and retry?\nThis discards branch {branchName} (and uncommitted changes), then queues the task to run from the beginning.");
if (!ok) return;
@@ -1279,31 +1280,31 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
// Nothing landed -- the target working dir has uncommitted changes (often another
// session's WIP in the shared main checkout). Offer the one-shot stash retry; git
// restores the changes itself once the merge is through.
if (result?.Status == "dirty_target" && ConfirmAsync != null
&& await ConfirmAsync(Loc.T("vm.detailsIsland.stashAndMergeConfirm")))
if (result?.Status == "dirty_target" && Dialogs != null
&& await Dialogs.ConfirmAsync(Loc.T("vm.detailsIsland.stashAndMergeConfirm")))
{
ApproveOp.Report(Loc.T("ops.merge.merging"));
result = await _worker.ApproveReviewAsync(
taskId, Merge.SelectedMergeTarget ?? "", autostash: true);
}
if (result?.Status == "dirty_target" && ShowErrorAsync != null)
await ShowErrorAsync(result.ErrorMessage ?? Loc.T("vm.detailsIsland.dirtyTarget"));
if (result?.Status == "dirty_target" && Dialogs != null)
await Dialogs.ShowErrorAsync(result.ErrorMessage ?? Loc.T("vm.detailsIsland.dirtyTarget"));
else if (!hasChildren && result?.Status == "conflict")
await _merge.ResolveConflictAsync(taskId, Merge.SelectedMergeTarget ?? "");
// The merge itself already landed; the verify command failed, so the task stayed
// out of Done. Surface that instead of silently looking like nothing happened.
else if (!hasChildren && result?.Status == "verify_failed" && ShowErrorAsync != null)
await ShowErrorAsync(result.ErrorMessage ?? Loc.T("vm.detailsIsland.verifyFailed"));
else if (!hasChildren && result?.Status == "verify_failed" && Dialogs != null)
await Dialogs.ShowErrorAsync(result.ErrorMessage ?? Loc.T("vm.detailsIsland.verifyFailed"));
// Nothing landed -- the merge was refused before touching the repo because the
// branch would have overwritten an untracked file in the target working directory.
else if (!hasChildren && result?.Status == "untracked_collision" && ShowErrorAsync != null)
await ShowErrorAsync(result.ErrorMessage ?? Loc.T("vm.detailsIsland.untrackedCollision"));
else if (!hasChildren && result?.Status == "untracked_collision" && Dialogs != null)
await Dialogs.ShowErrorAsync(result.ErrorMessage ?? Loc.T("vm.detailsIsland.untrackedCollision"));
}
catch (Exception ex)
{
if (ShowErrorAsync != null)
await ShowErrorAsync(ex.Message);
if (Dialogs != null)
await Dialogs.ShowErrorAsync(ex.Message);
}
finally
{
@@ -1348,7 +1349,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
try { await _worker.SubmitTaskForReviewAsync(Task.Id); }
catch (Exception ex)
{
if (ShowErrorAsync != null) await ShowErrorAsync(ex.Message);
if (Dialogs != null) await Dialogs.ShowErrorAsync(ex.Message);
}
}
@@ -1379,9 +1380,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
[RelayCommand]
private async System.Threading.Tasks.Task ResetReviewAsync()
{
if (Task is null || !_worker.IsConnected || ConfirmAsync is null) return;
if (Task is null || !_worker.IsConnected || Dialogs is null) return;
var branchName = $"claudedo/{Task.Id.Replace("-", "")}";
var ok = await ConfirmAsync(
var ok = await Dialogs.ConfirmAsync(
$"Reset working tree?\nThis discards branch {branchName} (and all changes) and returns the task to Idle.");
if (!ok) return;
try { await _worker.ResetTaskAsync(Task.Id); }
@@ -6,15 +6,18 @@ namespace ClaudeDo.Ui.ViewModels.Islands;
/// <summary>
/// A sidebar folder row. Not a <see cref="ListNavItemViewModel"/> and not a
/// <see cref="ListKind"/> — a folder holds no tasks and is never selectable, so tapping it
/// toggles <see cref="IsCollapsed"/> instead of changing the selection.
/// toggles <see cref="IsCollapsed"/> instead of changing the selection. Folders nest arbitrarily
/// deep via <see cref="ParentFolderId"/>; <see cref="Children"/> mixes lists and subfolders, the
/// same way <see cref="ListsIslandViewModel.TopLevel"/> mixes folders and folder-less lists.
/// </summary>
public sealed partial class ListFolderNavItemViewModel : ViewModelBase
{
public required string Id { get; init; }
public string? ParentFolderId { get; set; }
[ObservableProperty] private string _name = "";
[ObservableProperty] private string _iconKey = "Folder";
[ObservableProperty] private bool _isCollapsed;
// Sum of the child lists' counts — a collapsed folder still shows what's inside it.
// Sum of the descendant lists' counts, recursive — a collapsed folder still shows what's inside it.
[ObservableProperty] private int _count;
// Set while a dragged list row hovers over the middle of this folder header (move-into-folder).
[ObservableProperty] private bool _isDropTarget;
@@ -23,5 +26,5 @@ public sealed partial class ListFolderNavItemViewModel : ViewModelBase
[ObservableProperty] private bool _dropHintAbove;
[ObservableProperty] private bool _dropHintBelow;
public ObservableCollection<ListNavItemViewModel> Lists { get; } = new();
public ObservableCollection<object> Children { get; } = new();
}
@@ -215,10 +215,20 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
/// This is what the view binds to; <see cref="UserLists"/> stays the flat superset.</summary>
public ObservableCollection<object> TopLevel { get; } = new();
/// <summary>Sidebar folders in order — a projection of <see cref="TopLevel"/>, not a second
/// collection to keep in sync.</summary>
public IReadOnlyList<ListFolderNavItemViewModel> Folders =>
TopLevel.OfType<ListFolderNavItemViewModel>().ToList();
/// <summary>Every sidebar folder, at any depth — a recursive projection of
/// <see cref="TopLevel"/>, not a second collection to keep in sync.</summary>
public IReadOnlyList<ListFolderNavItemViewModel> Folders => AllFolders(TopLevel).ToList();
private static IEnumerable<ListFolderNavItemViewModel> AllFolders(IEnumerable<object> items)
{
foreach (var item in items)
{
if (item is not ListFolderNavItemViewModel folder) continue;
yield return folder;
foreach (var descendant in AllFolders(folder.Children))
yield return descendant;
}
}
/// <summary>The user lists that sit outside any folder — likewise a projection.</summary>
public IReadOnlyList<ListNavItemViewModel> RootLists =>
TopLevel.OfType<ListNavItemViewModel>().ToList();
@@ -320,10 +330,18 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var lists = new ListRepository(ctx);
// Folders and top-level lists share one sort_order space, so both go into a single
// ordered bucket and get merged by it — that's what interleaves them.
var topLevel = new List<(int Sort, object Item)>();
// Every container (the top level, and each folder's children) gets its own ordered
// bucket keyed by sort_order, since folders and lists at any depth share one sort_order
// space. Buckets are keyed by parent folder id; null (via topLevelBucket) is the top level.
var topLevelBucket = new List<(int Sort, object Item)>();
var childBuckets = new Dictionary<string, List<(int Sort, object Item)>>();
var foldersById = new Dictionary<string, ListFolderNavItemViewModel>();
List<(int Sort, object Item)> BucketFor(string? parentFolderId) =>
parentFolderId is { } pid && foldersById.ContainsKey(pid)
? childBuckets.TryGetValue(pid, out var b) ? b : childBuckets[pid] = new()
: topLevelBucket;
foreach (var f in await lists.GetFoldersAsync(ct))
{
var folder = new ListFolderNavItemViewModel
@@ -332,9 +350,12 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
Name = f.Name,
IconKey = f.Icon ?? ListFolderIcon,
IsCollapsed = f.IsCollapsed,
ParentFolderId = f.ParentFolderId,
};
foldersById[f.Id] = folder;
topLevel.Add((f.SortOrder, folder));
// Parent always precedes its children in sort_order (PersistOrderAsync walks
// depth-first), so the parent bucket already exists by the time a child is seen.
BucketFor(f.ParentFolderId).Add((f.SortOrder, folder));
}
var seedNames = new HashSet<string>(new[] { "My Day", "Important", "Planned" });
@@ -357,16 +378,21 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
};
Items.Add(item);
UserLists.Add(item);
if (item.FolderId is { } fid && foldersById.TryGetValue(fid, out var owner))
owner.Lists.Add(item);
else
topLevel.Add((l.SortOrder, item));
BucketFor(item.FolderId).Add((l.SortOrder, item));
}
// OrderBy is stable, so legacy rows (folders and lists each numbered from 0
// independently) keep the old look — folders first — until the first drag.
foreach (var (_, item) in topLevel.OrderBy(t => t.Sort))
TopLevel.Add(item);
void Materialize(List<(int Sort, object Item)> bucket, System.Collections.IList target)
{
foreach (var (_, item) in bucket.OrderBy(t => t.Sort))
{
target.Add(item);
if (item is ListFolderNavItemViewModel folder && childBuckets.TryGetValue(folder.Id, out var childBucket))
Materialize(childBucket, folder.Children);
}
}
Materialize(topLevelBucket, TopLevel);
ok = true;
}
finally { OperationTiming.Shared.Record("db", "ListsIsland.LoadAsync", sw.Elapsed, ok); }
@@ -517,10 +543,26 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
if (target is not null) target.IsDropTarget = true;
}
/// <summary>The collection a row is rendered in: its folder's <c>Lists</c>, or
/// <summary>The collection a row is rendered in: its folder's <c>Children</c>, or
/// <see cref="TopLevel"/> when it has no folder. Non-generic because the top level is mixed.</summary>
public System.Collections.IList ContainerOf(ListNavItemViewModel row) =>
Folders.FirstOrDefault(f => f.Id == row.FolderId)?.Lists ?? (System.Collections.IList)TopLevel;
Folders.FirstOrDefault(f => f.Id == row.FolderId)?.Children ?? (System.Collections.IList)TopLevel;
/// <summary>The collection a folder is rendered in: its parent's <c>Children</c>, or
/// <see cref="TopLevel"/> when it has none.</summary>
public System.Collections.IList ContainerOf(ListFolderNavItemViewModel folder) =>
Folders.FirstOrDefault(f => f.Id == folder.ParentFolderId)?.Children ?? (System.Collections.IList)TopLevel;
/// <summary>True when <paramref name="candidateId"/> is <paramref name="folder"/> itself or
/// nested anywhere under it — the guard against dropping a folder into its own subtree.</summary>
private static bool IsSelfOrDescendant(ListFolderNavItemViewModel folder, string? candidateId)
{
if (candidateId is null) return false;
if (candidateId == folder.Id) return true;
foreach (var child in folder.Children.OfType<ListFolderNavItemViewModel>())
if (IsSelfOrDescendant(child, candidateId)) return true;
return false;
}
/// <summary>Kept for the list→list case; <see cref="DropAsync"/> covers all four combinations.</summary>
public Task ReorderAsync(ListNavItemViewModel source, ListNavItemViewModel target, bool placeBelow) =>
@@ -528,10 +570,10 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
/// <summary>
/// Drops <paramref name="source"/> (a list row or a folder) at <paramref name="target"/>'s
/// position. Since a drop position implies a container, dropping a list onto a row in a
/// position. Since a drop position implies a container, dropping a row onto a row in a
/// different folder (or next to anything at the top level) also *moves* it there — that's the
/// only way to drag a list out of a folder. Folders live at the top level only, so a folder
/// dropped onto a row inside a folder is ignored rather than nested.
/// only way to drag something out of a folder. A folder dropped next to a target inside its own
/// subtree is ignored rather than nested into itself.
/// </summary>
public async Task DropAsync(object source, object target, bool placeBelow)
{
@@ -540,17 +582,43 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
if (source is ListFolderNavItemViewModel folder)
{
if (target is ListNavItemViewModel { FolderId: not null }) return;
MoveWithinCollection(TopLevel, folder, target, placeBelow);
await PersistOrderAsync(null);
var newParentId = target switch
{
ListFolderNavItemViewModel tf => tf.ParentFolderId,
ListNavItemViewModel tl => tl.FolderId,
_ => null,
};
if (IsSelfOrDescendant(folder, newParentId)) return;
var targetContainer = target switch
{
ListFolderNavItemViewModel tf => ContainerOf(tf),
ListNavItemViewModel tl => ContainerOf(tl),
_ => (System.Collections.IList)TopLevel,
};
var parentChanged = folder.ParentFolderId != newParentId;
if (parentChanged)
{
ContainerOf(folder).Remove(folder);
folder.ParentFolderId = newParentId;
targetContainer.Add(folder);
}
MoveWithinCollection(targetContainer, folder, target, placeBelow);
await PersistOrderAsync(folder);
return;
}
if (source is not ListNavItemViewModel row || row.Kind != ListKind.User) return;
// Next to a folder *header* means top level, not inside it — MoveToFolderAsync is the
// drop-into-the-folder path.
var targetFolderId = target is ListNavItemViewModel t ? t.FolderId : null;
// Next to a folder *header* means that folder's own container, not inside it —
// MoveToFolderAsync is the drop-into-the-folder path.
var targetFolderId = target switch
{
ListNavItemViewModel t => t.FolderId,
ListFolderNavItemViewModel tf => tf.ParentFolderId,
_ => null,
};
var folderChanged = row.FolderId != targetFolderId;
if (folderChanged)
{
@@ -563,40 +631,58 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
await PersistOrderAsync(folderChanged ? row : null);
}
/// <summary>Appends a list to a folder (or to the top level when <paramref name="folder"/> is
/// null) — the drop-onto-the-folder-header path, which also reaches collapsed and empty folders.</summary>
public async Task MoveToFolderAsync(ListNavItemViewModel source, ListFolderNavItemViewModel? folder)
/// <summary>Appends a row (list or folder) to <paramref name="folder"/> (or to the top level
/// when null) — the drop-onto-the-folder-header path, which also reaches collapsed and empty
/// folders. A folder source dropped into itself or one of its own subfolders is ignored.</summary>
public async Task MoveToFolderAsync(object source, ListFolderNavItemViewModel? folder)
{
if (source.Kind != ListKind.User) return;
if (source.FolderId == folder?.Id) return;
if (source is ListFolderNavItemViewModel sourceFolder)
{
if (ReferenceEquals(sourceFolder, folder)) return;
if (IsSelfOrDescendant(sourceFolder, folder?.Id)) return;
if (sourceFolder.ParentFolderId == folder?.Id) return;
ContainerOf(source).Remove(source);
source.FolderId = folder?.Id;
ContainerOf(source).Add(source);
await PersistOrderAsync(source);
ContainerOf(sourceFolder).Remove(sourceFolder);
sourceFolder.ParentFolderId = folder?.Id;
ContainerOf(sourceFolder).Add(sourceFolder);
await PersistOrderAsync(sourceFolder);
return;
}
if (source is not ListNavItemViewModel row || row.Kind != ListKind.User) return;
if (row.FolderId == folder?.Id) return;
ContainerOf(row).Remove(row);
row.FolderId = folder?.Id;
ContainerOf(row).Add(row);
await PersistOrderAsync(row);
}
/// <summary>
/// Writes the sidebar's visual order back as a single global <c>sort_order</c> sequence
/// (folders top to bottom, then the top-level lists) and, when a list changed folder, its new
/// <c>folder_id</c>.
/// Writes the sidebar's visual order back as a single global <c>sort_order</c> sequence,
/// walked depth-first (a folder, then its children, at any depth) and, when a row changed
/// container, its new <c>folder_id</c>/<c>parent_folder_id</c>.
/// </summary>
private async Task PersistOrderAsync(ListNavItemViewModel? movedRow)
private async Task PersistOrderAsync(object? movedItem)
{
var ordered = new List<(string Id, bool IsFolder)>();
foreach (var item in TopLevel)
void Walk(IEnumerable<object> items)
{
switch (item)
foreach (var item in items)
{
case ListFolderNavItemViewModel f:
ordered.Add((f.Id, true));
ordered.AddRange(f.Lists.Select(l => (RawId(l.Id), false)));
break;
case ListNavItemViewModel l:
ordered.Add((RawId(l.Id), false));
break;
switch (item)
{
case ListFolderNavItemViewModel f:
ordered.Add((f.Id, true));
Walk(f.Children);
break;
case ListNavItemViewModel l:
ordered.Add((RawId(l.Id), false));
break;
}
}
}
Walk(TopLevel);
var sw = System.Diagnostics.Stopwatch.StartNew();
var ok = false;
@@ -604,8 +690,15 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
var lists = new ListRepository(ctx);
if (movedRow is not null)
await lists.SetListFolderAsync(RawId(movedRow.Id), movedRow.FolderId);
switch (movedItem)
{
case ListNavItemViewModel movedRow:
await lists.SetListFolderAsync(RawId(movedRow.Id), movedRow.FolderId);
break;
case ListFolderNavItemViewModel movedFolder:
await lists.SetFolderParentAsync(movedFolder.Id, movedFolder.ParentFolderId);
break;
}
await lists.ReorderSidebarAsync(ordered);
ok = true;
}
@@ -622,16 +715,33 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
await MoveToFolderAsync(row, null);
}
private readonly Dictionary<string, CancellationTokenSource> _folderCollapseCts = new();
// Synchronous on purpose: the fold is pure UI state and must never miss a click waiting on
// the AsyncRelayCommand's own CanExecute lock while the DB write below is in flight. The
// write is fire-and-forget with a per-folder cancellation token so a second toggle before the
// first one lands cancels it — the DB always ends up with the last-clicked state, never a
// stale one racing ahead of it.
[RelayCommand]
private async Task ToggleFolderAsync(ListFolderNavItemViewModel? folder)
private void ToggleFolder(ListFolderNavItemViewModel? folder)
{
if (folder is null) return;
folder.IsCollapsed = !folder.IsCollapsed;
if (_folderCollapseCts.TryGetValue(folder.Id, out var previous)) previous.Cancel();
var cts = new CancellationTokenSource();
_folderCollapseCts[folder.Id] = cts;
_ = PersistFolderCollapsedAsync(folder.Id, folder.IsCollapsed, cts.Token);
}
private async Task PersistFolderCollapsedAsync(string folderId, bool isCollapsed, CancellationToken ct)
{
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
await new ListRepository(ctx).SetFolderCollapsedAsync(folder.Id, folder.IsCollapsed);
ct.ThrowIfCancellationRequested();
await new ListRepository(ctx).SetFolderCollapsedAsync(folderId, isCollapsed);
}
catch (OperationCanceledException) { }
catch (Exception ex)
{
// The fold itself already happened in the UI; only its persistence failed.
@@ -654,7 +764,22 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
private void RecomputeFolderCounts()
{
foreach (var f in Folders) f.Count = f.Lists.Sum(l => l.Count);
foreach (var item in TopLevel)
if (item is ListFolderNavItemViewModel f) ComputeFolderCount(f);
}
private static int ComputeFolderCount(ListFolderNavItemViewModel folder)
{
var sum = 0;
foreach (var child in folder.Children)
sum += child switch
{
ListNavItemViewModel l => l.Count,
ListFolderNavItemViewModel f => ComputeFolderCount(f),
_ => 0,
};
folder.Count = sum;
return sum;
}
private static string RawId(string rowId) =>
@@ -788,8 +913,8 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
.Select(l => l.Id)
.ToList()
.IndexOf(entity.Id);
folder.Lists.Insert(
containerIdx < 0 || containerIdx > folder.Lists.Count ? folder.Lists.Count : containerIdx,
folder.Children.Insert(
containerIdx < 0 || containerIdx > folder.Children.Count ? folder.Children.Count : containerIdx,
item);
}
else TopLevel.Add(item);
@@ -28,6 +28,10 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
[ObservableProperty] private ObservableCollection<string> _mergeTargetBranches = new();
[ObservableProperty] private string? _selectedMergeTarget;
// Mirrors MergeTargetsDto.TargetDirty — surfaced next to Approve so a dirty target working
// dir is visible before anyone clicks, not just after the merge modal refuses it.
[ObservableProperty] private bool _targetDirty;
// No CanExecute to gate here — RefreshMergePreviewAsync is triggered from the Bind path,
// not a [RelayCommand], so PreviewOp only drives the indicator, never locks a button.
public OperationStatus PreviewOp { get; } = new();
@@ -141,6 +145,7 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
MergePreviewText = "";
MergeIsClean = false;
MergeIsConflict = false;
TargetDirty = false;
SyncWorktree(null, null, null, null, null);
SyncTaskContext(null, null, false);
SyncChildOutcomes(false, 0);
@@ -769,12 +769,16 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
// A predecessor missing from both rowsById and the external cache counts as unmet -- mirrors
// QueuePicker/Worker's TaskBlocking.Unmet: an edge pointing at an unresolved/vanished task
// keeps blocking rather than being silently treated as satisfied.
// keeps blocking rather than being silently treated as satisfied. A landed merge satisfies
// the edge too (skipVerify parks a merged task in WaitingForReview until the batch verify);
// the external cache carries no worktree state, so a cross-list predecessor still shows as
// waiting until it reaches Done.
private static bool IsUnmet(
string id, Dictionary<string, TaskRowViewModel> rowsById,
IReadOnlyDictionary<string, (int Number, string Title, TaskStatus Status)> external)
{
if (rowsById.TryGetValue(id, out var p)) return p.Status != TaskStatus.Done;
if (rowsById.TryGetValue(id, out var p))
return p.Status != TaskStatus.Done && p.WorktreeState != WorktreeState.Merged;
if (external.TryGetValue(id, out var e)) return e.Status != TaskStatus.Done;
return true;
}
@@ -1205,9 +1209,9 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
// block removing a completed planning parent together with its done children.
var toDelete = completed.OrderByDescending(r => r.IsChild).ToList();
if (ConfirmAsync is not null)
if (Dialogs is not null)
{
var ok = await ConfirmAsync($"Clear {toDelete.Count} completed task(s)? This cannot be undone.");
var ok = await Dialogs.ConfirmAsync($"Clear {toDelete.Count} completed task(s)? This cannot be undone.", Loc.T("dialog.delete"));
if (!ok) return;
}
@@ -1335,9 +1339,9 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
private async Task DeleteTaskAsync(TaskRowViewModel? row)
{
if (row is null || row.HasInteractiveSession || _worker is null) return;
if (ConfirmAsync is not null)
if (Dialogs is not null)
{
var ok = await ConfirmAsync(Loc.T("vm.tasksIsland.deleteTaskConfirm", row.Title));
var ok = await Dialogs.ConfirmAsync(Loc.T("vm.tasksIsland.deleteTaskConfirm", row.Title), Loc.T("dialog.delete"));
if (!ok) return;
}
@@ -1649,8 +1653,8 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
if (outcome.Result == DiscardPlanningResult.BlockedByQueuedChildren)
{
if (ConfirmAsync is null) return;
var ok = await ConfirmAsync(
if (Dialogs is null) return;
var ok = await Dialogs.ConfirmAsync(
$"{outcome.QueuedChildrenCount} child task(s) are queued.\n" +
"Dequeue them and discard the planning session?");
if (!ok) return;
@@ -1659,18 +1663,13 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
}
else if (outcome.Result == DiscardPlanningResult.BlockedByRunningChildren)
{
if (ConfirmAsync is null) return;
await ConfirmAsync(
if (Dialogs is null) return;
await Dialogs.ConfirmAsync(
$"{outcome.RunningChildrenCount} child task(s) are still running.\n" +
"Cancel them first, then try again.");
}
}
/// <summary>
/// Wired by the view via <see cref="ShowConfirmAsync"/>. Returns true when the user confirms.
/// </summary>
public Func<string, Task<bool>>? ConfirmAsync { get; set; }
private bool CanRunPlanningOperation() => !PlanningOperation.IsRunning;
[RelayCommand(CanExecute = nameof(CanRunPlanningOperation))]
@@ -1,3 +1,5 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Threading;
using System;
using System.Threading;
@@ -66,7 +68,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
// Set by MainWindow so a reveal can bring the main window to the foreground.
public Action? BringToFront { get; set; }
// Single dialog seam (set by MainWindow); propagated to the lists island.
// Single dialog seam (set by MainWindow); propagated to the islands.
private IDialogService? _dialogs;
public IDialogService? Dialogs
{
@@ -76,6 +78,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
_dialogs = value;
if (Lists is not null) Lists.Dialogs = value;
if (Tasks is not null) Tasks.Dialogs = value;
if (Details is not null) Details.Dialogs = value;
}
}
@@ -205,6 +208,9 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
private void FlashFooter(string message, WorkerLogLevel level)
{
if (level == WorkerLogLevel.Error)
Serilog.Log.Warning("Footer error: {message}", message);
WorkerLogText = $"{DateTime.Now:HH:mm} · {message}";
WorkerLogLevel = level;
IsWorkerLogVisible = true;
@@ -655,7 +661,15 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
private void RestartWorkerService()
{
var exe = _workerLocator.Find();
// Prefer the exe path of the worker that's actually running: _workerLocator.Find()
// walks up from AppContext.BaseDirectory looking for install.json and falls back to
// the registry's InstallLocation otherwise — on a debug build launched from the repo
// there's no install.json above it, so it returns the *installed* worker's path. That
// never matches the running debug process, so KillMatchingWorkerProcesses silently
// skips it (it only kills a process whose path equals the given exe) and this either
// launches a second worker that immediately dies on the already-bound port, or leaves
// the real one running untouched while nothing visibly changes.
var exe = RunningWorkerExePath() ?? _workerLocator.Find();
if (exe is null) throw new InvalidOperationException("Worker executable not found.");
KillMatchingWorkerProcesses(exe);
@@ -665,6 +679,20 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
throw new InvalidOperationException($"worker process exited immediately (code {started.ExitCode})");
}
private static string? RunningWorkerExePath()
{
foreach (var p in System.Diagnostics.Process.GetProcessesByName("ClaudeDo.Worker"))
{
try
{
if (p.MainModule?.FileName is { } path) return path;
}
catch { /* inaccessible */ }
finally { p.Dispose(); }
}
return null;
}
// Only kill the worker belonging to THIS installation — not any other
// ClaudeDo.Worker on the machine (e.g. a second install).
private static void KillMatchingWorkerProcesses(string exe)
@@ -686,10 +714,6 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
}
}
// Set by MainWindow: force-closes the window without re-triggering its own close
// confirmation (the shutdown dialog above already confirmed).
public Action? CloseWindow { get; set; }
// Test seam: overrides the real kill-worker + close-window body.
internal Action? ShutdownOperation { get; set; }
@@ -706,7 +730,14 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
{
var exe = _workerLocator.Find();
if (exe is not null) KillMatchingWorkerProcesses(exe);
CloseWindow?.Invoke();
// Closing just the MainWindow relies on ShutdownMode.OnMainWindowClose, which asks
// every open window to close and backs off if any of them cancels — and
// MissionControlWindow always cancels its own close (it hides instead, see
// MissionControlWindow.OnClosing), so the process never actually exited. Shutdown()
// force-closes every window, ignoring that cancellation.
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
desktop.Shutdown();
}
private bool _updatingClaudeCli;
@@ -19,6 +19,25 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
// Only a task-based pane can be submitted for review (an ad-hoc directory session has no task).
public bool IsTaskBased => TaskId is not null;
/// <summary>Stable key behind <see cref="IdentityColorKey"/>. Defaults to the task id, so a
/// handoff — same task, new pane — keeps its colour for free; an ad-hoc pane gets a fresh one.
/// Settable so a handoff that mints a new key can inherit the outgoing pane's.</summary>
public string IdentityKey { get; init; }
/// <summary>Resource key of this tile's identity colour (via DotBrushConverter). Derived from
/// <see cref="IdentityKey"/>, never stored — the colour survives restarts without persistence.</summary>
public string IdentityColorKey => $"Identity{StableIndex(IdentityKey, 6) + 1}";
// FNV-1a, not string.GetHashCode: the latter is randomized per process, so the same task would
// change colour on every app start.
private static int StableIndex(string value, int buckets)
{
uint hash = 2166136261;
foreach (var c in value)
hash = (hash ^ c) * 16777619;
return (int)(hash % (uint)buckets);
}
/// <summary>Set by the host for a list-handler run ("Let Claude handle it" / "Let Claude merge
/// the reviews") pane -- shows the "Abandon run" action. Plain mutable property, not
/// <see cref="ObservableProperty"/>: the host sets it once right after construction, before the
@@ -60,7 +79,7 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
public event Action<string, int>? SessionDiedAfterStartup;
/// <summary>Task-based pane — dedup'd by <see cref="TaskId"/>. Pass null for an ad-hoc pane
/// (no task, never deduped); prefer <see cref="CreateAdHoc"/> at ad-hoc call sites.
/// (no task, never deduped) — its host sets <see cref="IdentityKey"/>, the tile's paneId.
/// <paramref name="descriptorFactory"/> is resolved by the pane itself (in <see cref="Start"/>)
/// so the tile — and its starting spinner — is visible while the launch spec is still being
/// fetched. The host must wire its handlers and then call <see cref="Start"/>.</summary>
@@ -70,6 +89,7 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
Func<System.Threading.Tasks.Task<TerminalLaunchDescriptor>> descriptorFactory)
{
TaskId = taskId;
IdentityKey = taskId ?? Guid.NewGuid().ToString("n");
_displayTitle = displayTitle;
_descriptorFactory = descriptorFactory;
Subscribe(Terminal);
@@ -112,11 +132,17 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
/// <see cref="ErrorReported"/> so a failed launch is not swallowed.</summary>
public void Start() => _ = StartAsync();
/// <summary>Working directory of the launched session, known once the launch spec resolved —
/// a handoff reopens the fresh session there.</summary>
public string? Cwd { get; private set; }
private async System.Threading.Tasks.Task StartAsync()
{
try
{
Terminal.Start(await _descriptorFactory());
var descriptor = await _descriptorFactory();
Cwd = descriptor.Cwd;
Terminal.Start(descriptor);
}
catch (Exception ex)
{
@@ -124,12 +150,6 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
}
}
/// <summary>Ad-hoc pane — no task, no dedup.</summary>
public static ConPtyPaneViewModel CreateAdHoc(
string displayTitle,
Func<System.Threading.Tasks.Task<TerminalLaunchDescriptor>> descriptorFactory)
=> new(null, displayTitle, descriptorFactory);
private void OnTerminalPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(InteractiveTerminalViewModel.StartError) && Terminal.StartError is { Length: > 0 } error)
@@ -5,6 +5,8 @@ using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.MissionControl;
@@ -75,6 +77,12 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
// fresh session is two clicks instead of navigating a folder picker.
public ObservableCollection<SessionTargetViewModel> SessionTargets { get; } = new();
/// <summary>"Recent sessions" flyout rows: each chain's origin session, its handoffs right below it.</summary>
public ObservableCollection<RecentSessionViewModel> RecentSessions { get; } = new();
/// <summary>False shows the flyout's empty-state line.</summary>
public bool HasRecentSessions => RecentSessions.Count > 0;
// View-layer seam: the folder picker needs a TopLevel, so the view owns it (see
// MissionControlView.OnNewSessionClicked).
public Action? BrowseForSessionRequested { get; set; }
@@ -108,6 +116,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
_onHandoffRequested = (taskId, survivingTaskIds, nextPhase) => { _ = OpenMergeHelperHandoffConPtySessionAsync(taskId, survivingTaskIds, nextPhase); };
_worker.HandoffRequestedEvent += _onHandoffRequested;
_worker.SessionHandoffRequestedEvent += OpenSessionHandoff;
_onListHandlerStartRequested = (listId, taskIds) => { OpenRequested?.Invoke(); _ = OpenMergeHelperConPtySessionAsync(listId, taskIds); };
_worker.ListHandlerStartRequestedEvent += _onListHandlerStartRequested;
@@ -214,6 +223,84 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.queueRefreshFailed", ex.Message)); }
}
/// <summary>Rebuilds <see cref="RecentSessions"/> from the last 14 days (max 50 sessions); runs on
/// every flyout open.</summary>
internal async System.Threading.Tasks.Task RefreshRecentSessionsAsync()
{
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
var sessions = await new MissionControlSessionRepository(ctx).ListRecentAsync(DateTime.UtcNow.AddDays(-14), 50);
var ids = sessions.Select(s => s.SessionId).ToList();
// A task's current session resumes through its task tile, so it stays deduped by TaskId.
var taskCurrent = (await ctx.Tasks.AsNoTracking()
.Where(t => t.InteractiveSessionId != null && ids.Contains(t.InteractiveSessionId))
.Select(t => t.InteractiveSessionId!)
.ToListAsync()).ToHashSet();
RecentSessions.Clear();
foreach (var (s, isHandoff) in NestHandoffs(sessions))
{
var time = DateTime.SpecifyKind(s.StartedAt, DateTimeKind.Utc).ToLocalTime().ToString("g");
var briefPath = s.BriefPath;
RecentSessions.Add(new RecentSessionViewModel
{
Title = s.Title,
Meta = isHandoff ? $"{Loc.T("missionControl.handoffSession")} · {time}" : $"{time} · {s.Cwd}",
IsHandoff = isHandoff,
ResumeCommand = new RelayCommand(() => ResumeSession(s, taskCurrent.Contains(s.SessionId))),
ShowBriefCommand = briefPath is null ? null : new RelayCommand(() => ShowBrief(briefPath)),
});
}
}
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.queueRefreshFailed", ex.Message)); }
OnPropertyChanged(nameof(HasRecentSessions));
}
/// <summary>Orders newest-first sessions into chains: each origin session (no recorded predecessor
/// in the set) followed by every session handed off from it, directly or indirectly, oldest
/// first. Chains are ordered by their newest member.</summary>
internal static IEnumerable<(MissionControlSessionEntity Session, bool IsHandoff)> NestHandoffs(
IReadOnlyList<MissionControlSessionEntity> newestFirst)
{
var byId = newestFirst.ToDictionary(s => s.SessionId);
string RootOf(MissionControlSessionEntity s)
{
var seen = new HashSet<string>();
while (s.PredecessorSessionId is { } p && byId.TryGetValue(p, out var prev) && seen.Add(s.SessionId))
s = prev;
return s.SessionId;
}
return newestFirst
.GroupBy(RootOf)
.SelectMany(chain => chain
.OrderBy(s => s.SessionId != chain.Key)
.ThenBy(s => s.StartedAt)
.Select(s => (s, s.SessionId != chain.Key)));
}
// A task's current session reopens its task tile (deduped, same worktree guards); any other
// session gets a fresh ad-hoc tile resuming it in its recorded cwd.
private void ResumeSession(MissionControlSessionEntity session, bool isTaskCurrent)
{
if (session.TaskId is { } taskId && isTaskCurrent)
{
_ = OpenConPtySessionAsync(taskId);
return;
}
var paneId = Guid.NewGuid().ToString("n");
var sessionId = session.SessionId;
AddConPtyPane(new ConPtyPaneViewModel(null, session.Title,
() => DescribeAsync(() => _worker.GetResumeSessionLaunchSpecAsync(sessionId, paneId))) { IdentityKey = paneId });
}
private void ShowBrief(string briefPath)
{
var (ok, error) = ShellOpen.Path(briefPath);
if (!ok) ErrorReported?.Invoke(Loc.T("missionControl.briefOpenFailed", error ?? briefPath));
}
// Drop-to-queue: a task dragged from the main app onto Mission Control gets queued. Goes
// through the worker hub (TaskStateService.EnqueueAsync) rather than a raw EF write so the
// manual/draft-child guards apply here too. The interactive-session check has to live here
@@ -322,11 +409,31 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
var title = Path.GetFileName(directory.TrimEnd('\\', '/'));
if (string.IsNullOrEmpty(title)) title = directory;
AddConPtyPane(ConPtyPaneViewModel.CreateAdHoc(title,
() => DescribeAsync(() => _worker.GetAdHocLaunchSpecAsync(directory))));
// The key is minted here, not in the pane, because the launch spec needs it as the paneId.
var paneId = Guid.NewGuid().ToString("n");
AddConPtyPane(new ConPtyPaneViewModel(null, title,
() => DescribeAsync(() => _worker.GetAdHocLaunchSpecAsync(directory, paneId))) { IdentityKey = paneId });
return System.Threading.Tasks.Task.CompletedTask;
}
/// <summary>A session called handoff_session: replaces its tile (same slot, same IdentityKey, so
/// same colour) with a fresh session in the same cwd that starts from the brief. No matching tile
/// (closed meanwhile, or another app instance) = nothing to replace.</summary>
internal void OpenSessionHandoff(string paneId, string briefPath)
{
if (ConPtySessions.FirstOrDefault(s => s.IdentityKey == paneId) is not { Cwd: { } cwd } outgoing) return;
OpenRequested?.Invoke();
var slot = ConPtySessions.IndexOf(outgoing);
var taskId = outgoing.TaskId;
var title = outgoing.DisplayTitle;
CloseConPtySession(outgoing);
AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
() => DescribeAsync(() => _worker.GetSessionHandoffLaunchSpecAsync(paneId, cwd, briefPath, taskId)))
{ IdentityKey = paneId }, slot);
}
// List-handler session over a hand-picked set of tasks ("Let Claude handle it"). Task-based:
// creates one new ClaudeDo task per run to own the session (title/diff/result), deduped by
// TaskId like OpenConPtySessionAsync. The handler still merges the tasks it handles itself
@@ -408,6 +515,11 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
if (string.IsNullOrEmpty(taskId) || survivingTaskIds is not { Count: > 0 }) return;
OpenRequested?.Invoke();
// Keep the tile where it was: the incoming phase re-enters at the outgoing pane's slot
// instead of being appended, so a handoff doesn't reshuffle every other tile in the grid.
var slot = ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is { } first
? ConPtySessions.IndexOf(first)
: (int?)null;
foreach (var stale in ConPtySessions.Where(s => s.TaskId == taskId).ToList())
CloseConPtySession(stale);
@@ -428,7 +540,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
() => DescribeAsync(() => _worker.GetMergeHelperHandoffLaunchSpecAsync(taskId, survivingTaskIds, nextPhase)))
{ IsListHandler = true });
{ IsListHandler = true }, slot);
}
// The 5h usage window is about to run out under this handler session: type the worker's warning
@@ -446,14 +558,14 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
// Wires a freshly built pane and shows it immediately — the pane resolves its own launch spec,
// so the tile is on screen (spinner running) while the worker is still preparing the worktree.
private void AddConPtyPane(ConPtyPaneViewModel pane)
private void AddConPtyPane(ConPtyPaneViewModel pane, int? index = null)
{
pane.ErrorReported += OnConPtyPaneError;
pane.CloseRequested += OnPaneCloseRequested;
pane.SubmitForReviewRequested += OnPaneSubmitForReview;
pane.SessionDiedAfterStartup += OnPaneSessionDied;
pane.AbandonRunRequested += OnPaneAbandonRun;
ConPtySessions.Add(pane);
ConPtySessions.Insert(Math.Min(index ?? ConPtySessions.Count, ConPtySessions.Count), pane);
pane.Start();
}
@@ -610,6 +722,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
_worker.TaskUpdatedEvent -= _onTaskUpdated;
_worker.ConnectionRestoredEvent -= _onConnectionRestored;
_worker.HandoffRequestedEvent -= _onHandoffRequested;
_worker.SessionHandoffRequestedEvent -= OpenSessionHandoff;
_worker.ListHandlerStartRequestedEvent -= _onListHandlerStartRequested;
_worker.HandlerUsageWarningEvent -= _onHandlerUsageWarning;
ConPtySessions.CollectionChanged -= OnConPtySessionsChanged;
@@ -647,6 +760,20 @@ public sealed class QueuedTaskViewModel
public IRelayCommand? OpenSessionCommand { get; init; }
}
/// <summary>One row in the "Recent sessions" flyout.</summary>
public sealed class RecentSessionViewModel
{
public required string Title { get; init; }
/// <summary>Start time plus cwd (origin) or the handoff label (handoff).</summary>
public required string Meta { get; init; }
/// <summary>Indents the row under its chain's origin session.</summary>
public bool IsHandoff { get; init; }
public required IRelayCommand ResumeCommand { get; init; }
/// <summary>Opens the session's brief.md; null for a session that did not start from one.</summary>
public IRelayCommand? ShowBriefCommand { get; init; }
public bool HasBrief => ShowBriefCommand is not null;
}
/// <summary>One entry in the "new session" menu: a configured list's working dir, or browse.</summary>
public sealed class SessionTargetViewModel
{
@@ -76,6 +76,14 @@ public sealed partial class MergeModalViewModel : ViewModelBase
SelectedBranch = Branches.Contains(targets.DefaultBranch)
? targets.DefaultBranch
: Branches.FirstOrDefault();
// Known dirty before anyone clicks Merge — offer the stash retry right away instead
// of making the user hit the "dirty_target" refusal first.
if (targets.TargetDirty)
{
WarningMessage = Loc.T("vm.merge.targetDirty");
ShowStashRetry = true;
}
}
catch (Exception ex)
{
@@ -16,6 +16,7 @@ public enum BatchMergeOutcome { None, Merging, Merged, Conflict, Blocked, Verify
public sealed partial class WorktreeOverviewRowViewModel : ViewModelBase
{
[ObservableProperty] private string _taskId = "";
[ObservableProperty] private int _taskNumber;
[ObservableProperty] private string _taskTitle = "";
[ObservableProperty][NotifyPropertyChangedFor(nameof(IsRunning))] private TaskStatus _taskStatus;
[ObservableProperty] private string _listId = "";
@@ -49,6 +50,9 @@ public sealed partial class WorktreeOverviewRowViewModel : ViewModelBase
public bool IsKept => State == WorktreeState.Kept;
public bool IsRunning => TaskStatus == TaskStatus.Running;
// 0 = row without an allocated number (test seeds); a bare "#0" would be meaningless.
public bool ShowNumberBadge => TaskNumber > 0;
private static string FormatAge(TimeSpan ts)
{
if (ts.TotalDays >= 1) return $"{(int)ts.TotalDays}d ago";
@@ -366,7 +370,7 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
private static WorktreeOverviewRowViewModel Map(WorktreeOverviewDto d) => new()
{
TaskId = d.TaskId, TaskTitle = d.TaskTitle, TaskStatus = d.TaskStatus,
TaskId = d.TaskId, TaskNumber = d.TaskNumber, TaskTitle = d.TaskTitle, TaskStatus = d.TaskStatus,
ListId = d.ListId, ListName = d.ListName,
Path = d.Path, BranchName = d.BranchName, BaseCommit = d.BaseCommit, State = d.State,
DiffStat = d.DiffStat, CreatedAt = d.CreatedAt, PathExistsOnDisk = d.PathExistsOnDisk,
@@ -78,8 +78,8 @@ public partial class ConflictResolverView : Window
// Panes are whole files here, not aligned rows, so raw pixel offsets line up and the
// gutter buttons only have to be repositioned once per scroll.
new VerticalScrollSync(new[] { OursEditor, ResultEditor, TheirsEditor },
afterSync: PositionGutters);
new ScrollSync(new[] { OursEditor, ResultEditor, TheirsEditor },
afterSync: PositionGutters);
var conflict = DiffEditorSetup.Brush(this, "MergeConflictTintBrush", Color.Parse("#28C87060"));
var resolved = DiffEditorSetup.Brush(this, "MergeResolvedTintBrush", Color.Parse("#206FA86B"));
@@ -75,9 +75,11 @@ internal static class DiffEditorSetup
}
/// <summary>
/// Keeps a set of editors scrolled to the same vertical position. Construction wires the panes;
/// there is nothing to dispose, and nothing to keep a reference to — the event subscriptions on
/// the text views own the instance.
/// Keeps a set of editors scrolled to the same position, vertically always and horizontally
/// whenever the panes share the same content width (no per-pane offset conversion, unlike
/// <see cref="TargetOffset"/> for Y). Construction wires the panes; there is nothing to dispose,
/// and nothing to keep a reference to — the event subscriptions on the text views own the
/// instance.
/// </summary>
/// <remarks>
/// <para>
@@ -96,7 +98,7 @@ internal static class DiffEditorSetup
/// leaves the ScrollViewer's own offset (and therefore the scrollbar thumb) behind.
/// </para>
/// </remarks>
internal sealed class VerticalScrollSync
internal sealed class ScrollSync
{
private readonly TextEditor[] _editors;
private readonly ScrollViewer?[] _viewers;
@@ -108,8 +110,8 @@ internal sealed class VerticalScrollSync
/// <param name="isActive">Gate for layouts where the panes are not side by side.</param>
/// <param name="anchorByLine">True while lines wrap — see <see cref="TargetOffset"/>.</param>
/// <param name="afterSync">Runs once per user scroll, not per echo.</param>
public VerticalScrollSync(TextEditor[] editors, Func<bool>? isActive = null,
Func<bool>? anchorByLine = null, Action? afterSync = null)
public ScrollSync(TextEditor[] editors, Func<bool>? isActive = null,
Func<bool>? anchorByLine = null, Action? afterSync = null)
{
_editors = editors;
_viewers = new ScrollViewer?[editors.Length];
@@ -136,15 +138,18 @@ internal sealed class VerticalScrollSync
_syncing = true;
try
{
var x = _editors[sourceIndex].TextArea.TextView.ScrollOffset.X;
for (var i = 0; i < _editors.Length; i++)
{
if (i == sourceIndex) continue;
if (Viewer(i) is not { } viewer) continue;
if (TargetOffset(_editors[sourceIndex], _editors[i]) is not { } y) continue;
// The echo from the target's own ScrollOffsetChanged lands after _syncing is
// cleared again, so this comparison — not the flag — is what ends the loop.
if (Math.Abs(viewer.Offset.Y - y) > 0.5)
viewer.Offset = new Vector(viewer.Offset.X, y);
// cleared again, so this comparison — not the flag — is what ends the loop. Both
// axes must be checked: writing only when Y moved left X's own echo unguarded,
// which is how it looped forever once X was written below.
if (Math.Abs(viewer.Offset.X - x) > 0.5 || Math.Abs(viewer.Offset.Y - y) > 0.5)
viewer.Offset = new Vector(x, y);
}
}
finally { _syncing = false; }
@@ -85,8 +85,8 @@ public partial class DiffTextView : UserControl
_leftTm = DiffEditorSetup.InstallHighlighting(LeftEditor);
_rightTm = DiffEditorSetup.InstallHighlighting(RightEditor);
ReloadFile();
new VerticalScrollSync(new[] { LeftEditor, RightEditor },
isActive: () => IsSplit, anchorByLine: () => WrapLines);
new ScrollSync(new[] { LeftEditor, RightEditor },
isActive: () => IsSplit, anchorByLine: () => WrapLines);
}
/// Brushes and the mono typeface only resolve once the control is in the visual tree —
@@ -1,10 +1,12 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Platform.Storage;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels;
namespace ClaudeDo.Ui.Views;
@@ -43,13 +45,54 @@ public partial class InteractiveTerminalView : UserControl
if (DataContext is not InteractiveTerminalViewModel vm) return;
e.Handled = true;
_ = PasteAndReportAsync(vm);
_ = PasteAndReportAsync(this, vm);
}
private static async System.Threading.Tasks.Task PasteAndReportAsync(InteractiveTerminalViewModel vm)
// A clipboard image (e.g. a screenshot) can't be typed into the child process, so it's written
// to a temp PNG and its path is inserted the same way a dropped file is — Claude reads the file
// itself. Plain-text clipboard content keeps going through the existing bracketed-paste flow.
// Once a bitmap is confirmed present, any failure (dir/file write, send) is a real paste
// failure — never silently fall back to a text paste of the same clipboard.
private static async System.Threading.Tasks.Task PasteAndReportAsync(InteractiveTerminalView view, InteractiveTerminalViewModel vm)
{
if (!await vm.PasteAsync())
Avalonia.Media.Imaging.Bitmap? bitmap = null;
try
{
bitmap = await TryGetClipboardBitmapAsync(TopLevel.GetTopLevel(view));
if (bitmap is null)
{
if (!await vm.PasteAsync()) vm.ReportSendFailed();
return;
}
var path = ClipboardImageTempStore.NewPngPath(DateTime.UtcNow);
Directory.CreateDirectory(ClipboardImageTempStore.Directory);
bitmap.Save(path);
if (!await vm.SendTextAsync(FormatDroppedPaths(new[] { path })))
vm.ReportSendFailed();
}
catch (Exception)
{
vm.ReportSendFailed();
}
finally
{
bitmap?.Dispose();
}
}
private static async System.Threading.Tasks.Task<Avalonia.Media.Imaging.Bitmap?> TryGetClipboardBitmapAsync(TopLevel? topLevel)
{
if (topLevel?.Clipboard is not { } clipboard) return null;
try
{
using var data = await clipboard.TryGetDataAsync();
return data is null ? null : await Avalonia.Input.AsyncDataTransferExtensions.TryGetBitmapAsync(data);
}
catch (Exception)
{
return null;
}
}
private static bool IsFilesDrop(DragEventArgs e) => e.DataTransfer?.Contains(DataFormat.File) == true;
@@ -327,6 +327,10 @@
<ComboBox ItemsSource="{Binding Merge.MergeTargetBranches}"
SelectedItem="{Binding Merge.SelectedMergeTarget, Mode=TwoWay}"
HorizontalAlignment="Stretch" />
<TextBlock Classes="meta" Text="{loc:Tr details.workConsole.targetDirty}"
TextWrapping="Wrap"
Foreground="{DynamicResource AmberBrush}"
IsVisible="{Binding Merge.TargetDirty}" />
</StackPanel>
<StackPanel Spacing="0">
@@ -173,8 +173,6 @@ public partial class DetailsIslandView : UserControl
await modal.ShowDialog(owner);
};
vm.ConfirmAsync = ShowConfirmAsync;
vm.ShowErrorAsync = ShowErrorDialogAsync;
}
}
@@ -213,87 +211,4 @@ public partial class DetailsIslandView : UserControl
_descriptionHeights[id] = DetailBodyGrid.RowDefinitions[0].Height.Value;
}
private async System.Threading.Tasks.Task ShowErrorDialogAsync(string message)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner == null) return;
var ok = new Button { Content = "OK", MinWidth = 90 };
var dialog = new Window
{
Title = "Error",
Width = 360,
SizeToContent = SizeToContent.Height,
CanResize = false,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
ShowInTaskbar = false,
Background = this.FindResource("SurfaceBrush") as IBrush,
Content = new StackPanel
{
Spacing = 16,
Margin = new Thickness(20),
Children =
{
new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },
new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 8,
HorizontalAlignment = HorizontalAlignment.Right,
Children = { ok }
}
}
}
};
ok.Click += (_, _) => dialog.Close();
await dialog.ShowDialog(owner);
}
private async System.Threading.Tasks.Task<bool> ShowConfirmAsync(string message)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner == null) return false;
var tcs = new TaskCompletionSource<bool>();
var cancel = new Button { Content = "Cancel", MinWidth = 90 };
var confirm = new Button { Content = "Delete", MinWidth = 90, Classes = { "danger" } };
var dialog = new Window
{
Title = "Confirm",
Width = 360,
SizeToContent = SizeToContent.Height,
CanResize = false,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
ShowInTaskbar = false,
Background = this.FindResource("SurfaceBrush") as IBrush,
Content = new StackPanel
{
Spacing = 16,
Margin = new Thickness(20),
Children =
{
new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },
new StackPanel
{
Orientation = Orientation.Horizontal,
Spacing = 8,
HorizontalAlignment = HorizontalAlignment.Right,
Children = { cancel, confirm }
}
}
}
};
cancel.Click += (_, _) => { tcs.TrySetResult(false); dialog.Close(); };
confirm.Click += (_, _) => { tcs.TrySetResult(true); dialog.Close(); };
dialog.Closed += (_, _) => tcs.TrySetResult(false);
_ = dialog.ShowDialog(owner);
return await tcs.Task;
}
}
@@ -139,7 +139,7 @@
</Grid>
</Border>
<ItemsControl ItemsSource="{Binding Lists}"
<ItemsControl ItemsSource="{Binding Children}"
IsVisible="{Binding !IsCollapsed}"
Margin="14,0,0,0"/>
</StackPanel>
@@ -142,18 +142,21 @@ public partial class ListsIslandView : UserControl
private enum FolderDrop { None, Into, Beside }
/// <summary>
/// What a drop on a folder header means. For a list the middle half moves it *into* the folder
/// What a drop on a folder header means: the middle half moves the source *into* the folder
/// (the only route into a collapsed or empty one) while the outer quarters insert next to the
/// folder at the top level; a folder source has no "into" — folders never nest — so it splits
/// in half.
/// folder in its own container. A folder dropped onto itself or one of its own subfolders has
/// no valid "into" or "beside" and is rejected.
/// </summary>
private static (FolderDrop Kind, bool PlaceBelow) ClassifyFolderDrop(
object source, ListFolderNavItemViewModel folder, double y, double height)
{
if (source is ListFolderNavItemViewModel dragged)
return ReferenceEquals(dragged, folder)
? (FolderDrop.None, false)
: (FolderDrop.Beside, y > height / 2);
{
if (IsSelfOrDescendant(dragged, folder)) return (FolderDrop.None, false);
if (y < height * 0.25) return (FolderDrop.Beside, false);
if (y > height * 0.75) return (FolderDrop.Beside, true);
return dragged.ParentFolderId == folder.Id ? (FolderDrop.None, false) : (FolderDrop.Into, false);
}
if (source is not ListNavItemViewModel row) return (FolderDrop.None, false);
if (y < height * 0.25) return (FolderDrop.Beside, false);
@@ -161,6 +164,14 @@ public partial class ListsIslandView : UserControl
return row.FolderId == folder.Id ? (FolderDrop.None, false) : (FolderDrop.Into, false);
}
private static bool IsSelfOrDescendant(ListFolderNavItemViewModel folder, ListFolderNavItemViewModel candidate)
{
if (ReferenceEquals(folder, candidate)) return true;
foreach (var child in folder.Children.OfType<ListFolderNavItemViewModel>())
if (IsSelfOrDescendant(child, candidate)) return true;
return false;
}
private void OnFolderDragOver(object? sender, DragEventArgs e)
{
if (DataContext is not ListsIslandViewModel vm) { e.DragEffects = DragDropEffects.None; return; }
@@ -201,8 +212,8 @@ public partial class ListsIslandView : UserControl
var (kind, placeBelow) = ClassifyFolderDrop(source, folder, e.GetPosition(b).Y, b.Bounds.Height);
vm.ClearDropHints();
if (kind == FolderDrop.Into && source is ListNavItemViewModel row)
await vm.MoveToFolderAsync(row, folder);
if (kind == FolderDrop.Into)
await vm.MoveToFolderAsync(source, folder);
else if (kind == FolderDrop.Beside)
await vm.DropAsync(source, folder, placeBelow);
}
@@ -224,8 +235,6 @@ public partial class ListsIslandView : UserControl
var source = DragSource(vm, e);
if (source is null || ReferenceEquals(source, target)) { Reject(vm, e); return; }
// A folder can only be dropped at the top level, so a row inside a folder is no target.
if (source is ListFolderNavItemViewModel && target.FolderId is not null) { Reject(vm, e); return; }
// Canonicalize: "drop below X" == "drop above X+1". Only the last row shows a below-line.
object hintTarget = target;
@@ -251,7 +260,6 @@ public partial class ListsIslandView : UserControl
var source = DragSource(vm, e);
if (source is null || ReferenceEquals(source, target)) return;
if (source is ListFolderNavItemViewModel && target.FolderId is not null) return;
var placeBelow = e.GetPosition(b).Y > b.Bounds.Height / 2;
vm.ClearDropHints();
@@ -55,7 +55,6 @@ public partial class TasksIslandView : UserControl
await modal.ShowDialog(owner);
// ShowDialog completes once the window is closed (CloseAction or OS close).
};
vm.ConfirmAsync = ShowConfirmAsync;
vm.SelectionChanged += (_, _) => ScrollSelectedIntoView();
}
};
@@ -71,50 +70,6 @@ public partial class TasksIslandView : UserControl
Dispatcher.UIThread.Post(() => RowsListBox.ScrollIntoView(target), DispatcherPriority.Background);
}
private async System.Threading.Tasks.Task<bool> ShowConfirmAsync(string message)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return false;
var tcs = new TaskCompletionSource<bool>();
var cancel = new Button { Content = "Cancel", MinWidth = 90 };
var confirm = new Button { Content = "Confirm", MinWidth = 90, Classes = { "danger" } };
var dialog = new Window
{
Title = "Confirm",
Width = 380,
SizeToContent = SizeToContent.Height,
CanResize = false,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
ShowInTaskbar = false,
Background = this.FindResource("SurfaceBrush") as IBrush,
Content = new StackPanel
{
Margin = new Thickness(20),
Spacing = 16,
Children =
{
new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },
new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
Spacing = 8,
Children = { cancel, confirm },
},
},
},
};
cancel.Click += (_, _) => { tcs.TrySetResult(false); dialog.Close(); };
confirm.Click += (_, _) => { tcs.TrySetResult(true); dialog.Close(); };
dialog.Closed += (_, _) => tcs.TrySetResult(false);
_ = dialog.ShowDialog(owner);
return await tcs.Task;
}
// ── Custom ghost drag ────────────────────────────────────────────────────
// Replaces both the OLE DoDragDropAsync reorder and the OLE drop-to-queue path: a hand-built
// drag (pointer capture + a transparent topmost ghost window) is the only way to get a
@@ -80,11 +80,6 @@ public partial class MainWindow : Window
WindowState = WindowState.Normal;
Activate();
};
vm.CloseWindow = () =>
{
_forceClose = true;
Close();
};
if (vm.Tasks is not null)
vm.Tasks.IsWindowVisible = WindowState != WindowState.Minimized;
}
@@ -4,11 +4,16 @@
xmlns:views="using:ClaudeDo.Ui.Views"
xmlns:loc="using:ClaudeDo.Ui.Localization"
xmlns:conv="using:Avalonia.Data.Converters"
xmlns:cconv="using:ClaudeDo.Ui.Converters"
x:DataType="vm:ConPtyPaneViewModel"
x:Class="ClaudeDo.Ui.Views.MissionControl.ConPtyPaneView">
<Border Classes="monitor-pane" BorderThickness="1" CornerRadius="10" ClipToBounds="True">
<DockPanel LastChildFill="True">
<!-- Identity strip: same colour for the same session across handoffs and restarts. -->
<Border DockPanel.Dock="Left" Width="3"
Background="{Binding IdentityColorKey, Converter={x:Static cconv:DotBrushConverter.Instance}}" />
<!-- Header: title + close -->
<Border DockPanel.Dock="Top"
Background="{DynamicResource Surface2Brush}"
@@ -48,6 +48,60 @@
</MenuFlyout>
</Button.Flyout>
</Button>
<Button x:Name="RecentSessionsButton" Classes="icon-btn"
ToolTip.Tip="{loc:Tr missionControl.recentSessions}">
<PathIcon Data="{StaticResource Icon.Clock}" Width="15" Height="15"/>
<Button.Flyout>
<Flyout Placement="BottomEdgeAlignedRight" ShowMode="Standard" Opening="OnRecentSessionsFlyoutOpening">
<StackPanel Width="380" Spacing="8" Margin="4">
<TextBlock Classes="eyebrow"
Text="{loc:Tr missionControl.recentSessions}"
Foreground="{DynamicResource TextMuteBrush}"
LetterSpacing="1.4" />
<TextBlock Classes="meta"
Text="{loc:Tr missionControl.recentSessionsEmpty}"
IsVisible="{Binding !HasRecentSessions}"
Foreground="{DynamicResource TextMuteBrush}" />
<ScrollViewer MaxHeight="420">
<ItemsControl x:Name="RecentSessionsList" ItemsSource="{Binding RecentSessions}">
<ItemsControl.Styles>
<Style Selector="Grid.handoff">
<Setter Property="Margin" Value="{StaticResource NestedRowIndent}" />
</Style>
</ItemsControl.Styles>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:RecentSessionViewModel">
<Grid ColumnDefinitions="*,Auto" Classes.handoff="{Binding IsHandoff}">
<StackPanel Grid.Column="0" Spacing="2" Margin="0,4" VerticalAlignment="Center">
<TextBlock Text="{Binding Title}"
TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding Title}"
Foreground="{DynamicResource TextDimBrush}" />
<TextBlock Classes="meta"
Text="{Binding Meta}"
TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding Meta}"
Foreground="{DynamicResource TextMuteBrush}" />
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4"
VerticalAlignment="Center">
<Button Classes="btn"
Content="{loc:Tr missionControl.showBrief}"
Command="{Binding ShowBriefCommand}"
IsVisible="{Binding HasBrief}" />
<Button Classes="btn"
Content="{loc:Tr missionControl.resumeSession}"
Command="{Binding ResumeCommand}" />
</StackPanel>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</StackPanel>
</Flyout>
</Button.Flyout>
</Button>
<Button Classes="icon-btn"
Command="{Binding ToggleLayoutCommand}"
ToolTip.Tip="{Binding LayoutToggleLabel}">
@@ -84,7 +138,11 @@
IsVisible="{Binding IsFocusMode}">
<TabControl.ItemTemplate>
<DataTemplate x:DataType="vmm:ConPtyPaneViewModel">
<TextBlock Text="{Binding DisplayTitle}" TextTrimming="CharacterEllipsis" MaxWidth="160" />
<StackPanel Orientation="Horizontal" Spacing="6">
<Ellipse Width="7" Height="7" VerticalAlignment="Center"
Fill="{Binding IdentityColorKey, Converter={x:Static conv:DotBrushConverter.Instance}}" />
<TextBlock Text="{Binding DisplayTitle}" TextTrimming="CharacterEllipsis" MaxWidth="160" />
</StackPanel>
</DataTemplate>
</TabControl.ItemTemplate>
</TabControl>
@@ -8,6 +8,7 @@ using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Platform.Storage;
using Avalonia.VisualTree;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.ViewModels;
namespace ClaudeDo.Ui.Views.MissionControl;
@@ -28,10 +29,14 @@ public partial class MissionControlView : UserControl
InitializeComponent();
DataContextChanged += OnDataContextChanged;
QueueSplitter.DragCompleted += (_, _) => _queueWidth = BodyGrid.ColumnDefinitions[2].Width;
// Resume/Brief buttons don't close a plain Flyout on their own.
RecentSessionsList.AddHandler(Button.ClickEvent, (_, _) => RecentSessionsButton.Flyout?.Hide());
}
private void OnSessionFlyoutOpening(object? sender, EventArgs e) => _ = _vm?.RefreshSessionTargetsAsync();
private void OnRecentSessionsFlyoutOpening(object? sender, EventArgs e) => _ = _vm?.RefreshRecentSessionsAsync();
// Ad-hoc ConPTY session: the view owns the folder picker, the VM only takes the chosen path.
private async void BrowseForSessionAsync()
{
@@ -73,20 +78,21 @@ public partial class MissionControlView : UserControl
UpdateQueueColumn();
}
// Abandon-run confirmation -- same TaskCompletionSource-backed plain Window as
// TasksIslandView.ShowConfirmAsync (no shared control for this shape yet).
// Abandon-run confirmation -- own plain-Window TaskCompletionSource dialog, not routed
// through WindowDialogService: it's owned by the main window, so showing it while Mission
// Control (a separate Window) is focused would put it behind Mission Control.
private async Task<bool> ShowConfirmAsync(string message)
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return false;
var tcs = new TaskCompletionSource<bool>();
var cancel = new Button { Content = "Cancel", MinWidth = 90 };
var confirm = new Button { Content = "Confirm", MinWidth = 90, Classes = { "danger" } };
var cancel = new Button { Content = Loc.T("dialog.cancel"), MinWidth = 90 };
var confirm = new Button { Content = Loc.T("dialog.confirm"), MinWidth = 90, Classes = { "danger" } };
var dialog = new Window
{
Title = "Confirm",
Title = Loc.T("dialog.confirmTitle"),
Width = 380,
SizeToContent = SizeToContent.Height,
CanResize = false,
@@ -70,6 +70,11 @@
TextWrapping="Wrap"
IsVisible="{Binding ErrorMessage, Converter={x:Static ObjectConverters.IsNotNull}}" />
<TextBlock Text="{Binding WarningMessage}"
Foreground="{DynamicResource AmberBrush}"
TextWrapping="Wrap"
IsVisible="{Binding WarningMessage, Converter={x:Static ObjectConverters.IsNotNull}}" />
<Button Classes="btn" HorizontalAlignment="Left"
Content="{loc:Tr modals.merge.stashAndMerge}"
Command="{Binding StashAndMergeCommand}"
@@ -58,13 +58,18 @@
CommandParameter="{Binding}"/>
</ContextMenu>
</Border.ContextMenu>
<Grid ColumnDefinitions="Auto,*,90,90,80,80">
<Grid ColumnDefinitions="Auto,*,90,90,110,80">
<CheckBox Grid.Column="0" VerticalAlignment="Center" Margin="0,0,8,0"
IsChecked="{Binding IsChecked, Mode=TwoWay}"
IsEnabled="{Binding IsActive}"
IsVisible="{Binding IsActive}"/>
<StackPanel Grid.Column="1" Orientation="Vertical" Spacing="2">
<TextBlock Classes="title" Text="{Binding TaskTitle}"/>
<Grid ColumnDefinitions="Auto,*">
<TextBlock Grid.Column="0" Classes="meta" Text="{Binding TaskNumber, StringFormat='#{0}'}"
IsVisible="{Binding ShowNumberBadge}"
VerticalAlignment="Center" Margin="0,0,6,0"/>
<TextBlock Grid.Column="1" Classes="title" Text="{Binding TaskTitle}"/>
</Grid>
<StackPanel Orientation="Horizontal" Spacing="4">
<TextBlock Classes="meta" Text="{Binding TaskStatus}"/>
<TextBlock Classes="meta" Text="•"
@@ -85,7 +90,8 @@
HorizontalAlignment="Left" VerticalAlignment="Center">
<TextBlock Text="{Binding State}"/>
</Border>
<TextBlock Grid.Column="4" Classes="meta" Text="{Binding DiffStat}" VerticalAlignment="Center"/>
<TextBlock Grid.Column="4" Classes="meta" Text="{Binding DiffStat}" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" ToolTip.Tip="{Binding DiffStat}"/>
<TextBlock Grid.Column="5" Classes="meta" Text="{Binding AgeText}" VerticalAlignment="Center"/>
</Grid>
</Border>
@@ -146,8 +152,11 @@
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:WorktreeOverviewRowViewModel">
<Grid ColumnDefinitions="*,Auto" Margin="0,2">
<TextBlock Grid.Column="0" Classes="meta" VerticalAlignment="Center"
Text="{Binding TaskTitle}"/>
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
<TextBlock Classes="meta" Text="{Binding TaskNumber, StringFormat='#{0}'}"
IsVisible="{Binding ShowNumberBadge}"/>
<TextBlock Classes="meta" Text="{Binding TaskTitle}"/>
</StackPanel>
<Button Grid.Column="1" Classes="btn"
Content="{loc:Tr modals.worktreesOverview.resolve}"
Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).ResolveConflictCommand}"
@@ -159,7 +168,7 @@
</StackPanel>
</Border>
<!-- Column headers -->
<Grid ColumnDefinitions="Auto,*,90,90,80,80" Margin="12,0,12,4">
<Grid ColumnDefinitions="Auto,*,90,90,110,80" Margin="12,0,12,4">
<TextBlock Grid.Column="1" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnTask}"/>
<TextBlock Grid.Column="2" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnOutcome}"/>
<TextBlock Grid.Column="3" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnState}"/>
+10 -9
View File
@@ -6,6 +6,7 @@ using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Layout;
using Avalonia.Media;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels;
using ClaudeDo.Ui.ViewModels.Conflicts;
@@ -71,7 +72,7 @@ public sealed class WindowDialogService : IDialogService
public async Task ShowSettingsAsync(SettingsModalViewModel vm)
{
vm.Worktrees.ConfirmAsync = ConfirmAsync;
vm.Worktrees.ConfirmAsync = message => ConfirmAsync(message);
var dlg = new SettingsModalView { DataContext = vm };
vm.RequestRepoImport = () =>
{
@@ -90,7 +91,7 @@ public sealed class WindowDialogService : IDialogService
{
var dlg = new ListSettingsModalView { DataContext = vm };
vm.CloseAction = () => dlg.Close();
vm.ConfirmAsync = ConfirmAsync;
vm.ConfirmAsync = message => ConfirmAsync(message);
vm.ShowErrorAsync = ShowErrorAsync;
await dlg.ShowDialog(_owner);
}
@@ -144,7 +145,7 @@ public sealed class WindowDialogService : IDialogService
_diffViewerWindow = diffDlg;
diffDlg.Show(_owner);
};
vm.ConfirmAction = ConfirmAsync;
vm.ConfirmAction = message => ConfirmAsync(message);
if (Shell is { } shell)
{
vm.ResolveMergeVm = shell.ResolveMergeVm;
@@ -206,13 +207,13 @@ public sealed class WindowDialogService : IDialogService
_cheatSheets.Activate();
}
public Task<bool> ConfirmAsync(string message)
public Task<bool> ConfirmAsync(string message, string? confirmLabel = null)
{
var tcs = new TaskCompletionSource<bool>();
var cancel = new Button { Content = "Cancel", MinWidth = 90 };
var confirm = new Button { Content = "Confirm", MinWidth = 90, Classes = { "danger" } };
var cancel = new Button { Content = Loc.T("dialog.cancel"), MinWidth = 90 };
var confirm = new Button { Content = confirmLabel ?? Loc.T("dialog.confirm"), MinWidth = 90, Classes = { "danger" } };
var dialog = NoticeWindow("Confirm", 380, message,
var dialog = NoticeWindow(Loc.T("dialog.confirmTitle"), 380, message,
new StackPanel
{
Orientation = Orientation.Horizontal,
@@ -231,8 +232,8 @@ public sealed class WindowDialogService : IDialogService
public Task ShowErrorAsync(string message)
{
var ok = new Button { Content = "OK", MinWidth = 90 };
var dialog = NoticeWindow("Error", 360, message,
var ok = new Button { Content = Loc.T("dialog.ok"), MinWidth = 90 };
var dialog = NoticeWindow(Loc.T("dialog.errorTitle"), 360, message,
new StackPanel
{
Orientation = Orientation.Horizontal,
+6 -3
View File
@@ -51,7 +51,7 @@ subfolder within their area; the namespace stays the area namespace.
- **Program.cs** — loads config, inits schema, registers DI, configures SignalR on `/hub`, binds to `127.0.0.1:47821`
- **TaskStateService** — the **only** component that writes `Status`, `PlanningPhase`, `BlockedByTaskId`, and the `task_dependencies` edges. All transitions return a `TransitionResult` (no exceptions on invalid moves). Wakes the queue and advances the planning chain on child terminal transitions. `SubmitForChildrenAsync` (Running → WaitingForChildren) also chains and immediately queues a normal parent's own Idle/Queued children via `PlanningChainCoordinator.SetupChainAsync(enqueue: true)` — unlike a planning parent (`PlanningPhase != None`), whose children stay untouched here and wait for the user's explicit "Queue plan" (`QueuePlanAsync`); a child already `Running` makes `SetupChainAsync` throw, which is caught and logged rather than failing the parent's own transition. Every transition broadcasts via a private `NotifyAsync(taskId)` — never call `_broadcaster.TaskUpdated` directly from this class, `NotifyAsync` also drives `TicketStatusSync.SyncAsync` (see Tickets below) and a direct call skips that transition's ticket write-back. `SetDependsOnAsync` replaces the task's whole dependency **set** (empty list clears it) and rejects a self-reference, an unknown dependency id, or any link that would create a cycle — a BFS over the whole `task_dependencies` edge set from every proposed predecessor, so a multi-edge loop (A→B→C→A) is caught too, and one bad edge leaves the entire set unwritten.
- **IQueueWaker / IQueuePicker / QueueService** — waker is a singleton `SemaphoreSlim`; picker performs the atomic `Queued → Running` claim filtered by `BlockedByTaskId IS NULL`, `is_manual = 0`, schedule, and "no `task_dependencies` edge whose predecessor isn't `done`" (doubly negated on purpose: zero edges stays eligible, all edges must be done, and an edge pointing at a vanished task keeps blocking); QueueService is a thin `BackgroundService` looping on the waker, dispatching via `TaskRunner`. Per tick it also applies the usage throttle and gate → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md).
- **IQueueWaker / IQueuePicker / QueueService** — waker is a singleton `SemaphoreSlim`; picker performs the atomic `Queued → Running` claim filtered by `BlockedByTaskId IS NULL`, `is_manual = 0`, schedule, and "no `task_dependencies` edge whose predecessor isn't satisfied" — satisfied = `status = 'done'` **or** a landed merge (`worktrees.state = 'merged'`), so a predecessor merged with `skipVerify` and parked in `WaitingForReview` until the batch `verify_merges` no longer stalls its successors (doubly negated on purpose: zero edges stays eligible, all edges must be satisfied, and an edge pointing at a vanished task keeps blocking); QueueService is a thin `BackgroundService` looping on the waker, dispatching via `TaskRunner`. Per tick it also applies the usage throttle and gate → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md).
- **RunCancellationRegistry** — taskId → running-run CTS. Lets `TaskStateService.CancelAsync` kill a cancelled task's process without a DI cycle.
- **OverrideSlotService** — owns `RunNow` / `ContinueTask`; goes through `TaskStateService.StartRunningAsync` (caller-driven, serialized by slot lock).
- **StaleTaskRecovery** — startup-only; calls `TaskStateService.RecoverStaleRunningAsync` to flip orphaned `Running` rows to `Failed`.
@@ -67,7 +67,7 @@ not conflated.
| `Status` | `Idle`, `Queued`, `Running`, `WaitingForChildren`, `WaitingForReview`, `Done`, `Failed`, `Cancelled` | Lifecycle only. `WaitingForChildren` = parent's own work done, waiting on children. |
| `PlanningPhase` | `None`, `Active`, `Finalized` | Parent-only marker. `Active` ≈ legacy `Planning`; `Finalized` ≈ legacy `Planned`. |
| `BlockedByTaskId` | nullable FK | Replaces legacy `Waiting`. A queued row with a non-null value is skipped by the picker. Internal to `PlanningChainCoordinator` — resolves (or cascades) on ANY terminal state of the predecessor. Because it self-resolves, `wait_for_task_change` deliberately keeps **waiting** through it instead of reporting `"Blocked"`. |
| `Dependencies` | 0..n rows in `task_dependencies` | User/MCP-declared predecessors (`add_task`/`update_task`), separate from `BlockedByTaskId` because the semantics differ: the picker skips a queued row until **every** dependency reached `Done` -- a Failed/Cancelled dependency does **not** cascade or auto-resolve, the dependent just stays blocked (see `QueuePicker`, `TaskStateService.SetDependsOnAsync`). `get_task`/`list_tasks`/`batch_get_tasks` surface this as `dependsOnTaskIds` + `blocked`/`blockedReason` (the reason names **all** open predecessors, via `External/TaskBlocking`); `wait_for_task_change` reports `"Blocked"` instead of silently running out its timeout. |
| `Dependencies` | 0..n rows in `task_dependencies` | User/MCP-declared predecessors (`add_task`/`update_task`), separate from `BlockedByTaskId` because the semantics differ: the picker skips a queued row until **every** dependency's merge has landed — `Done`, or `worktrees.state = 'merged'` while the batch `verify_merges` is still pending. A Failed/Cancelled dependency does **not** cascade or auto-resolve, the dependent just stays blocked; `revert_merge`/`abort_merge` put the worktree back to `Active` and re-block the successors; a failing `verify_merges` does **not** (the code is on the target branch either way). See `QueuePicker`, `TaskStateService.SetDependsOnAsync`. `get_task`/`list_tasks`/`batch_get_tasks` surface this as `dependsOnTaskIds` + `blocked`/`blockedReason` (the reason names **all** open predecessors, via `External/TaskBlocking`); `wait_for_task_change` reports `"Blocked"` instead of silently running out its timeout. |
| `IsManual` | bool | Reminder only the user can do. `EnqueueAsync`/`StartRunningAsync` refuse it, the picker skips it, `GetDailyPrepCandidates` never offers it. An interactive ConPTY session is still allowed. |
| `ReviewFeedback` | nullable string | Reviewer's rejection comment; consumed and cleared by `QueueService` on the next re-run. |
@@ -254,7 +254,10 @@ non-obvious, behaviour-changing. A fixed bug is git history, not a finding.
Both edit the DI-singleton `WorkerConfig` in place *and* persist via their own `SaveKey` call
(`SaveClaudeBin`/`SaveTicketApiBaseUrl`), so each applies without a restart. Every other field
here is hand-edit only.
- `usage_poll_interval_seconds` (60, clamped to min 15 on load)
- `usage_poll_interval_active_seconds` / `usage_poll_interval_idle_seconds` (120 each, clamped to
min 90 on load) — measured 2026-09-16: the endpoint allows exactly 5 requests per 5-minute
window, then 429s with `Retry-After: 300`; 120s leaves margin for manual refreshes and other
consumers of the same OAuth token, 60s eats the whole quota (see [usage-monitoring](../../docs/explore-notes/usage-monitoring.md)).
- `online_inbox` — `enabled` (false by default; when false the entire `Online/` stack is not registered), `api_base_url` (must be HTTPS or loopback, validated at startup), `poll_interval_seconds` (60), `zitadel.authority`/`client_id`/`scopes`. The refresh token is **not** in this file — DPAPI-encrypted at `~/.claudeDo/online-inbox.token`.
Per-list config (`list_config` in DB) provides defaults for `model`, `system_prompt`,
+15 -6
View File
@@ -46,16 +46,25 @@ public sealed class WorkerConfig
/// <summary>
/// Usage-monitor poll interval while at least one task is Running. Clamped to a minimum
/// of 60s on load — the endpoint rate-limits (429) on tighter polling.
/// of 90s on load. Measured 2026-09-16 against the live endpoint: a clean burst allows
/// exactly 5 requests before a 429, with <c>Retry-After: 300</c> (a flat 5-req/5-min quota,
/// no <c>ratelimit-*</c> headers). 120s leaves ~2.5 requests of headroom per 5-minute window
/// for manual refreshes and any other consumer of the same OAuth token — 60s consumes the
/// whole quota by itself with zero margin, which is what made the old flat-60s cadence 429
/// (see commit 2700c3d8). Same default as <see cref="UsagePollIntervalIdleSeconds"/>: the
/// quota is a flat per-account limit, not tied to ClaudeDo's own activity, so an active/idle
/// split buys nothing safety-wise. The two config keys stay separate for compatibility.
/// </summary>
[JsonPropertyName("usage_poll_interval_active_seconds")]
public int UsagePollIntervalActiveSeconds { get; set; } = 300;
public int UsagePollIntervalActiveSeconds { get; set; } = 120;
/// <summary>
/// Usage-monitor poll interval while nothing is running. Clamped to a minimum of 60s on load.
/// Usage-monitor poll interval while nothing is running. Clamped to a minimum of 90s on
/// load. Same default as <see cref="UsagePollIntervalActiveSeconds"/> — see that doc for the
/// measured rate limit this is sized against.
/// </summary>
[JsonPropertyName("usage_poll_interval_idle_seconds")]
public int UsagePollIntervalIdleSeconds { get; set; } = 900;
public int UsagePollIntervalIdleSeconds { get; set; } = 120;
/// <summary>Base URL of the Bandel ticket-system API. Null = ticket integration off entirely.</summary>
[JsonPropertyName("ticket_api_base_url")]
@@ -88,8 +97,8 @@ public sealed class WorkerConfig
cfg.SandboxRoot = Paths.Expand(cfg.SandboxRoot);
cfg.LogRoot = Paths.Expand(cfg.LogRoot);
cfg.CentralWorktreeRoot = Paths.Expand(cfg.CentralWorktreeRoot);
cfg.UsagePollIntervalActiveSeconds = Math.Max(60, cfg.UsagePollIntervalActiveSeconds);
cfg.UsagePollIntervalIdleSeconds = Math.Max(60, cfg.UsagePollIntervalIdleSeconds);
cfg.UsagePollIntervalActiveSeconds = Math.Max(90, cfg.UsagePollIntervalActiveSeconds);
cfg.UsagePollIntervalIdleSeconds = Math.Max(90, cfg.UsagePollIntervalIdleSeconds);
return cfg;
}
+17 -10
View File
@@ -216,14 +216,19 @@ public sealed class ExternalMcpService
.Distinct()
.ToList();
var dependencyStatuses = new Dictionary<string, TaskStatus>();
var predecessors = new Dictionary<string, (TaskStatus Status, bool Merged)>();
if (dependencyIds.Count > 0)
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
dependencyStatuses = await ctx.Tasks.AsNoTracking()
predecessors = await ctx.Tasks.AsNoTracking()
.Where(t => dependencyIds.Contains(t.Id))
.Select(t => new { t.Id, t.Status })
.ToDictionaryAsync(t => t.Id, t => t.Status, ct);
.Select(t => new
{
t.Id,
t.Status,
Merged = t.Worktree != null && t.Worktree.State == WorktreeState.Merged,
})
.ToDictionaryAsync(t => t.Id, t => (t.Status, t.Merged), ct);
}
var result = new Dictionary<string, (bool Blocked, string? Reason)>();
@@ -239,7 +244,7 @@ public sealed class ExternalMcpService
}
else
{
var unmet = TaskBlocking.Unmet(t.DependsOnTaskIds, dependencyStatuses);
var unmet = TaskBlocking.Unmet(t.DependsOnTaskIds, predecessors);
result[t.Id] = unmet.Count == 0 ? (false, null) : (true, TaskBlocking.Describe(unmet));
}
}
@@ -290,13 +295,14 @@ public sealed class ExternalMcpService
"for normal coding, 'opus' only for complex or cross-cutting work. null inherits the " +
"list/global default (normally sonnet).")]
string? model = null,
[Description("Id of a task that must reach Done (i.e. be merged) before the picker will claim this one, " +
"even once queued. Shorthand for a one-element dependsOnTaskIds; ignored when that is given. " +
[Description("Id of a task whose merge must have landed before the picker will claim this one, even once " +
"queued (Done, or merged and still awaiting the batch verify). Shorthand for a " +
"one-element dependsOnTaskIds; ignored when that is given. " +
"Rejected if it doesn't exist, is this task's own id, or would create a " +
"dependency cycle. If that predecessor instead ends up Failed or Cancelled, this task simply " +
"stays blocked rather than starving silently -- check get_task/list_tasks' blocked field.")]
string? dependsOnTaskId = null,
[Description("Ids of ALL tasks that must reach Done before the picker will claim this one. Use this when a " +
[Description("Ids of ALL tasks whose merges must have landed before the picker will claim this one. Use this when a " +
"task wires together, or verifies, the results of several independent tasks -- one waiting on " +
"A and B is the normal shape of a wave-style plan. Same rejection rules as dependsOnTaskId.")]
string[]? dependsOnTaskIds = null,
@@ -460,12 +466,13 @@ public sealed class ExternalMcpService
string? title = null,
string? description = null,
string? commitType = null,
[Description("Id of a task that must reach Done before the picker will claim this one. Shorthand for a " +
[Description("Id of a task whose merge must have landed (Done, or merged and still awaiting the batch " +
"verify) before the picker will claim this one. Shorthand for a " +
"one-element dependsOnTaskIds; ignored when that is given. Pass an empty " +
"string to clear all existing links; null leaves them unchanged. Rejected if it doesn't exist, " +
"is this task's own id, or would create a dependency cycle.")]
string? dependsOnTaskId = null,
[Description("Ids of ALL tasks that must reach Done before the picker will claim this one, replacing the " +
[Description("Ids of ALL tasks whose merges must have landed before the picker will claim this one, replacing the " +
"task's current set. Pass an empty array to clear it; null leaves it unchanged. Use this when " +
"a task wires together, or verifies, the results of several independent tasks.")]
string[]? dependsOnTaskIds = null,
+309 -43
View File
@@ -1,6 +1,8 @@
using System.ComponentModel;
using System.Diagnostics;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
@@ -61,7 +63,14 @@ public sealed record MergePreviewSetEntryDto(
// Mirrors TaskEntity.ExpectsNoDiff -- lets the merge helper tell a deliberately empty diff
// (verification/audit/research task) apart from a task that silently failed to commit, both
// of which otherwise show IsEmpty=true with no other distinguishing signal.
bool ExpectsNoDiff = false);
bool ExpectsNoDiff = false,
// Top-level C# declarations (methods/properties/fields/types/events) this task's diff removes
// -- a line-pattern scan over `-` lines, not a parser: false positives are possible, a missed
// removal is not the intent. Skips .axaml/.json. Null for an entry with Error set. A name
// another task in the same set still touches on a `+` line surfaces in SymbolWarnings below.
IReadOnlyList<RemovedSymbolDto>? RemovedSymbols = null);
public sealed record RemovedSymbolDto(string Name, string File);
public sealed record FileOverlapDto(string File, IReadOnlyList<string> TaskIds, IReadOnlyList<int> Numbers);
@@ -71,9 +80,16 @@ public sealed record FileOverlapDto(string File, IReadOnlyList<string> TaskIds,
// signal that TaskId's work may already be entirely covered by SupersetTaskId.
public sealed record SubsetRelationDto(string TaskId, int Number, string SupersetTaskId, int SupersetNumber);
// RemovedByTaskId's diff removes Symbol (in RemovedInFile); ReferencedByTaskId's diff still touches
// that name on a `+` line elsewhere -- no textual file conflict, so Overlaps alone misses it (e.g.
// one task deletes a method, a sibling task's edit in a different file still calls it).
public sealed record SymbolReferenceWarningDto(
string Symbol, string RemovedInFile, string RemovedByTaskId, int RemovedByNumber,
string ReferencedByTaskId, int ReferencedByNumber, string ReferencedInFile);
public sealed record MergePreviewSetResultDto(
IReadOnlyList<MergePreviewSetEntryDto> Tasks, IReadOnlyList<FileOverlapDto> Overlaps,
IReadOnlyList<SubsetRelationDto> Subsets);
IReadOnlyList<SubsetRelationDto> Subsets, IReadOnlyList<SymbolReferenceWarningDto> SymbolWarnings);
public sealed record RevertMergeResultDto(
bool Reverted, string? RevertCommit, IReadOnlyList<string> Conflicts, string? Message);
@@ -446,26 +462,30 @@ public sealed class MergeMcpTools
"untracked, in the target working directory — merge-tree can't see the working tree at all, so this is the " +
"only way to catch it before a real merge either refuses or, if that path became tracked in the meantime, " +
"silently overwrites it); behind counts commits on targetBranch not yet on this branch, which flags a stale " +
"branch even when the preview is clean. If the list has a verify command configured, a clean preview is " +
"additionally built/tested in a scratch worktree (still without touching the real working tree) — " +
"verifyExitCode 0 means it built clean, non-zero or -1 (timeout/failed to start) means it doesn't, with the " +
"tail of its output in verifyOutputTail; verifyExitCode stays null when no verify command is configured. " +
"isEmpty=true means the task's review range contributed nothing; check that flag rather than reading a " +
"small changedFileCount as empty. staleFiles lists files this branch touches that the target branch ALSO " +
"changed since this branch's fork point — a more honest staleness signal than `behind` alone, since a " +
"branch can be far behind yet touch nothing the target changed, or barely behind yet collide on the one " +
"file that matters (always empty for a worktree-less handler task, which has no fork point). Throws if the " +
"task has neither an active worktree nor a handler commit range, or the list's working directory is missing " +
"from disk." + McpToolDocs.ProgressHint)]
"branch even when the preview is clean. runVerify=false (default) never builds. runVerify=true additionally " +
"builds/tests a clean preview in a scratch worktree (still without touching the real working tree), if the " +
"list has a verify command configured — verifyExitCode 0 means it built clean, non-zero or -1 " +
"(timeout/failed to start) means it doesn't, with the tail of its output in verifyOutputTail; " +
"verifyExitCode/verifyDurationMs/verifyOutputTail stay null without runVerify=true or without a configured " +
"verify command. isEmpty=true means the task's review range contributed nothing; check that flag rather " +
"than reading a small changedFileCount as empty. staleFiles lists files this branch touches that the " +
"target branch ALSO changed since this branch's fork point — a more honest staleness signal than `behind` " +
"alone, since a branch can be far behind yet touch nothing the target changed, or barely behind yet " +
"collide on the one file that matters (always empty for a worktree-less handler task, which has no fork " +
"point). Throws if the task has neither an active worktree nor a handler commit range, or the list's " +
"working directory is missing from disk." + McpToolDocs.ProgressHint)]
public async Task<MergePreviewToolDto> PreviewMerge(
string taskId,
[Description("Branch to preview against; defaults to the repo's current branch.")]
string? targetBranch = null,
[Description("true: also run the verify command (if configured), building in a scratch worktree. " +
"false (default): no build.")]
bool runVerify = false,
CancellationToken cancellationToken = default,
IProgress<ProgressNotificationValue>? progress = null)
{
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
var (preview, behind, _, isEmpty, staleFiles, _, _) = await PreviewMergeCoreAsync(taskId, targetBranch, runVerify: true, cancellationToken, progress);
var (preview, behind, _, isEmpty, staleFiles, _, _, _, _, _) = await PreviewMergeCoreAsync(taskId, targetBranch, runVerify, cancellationToken, progress);
return new MergePreviewToolDto(preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, isEmpty,
preview.VerifyExitCode, preview.VerifyDurationMs, preview.VerifyOutputTail, staleFiles);
}
@@ -473,16 +493,21 @@ public sealed class MergeMcpTools
[McpServerTool, Description(
"Plan a batch merge: preview_merge for several tasks against the same targetBranch, plus a file-overlap " +
"check between them. Per entry you get preview_merge's fields, or error instead when that task could not " +
"be previewed (it is then left out of the overlap/subset computation). overlaps names, for each file " +
"touched by MORE THAN ONE of the given tasks, which tasks touch it — a single taskId always yields no " +
"overlaps. subsets flags a stronger relation: TaskId's changed files are a PROPER subset of " +
"be previewed (it is then left out of the overlap/subset/symbol computation). overlaps names, for each " +
"file touched by MORE THAN ONE of the given tasks, which tasks touch it — a single taskId always yields " +
"no overlaps; for a .json file this is narrowed to nested-key-path level (parsing both the base commit " +
"and the task branch's head), so two tasks adding different keys to the same locale file are not flagged " +
"and only a genuinely shared key is — unparsable JSON on either side falls back to the plain file-level " +
"check. subsets flags a stronger relation: TaskId's changed files are a PROPER subset of " +
"SupersetTaskId's — the strongest hint you get post-hoc that TaskId may be redundant with SupersetTaskId, " +
"worth checking before merging both. IMPORTANT: neither overlap nor subset is a safety guarantee — two " +
"tasks touching entirely different files (one deleting a symbol, another still referencing it) can still " +
"collide unflagged, and as with preview_merge a clean result does not mean the merge builds. " +
"runVerify=false (default) never builds — set it true to also run each task's list's verify command in a " +
"scratch worktree per entry (same fields as preview_merge); this can take a long time across many tasks, " +
"since builds run one at a time." + McpToolDocs.ProgressHint)]
"worth checking before merging both. removedSymbols (per task entry) lists C# methods/properties/fields/" +
"types/events that task's diff deletes (a line-pattern scan, not a parser — false positives possible, " +
"skips .axaml/.json); symbolWarnings then cross-references those names against every OTHER task's added " +
"lines in the same set, catching a same-symbol collision even when the two tasks touch entirely different " +
"files. IMPORTANT: none of overlaps/subsets/symbolWarnings is a safety guarantee, and as with preview_merge " +
"a clean result does not mean the merge builds. runVerify=false (default) never builds — set it true to " +
"also run each task's list's verify command in a scratch worktree per entry (same fields as preview_merge); " +
"this can take a long time across many tasks, since builds run one at a time." + McpToolDocs.ProgressHint)]
public async Task<MergePreviewSetResultDto> PreviewMergeSet(
IReadOnlyList<string> taskIds,
[Description("Branch to preview every task against; defaults to the repo's current branch.")]
@@ -501,18 +526,37 @@ public sealed class MergeMcpTools
var entries = new List<MergePreviewSetEntryDto>();
var filesByTask = new Dictionary<string, IReadOnlyList<string>>();
var numbersByTask = new Dictionary<string, int>();
var repoPathByTask = new Dictionary<string, string>();
var baseRefByTask = new Dictionary<string, string>();
var headRefByTask = new Dictionary<string, string?>();
var diffLinesByTask = new Dictionary<string, List<DiffLine>>();
var removedSymbolsByTask = new Dictionary<string, IReadOnlyList<RemovedSymbolDto>>();
for (var i = 0; i < taskIds.Count; i++)
{
var taskId = taskIds[i];
try
{
var (preview, behind, changedFiles, isEmpty, staleFiles, number, expectsNoDiff) = await PreviewMergeCoreAsync(taskId, targetBranch, runVerify, cancellationToken, progress);
entries.Add(new MergePreviewSetEntryDto(
taskId, preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, null, isEmpty,
preview.VerifyExitCode, preview.VerifyDurationMs, preview.VerifyOutputTail, staleFiles, number, expectsNoDiff));
var (preview, behind, changedFiles, isEmpty, staleFiles, number, expectsNoDiff, repoPath, baseRef, headRef) =
await PreviewMergeCoreAsync(taskId, targetBranch, runVerify, cancellationToken, progress);
filesByTask[taskId] = changedFiles;
numbersByTask[taskId] = number;
repoPathByTask[taskId] = repoPath;
baseRefByTask[taskId] = baseRef;
headRefByTask[taskId] = headRef;
var diff = headRef is null
? await _git.GetBranchDiffAsync(repoPath, baseRef, ct: cancellationToken)
: await _git.GetCommitRangeDiffAsync(repoPath, baseRef, headRef, ct: cancellationToken);
var diffLines = ParseDiffLines(diff);
diffLinesByTask[taskId] = diffLines;
var removedSymbols = ExtractRemovedSymbols(diffLines);
removedSymbolsByTask[taskId] = removedSymbols;
entries.Add(new MergePreviewSetEntryDto(
taskId, preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, null, isEmpty,
preview.VerifyExitCode, preview.VerifyDurationMs, preview.VerifyOutputTail, staleFiles, number,
expectsNoDiff, removedSymbols));
}
catch (InvalidOperationException ex)
{
@@ -525,23 +569,243 @@ public sealed class MergeMcpTools
ProgressReporter.ReportItem(progress, "Previewing merges", i + 1, taskIds.Count);
}
var overlaps = filesByTask
.SelectMany(kv => kv.Value.Select(f => (File: f, TaskId: kv.Key)))
.GroupBy(x => x.File, StringComparer.OrdinalIgnoreCase)
.Where(g => g.Select(x => x.TaskId).Distinct().Count() > 1)
.Select(g =>
{
var ids = g.Select(x => x.TaskId).Distinct().ToList();
return new FileOverlapDto(g.Key, ids, ids.Select(id => numbersByTask[id]).ToList());
})
.OrderBy(o => o.File, StringComparer.OrdinalIgnoreCase)
.ToList();
var overlaps = await ComputeOverlapsAsync(filesByTask, numbersByTask, repoPathByTask, baseRefByTask, headRefByTask, cancellationToken);
var subsets = FindSubsetRelations(filesByTask, numbersByTask);
var symbolWarnings = FindSymbolWarnings(removedSymbolsByTask, numbersByTask, diffLinesByTask);
return new MergePreviewSetResultDto(entries, overlaps, subsets);
return new MergePreviewSetResultDto(entries, overlaps, subsets, symbolWarnings);
}
// Cross-references each task's removed symbols (Teil 1) against every OTHER task's added
// lines in the same set -- a same-named symbol collision that overlaps/subsets (both purely
// file-based) can never catch, since the two tasks may not share a single file.
private static List<SymbolReferenceWarningDto> FindSymbolWarnings(
Dictionary<string, IReadOnlyList<RemovedSymbolDto>> removedSymbolsByTask,
Dictionary<string, int> numbersByTask,
Dictionary<string, List<DiffLine>> diffLinesByTask)
{
var warnings = new List<SymbolReferenceWarningDto>();
foreach (var (taskId, removed) in removedSymbolsByTask)
{
foreach (var symbol in removed)
{
foreach (var (otherTaskId, diffLines) in diffLinesByTask)
{
if (otherTaskId == taskId) continue;
var hit = diffLines.FirstOrDefault(l => l.Sign == '+' && ContainsWord(l.Text, symbol.Name));
if (hit.File is not null)
warnings.Add(new SymbolReferenceWarningDto(
symbol.Name, symbol.File, taskId, numbersByTask[taskId],
otherTaskId, numbersByTask[otherTaskId], hit.File));
}
}
}
return warnings
.OrderBy(w => w.Symbol, StringComparer.Ordinal)
.ThenBy(w => w.RemovedByTaskId, StringComparer.Ordinal)
.ThenBy(w => w.ReferencedByTaskId, StringComparer.Ordinal)
.ToList();
}
// File-level overlap for everything except .json; for .json, narrows to nested-key-path
// collisions between the tasks that touch it (Teil 2) -- unless either side fails to parse,
// in which case it falls back to the plain file-level check for that file.
private async Task<List<FileOverlapDto>> ComputeOverlapsAsync(
Dictionary<string, IReadOnlyList<string>> filesByTask, Dictionary<string, int> numbersByTask,
Dictionary<string, string> repoPathByTask, Dictionary<string, string> baseRefByTask,
Dictionary<string, string?> headRefByTask, CancellationToken ct)
{
var overlaps = new List<FileOverlapDto>();
var byFile = filesByTask
.SelectMany(kv => kv.Value.Select(f => (File: f, TaskId: kv.Key)))
.GroupBy(x => x.File, StringComparer.OrdinalIgnoreCase);
foreach (var group in byFile)
{
var taskIds = group.Select(x => x.TaskId).Distinct().ToList();
if (taskIds.Count <= 1) continue;
if (!group.Key.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
{
overlaps.Add(MakeOverlap(group.Key, taskIds, numbersByTask));
continue;
}
var colliding = await FindJsonKeyCollisionTasksAsync(
group.Key, taskIds, repoPathByTask, baseRefByTask, headRefByTask, ct);
if (colliding is null)
overlaps.Add(MakeOverlap(group.Key, taskIds, numbersByTask)); // unparsable -- fall back
else if (colliding.Count > 1)
overlaps.Add(MakeOverlap(group.Key, colliding, numbersByTask));
}
return overlaps.OrderBy(o => o.File, StringComparer.OrdinalIgnoreCase).ToList();
}
private static FileOverlapDto MakeOverlap(string file, IReadOnlyList<string> taskIds, Dictionary<string, int> numbersByTask)
=> new(file, taskIds, taskIds.Select(id => numbersByTask[id]).ToList());
// Null = give up and fall back to file-level (JSON unparsable on some side for some task).
// Otherwise: the subset of taskIds whose changed/added/removed key paths in `file` intersect
// with at least one other task's -- two tasks touching disjoint keys in the same file never appear.
private async Task<List<string>?> FindJsonKeyCollisionTasksAsync(
string file, List<string> taskIds,
Dictionary<string, string> repoPathByTask, Dictionary<string, string> baseRefByTask,
Dictionary<string, string?> headRefByTask, CancellationToken ct)
{
var keysByTask = new Dictionary<string, HashSet<string>>();
foreach (var taskId in taskIds)
{
var baseContent = await ReadFileContentAsync(repoPathByTask[taskId], baseRefByTask[taskId], file, ct);
var headContent = await ReadFileContentAsync(repoPathByTask[taskId], headRefByTask[taskId], file, ct);
if (!TryDiffJsonKeyPaths(baseContent, headContent, out var keys))
return null;
keysByTask[taskId] = keys;
}
var colliding = new HashSet<string>();
foreach (var a in taskIds)
foreach (var b in taskIds)
{
if (a == b) continue;
if (keysByTask[a].Overlaps(keysByTask[b]))
{
colliding.Add(a);
colliding.Add(b);
}
}
return taskIds.Where(colliding.Contains).ToList();
}
// gitRef null = read the live working tree off disk (mirrors GetBranchDiffAsync's own base-vs-
// working-tree comparison); non-null = `git show ref:path` (handler task's fixed commit range).
private async Task<string?> ReadFileContentAsync(string repoPath, string? gitRef, string relativePath, CancellationToken ct)
{
if (gitRef is null)
{
var full = Path.Combine(repoPath, relativePath);
return File.Exists(full) ? await File.ReadAllTextAsync(full, ct) : null;
}
return await _git.ShowFileAsync(repoPath, gitRef, relativePath, ct);
}
private static bool TryDiffJsonKeyPaths(string? baseContent, string? headContent, out HashSet<string> changedPaths)
{
changedPaths = new HashSet<string>(StringComparer.Ordinal);
if (!TryFlattenJson(baseContent, out var baseMap) || !TryFlattenJson(headContent, out var headMap))
return false;
foreach (var (path, value) in headMap)
if (!baseMap.TryGetValue(path, out var baseValue) || baseValue != value)
changedPaths.Add(path);
foreach (var path in baseMap.Keys)
if (!headMap.ContainsKey(path))
changedPaths.Add(path);
return true;
}
// Missing content (new/deleted file, either side) is an empty object, not a parse failure --
// only actually-malformed JSON triggers the file-level fallback.
private static bool TryFlattenJson(string? content, out Dictionary<string, string> flattened)
{
flattened = new Dictionary<string, string>(StringComparer.Ordinal);
if (string.IsNullOrWhiteSpace(content))
return true;
try
{
using var doc = JsonDocument.Parse(content);
FlattenJson(doc.RootElement, "", flattened);
return true;
}
catch (JsonException)
{
return false;
}
}
private static void FlattenJson(JsonElement element, string prefix, Dictionary<string, string> into)
{
if (element.ValueKind == JsonValueKind.Object)
{
foreach (var prop in element.EnumerateObject())
FlattenJson(prop.Value, prefix.Length == 0 ? prop.Name : $"{prefix}.{prop.Name}", into);
}
else
{
into[prefix] = element.GetRawText();
}
}
// One line of a unified diff, tagged with the file it belongs to ('-'/'+' only; context lines
// are dropped). File is set from the `diff --git a/<path> b/<path>` header, which (unlike
// `+++ b/...`) is present and names the real path even for a deleted file (`+++ /dev/null`).
private readonly record struct DiffLine(string? File, char Sign, string Text);
private static List<DiffLine> ParseDiffLines(string diff)
{
var result = new List<DiffLine>();
string? currentFile = null;
foreach (var raw in diff.Split('\n'))
{
var line = raw.TrimEnd('\r');
if (line.StartsWith("diff --git a/", StringComparison.Ordinal))
{
var rest = line["diff --git a/".Length..];
var sep = rest.IndexOf(" b/", StringComparison.Ordinal);
currentFile = sep >= 0 ? rest[..sep] : rest;
continue;
}
if (currentFile is null) continue;
if (line.StartsWith("---", StringComparison.Ordinal) || line.StartsWith("+++", StringComparison.Ordinal) ||
line.StartsWith("@@", StringComparison.Ordinal) || line.StartsWith("index ", StringComparison.Ordinal))
continue;
if (line.Length > 0 && line[0] == '-')
result.Add(new DiffLine(currentFile, '-', line[1..]));
else if (line.Length > 0 && line[0] == '+')
result.Add(new DiffLine(currentFile, '+', line[1..]));
}
return result;
}
// Deliberately a line-pattern scan, not a Roslyn parse: requires an explicit access modifier
// (this codebase's style never omits one on a real member/type), which is also what keeps a
// removed local variable or statement inside a method body from matching.
private static readonly Regex RemovedDeclarationRegex = new(
@"^\s*(?:\[[^\]]*\]\s*)*(?:public|private|protected|internal)(?:\s+(?:static|readonly|sealed|abstract|virtual|override|async|partial|const|new|extern|unsafe|required))*\s+" +
@"(?:(?:class|struct|interface|enum|record)\s+(?<name>[A-Za-z_]\w*)" +
@"|event\s+[\w<>\[\],.?]+\s+(?<name>[A-Za-z_]\w*)" +
@"|[\w<>\[\],.?]+\??\s+(?<name>[A-Za-z_]\w*)\s*(?:\(|\{|=>|;|=))",
RegexOptions.Compiled);
private static List<RemovedSymbolDto> ExtractRemovedSymbols(List<DiffLine> diffLines)
{
var seen = new HashSet<(string Name, string File)>();
var result = new List<RemovedSymbolDto>();
foreach (var line in diffLines)
{
if (line.Sign != '-' || line.File is null) continue;
if (line.File.EndsWith(".axaml", StringComparison.OrdinalIgnoreCase) ||
line.File.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
continue;
var match = RemovedDeclarationRegex.Match(line.Text);
if (!match.Success) continue;
if (seen.Add((match.Groups["name"].Value, line.File)))
result.Add(new RemovedSymbolDto(match.Groups["name"].Value, line.File));
}
return result;
}
private static bool ContainsWord(string text, string word)
=> Regex.IsMatch(text, $@"\b{Regex.Escape(word)}\b");
// A task's changed-file set is flagged only against another task's STRICTLY LARGER set that
// contains every one of its files -- an empty set (isEmpty task) never qualifies, since that
// case is already surfaced via IsEmpty and would otherwise match every other task trivially.
@@ -577,7 +841,7 @@ public sealed class MergeMcpTools
// (its commits already sit on the list's working dir) — falls back to the fixed
// HandlerBaseCommit..HandlerHeadCommit range, reporting a synthetic "clean" preview of that
// range's own diff-stat instead of throwing "has no worktree".
private async Task<(MergePreviewResult Preview, int Behind, IReadOnlyList<string> ChangedFiles, bool IsEmpty, IReadOnlyList<string> StaleFiles, int Number, bool ExpectsNoDiff)> PreviewMergeCoreAsync(
private async Task<(MergePreviewResult Preview, int Behind, IReadOnlyList<string> ChangedFiles, bool IsEmpty, IReadOnlyList<string> StaleFiles, int Number, bool ExpectsNoDiff, string RepoPath, string BaseRef, string? HeadRef)> PreviewMergeCoreAsync(
string taskId, string? targetBranch, bool runVerify, CancellationToken ct,
IProgress<ProgressNotificationValue>? progress = null)
{
@@ -619,7 +883,8 @@ public sealed class MergeMcpTools
.Intersect(targetChangedSinceFork, StringComparer.OrdinalIgnoreCase)
.ToList();
return (preview, behind, changedFiles, changedFiles.Count == 0, staleFiles, task.Number, task.ExpectsNoDiff);
return (preview, behind, changedFiles, changedFiles.Count == 0, staleFiles, task.Number, task.ExpectsNoDiff,
wt.Path, wt.BaseCommit, null);
}
if (task.HandlerBaseCommit is { Length: > 0 } handlerBase && task.HandlerHeadCommit is { Length: > 0 } handlerHead)
@@ -636,7 +901,8 @@ public sealed class MergeMcpTools
// No fork point to diff against: a handler task commits straight onto the list's
// working dir instead of a branch, so there is nothing else that could have "changed
// in the target since the fork".
return (preview, 0, changedFiles, isEmpty, Array.Empty<string>(), task.Number, task.ExpectsNoDiff);
return (preview, 0, changedFiles, isEmpty, Array.Empty<string>(), task.Number, task.ExpectsNoDiff,
list.WorkingDir, handlerBase, handlerHead);
}
throw new InvalidOperationException($"Task {taskId} has no worktree.");
+90
View File
@@ -0,0 +1,90 @@
using System.ComponentModel;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Runner;
using Microsoft.AspNetCore.Http;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
namespace ClaudeDo.Worker.External;
/// <summary>Confirmation of a handoff_session call: where the brief was written.</summary>
public sealed record SessionHandoffResult(bool Requested, string BriefPath);
/// <summary>Generic Mission Control handoff. Reached only through the per-tile <c>claudedo_pane</c>
/// MCP entry, whose headers identify the tile and session — the model never passes an id.</summary>
[McpServerToolType]
public sealed class SessionHandoffMcpTools
{
/// <summary>Header naming the calling tile (ConPtyPaneViewModel.IdentityKey).</summary>
public const string PaneHeader = "X-ClaudeDo-Pane";
/// <summary>Header naming the calling session's claude session id.</summary>
public const string SessionHeader = "X-ClaudeDo-Session";
/// <summary>The only tool the <c>claudedo_pane</c> entry lists, and the one the plain entry hides.</summary>
public const string ToolName = "handoff_session";
private readonly HubBroadcaster _broadcaster;
private readonly IHttpContextAccessor _http;
public SessionHandoffMcpTools(HubBroadcaster broadcaster, IHttpContextAccessor http)
{
_broadcaster = broadcaster;
_http = http;
}
/// <summary>Writes the brief under the tile's session dir and asks the UI to replace the tile.</summary>
[McpServerTool(Name = ToolName), Description(
"Hand this session off to a fresh session in the same Mission Control tile and directory, when the " +
"user asks for it (e.g. the context is full). The brief is the ONLY context the fresh session gets -- " +
"make it self-contained. This tile closes right after the call; end your turn immediately.")]
public async Task<SessionHandoffResult> HandoffSession(
[Description("Markdown brief for the fresh session: goal, done, decisions, current state, next steps, open questions, file paths.")] string brief,
CancellationToken cancellationToken = default)
{
var headers = _http.HttpContext?.Request.Headers;
var paneId = headers?[PaneHeader].ToString();
if (string.IsNullOrEmpty(paneId))
throw new InvalidOperationException("handoff_session only works from a ClaudeDo Mission Control tile.");
if (string.IsNullOrWhiteSpace(brief))
throw new InvalidOperationException("brief must not be empty.");
var dir = Path.Combine(InteractiveLaunchSpecService.PaneSessionDir(paneId), "handoffs", Guid.NewGuid().ToString("n"));
Directory.CreateDirectory(dir);
var briefPath = Path.Combine(dir, "brief.md");
var fromSession = headers![SessionHeader].ToString();
var header = fromSession.Length == 0 ? "" : $"{HandedOffFromPrefix}{fromSession}`.\n\n";
await File.WriteAllTextAsync(briefPath, header + brief.Trim() + "\n", cancellationToken);
await _broadcaster.SessionHandoffRequested(paneId, briefPath);
return new SessionHandoffResult(true, briefPath);
}
/// <summary>Start of a brief's first line; the handing-off session id and a closing backtick follow.</summary>
private const string HandedOffFromPrefix = "Handed off from session `";
/// <summary>The session id a brief was handed off from, or null when its first line carries none.</summary>
public static string? ReadHandedOffFrom(string briefPath)
{
var first = File.ReadLines(briefPath).FirstOrDefault();
if (first is null || !first.StartsWith(HandedOffFromPrefix, StringComparison.Ordinal)) return null;
var end = first.IndexOf('`', HandedOffFromPrefix.Length);
return end > HandedOffFromPrefix.Length ? first[HandedOffFromPrefix.Length..end] : null;
}
/// <summary>List-tools filter: a request carrying <see cref="PaneHeader"/> (the per-tile entry) sees
/// only handoff_session; every other request sees everything but it.</summary>
public static McpRequestHandler<ListToolsRequestParams, ListToolsResult> FilterByPane(
McpRequestHandler<ListToolsRequestParams, ListToolsResult> next) =>
async (request, cancellationToken) =>
{
var result = await next(request, cancellationToken);
var http = request.Services?.GetService<IHttpContextAccessor>()?.HttpContext;
result.Tools = result.Tools.Where(t => IsVisible(t.Name, http)).ToList();
return result;
};
/// <summary>Visibility rule behind <see cref="FilterByPane"/>.</summary>
internal static bool IsVisible(string toolName, HttpContext? http) =>
(toolName == ToolName) == !string.IsNullOrEmpty(http?.Request.Headers[PaneHeader].ToString());
}
+9 -6
View File
@@ -10,19 +10,22 @@ namespace ClaudeDo.Worker.External;
internal static class TaskBlocking
{
/// <summary>
/// The predecessors of <paramref name="dependencyIds"/> that have not reached Done — an id
/// missing from <paramref name="statuses"/> counts as unmet (the picker treats a dangling
/// edge as blocking too).
/// The predecessors of <paramref name="dependencyIds"/> the picker would still skip on: not
/// Done <i>and</i> without a landed merge. Mirrors <c>QueuePicker</c>'s SQL predicate — a
/// task merged with <c>skipVerify</c> sits in WaitingForReview until the batch verify runs,
/// yet its code is on the target branch, so it no longer blocks. An id missing from
/// <paramref name="predecessors"/> counts as unmet (a dangling edge blocks there too).
/// </summary>
public static List<(string Id, TaskStatus? Status)> Unmet(
IEnumerable<string> dependencyIds, IReadOnlyDictionary<string, TaskStatus> statuses)
IEnumerable<string> dependencyIds,
IReadOnlyDictionary<string, (TaskStatus Status, bool Merged)> predecessors)
{
var unmet = new List<(string Id, TaskStatus? Status)>();
foreach (var id in dependencyIds)
{
if (statuses.TryGetValue(id, out var status))
if (predecessors.TryGetValue(id, out var p))
{
if (status != TaskStatus.Done) unmet.Add((id, status));
if (p.Status != TaskStatus.Done && !p.Merged) unmet.Add((id, p.Status));
}
else
{
+11 -5
View File
@@ -1,5 +1,6 @@
using System.ComponentModel;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using Microsoft.EntityFrameworkCore;
using ModelContextProtocol;
@@ -152,12 +153,17 @@ public sealed class TaskWaitMcpTools
.SelectMany(r => r.DependsOnTaskIds)
.Distinct()
.ToList();
var dependencyStatuses = dependencyIds.Count == 0
? new Dictionary<string, TaskStatus>()
var predecessors = dependencyIds.Count == 0
? new Dictionary<string, (TaskStatus Status, bool Merged)>()
: await ctx.Tasks.AsNoTracking()
.Where(t => dependencyIds.Contains(t.Id))
.Select(t => new { t.Id, t.Status })
.ToDictionaryAsync(t => t.Id, t => t.Status, ct);
.Select(t => new
{
t.Id,
t.Status,
Merged = t.Worktree != null && t.Worktree.State == WorktreeState.Merged,
})
.ToDictionaryAsync(t => t.Id, t => (t.Status, t.Merged), ct);
var result = new List<TaskStatusChangeDto>();
foreach (var id in taskIds)
@@ -175,7 +181,7 @@ public sealed class TaskWaitMcpTools
// every chained child and turn the wait into a one-turn-per-poll busy loop.
if (row.Status == TaskStatus.Queued && row.BlockedByTaskId is null)
{
var unmet = TaskBlocking.Unmet(row.DependsOnTaskIds, dependencyStatuses);
var unmet = TaskBlocking.Unmet(row.DependsOnTaskIds, predecessors);
if (unmet.Count > 0)
{
result.Add(new TaskStatusChangeDto(
@@ -37,6 +37,11 @@ public sealed class HubBroadcaster : IPrimeBroadcaster, IRefineBroadcaster
public Task HandoffRequested(string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase) =>
_hub.Clients.All.SendAsync("HandoffRequested", taskId, survivingTaskIds, nextPhase);
/// <summary>A Mission Control session called handoff_session -- the UI replaces the tile whose
/// IdentityKey is paneId with a fresh session that starts from the brief.</summary>
public Task SessionHandoffRequested(string paneId, string briefPath) =>
_hub.Clients.All.SendAsync("SessionHandoffRequested", paneId, briefPath);
// start_list_handler (External MCP) -- the App creates the handler task and opens the triage
// tile, exactly like the "Let Claude handle it" button.
public Task ListHandlerStartRequested(string listId, IReadOnlyList<string> taskIds) =>
@@ -0,0 +1,27 @@
using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
namespace ClaudeDo.Worker.Hub;
/// <summary>
/// Logs a Warning for any exception thrown out of a hub method body, so a failed UI action
/// leaves a trace in <c>worker-.log</c> instead of only a generic error at the caller.
/// Does NOT see an argument-binding failure (e.g. a stray CancellationToken parameter) — SignalR
/// rejects those while parsing the invocation message, before a hub method is ever dispatched.
/// </summary>
public sealed class HubExceptionLoggingFilter(ILogger<HubExceptionLoggingFilter> logger) : IHubFilter
{
public async ValueTask<object?> InvokeMethodAsync(
HubInvocationContext invocationContext, Func<HubInvocationContext, ValueTask<object?>> next)
{
try
{
return await next(invocationContext);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Hub method {method_name} failed", invocationContext.HubMethodName);
throw;
}
}
}
+30 -7
View File
@@ -277,8 +277,11 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
// TryAdvanceParentAsync side effect — a direct-repo delete from the details pane
// used to skip it, permanently wedging a WaitingForChildren parent whose last
// child was deleted from there.
public async Task DeleteTask(string taskId, CancellationToken cancellationToken)
// No CancellationToken parameter: SignalR can't bind one on a non-streaming method and fails
// every invocation with a generic server error before the body runs.
public async Task DeleteTask(string taskId)
{
var cancellationToken = CancellationToken.None;
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
var repo = new TaskRepository(ctx);
var task = await repo.GetByIdAsync(taskId, cancellationToken)
@@ -449,7 +452,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
var rows = await _wtMaintenance.GetOverviewAsync(listId, Context.ConnectionAborted);
return rows.Select(r => new WorktreeOverviewDto(
r.TaskId, r.TaskTitle, r.TaskStatus, r.ListId, r.ListName,
r.Path, r.BranchName, r.BaseCommit, r.State, r.DiffStat, r.CreatedAt, r.PathExistsOnDisk)).ToList();
r.Path, r.BranchName, r.BaseCommit, r.State, r.DiffStat, r.CreatedAt, r.PathExistsOnDisk,
r.TaskNumber)).ToList();
}
public async Task<bool> SetWorktreeState(string taskId, WorktreeState newState)
@@ -498,7 +502,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
=> HubGuard(async () =>
{
var t = await _mergeService.GetTargetsAsync(taskId, CancellationToken.None);
return new MergeTargetsDto(t.DefaultBranch, t.LocalBranches, t.DefaultCommitMessage);
return new MergeTargetsDto(t.DefaultBranch, t.LocalBranches, t.DefaultCommitMessage, t.TargetDirty);
});
public Task<MergePreviewDto> PreviewMerge(string taskId, string targetBranch)
@@ -774,16 +778,35 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
{
if (_interactiveLaunchSpec is null)
throw new InvalidOperationException("Interactive launch spec service is not configured.");
return _interactiveLaunchSpec.BuildForTaskAsync(taskId, Context.ConnectionAborted);
_broadcaster.WorktreeUpdated(taskId);
// A task tile's paneId is its task id (ConPtyPaneViewModel.IdentityKey).
return _interactiveLaunchSpec.BuildForTaskAsync(taskId, Context.ConnectionAborted, paneId: taskId);
});
// Builds the launch spec for an ad-hoc interactive session in an arbitrary directory --
// no task, no worktree, no session-skills seeding.
public Task<LaunchSpec> GetAdHocLaunchSpec(string directory) => HubGuard(() =>
// no task, no worktree, no session-skills seeding. paneId is the tile's IdentityKey, rendered
// into the handoff hint.
public Task<LaunchSpec> GetAdHocLaunchSpec(string directory, string paneId) => HubGuard(() =>
{
if (_interactiveLaunchSpec is null)
throw new InvalidOperationException("Interactive launch spec service is not configured.");
return _interactiveLaunchSpec.BuildForDirectoryAsync(directory, Context.ConnectionAborted);
return _interactiveLaunchSpec.BuildForDirectoryAsync(directory, paneId, Context.ConnectionAborted);
});
/// <summary>Launch spec for the fresh session a tile hands off to after handoff_session.</summary>
public Task<LaunchSpec> GetSessionHandoffLaunchSpec(string paneId, string cwd, string briefPath, string? taskId) => HubGuard(() =>
{
if (_interactiveLaunchSpec is null)
throw new InvalidOperationException("Interactive launch spec service is not configured.");
return _interactiveLaunchSpec.BuildHandoffAsync(paneId, cwd, briefPath, taskId, Context.ConnectionAborted);
});
/// <summary>Launch spec resuming a recorded Mission Control session ("Recent sessions") in a new tile.</summary>
public Task<LaunchSpec> GetResumeSessionLaunchSpec(string sessionId, string paneId) => HubGuard(() =>
{
if (_interactiveLaunchSpec is null)
throw new InvalidOperationException("Interactive launch spec service is not configured.");
return _interactiveLaunchSpec.BuildResumeAsync(sessionId, paneId, Context.ConnectionAborted);
});
// Builds the launch spec for an embedded ConPTY "merge helper" session that drives the given
@@ -2,6 +2,7 @@ using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.State;
using Microsoft.EntityFrameworkCore;
@@ -20,17 +21,20 @@ public sealed class InteractiveReviewSubmissionService
private readonly GitService _git;
private readonly ITaskStateService _state;
private readonly WorktreeManager? _worktreeManager;
private readonly HubBroadcaster? _broadcaster;
public InteractiveReviewSubmissionService(
IDbContextFactory<ClaudeDoDbContext> dbFactory,
GitService git,
ITaskStateService state,
WorktreeManager? worktreeManager = null)
WorktreeManager? worktreeManager = null,
HubBroadcaster? broadcaster = null)
{
_dbFactory = dbFactory;
_git = git;
_state = state;
_worktreeManager = worktreeManager;
_broadcaster = broadcaster;
}
public async Task SubmitAsync(string taskId, CancellationToken ct)
@@ -86,5 +90,14 @@ public sealed class InteractiveReviewSubmissionService
var result = await _state.SubmitInteractiveForReviewAsync(taskId, DateTime.UtcNow, ct);
if (!result.Ok)
throw new InvalidOperationException(result.Reason ?? "Could not submit for review.");
// The transition itself only broadcasts TaskUpdated, and the detail pane answers that by
// refreshing the task row alone -- its whole REVIEW/MERGE block (Open diff, branch picker,
// change summary) is gated on the worktree it last read, refreshed only on WorktreeUpdated.
// An interactive session creates its worktree at launch and gets it committed HERE, both
// outside the autonomous pipeline that would normally announce it, so without this the task
// arrives in WaitingForReview with the diff button missing until it is re-selected.
if (_broadcaster is not null)
await _broadcaster.WorktreeUpdated(taskId);
}
}
@@ -20,7 +20,8 @@ public sealed record MergeResult(
public sealed record MergeTargets(
string DefaultBranch,
IReadOnlyList<string> LocalBranches,
string DefaultCommitMessage);
string DefaultCommitMessage,
bool TargetDirty = false);
// VerifyExitCode/VerifyDurationMs/VerifyOutputTail are only populated when the caller asked for
// a verify run (runVerify=true) AND the list has a verify command configured AND the preview came
@@ -875,7 +876,8 @@ public sealed class TaskMergeService
var current = await _git.GetCurrentBranchAsync(list.WorkingDir, ct);
var branches = await _git.ListLocalBranchesAsync(list.WorkingDir, ct);
return new MergeTargets(current, branches, defaultMessage);
var dirty = await _git.HasChangesAsync(list.WorkingDir, includeUntracked: false, ct);
return new MergeTargets(current, branches, defaultMessage, dirty);
}
/// The commit message a merge uses when the caller passes none. Built here rather than in the
+17 -2
View File
@@ -28,6 +28,7 @@ using ClaudeDo.Worker.Usage.Interfaces;
using ClaudeDo.Worker.Usage.TokenTracker;
using ClaudeDo.Worker.Usage.TokenTracker.Interfaces;
using ClaudeDo.Worker.Worktrees;
using Microsoft.AspNetCore.SignalR;
using Microsoft.EntityFrameworkCore;
using Serilog;
@@ -58,6 +59,11 @@ builder.Host.UseSerilog((ctx, lc) => lc
.MinimumLevel.Information()
.MinimumLevel.Override("Microsoft.EntityFrameworkCore", Serilog.Events.LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore", Serilog.Events.LogEventLevel.Warning)
// SignalR logs an argument-binding failure (e.g. a stray CancellationToken parameter, see
// fix 352aaa52) at Debug on this specific source context — HubExceptionLoggingFilter can't
// see it either, since SignalR rejects the invocation while parsing the message, before any
// hub method (and its filter pipeline) is dispatched.
.MinimumLevel.Override("Microsoft.AspNetCore.SignalR.Internal.DefaultHubDispatcher", Serilog.Events.LogEventLevel.Debug)
.WriteTo.File(
System.IO.Path.Combine(logRoot, "worker-.log"),
rollingInterval: RollingInterval.Day,
@@ -90,7 +96,11 @@ builder.Services.AddHostedService<UntrackedWorktreeRecovery>();
// Default MaximumParallelInvocationsPerClient=1 serializes every hub call on one connection —
// a slow one (e.g. GetTicketProjects hitting an unreachable ticket system) blocks unrelated
// calls like UpdateList/UpdateListConfig behind it for the same 10s.
builder.Services.AddSignalR(options => options.MaximumParallelInvocationsPerClient = 10)
builder.Services.AddSignalR(options =>
{
options.MaximumParallelInvocationsPerClient = 10;
options.AddFilter<HubExceptionLoggingFilter>();
})
.AddJsonProtocol(options =>
{
options.PayloadSerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
@@ -375,6 +385,8 @@ if (cfg.ExternalMcpPort > 0)
externalBuilder.Services.AddScoped<AgentMcpTools>();
externalBuilder.Services.AddScoped<LifecycleMcpTools>();
externalBuilder.Services.AddScoped<HandoffMcpTools>();
externalBuilder.Services.AddScoped<SessionHandoffMcpTools>();
externalBuilder.Services.AddHttpContextAccessor();
externalBuilder.Services.AddScoped<ListHandlerMcpTools>();
externalBuilder.Services.AddScoped<AppSettingsMcpTools>();
externalBuilder.Services.AddScoped<TaskWaitMcpTools>();
@@ -385,7 +397,9 @@ if (cfg.ExternalMcpPort > 0)
externalBuilder.Services.AddScoped<NotesMcpTools>();
externalBuilder.Services.AddMcpServer()
.WithHttpTransport(o => o.Stateless = true)
.WithRequestFilters(f => f.AddCallToolFilter(ExternalMcpExceptionFilter.Wrap))
.WithRequestFilters(f => f
.AddCallToolFilter(ExternalMcpExceptionFilter.Wrap)
.AddListToolsFilter(SessionHandoffMcpTools.FilterByPane))
.WithTools<ExternalMcpService>()
.WithTools<MergeMcpTools>()
.WithTools<WorktreeMcpTools>()
@@ -396,6 +410,7 @@ if (cfg.ExternalMcpPort > 0)
.WithTools<AgentMcpTools>()
.WithTools<LifecycleMcpTools>()
.WithTools<HandoffMcpTools>()
.WithTools<SessionHandoffMcpTools>()
.WithTools<ListHandlerMcpTools>()
.WithTools<AppSettingsMcpTools>()
.WithTools<TaskWaitMcpTools>()
+18 -6
View File
@@ -21,11 +21,19 @@ public sealed class QueuePicker : IQueuePicker
// Atomic queue claim: UPDATE + RETURNING in a single statement prevents TOCTOU races.
// Raw SQL because EF cannot express UPDATE...RETURNING.
// Eligible task must be Queued, unblocked (chain and depends-on), not manual, and due
// (or unscheduled). A task_dependencies edge only gates on the dependency's Status='done'
// -- unlike blocked_by_task_id there is no cascade on the dependency failing, so a task
// with a Failed dependency simply stays here, skipped, until someone intervenes. The
// depends-on clause is doubly negated on purpose: "no edge whose predecessor isn't done"
// means a task with zero edges is eligible, ALL edges must be done, and an edge pointing
// (or unscheduled). A task_dependencies edge is satisfied once the predecessor is 'done'
// OR its merge has landed (worktrees.state = 'merged'), because review_task(skipVerify)
// deliberately parks a successfully merged task in WaitingForReview until the batch
// verify_merges runs -- gating on 'done' alone stalled every successor for the whole
// batch. Consequences, all deliberate: a failing verify_merges leaves the merges on the
// target branch, so successors do run (the predecessor's code is there either way);
// revert_merge/abort_merge flip the worktree back to 'active' and re-block; a rejected
// task (reject_rerun/reject_park) never reached 'merged' and stays unmet; a task with no
// worktree (handler task, empty diff) is only ever satisfied via 'done'. Unlike
// blocked_by_task_id there is no cascade on the dependency failing, so a task with a
// Failed dependency simply stays here, skipped, until someone intervenes. The depends-on
// clause is doubly negated on purpose: "no edge whose predecessor isn't satisfied" means
// a task with zero edges is eligible, ALL edges must be satisfied, and an edge pointing
// at a vanished task keeps blocking (same as the old single-column form did).
// EF SQLite stores DateTime as "yyyy-MM-dd HH:mm:ss.fffffff" — same format used here for comparison.
var rows = await ctx.Tasks.FromSqlRaw("""
@@ -41,7 +49,11 @@ public sealed class QueuePicker : IQueuePicker
WHERE td.task_id = t.id
AND NOT EXISTS (
SELECT 1 FROM tasks d
WHERE d.id = td.depends_on_task_id AND d.status = 'done'
WHERE d.id = td.depends_on_task_id
AND (d.status = 'done' OR EXISTS (
SELECT 1 FROM worktrees w
WHERE w.task_id = d.id AND w.state = 'merged'
))
)
)
ORDER BY t.sort_order ASC, t.created_at ASC
@@ -1,4 +1,5 @@
using System.Diagnostics;
using System.Text.Json;
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
@@ -43,6 +44,7 @@ public sealed class InteractiveLaunchSpecService
private readonly WorktreeManager _wtManager;
private readonly GitService _git;
private readonly string _claudePath;
private readonly WorkerConfig _cfg;
private readonly HandoffRoundTracker _handoffRounds;
private readonly HandlerRoundState _roundState;
@@ -67,6 +69,7 @@ public sealed class InteractiveLaunchSpecService
_wtManager = wtManager;
_git = git;
_claudePath = cfg.ClaudeBin;
_cfg = cfg;
_handoffRounds = handoffRounds;
_roundState = roundState;
}
@@ -81,8 +84,10 @@ public sealed class InteractiveLaunchSpecService
/// it has one, else the latest autonomous run's session; a task that has never run either
/// way, or whose worktree was just created fresh, gets a fresh-start spec instead -- pre-
/// assigned a new session id via --session-id and persisted to InteractiveSessionId before
/// launch, so a closed/aborted session can be resumed next time.</summary>
public async Task<LaunchSpec> BuildForTaskAsync(string taskId, CancellationToken ct)
/// launch, so a closed/aborted session can be resumed next time. A non-null
/// <paramref name="paneId"/> appends the Mission Control handoff hint (see
/// <see cref="PaneSessionArgsAsync"/>).</summary>
public async Task<LaunchSpec> BuildForTaskAsync(string taskId, CancellationToken ct, string? paneId = null)
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
@@ -184,17 +189,28 @@ public sealed class InteractiveLaunchSpecService
// persisted HERE, before launch, so a closed/aborted session -- even one that never got
// past startup -- still leaves an id the next open can resume.
IReadOnlyList<string> args;
string sessionId;
if (resumeSessionId is { Length: > 0 })
{
sessionId = resumeSessionId;
args = WithEffort(WindowsTerminalLauncher.BuildResumeArgs(resumeSessionId), effort);
}
else
{
var sessionId = Guid.NewGuid().ToString();
sessionId = Guid.NewGuid().ToString();
await new TaskRepository(ctx).SetInteractiveSessionIdAsync(taskId, sessionId, ct);
args = await BuildFreshTaskArgsAsync(task, effort, sessionId, ct);
}
if (paneId is not null)
{
args = [.. await PaneSessionArgsAsync(paneId, sessionId, ct), .. args];
await new MissionControlSessionRepository(ctx).RecordAsync(new MissionControlSessionEntity
{
SessionId = sessionId, Cwd = sessionDir, TaskId = taskId, Title = task.Title,
}, ct);
}
// Same run environment variable ClaudeProcess sets for every headless run: the
// AskUser MCP tool call and wait_for_task_change cap at 60s unless raised, and lifting
// it is harmless for every other tool. Keep in sync with ClaudeProcess's MCP_TOOL_TIMEOUT.
@@ -253,8 +269,9 @@ public sealed class InteractiveLaunchSpecService
/// <summary>Builds a LaunchSpec for an ad-hoc interactive session in an arbitrary directory --
/// no task, no worktree, no session-skills seeding. Throws InvalidOperationException if the
/// directory doesn't exist.</summary>
public async Task<LaunchSpec> BuildForDirectoryAsync(string directory, CancellationToken ct)
/// directory doesn't exist. A non-null <paramref name="paneId"/> pre-assigns a session id and
/// appends the Mission Control handoff hint.</summary>
public async Task<LaunchSpec> BuildForDirectoryAsync(string directory, string? paneId, CancellationToken ct)
{
if (!Directory.Exists(directory))
throw new InvalidOperationException($"Directory does not exist: {directory}");
@@ -271,9 +288,166 @@ public sealed class InteractiveLaunchSpecService
};
// No task and no list here — the global default model's preset decides the effort.
return new LaunchSpec(
directory, resolvedClaude,
WithEffort(Array.Empty<string>(), EffortFor(settings, settings.DefaultModel)), env);
var args = WithEffort(Array.Empty<string>(), EffortFor(settings, settings.DefaultModel));
if (paneId is not null)
{
// Pre-assigned so the pane entry can carry it -- a handoff records it as the brief's origin.
var sessionId = Guid.NewGuid().ToString();
args = [.. await PaneSessionArgsAsync(paneId, sessionId, ct), .. args, "--session-id", sessionId];
await new MissionControlSessionRepository(ctx).RecordAsync(new MissionControlSessionEntity
{
SessionId = sessionId, Cwd = directory, Title = FolderTitle(directory),
}, ct);
}
return new LaunchSpec(directory, resolvedClaude, args, env);
}
/// <summary>Builds the LaunchSpec for the fresh session a Mission Control tile hands off to via
/// handoff_session: same cwd, a new pre-assigned session id, the brief (written by the tool
/// under <see cref="PaneSessionDir"/>) as the single-line kickoff, and the handoff hint again so
/// the chain can continue. With a taskId the new session id becomes the task's
/// InteractiveSessionId, so reopening the task resumes the handed-off session. Throws
/// InvalidOperationException for a missing cwd or a brief outside the pane's own dir.</summary>
public async Task<LaunchSpec> BuildHandoffAsync(
string paneId, string cwd, string briefPath, string? taskId, CancellationToken ct)
{
if (!Directory.Exists(cwd))
throw new InvalidOperationException($"Directory does not exist: {cwd}");
briefPath = Path.GetFullPath(briefPath);
if (!briefPath.StartsWith(PaneSessionDir(paneId) + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase)
|| !File.Exists(briefPath))
throw new InvalidOperationException($"Handoff brief not found: {briefPath}");
var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath)
?? throw new InvalidOperationException($"claude executable not found: {_claudePath}");
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var settings = await new AppSettingsRepository(ctx).GetAsync(ct);
var sessionId = Guid.NewGuid().ToString();
var model = settings.DefaultModel;
TaskEntity? task = null;
if (taskId is not null)
{
var taskRepo = new TaskRepository(ctx);
task = await taskRepo.GetByIdAsync(taskId, ct) ?? throw new KeyNotFoundException($"Task not found: {taskId}");
model = task.Model ?? (await new ListRepository(ctx).GetConfigAsync(task.ListId, ct))?.Model;
await taskRepo.SetInteractiveSessionIdAsync(taskId, sessionId, ct);
}
var sessions = new MissionControlSessionRepository(ctx);
var fromSession = SessionHandoffMcpTools.ReadHandedOffFrom(briefPath);
var predecessor = fromSession is null ? null : await sessions.GetAsync(fromSession, ct);
await sessions.RecordAsync(new MissionControlSessionEntity
{
SessionId = sessionId,
Cwd = cwd,
TaskId = taskId ?? predecessor?.TaskId,
Title = predecessor?.Title ?? task?.Title ?? FolderTitle(cwd),
PredecessorSessionId = fromSession,
BriefPath = briefPath,
}, ct);
// --add-dir is variadic: the single-value flags after it end it, and the kickoff stays last.
var args = new List<string>(await PaneSessionArgsAsync(paneId, sessionId, ct))
{
"--add-dir", Path.GetDirectoryName(briefPath)!,
"--effort", EffortFor(settings, model),
"--session-id", sessionId,
$"Read the file {briefPath} first. It is the handoff brief from the session that worked here " +
"before you. After reading it, continue the work as it describes.",
};
var env = new Dictionary<string, string>
{
["MCP_TOOL_TIMEOUT"] = McpToolTimeoutMs,
};
return new LaunchSpec(Paths.TrimTrailingSeparator(cwd)!, resolvedClaude, args, env);
}
/// <summary>Builds the LaunchSpec that resumes a recorded Mission Control session (`--resume`) in
/// its own cwd, with the handoff hint so the resumed session can hand off again. Throws
/// KeyNotFoundException for an unrecorded session, InvalidOperationException for a missing cwd.</summary>
public async Task<LaunchSpec> BuildResumeAsync(string sessionId, string paneId, CancellationToken ct)
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var session = await new MissionControlSessionRepository(ctx).GetAsync(sessionId, ct)
?? throw new KeyNotFoundException($"Session not found: {sessionId}");
if (!Directory.Exists(session.Cwd))
throw new InvalidOperationException($"Directory does not exist: {session.Cwd}");
var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath)
?? throw new InvalidOperationException($"claude executable not found: {_claudePath}");
var settings = await new AppSettingsRepository(ctx).GetAsync(ct);
IReadOnlyList<string> args =
[
.. await PaneSessionArgsAsync(paneId, sessionId, ct),
.. WithEffort(WindowsTerminalLauncher.BuildResumeArgs(sessionId), EffortFor(settings, settings.DefaultModel)),
];
var env = new Dictionary<string, string>
{
["MCP_TOOL_TIMEOUT"] = McpToolTimeoutMs,
};
return new LaunchSpec(session.Cwd, resolvedClaude, args, env);
}
// Same title an ad-hoc tile shows: the folder name, or the whole path for a drive root.
private static string FolderTitle(string directory) =>
Path.GetFileName(directory.TrimEnd('\\', '/')) is { Length: > 0 } name ? name : directory;
/// <summary>Per-tile dir under the app data root holding the handoff hint and every brief the
/// tile's sessions handed off with. Throws InvalidOperationException unless paneId is a GUID —
/// it becomes a path segment.</summary>
public static string PaneSessionDir(string paneId)
{
if (!Guid.TryParse(paneId, out _))
throw new InvalidOperationException($"Invalid paneId: {paneId}");
return Path.GetFullPath(Path.Combine(Paths.AppDataRoot(), "pane-sessions", paneId));
}
/// <summary>Name of the per-tile MCP entry that carries the tile's identity in its headers.</summary>
public const string PaneMcpServerName = "claudedo_pane";
// `--mcp-config <file> --append-system-prompt-file <file>` giving the session a `claudedo_pane`
// entry whose headers name this tile and session, so handoff_session needs no id from the model.
// Empty without the external MCP -- there is nothing to hand off through.
private async Task<IReadOnlyList<string>> PaneSessionArgsAsync(string paneId, string sessionId, CancellationToken ct)
{
if (_cfg.ExternalMcpPort <= 0) return [];
var dir = PaneSessionDir(paneId);
Directory.CreateDirectory(dir);
var headers = new Dictionary<string, string>
{
[SessionHandoffMcpTools.PaneHeader] = paneId,
[SessionHandoffMcpTools.SessionHeader] = sessionId,
};
if (!string.IsNullOrEmpty(_cfg.ExternalMcpApiKey))
headers["X-ClaudeDo-Key"] = _cfg.ExternalMcpApiKey;
var mcpConfigPath = Path.Combine(dir, "mcp.json");
await File.WriteAllTextAsync(mcpConfigPath, JsonSerializer.Serialize(new Dictionary<string, object>
{
["mcpServers"] = new Dictionary<string, object>
{
[PaneMcpServerName] = new { type = "http", url = $"http://127.0.0.1:{_cfg.ExternalMcpPort}/mcp", headers },
},
}), ct);
var hintPath = Path.Combine(dir, "handoff-hint.md");
await File.WriteAllTextAsync(hintPath,
$"""
You are running inside a ClaudeDo Mission Control tile. Only when the user asks you to hand off
to a fresh session, call mcp__{PaneMcpServerName}__{SessionHandoffMcpTools.ToolName} with a brief.
The brief is the ONLY context the fresh session gets, so make it self-contained: the goal, what
is done, decisions made and why, the current state (branch, uncommitted changes, failing tests),
what comes next, open questions, and the relevant file paths. This tile closes and a fresh
session opens in its place right after the call -- end your turn immediately.
""", ct);
return ["--mcp-config", mcpConfigPath, "--append-system-prompt-file", hintPath];
}
// Tools the merge helper may use without prompting: the claudedo MCP surface (run, poll,
@@ -708,7 +882,8 @@ public sealed class InteractiveLaunchSpecService
"--effort", effort,
"--session-id", sessionId,
$"Read the file {briefPath} first. It contains the task you must work on. " +
"After reading it, begin the session as your instructions describe.",
"Then, before changing anything, reply with a one-line restatement of what you intend to do " +
"plus any open questions, and wait for my go-ahead.",
};
}
@@ -144,4 +144,49 @@ public sealed class ListFolderTests : IDisposable
Assert.Equal(["One", "Two"], folders.Select(f => f.Name));
Assert.Equal([0, 1], folders.Select(f => f.SortOrder));
}
[Fact]
public async Task SetFolderParentAsync_nests_a_folder_and_moves_it_back_out()
{
await using (var ctx = Open())
{
var repo = new ListRepository(ctx);
await repo.AddFolderAsync(NewFolder("parent"));
await repo.AddFolderAsync(NewFolder("child"));
}
await using (var ctx = Open())
await new ListRepository(ctx).SetFolderParentAsync("child", "parent");
await using (var ctx = Open())
Assert.Equal("parent", (await ctx.ListFolders.AsNoTracking().FirstAsync(f => f.Id == "child")).ParentFolderId);
await using (var ctx = Open())
await new ListRepository(ctx).SetFolderParentAsync("child", null);
await using (var ctx = Open())
Assert.Null((await ctx.ListFolders.AsNoTracking().FirstAsync(f => f.Id == "child")).ParentFolderId);
}
[Fact]
public async Task Deleting_a_folder_keeps_its_subfolder_and_moves_it_to_the_top_level()
{
await using (var ctx = Open())
{
var repo = new ListRepository(ctx);
await repo.AddFolderAsync(NewFolder("parent"));
await repo.AddFolderAsync(NewFolder("child"));
await repo.SetFolderParentAsync("child", "parent");
}
await using (var ctx = Open())
await new ListRepository(ctx).DeleteFolderAsync("parent");
await using (var ctx = Open())
{
var child = await ctx.ListFolders.AsNoTracking().SingleAsync();
Assert.Equal("child", child.Id);
Assert.Null(child.ParentFolderId);
}
}
}
@@ -44,7 +44,7 @@ public class IslandsShellViewModelShutdownTests
private sealed class FakeDialogService(bool confirmResult) : IDialogService
{
public Task<bool> ConfirmAsync(string message) => Task.FromResult(confirmResult);
public Task<bool> ConfirmAsync(string message, string? confirmLabel = null) => Task.FromResult(confirmResult);
public Task ShowAboutAsync(AboutModalViewModel vm) => throw new NotImplementedException();
public Task ShowWeeklyReportAsync(WeeklyReportModalViewModel vm) => throw new NotImplementedException();
@@ -0,0 +1,52 @@
using ClaudeDo.Ui.Services;
namespace ClaudeDo.Ui.Tests.Services;
public class ClipboardImageTempStoreTests : IDisposable
{
private readonly string _dir;
public ClipboardImageTempStoreTests()
{
_dir = Path.Combine(Path.GetTempPath(), "ClaudeDo.Ui.Tests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_dir);
}
public void Dispose() { try { Directory.Delete(_dir, true); } catch { } }
[Fact]
public void NewPngPath_IsUniqueAndUnderTheGivenDir()
{
var now = new DateTime(2026, 9, 16, 10, 30, 0, DateTimeKind.Utc);
var a = ClipboardImageTempStore.NewPngPath(_dir, now);
var b = ClipboardImageTempStore.NewPngPath(_dir, now);
Assert.NotEqual(a, b);
Assert.Equal(_dir, Path.GetDirectoryName(a));
Assert.Equal(".png", Path.GetExtension(a));
}
[Fact]
public void DeleteOlderThan_RemovesOnlyFilesPastTheMaxAge()
{
var now = DateTime.UtcNow;
var oldFile = Path.Combine(_dir, "old.png");
var freshFile = Path.Combine(_dir, "fresh.png");
File.WriteAllText(oldFile, "x");
File.WriteAllText(freshFile, "x");
File.SetLastWriteTimeUtc(oldFile, now - TimeSpan.FromHours(25));
File.SetLastWriteTimeUtc(freshFile, now - TimeSpan.FromHours(1));
ClipboardImageTempStore.DeleteOlderThan(_dir, TimeSpan.FromHours(24), now);
Assert.False(File.Exists(oldFile));
Assert.True(File.Exists(freshFile));
}
[Fact]
public void DeleteOlderThan_NonExistentDir_DoesNotThrow()
{
ClipboardImageTempStore.DeleteOlderThan(Path.Combine(_dir, "missing"), TimeSpan.FromHours(24), DateTime.UtcNow);
}
}
+7 -1
View File
@@ -26,6 +26,7 @@ public abstract class StubWorkerClient : IWorkerClient
public event Action<string, string, string>? TaskQuestionAskedEvent;
public event Action<string, string>? TaskQuestionResolvedEvent;
public event Action<string, IReadOnlyList<string>, string>? HandoffRequestedEvent;
public event Action<string, string>? SessionHandoffRequestedEvent;
public event Action<string, IReadOnlyList<string>>? ListHandlerStartRequestedEvent;
public event Action<string, string>? HandlerUsageWarningEvent;
public event Action? NotesUpdatedEvent;
@@ -59,6 +60,7 @@ public abstract class StubWorkerClient : IWorkerClient
public void RaiseTaskQuestionResolved(string taskId, string questionId) => TaskQuestionResolvedEvent?.Invoke(taskId, questionId);
public void RaiseListHandlerStartRequested(string listId, IReadOnlyList<string> taskIds) => ListHandlerStartRequestedEvent?.Invoke(listId, taskIds);
public void RaiseHandoffRequested(string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase = "wait") => HandoffRequestedEvent?.Invoke(taskId, survivingTaskIds, nextPhase);
public void RaiseSessionHandoffRequested(string paneId, string briefPath) => SessionHandoffRequestedEvent?.Invoke(paneId, briefPath);
public void RaiseHandlerUsageWarning(string taskId, string message) => HandlerUsageWarningEvent?.Invoke(taskId, message);
public void RaisePlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList<string> files, bool externallyDriven)
=> PlanningMergeConflictEvent?.Invoke(planningTaskId, subtaskId, files, externallyDriven);
@@ -124,8 +126,12 @@ public abstract class StubWorkerClient : IWorkerClient
public virtual Task SubmitTaskForReviewAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
public virtual Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
public virtual Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, string paneId, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(directory, "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<LaunchSpec> GetSessionHandoffLaunchSpecAsync(string paneId, string cwd, string briefPath, string? taskId, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(cwd, "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<LaunchSpec> GetResumeSessionLaunchSpecAsync(string sessionId, string paneId, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, string? model = null, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<string> CreateMergeHelperTaskAsync(
+43
View File
@@ -1,6 +1,9 @@
using ClaudeDo.Data;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.Services.Interfaces;
using ClaudeDo.Ui.ViewModels;
using ClaudeDo.Ui.ViewModels.Conflicts;
using ClaudeDo.Ui.ViewModels.Modals;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Ui.Tests;
@@ -31,3 +34,43 @@ public sealed class StubNotesApi : INotesApi
/// <summary><see cref="StubWorkerClient"/> is abstract — this is the concrete no-op subclass.</summary>
public sealed class DefaultStub : StubWorkerClient { }
/// <summary>
/// Records the message/label of the last Confirm/Error call and returns a configurable
/// Confirm result. Covers the ViewModels (DetailsIsland, TasksIsland, ...) that take their
/// dialog seam via a settable <c>Dialogs</c> property instead of a per-call constructor arg.
/// </summary>
public sealed class RecordingDialogService : IDialogService
{
public bool ConfirmResult { get; set; } = true;
public string? LastConfirmMessage { get; private set; }
public string? LastConfirmLabel { get; private set; }
public string? LastErrorMessage { get; private set; }
public Task<bool> ConfirmAsync(string message, string? confirmLabel = null)
{
LastConfirmMessage = message;
LastConfirmLabel = confirmLabel;
return Task.FromResult(ConfirmResult);
}
public Task ShowErrorAsync(string message)
{
LastErrorMessage = message;
return Task.CompletedTask;
}
public Task ShowAboutAsync(AboutModalViewModel vm) => throw new NotImplementedException();
public Task ShowWeeklyReportAsync(WeeklyReportModalViewModel vm) => throw new NotImplementedException();
public Task ShowUsageMonitorAsync(UsageMonitorModalViewModel vm) => throw new NotImplementedException();
public Task ShowSettingsAsync(SettingsModalViewModel vm) => throw new NotImplementedException();
public Task ShowListSettingsAsync(ListSettingsModalViewModel vm) => throw new NotImplementedException();
public Task ShowRepoImportAsync(RepoImportModalViewModel vm) => throw new NotImplementedException();
public Task ShowWorktreesOverviewAsync(WorktreesOverviewModalViewModel vm) => throw new NotImplementedException();
public Task<IReadOnlyList<string>?> ShowMergeHelperSelectionAsync(MergeHelperSelectionModalViewModel vm) => throw new NotImplementedException();
public Task ShowWorkerConnectionAsync(WorkerConnectionModalViewModel vm) => throw new NotImplementedException();
public Task ShowConflictResolverAsync(ConflictResolverViewModel vm) => throw new NotImplementedException();
public Task ShowLogVisualizerAsync(LogVisualizerViewModel vm) => throw new NotImplementedException();
public void ShowMissionControl(MissionControlViewModel vm) => throw new NotImplementedException();
public void ShowCheatSheets(CheatSheetsViewModel vm) => throw new NotImplementedException();
}
@@ -90,12 +90,12 @@ public class DetailsIslandDeleteTaskTests : IDisposable
vm.DeleteFromList = _ => { deleteFromListCalled = true; return Task.CompletedTask; };
var closed = false;
vm.CloseDetail = () => closed = true;
string? reportedError = null;
vm.ShowErrorAsync = msg => { reportedError = msg; return Task.CompletedTask; };
var dialogs = new RecordingDialogService();
vm.Dialogs = dialogs;
await vm.DeleteTaskCommand.ExecuteAsync(null);
Assert.Equal(fkMessage, reportedError);
Assert.Equal(fkMessage, dialogs.LastErrorMessage);
Assert.False(deleteFromListCalled);
Assert.False(closed);
}
@@ -136,12 +136,12 @@ public class DetailsIslandDeleteTaskTests : IDisposable
vm.DeleteFromList = _ => { deleteFromListCalled = true; return Task.CompletedTask; };
var closed = false;
vm.CloseDetail = () => closed = true;
string? reportedError = null;
vm.ShowErrorAsync = msg => { reportedError = msg; return Task.CompletedTask; };
var dialogs = new RecordingDialogService();
vm.Dialogs = dialogs;
await vm.DeleteTaskCommand.ExecuteAsync(null);
Assert.Equal("Connection is not active.", reportedError);
Assert.Equal("Connection is not active.", dialogs.LastErrorMessage);
Assert.False(deleteFromListCalled);
Assert.False(closed);
}
@@ -164,7 +164,7 @@ public class DetailsIslandErrorFeedbackTests : IDisposable
var vm = BuildVm(worker);
vm.Bind(new TaskRowViewModel { Id = "task-reset-throw", Status = TaskStatus.Failed });
vm.Monitor.ApplyState(TaskStatus.Failed);
vm.ConfirmAsync = _ => Task.FromResult(true);
vm.Dialogs = new RecordingDialogService { ConfirmResult = true };
string? reportedError = null;
vm.ErrorReported += msg => reportedError = msg;
@@ -153,12 +153,12 @@ public class DetailsIslandReviewActionsTests : IDisposable
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; };
var dialogs = new RecordingDialogService();
vm.Dialogs = dialogs;
await vm.ApproveReviewCommand.ExecuteAsync(null);
Assert.Equal(worker.ExceptionMessage, reportedError);
Assert.Equal(worker.ExceptionMessage, dialogs.LastErrorMessage);
Assert.False(vm.ApproveOp.IsRunning);
Assert.True(vm.ApproveReviewCommand.CanExecute(null));
}
@@ -62,7 +62,7 @@ public class ListsIslandCreateListCancelTests : IDisposable
public Task ShowWorkerConnectionAsync(WorkerConnectionModalViewModel vm) => throw new NotImplementedException();
public Task ShowConflictResolverAsync(ConflictResolverViewModel vm) => throw new NotImplementedException();
public Task ShowLogVisualizerAsync(LogVisualizerViewModel vm) => throw new NotImplementedException();
public Task<bool> ConfirmAsync(string message) => Task.FromResult(true);
public Task<bool> ConfirmAsync(string message, string? confirmLabel = null) => Task.FromResult(true);
public Task ShowErrorAsync(string message) => Task.CompletedTask;
public void ShowMissionControl(MissionControlViewModel vm) => throw new NotImplementedException();
public void ShowCheatSheets(CheatSheetsViewModel vm) => throw new NotImplementedException();
@@ -88,12 +88,62 @@ public class ListsIslandFolderTests : IDisposable
await vm.LoadAsync();
var folder = Assert.Single(vm.Folders);
Assert.Equal("Inside", Assert.Single(folder.Lists).Name);
Assert.Equal("Inside", Assert.Single(folder.Children.OfType<ListNavItemViewModel>()).Name);
Assert.Equal("Outside", Assert.Single(vm.RootLists).Name);
// UserLists stays the flat superset — counts and row refreshes work over it.
Assert.Equal(2, vm.UserLists.Count);
}
[Fact]
public async Task LoadAsync_builds_an_arbitrarily_deep_folder_tree()
{
await using (var db = NewContext())
{
db.ListFolders.Add(new ListFolderEntity { Id = "top", Name = "Top", SortOrder = 0, CreatedAt = DateTime.UtcNow });
db.ListFolders.Add(new ListFolderEntity { Id = "mid", Name = "Mid", SortOrder = 1, ParentFolderId = "top", CreatedAt = DateTime.UtcNow });
db.ListFolders.Add(new ListFolderEntity { Id = "leaf", Name = "Leaf", SortOrder = 2, ParentFolderId = "mid", CreatedAt = DateTime.UtcNow });
db.Lists.Add(new ListEntity { Id = "deep", Name = "Deep", SortOrder = 3, FolderId = "leaf", CreatedAt = DateTime.UtcNow });
await db.SaveChangesAsync();
}
var vm = NewVm();
await vm.LoadAsync();
var top = Assert.Single(vm.Folders, f => f.ParentFolderId is null);
Assert.Equal("top", top.Id);
var mid = Assert.Single(top.Children.OfType<ListFolderNavItemViewModel>());
Assert.Equal("mid", mid.Id);
var leaf = Assert.Single(mid.Children.OfType<ListFolderNavItemViewModel>());
Assert.Equal("leaf", leaf.Id);
Assert.Equal("Deep", Assert.Single(leaf.Children.OfType<ListNavItemViewModel>()).Name);
// The flat recursive projection sees all three levels.
Assert.Equal(3, vm.Folders.Count);
}
[Fact]
public async Task Folder_count_sums_recursively_through_nested_subfolders()
{
await using (var db = NewContext())
{
db.ListFolders.Add(new ListFolderEntity { Id = "top", Name = "Top", SortOrder = 0, CreatedAt = DateTime.UtcNow });
db.ListFolders.Add(new ListFolderEntity { Id = "leaf", Name = "Leaf", SortOrder = 1, ParentFolderId = "top", CreatedAt = DateTime.UtcNow });
db.Lists.Add(new ListEntity { Id = "a", Name = "A", SortOrder = 2, FolderId = "top", CreatedAt = DateTime.UtcNow });
db.Lists.Add(new ListEntity { Id = "b", Name = "B", SortOrder = 3, FolderId = "leaf", CreatedAt = DateTime.UtcNow });
await db.SaveChangesAsync();
}
await SeedTaskAsync("a", 1);
await SeedTaskAsync("b", 2);
await SeedTaskAsync("b", 3);
var vm = NewVm();
await vm.LoadAsync();
var top = vm.Folders.Single(f => f.Id == "top");
// 1 task directly in "a" (under top) + 2 tasks in "b" (under the nested "leaf").
Assert.Equal(3, top.Count);
Assert.Equal(2, vm.Folders.Single(f => f.Id == "leaf").Count);
}
[Fact]
public async Task Folder_count_is_the_sum_of_its_lists()
{
@@ -121,7 +171,7 @@ public class ListsIslandFolderTests : IDisposable
await vm.MoveToFolderAsync(mover, vm.Folders[0]);
Assert.Empty(vm.RootLists);
Assert.Equal(2, vm.Folders[0].Lists.Count);
Assert.Equal(2, vm.Folders[0].Children.Count);
Assert.Equal("f1", mover.FolderId);
await using var db = NewContext();
@@ -135,11 +185,11 @@ public class ListsIslandFolderTests : IDisposable
var vm = NewVm();
await vm.LoadAsync();
var row = vm.Folders[0].Lists.Single();
var row = vm.Folders[0].Children.OfType<ListNavItemViewModel>().Single();
await vm.MoveToFolderAsync(row, null);
Assert.Empty(vm.Folders[0].Lists);
Assert.Empty(vm.Folders[0].Children);
Assert.Same(row, Assert.Single(vm.RootLists));
Assert.Null(row.FolderId);
@@ -156,12 +206,12 @@ public class ListsIslandFolderTests : IDisposable
var vm = NewVm();
await vm.LoadAsync();
var mover = vm.RootLists.Single();
var inside = vm.Folders[0].Lists.Single();
var inside = vm.Folders[0].Children.OfType<ListNavItemViewModel>().Single();
await vm.ReorderAsync(mover, inside, placeBelow: false);
Assert.Empty(vm.RootLists);
Assert.Equal(["Mover", "Inside"], vm.Folders[0].Lists.Select(l => l.Name));
Assert.Equal(["Mover", "Inside"], vm.Folders[0].Children.OfType<ListNavItemViewModel>().Select(l => l.Name));
Assert.Equal("f1", mover.FolderId);
await using var db = NewContext();
@@ -221,20 +271,93 @@ public class ListsIslandFolderTests : IDisposable
}
[Fact]
public async Task DropAsync_never_nests_a_folder_in_a_folder()
public async Task DropAsync_nests_a_folder_beside_a_list_inside_another_folder()
{
// Folders now nest anywhere (drag-and-drop only, arbitrary depth) — dropping f2 next to a
// list that lives inside f1 moves f2 into f1's container alongside it.
await SeedAsync("f1", 0, ("inside", "Inside", 1));
await SeedAsync("f2", folderSort: 2);
var vm = NewVm();
await vm.LoadAsync();
var inside = vm.Folders[0].Lists.Single();
var f2 = vm.Folders[1];
var f1 = vm.Folders.Single(f => f.Id == "f1");
var f2 = vm.Folders.Single(f => f.Id == "f2");
var inside = f1.Children.OfType<ListNavItemViewModel>().Single();
await vm.DropAsync(f2, inside, placeBelow: true);
Assert.Equal(2, vm.TopLevel.Count);
Assert.Single(vm.Folders[0].Lists);
Assert.Equal("f1", f2.ParentFolderId);
Assert.Equal(new object[] { inside, f2 }, f1.Children);
Assert.Single(vm.TopLevel);
await using var db = NewContext();
Assert.Equal("f1", (await db.ListFolders.AsNoTracking().FirstAsync(f => f.Id == "f2")).ParentFolderId);
}
[Fact]
public async Task DropAsync_ignores_dropping_a_folder_beside_a_target_in_its_own_subtree()
{
await using (var db = NewContext())
{
db.ListFolders.Add(new ListFolderEntity { Id = "top", Name = "Top", SortOrder = 0, CreatedAt = DateTime.UtcNow });
db.ListFolders.Add(new ListFolderEntity { Id = "child", Name = "Child", SortOrder = 1, ParentFolderId = "top", CreatedAt = DateTime.UtcNow });
await db.SaveChangesAsync();
}
var vm = NewVm();
await vm.LoadAsync();
var top = vm.Folders.Single(f => f.Id == "top");
var child = vm.Folders.Single(f => f.Id == "child");
// Would require nesting "top" inside its own descendant "child" — silently ignored.
await vm.DropAsync(top, child, placeBelow: true);
Assert.Null(top.ParentFolderId);
Assert.Equal("top", child.ParentFolderId);
Assert.Single(vm.TopLevel);
}
[Fact]
public async Task MoveToFolderAsync_nests_a_folder_into_another_folder_and_persists_it()
{
await SeedAsync("f1", folderSort: 0);
await SeedAsync("f2", folderSort: 1);
var vm = NewVm();
await vm.LoadAsync();
var f1 = vm.Folders.Single(f => f.Id == "f1");
var f2 = vm.Folders.Single(f => f.Id == "f2");
await vm.MoveToFolderAsync(f2, f1);
Assert.Equal("f1", f2.ParentFolderId);
Assert.Single(vm.TopLevel);
Assert.Same(f2, Assert.Single(f1.Children));
await using var db = NewContext();
Assert.Equal("f1", (await db.ListFolders.AsNoTracking().FirstAsync(f => f.Id == "f2")).ParentFolderId);
}
[Fact]
public async Task MoveToFolderAsync_ignores_moving_a_folder_into_itself_or_a_descendant()
{
await using (var db = NewContext())
{
db.ListFolders.Add(new ListFolderEntity { Id = "top", Name = "Top", SortOrder = 0, CreatedAt = DateTime.UtcNow });
db.ListFolders.Add(new ListFolderEntity { Id = "child", Name = "Child", SortOrder = 1, ParentFolderId = "top", CreatedAt = DateTime.UtcNow });
await db.SaveChangesAsync();
}
var vm = NewVm();
await vm.LoadAsync();
var top = vm.Folders.Single(f => f.Id == "top");
var child = vm.Folders.Single(f => f.Id == "child");
await vm.MoveToFolderAsync(top, top);
await vm.MoveToFolderAsync(top, child);
Assert.Null(top.ParentFolderId);
Assert.Equal("top", child.ParentFolderId);
}
[Fact]
@@ -244,13 +367,14 @@ public class ListsIslandFolderTests : IDisposable
var vm = NewVm();
await vm.LoadAsync();
var a = vm.Folders[0].Lists[0];
var b = vm.Folders[0].Lists[1];
var children = vm.Folders[0].Children.OfType<ListNavItemViewModel>().ToList();
var (a, b) = (children[0], children[1]);
await vm.ReorderAsync(b, a, placeBelow: false);
Assert.Equal(["B", "A"], vm.Folders[0].Lists.Select(l => l.Name));
Assert.All(vm.Folders[0].Lists, l => Assert.Equal("f1", l.FolderId));
var reordered = vm.Folders[0].Children.OfType<ListNavItemViewModel>().ToList();
Assert.Equal(["B", "A"], reordered.Select(l => l.Name));
Assert.All(reordered, l => Assert.Equal("f1", l.FolderId));
}
[Fact]
@@ -263,7 +387,8 @@ public class ListsIslandFolderTests : IDisposable
var folder = vm.Folders[0];
Assert.False(folder.IsCollapsed);
await vm.ToggleFolderCommand.ExecuteAsync(folder);
vm.ToggleFolderCommand.Execute(folder);
await Task.Delay(100);
Assert.True(folder.IsCollapsed);
await using var db = NewContext();
@@ -275,6 +400,31 @@ public class ListsIslandFolderTests : IDisposable
Assert.True(reloaded.Folders[0].IsCollapsed);
}
[Fact]
public async Task ToggleFolder_rapid_double_click_both_toggles_land()
{
// Bug F1 (docs/test-session-2026-09-16.md): the command used to be an async
// [RelayCommand], so AsyncRelayCommand's own CanExecute lock swallowed the second click
// while the first one's DB write was still in flight. Firing two toggles back-to-back
// (no await between them) reproduces exactly that window.
await SeedAsync("f1", ("inside", "Inside", 0));
var vm = NewVm();
await vm.LoadAsync();
var folder = vm.Folders[0];
Assert.False(folder.IsCollapsed);
vm.ToggleFolderCommand.Execute(folder);
vm.ToggleFolderCommand.Execute(folder);
await Task.Delay(100);
// Two flips land back on the original (visible) state...
Assert.False(folder.IsCollapsed);
// ...and the DB ends up agreeing with it, not with the stale mid-flight value.
await using var db = NewContext();
Assert.False((await db.ListFolders.AsNoTracking().FirstAsync(f => f.Id == "f1")).IsCollapsed);
}
[Fact]
public async Task RemoveFromFolder_command_is_the_context_menu_route_out()
{
@@ -282,7 +432,7 @@ public class ListsIslandFolderTests : IDisposable
var vm = NewVm();
await vm.LoadAsync();
var row = vm.Folders[0].Lists.Single();
var row = vm.Folders[0].Children.OfType<ListNavItemViewModel>().Single();
await vm.RemoveFromFolderCommand.ExecuteAsync(row);
@@ -69,6 +69,30 @@ public class MergeModalViewModelTests
Assert.NotEqual("", vm.CommitMessage);
}
[Fact]
public async Task Initialize_flags_a_dirty_target_up_front()
{
var (vm, worker) = Build();
worker.Targets = new MergeTargetsDto("main", new[] { "main", "dev" }, "fix(my-list): merge t", TargetDirty: true);
await vm.InitializeAsync("task-1", "do the thing");
Assert.True(vm.ShowStashRetry);
Assert.False(string.IsNullOrWhiteSpace(vm.WarningMessage));
}
[Fact]
public async Task Initialize_does_not_flag_a_clean_target()
{
var (vm, worker) = Build();
worker.Targets = new MergeTargetsDto("main", new[] { "main", "dev" }, "fix(my-list): merge t", TargetDirty: false);
await vm.InitializeAsync("task-1", "do the thing");
Assert.False(vm.ShowStashRetry);
Assert.Null(vm.WarningMessage);
}
[Fact]
public async Task Submit_shows_a_phase_while_the_merge_is_pending_and_clears_it_after()
{
@@ -53,7 +53,7 @@ public class ConPtyPaneViewModelTests
[Fact]
public void CanSubmitForReview_False_ForAdHocPane_EvenWhileRunning()
{
using var pane = ConPtyPaneViewModel.CreateAdHoc("Ad hoc", NeverCompletes);
using var pane = new ConPtyPaneViewModel(null, "Ad hoc", NeverCompletes);
SetRunning(pane);
Assert.False(pane.IsTaskBased);
@@ -192,7 +192,7 @@ public class ConPtyPaneViewModelTests
[Fact]
public void SessionDiedAfterStartup_AdHocPane_NeverRaises()
{
using var pane = ConPtyPaneViewModel.CreateAdHoc("Ad hoc", NeverCompletes);
using var pane = new ConPtyPaneViewModel(null, "Ad hoc", NeverCompletes);
var raised = false;
pane.SessionDiedAfterStartup += (_, _) => raised = true;
@@ -42,6 +42,23 @@ public class MissionControlViewModelTests : IDisposable
private MissionControlViewModel BuildVm(StubWorkerClient worker)
=> new MissionControlViewModel(new TestDbFactory(NewContext), worker, new UsagePillViewModel(worker));
[Fact]
public void RecentSessions_NestHandoffs_PutsIndirectHandoffsUnderTheirOrigin()
{
var t0 = new DateTime(2026, 9, 28, 8, 0, 0, DateTimeKind.Utc);
MissionControlSessionEntity S(string id, int minutes, string? from = null)
=> new() { SessionId = id, Cwd = "c", Title = id, StartedAt = t0.AddMinutes(minutes), PredecessorSessionId = from };
// Newest first, as ListRecentAsync returns them. "orphan" names a predecessor outside the window.
var newestFirst = new[] { S("c", 40, "b"), S("other", 30), S("b", 20, "a"), S("orphan", 15, "gone"), S("a", 10) };
var rows = MissionControlViewModel.NestHandoffs(newestFirst)
.Select(r => (r.Session.SessionId, r.IsHandoff)).ToList();
Assert.Equal(
new[] { ("a", false), ("b", true), ("c", true), ("other", false), ("orphan", false) },
rows);
}
// Gates the title/list-name DB lookup behind a manually-released TaskCompletionSource so two
// overlapping Open*ConPtySessionAsync calls both run their synchronous dedupe-check prefix
// to completion before either resumes past the DB await. This reproduces the open-path races
@@ -490,7 +507,7 @@ public class MissionControlViewModelTests : IDisposable
private sealed class ThrowingAdHocLaunchSpecWorker : StubWorkerClient
{
public override Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
public override Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, string paneId, CancellationToken ct = default)
=> throw new InvalidOperationException("bad directory");
}
@@ -747,6 +764,105 @@ public class MissionControlViewModelTests : IDisposable
Assert.Equal(0, SubscriberCount(outgoingPane, "SubmitForReviewRequested"));
}
// The handoff replaces a tile in place -- appending it would push it to the end of the grid and
// reshuffle every other tile on every phase change.
[Fact]
public async Task OpenMergeHelperHandoffConPtySessionAsync_KeepsPanePosition()
{
var worker = new FakeWorker();
using var vm = BuildVm(worker);
var dir = Path.Combine("C:", "Some", "Path");
await vm.OpenAdHocConPtySessionAsync(dir);
await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" });
await vm.OpenAdHocConPtySessionAsync(dir);
var taskId = vm.ConPtySessions[1].TaskId!;
var color = vm.ConPtySessions[1].IdentityColorKey;
await vm.OpenMergeHelperHandoffConPtySessionAsync(taskId, new[] { "t1" }, "wait");
Assert.Equal(3, vm.ConPtySessions.Count);
Assert.Equal(taskId, vm.ConPtySessions[1].TaskId);
Assert.Equal(taskId, vm.Panes[1].TaskId);
// Same task, new pane -- the identity colour has to survive the handoff, or the tile the
// user was tracking changes colour mid-run.
Assert.Equal(color, vm.ConPtySessions[1].IdentityColorKey);
}
private sealed class RecordingSessionHandoffWorker : StubWorkerClient
{
public string? AdHocPaneId;
public readonly TaskCompletionSource<(string PaneId, string Cwd, string BriefPath, string? TaskId)> HandoffCall = new();
public override Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, string paneId, CancellationToken ct = default)
{
AdHocPaneId ??= paneId;
return base.GetAdHocLaunchSpecAsync(directory, paneId, ct);
}
public override Task<LaunchSpec> GetSessionHandoffLaunchSpecAsync(string paneId, string cwd, string briefPath, string? taskId, CancellationToken ct = default)
{
HandoffCall.TrySetResult((paneId, cwd, briefPath, taskId));
return base.GetSessionHandoffLaunchSpecAsync(paneId, cwd, briefPath, taskId, ct);
}
}
// handoff_session replaces the tile in place: same slot, same identity colour, same cwd, and the
// outgoing pane is really closed rather than left running beside the new one.
[Fact]
public async Task SessionHandoffRequested_ReplacesPaneInPlace_KeepingIdentityAndCwd()
{
var worker = new RecordingSessionHandoffWorker();
using var vm = BuildVm(worker);
var dir = Path.Combine("C:", "Some", "Path");
await vm.OpenConPtySessionAsync("t1");
await vm.OpenAdHocConPtySessionAsync(dir);
await vm.OpenConPtySessionAsync("t2");
var outgoing = vm.ConPtySessions[1];
Assert.Equal(outgoing.IdentityKey, worker.AdHocPaneId);
var color = outgoing.IdentityColorKey;
var opened = 0;
vm.OpenRequested += () => opened++;
worker.RaiseSessionHandoffRequested(outgoing.IdentityKey, "brief.md");
Assert.Equal(3, vm.ConPtySessions.Count);
var incoming = vm.ConPtySessions[1];
Assert.NotSame(outgoing, incoming);
Assert.Null(incoming.TaskId);
Assert.Equal(outgoing.IdentityKey, incoming.IdentityKey);
Assert.Equal(color, incoming.IdentityColorKey);
Assert.Same(incoming, vm.Panes[1]);
Assert.Equal(1, opened);
Assert.Equal(0, SubscriberCount(outgoing, "SubmitForReviewRequested"));
var call = await worker.HandoffCall.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal((outgoing.IdentityKey, dir, "brief.md", (string?)null), call);
}
[Fact]
public async Task SessionHandoffRequested_TaskPane_KeepsTaskId()
{
var worker = new RecordingSessionHandoffWorker();
using var vm = BuildVm(worker);
await vm.OpenConPtySessionAsync("t1");
worker.RaiseSessionHandoffRequested("t1", "brief.md");
Assert.Equal("t1", Assert.Single(vm.ConPtySessions).TaskId);
var call = await worker.HandoffCall.Task.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Equal("t1", call.TaskId);
}
[Fact]
public void SessionHandoffRequested_UnknownPane_DoesNothing()
{
var worker = new RecordingSessionHandoffWorker();
using var vm = BuildVm(worker);
worker.RaiseSessionHandoffRequested("nope", "brief.md");
Assert.Empty(vm.ConPtySessions);
Assert.False(worker.HandoffCall.Task.IsCompleted);
}
[Fact]
public async Task OpenMergeHelperHandoffConPtySessionAsync_SubmitForReview_ResolvesToSurvivingPane()
{
@@ -43,7 +43,7 @@ public class TasksIslandMoveToListTests : IDisposable
public int ConfirmCalls;
public string? LastMessage;
public Task<bool> ConfirmAsync(string message)
public Task<bool> ConfirmAsync(string message, string? confirmLabel = null)
{
ConfirmCalls++;
LastMessage = message;
@@ -114,6 +114,32 @@ public class WorktreesOverviewReconcileTickTests
Assert.Equal(1, vm.SelectedCount);
}
[Fact]
public async Task Map_carries_the_task_number_and_hides_the_badge_when_zero()
{
var worker = new FakeWorker
{
Worktrees =
{
Wt("a") with { TaskNumber = 42 },
Wt("b") with { TaskNumber = 0 },
},
};
var vm = new WorktreesOverviewModalViewModel(
worker, () => throw new InvalidOperationException("no diff vm in this test"),
new ClaudeDo.Ui.Services.MergeCoordinator());
vm.Configure("L1", "Work");
await vm.LoadAsync();
var withNumber = vm.Rows.Single(r => r.TaskId == "a");
var withoutNumber = vm.Rows.Single(r => r.TaskId == "b");
Assert.Equal(42, withNumber.TaskNumber);
Assert.True(withNumber.ShowNumberBadge);
Assert.Equal(0, withoutNumber.TaskNumber);
Assert.False(withoutNumber.ShowNumberBadge);
}
[Theory]
[InlineData("refresh")]
[InlineData("cleanup")]
@@ -472,6 +472,34 @@ public sealed class ExternalMcpServiceTests : IDisposable
Assert.Null(dto.BlockedReason);
}
[Fact]
public async Task GetTask_QueuedWithMergedButUnverifiedDependsOn_ReportsBlockedFalse()
{
// Same rule as QueuePicker: a predecessor merged with skipVerify sits in
// WaitingForReview until the batch verify, but the picker already claims the successor --
// get_task must not contradict that by still calling it blocked.
var listId = await SeedListAsync();
var predecessor = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
_ctx.Worktrees.Add(new WorktreeEntity
{
TaskId = predecessor.Id,
Path = $"/tmp/{predecessor.Id}",
BranchName = $"claudedo/{predecessor.Id}",
BaseCommit = "abc123",
State = WorktreeState.Merged,
CreatedAt = DateTime.UtcNow,
});
await _ctx.SaveChangesAsync();
var task = await SeedTaskAsync(listId, status: TaskStatus.Queued);
await _ctx.SeedDependsOnAsync(task.Id, predecessor.Id);
var sut = BuildSut(CreateQueue());
var dto = await sut.GetTask(task.Id, CancellationToken.None);
Assert.False(dto.Blocked);
Assert.Null(dto.BlockedReason);
}
[Fact]
public async Task GetTask_IdleWithDependsOnTaskId_ReportsBlockedFalse_NotYetQueued()
{
@@ -106,7 +106,7 @@ public sealed class ExternalMcpToolSchemaTests
"list_runs", "get_run", "get_task_log",
"list_agents",
"reset_failed_task", "submit_task_for_review",
"handoff_list_handler", "get_handoff_status", "start_list_handler",
"handoff_list_handler", "get_handoff_status", "start_list_handler", "handoff_session",
"get_app_settings",
"wait_for_task_change",
"get_queue_state",
+177 -14
View File
@@ -163,12 +163,13 @@ public sealed class MergeMcpToolsTests : IDisposable
}
private async Task<string> CreateActiveWorktreeAsync(
GitRepoFixture repo, string taskId, string fileName, string content)
GitRepoFixture repo, string taskId, string fileName, string content, string? baseCommit = null)
{
baseCommit ??= repo.BaseCommit;
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
_worktreeCleanups.Add((repo.RepoDir, wtPath));
var branch = $"claudedo/{taskId[..8]}";
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, repo.BaseCommit);
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", branch, wtPath, baseCommit);
File.WriteAllText(Path.Combine(wtPath, fileName), content);
GitRepoFixture.RunGit(wtPath, "add", fileName);
GitRepoFixture.RunGit(wtPath, "commit", "-m", $"edit {fileName}");
@@ -177,7 +178,7 @@ public sealed class MergeMcpToolsTests : IDisposable
ctx.Worktrees.Add(new WorktreeEntity
{
TaskId = taskId, Path = wtPath, BranchName = branch,
BaseCommit = repo.BaseCommit,
BaseCommit = baseCommit,
HeadCommit = GitRepoFixture.RunGit(wtPath, "rev-parse", "HEAD").Trim(),
State = WorktreeState.Active, CreatedAt = DateTime.UtcNow,
});
@@ -793,7 +794,7 @@ public sealed class MergeMcpToolsTests : IDisposable
var headBefore = await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None);
var sut = BuildSut(CreateQueue());
var result = await sut.PreviewMerge(task.Id, target, CancellationToken.None);
var result = await sut.PreviewMerge(task.Id, target, cancellationToken: CancellationToken.None);
Assert.Equal(TaskMergeService.PreviewClean, result.Status);
Assert.Empty(result.ConflictFiles);
@@ -815,7 +816,7 @@ public sealed class MergeMcpToolsTests : IDisposable
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
var sut = BuildSut(CreateQueue());
var result = await sut.PreviewMerge(task.Id, target, CancellationToken.None);
var result = await sut.PreviewMerge(task.Id, target, cancellationToken: CancellationToken.None);
Assert.Equal(TaskMergeService.PreviewClean, result.Status);
Assert.Equal(0, result.ChangedFileCount);
@@ -843,7 +844,7 @@ public sealed class MergeMcpToolsTests : IDisposable
}
var sut = BuildSut(CreateQueue());
var result = await sut.PreviewMerge(task.Id, null, CancellationToken.None);
var result = await sut.PreviewMerge(task.Id, null, cancellationToken: CancellationToken.None);
Assert.Equal(TaskMergeService.PreviewClean, result.Status);
Assert.Equal(0, result.ChangedFileCount);
@@ -875,7 +876,7 @@ public sealed class MergeMcpToolsTests : IDisposable
}
var sut = BuildSut(CreateQueue());
var result = await sut.PreviewMerge(task.Id, null, CancellationToken.None);
var result = await sut.PreviewMerge(task.Id, null, cancellationToken: CancellationToken.None);
Assert.Equal(TaskMergeService.PreviewClean, result.Status);
Assert.Equal(1, result.ChangedFileCount);
@@ -893,7 +894,7 @@ public sealed class MergeMcpToolsTests : IDisposable
var headBefore = await git.RevParseHeadAsync(list.WorkingDir!, CancellationToken.None);
var sut = BuildSut(CreateQueue());
var result = await sut.PreviewMerge(task.Id, target, CancellationToken.None);
var result = await sut.PreviewMerge(task.Id, target, cancellationToken: CancellationToken.None);
Assert.Equal(TaskMergeService.PreviewConflict, result.Status);
Assert.Contains("README.md", result.ConflictFiles);
@@ -910,7 +911,7 @@ public sealed class MergeMcpToolsTests : IDisposable
var sut = BuildSut(CreateQueue());
await Assert.ThrowsAsync<InvalidOperationException>(
() => sut.PreviewMerge(task.Id, null, CancellationToken.None));
() => sut.PreviewMerge(task.Id, null, cancellationToken: CancellationToken.None));
}
[Fact]
@@ -928,7 +929,7 @@ public sealed class MergeMcpToolsTests : IDisposable
var sut = BuildSut(CreateQueue());
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => sut.PreviewMerge(task.Id, null, CancellationToken.None));
() => sut.PreviewMerge(task.Id, null, cancellationToken: CancellationToken.None));
Assert.Contains("working directory", ex.Message, StringComparison.OrdinalIgnoreCase);
}
@@ -952,7 +953,7 @@ public sealed class MergeMcpToolsTests : IDisposable
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "ok") };
var sut = BuildSut(CreateQueue(), fakeVerify);
var result = await sut.PreviewMerge(task.Id, target, CancellationToken.None);
var result = await sut.PreviewMerge(task.Id, target, runVerify: true, cancellationToken: CancellationToken.None);
Assert.Equal(TaskMergeService.PreviewClean, result.Status);
Assert.Equal(0, result.VerifyExitCode);
@@ -962,6 +963,34 @@ public sealed class MergeMcpToolsTests : IDisposable
Assert.False(await git.HasChangesAsync(list.WorkingDir!, CancellationToken.None));
}
[Fact]
public async Task PreviewMerge_DefaultRunVerifyFalse_NeverInvokesRunnerEvenWithCommandConfigured()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content");
var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None);
using (var ctx = _db.CreateContext())
await new ListRepository(ctx).SetConfigAsync(new ListConfigEntity { ListId = list.Id, VerifyCommand = "dotnet build" });
var git = new GitService();
var target = await git.GetCurrentBranchAsync(list.WorkingDir!, CancellationToken.None);
var fakeVerify = new FakeVerifyCommandRunner { Result = new VerifyCommandResult(0, false, "ok") };
var sut = BuildSut(CreateQueue(), fakeVerify);
var result = await sut.PreviewMerge(task.Id, target, cancellationToken: CancellationToken.None);
Assert.Equal(TaskMergeService.PreviewClean, result.Status);
Assert.Null(result.VerifyExitCode);
Assert.Null(result.VerifyDurationMs);
Assert.Null(result.VerifyOutputTail);
Assert.Null(fakeVerify.CapturedCommand);
}
[Fact]
public async Task PreviewMerge_TargetChangedSameFileSinceFork_ReportsStaleFiles()
{
@@ -982,7 +1011,7 @@ public sealed class MergeMcpToolsTests : IDisposable
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main also touched shared.txt");
var sut = BuildSut(CreateQueue());
var result = await sut.PreviewMerge(task.Id, "main", CancellationToken.None);
var result = await sut.PreviewMerge(task.Id, "main", cancellationToken: CancellationToken.None);
Assert.Equal(1, result.Behind);
Assert.NotNull(result.StaleFiles);
@@ -1010,7 +1039,7 @@ public sealed class MergeMcpToolsTests : IDisposable
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main touched something else");
var sut = BuildSut(CreateQueue());
var result = await sut.PreviewMerge(task.Id, "main", CancellationToken.None);
var result = await sut.PreviewMerge(task.Id, "main", cancellationToken: CancellationToken.None);
Assert.Equal(1, result.Behind);
Assert.NotNull(result.StaleFiles);
@@ -1042,7 +1071,7 @@ public sealed class MergeMcpToolsTests : IDisposable
}
var sut = BuildSut(CreateQueue());
var result = await sut.PreviewMerge(task.Id, null, CancellationToken.None);
var result = await sut.PreviewMerge(task.Id, null, cancellationToken: CancellationToken.None);
Assert.NotNull(result.StaleFiles);
Assert.Empty(result.StaleFiles!);
@@ -1289,4 +1318,138 @@ public sealed class MergeMcpToolsTests : IDisposable
Assert.Equal(0, result.Tasks.Single().VerifyExitCode);
Assert.Equal("dotnet build", fakeVerify.CapturedCommand);
}
[Fact]
public async Task PreviewMergeSet_RemovedSymbolReferencedByAnotherTask_ReportsSymbolWarning()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = new GitRepoFixture();
_repos.Add(repo);
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
File.WriteAllText(Path.Combine(repo.RepoDir, "Foo.cs"),
"public class Foo\n{\n private void RestartWorkerStatus()\n {\n }\n}\n");
File.WriteAllText(Path.Combine(repo.RepoDir, "Bar.cs"), "public class Bar\n{\n}\n");
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "add Foo/Bar");
var baseCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
var listId = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
var removerTask = await SeedTaskAsync(listId, "removes", TaskStatus.WaitingForReview);
var referencerTask = await SeedTaskAsync(listId, "references", TaskStatus.WaitingForReview);
await CreateActiveWorktreeAsync(repo, removerTask.Id, "Foo.cs", "public class Foo\n{\n}\n", baseCommit);
await CreateActiveWorktreeAsync(repo, referencerTask.Id, "Bar.cs",
"public class Bar\n{\n private void Call()\n {\n RestartWorkerStatus();\n }\n}\n", baseCommit);
var sut = BuildSut(CreateQueue());
var result = await sut.PreviewMergeSet(
[removerTask.Id, referencerTask.Id], "main", cancellationToken: CancellationToken.None);
var removerEntry = result.Tasks.Single(t => t.TaskId == removerTask.Id);
Assert.Contains(removerEntry.RemovedSymbols!, s => s.Name == "RestartWorkerStatus" && s.File == "Foo.cs");
var warning = Assert.Single(result.SymbolWarnings);
Assert.Equal("RestartWorkerStatus", warning.Symbol);
Assert.Equal("Foo.cs", warning.RemovedInFile);
Assert.Equal(removerTask.Id, warning.RemovedByTaskId);
Assert.Equal(referencerTask.Id, warning.ReferencedByTaskId);
Assert.Equal("Bar.cs", warning.ReferencedInFile);
}
[Fact]
public async Task PreviewMergeSet_TwoTasksAddDifferentJsonKeys_ReportsNoOverlap()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = new GitRepoFixture();
_repos.Add(repo);
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
File.WriteAllText(Path.Combine(repo.RepoDir, "en.json"), "{}\n");
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "add en.json");
var baseCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
var listId = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
var taskA = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview);
var taskB = await SeedTaskAsync(listId, "b", TaskStatus.WaitingForReview);
await CreateActiveWorktreeAsync(repo, taskA.Id, "en.json", "{\"a\": {\"one\": \"1\"}}\n", baseCommit);
await CreateActiveWorktreeAsync(repo, taskB.Id, "en.json", "{\"a\": {\"two\": \"2\"}}\n", baseCommit);
var sut = BuildSut(CreateQueue());
var result = await sut.PreviewMergeSet([taskA.Id, taskB.Id], "main", cancellationToken: CancellationToken.None);
Assert.Empty(result.Overlaps);
}
[Fact]
public async Task PreviewMergeSet_TwoTasksChangeSameJsonKey_ReportsOverlap()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = new GitRepoFixture();
_repos.Add(repo);
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
File.WriteAllText(Path.Combine(repo.RepoDir, "en.json"), "{}\n");
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "add en.json");
var baseCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
var listId = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
var taskA = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview);
var taskB = await SeedTaskAsync(listId, "b", TaskStatus.WaitingForReview);
await CreateActiveWorktreeAsync(repo, taskA.Id, "en.json", "{\"a\": {\"one\": \"1\"}}\n", baseCommit);
await CreateActiveWorktreeAsync(repo, taskB.Id, "en.json", "{\"a\": {\"one\": \"2\"}}\n", baseCommit);
var sut = BuildSut(CreateQueue());
var result = await sut.PreviewMergeSet([taskA.Id, taskB.Id], "main", cancellationToken: CancellationToken.None);
var overlap = Assert.Single(result.Overlaps);
Assert.Equal("en.json", overlap.File);
Assert.Equal(
new[] { taskA.Id, taskB.Id }.OrderBy(x => x),
overlap.TaskIds.OrderBy(x => x));
}
[Fact]
public async Task PreviewMergeSet_UnparsableJsonOnOneSide_FallsBackToFileOverlap()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = new GitRepoFixture();
_repos.Add(repo);
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
File.WriteAllText(Path.Combine(repo.RepoDir, "en.json"), "{}\n");
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "add en.json");
var baseCommit = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
var listId = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", WorkingDir = repo.RepoDir, CreatedAt = DateTime.UtcNow });
var taskA = await SeedTaskAsync(listId, "a", TaskStatus.WaitingForReview);
var taskB = await SeedTaskAsync(listId, "b", TaskStatus.WaitingForReview);
// taskA leaves the file syntactically broken -- both sides must still be flagged since
// there's no way to tell whether the keys actually collide.
await CreateActiveWorktreeAsync(repo, taskA.Id, "en.json", "{ not valid json", baseCommit);
await CreateActiveWorktreeAsync(repo, taskB.Id, "en.json", "{\"a\": {\"one\": \"2\"}}\n", baseCommit);
var sut = BuildSut(CreateQueue());
var result = await sut.PreviewMergeSet([taskA.Id, taskB.Id], "main", cancellationToken: CancellationToken.None);
var overlap = Assert.Single(result.Overlaps);
Assert.Equal("en.json", overlap.File);
Assert.Equal(
new[] { taskA.Id, taskB.Id }.OrderBy(x => x),
overlap.TaskIds.OrderBy(x => x));
}
}
@@ -0,0 +1,74 @@
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.AspNetCore.Http;
namespace ClaudeDo.Worker.Tests.External;
public sealed class SessionHandoffMcpToolsTests : IDisposable
{
private readonly CapturingHubContext _hubContext = new();
private readonly string _paneId = Guid.NewGuid().ToString("n");
public void Dispose()
{
try { Directory.Delete(InteractiveLaunchSpecService.PaneSessionDir(_paneId), true); } catch { }
}
private static HttpContext Request(string? paneId, string? sessionId = null)
{
var http = new DefaultHttpContext();
if (paneId is not null) http.Request.Headers[SessionHandoffMcpTools.PaneHeader] = paneId;
if (sessionId is not null) http.Request.Headers[SessionHandoffMcpTools.SessionHeader] = sessionId;
return http;
}
private SessionHandoffMcpTools BuildSut(HttpContext http) =>
new(new HubBroadcaster(_hubContext), new HttpContextAccessor { HttpContext = http });
[Fact]
public async Task HandoffSession_IdentityFromHeaders_WritesBriefWithOrigin_AndBroadcasts()
{
var result = await BuildSut(Request(_paneId, "sess-1")).HandoffSession(" do the thing ", CancellationToken.None);
Assert.True(result.Requested);
Assert.StartsWith(InteractiveLaunchSpecService.PaneSessionDir(_paneId), result.BriefPath);
Assert.Equal("Handed off from session `sess-1`.\n\ndo the thing\n", File.ReadAllText(result.BriefPath));
var call = Assert.Single(_hubContext.Proxy.Calls);
Assert.Equal("SessionHandoffRequested", call.Method);
Assert.Equal(new object?[] { _paneId, result.BriefPath }, call.Args);
}
[Theory]
[InlineData(null)]
[InlineData("not-a-guid")]
[InlineData(@"..\..\evil")]
public async Task HandoffSession_MissingOrInvalidPaneHeader_ThrowsWithoutBroadcast(string? paneId)
{
await Assert.ThrowsAsync<InvalidOperationException>(() =>
BuildSut(Request(paneId)).HandoffSession("brief", CancellationToken.None));
Assert.Empty(_hubContext.Proxy.Calls);
}
[Fact]
public async Task HandoffSession_EmptyBrief_Throws()
{
await Assert.ThrowsAsync<InvalidOperationException>(() =>
BuildSut(Request(_paneId)).HandoffSession(" ", CancellationToken.None));
Assert.Empty(_hubContext.Proxy.Calls);
}
// The per-tile entry lists only handoff_session; the plain entry every session has hides it, so
// the tool is never duplicated and never offered where it can't identify the tile.
[Theory]
[InlineData("handoff_session", true, true)]
[InlineData("handoff_session", false, false)]
[InlineData("get_task", true, false)]
[InlineData("get_task", false, true)]
public void IsVisible_SplitsToolsByPaneHeader(string tool, bool paneRequest, bool expected)
{
Assert.Equal(expected, SessionHandoffMcpTools.IsVisible(tool, Request(paneRequest ? _paneId : null)));
}
}
@@ -66,7 +66,7 @@ public sealed class DeleteTaskHubTests : IDisposable
var (parentId, childId) = await SeedParentWithChildAsync(TaskStatus.Idle);
var hub = CreateHub();
await hub.DeleteTask(childId, default);
await hub.DeleteTask(childId);
await using var ctx = _db.CreateContext();
Assert.Null(await ctx.Tasks.FindAsync(childId));
@@ -80,7 +80,7 @@ public sealed class DeleteTaskHubTests : IDisposable
var (_, childId) = await SeedParentWithChildAsync(TaskStatus.Running);
var hub = CreateHub();
await Assert.ThrowsAsync<HubException>(() => hub.DeleteTask(childId, default));
await Assert.ThrowsAsync<HubException>(() => hub.DeleteTask(childId));
await using var ctx = _db.CreateContext();
Assert.NotNull(await ctx.Tasks.FindAsync(childId));
@@ -92,7 +92,7 @@ public sealed class DeleteTaskHubTests : IDisposable
var (parentId, _) = await SeedParentWithChildAsync(TaskStatus.Idle);
var hub = CreateHub();
var ex = await Assert.ThrowsAsync<HubException>(() => hub.DeleteTask(parentId, default));
var ex = await Assert.ThrowsAsync<HubException>(() => hub.DeleteTask(parentId));
Assert.Contains("child tasks", ex.Message, StringComparison.OrdinalIgnoreCase);
}
@@ -102,6 +102,22 @@ public sealed class DeleteTaskHubTests : IDisposable
{
var hub = CreateHub();
await Assert.ThrowsAsync<HubException>(() => hub.DeleteTask("does-not-exist", default));
await Assert.ThrowsAsync<HubException>(() => hub.DeleteTask("does-not-exist"));
}
/// <summary>A CancellationToken on a non-streaming hub method breaks SignalR binding (every call fails with a generic server error); direct-call tests can't see that.</summary>
[Fact]
public void NoNonStreamingHubMethod_TakesCancellationToken()
{
var offenders = typeof(WorkerHub)
.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.DeclaredOnly)
.Where(m => !m.IsSpecialName)
.Where(m => !(m.ReturnType.IsGenericType && m.ReturnType.GetGenericTypeDefinition() is var g
&& (g == typeof(IAsyncEnumerable<>) || g == typeof(System.Threading.Channels.ChannelReader<>))))
.Where(m => m.GetParameters().Any(p => p.ParameterType == typeof(CancellationToken)))
.Select(m => m.Name)
.ToList();
Assert.Empty(offenders);
}
}
@@ -138,6 +138,74 @@ public sealed class QueuePickerTests : IDisposable
Assert.Equal(dependent.Id, picked!.Id);
}
private async Task SeedWorktreeAsync(string taskId, WorktreeState state)
{
_ctx.Worktrees.Add(new WorktreeEntity
{
TaskId = taskId,
Path = $"/tmp/{taskId}",
BranchName = $"claudedo/{taskId}",
BaseCommit = "abc123",
State = state,
CreatedAt = DateTime.UtcNow,
});
await _ctx.SaveChangesAsync();
}
[Fact]
public async Task ClaimNextAsync_Claims_TaskWhoseDependsOnIsMergedButAwaitingBatchVerify()
{
// review_task(skipVerify: true) leaves a successfully merged task in WaitingForReview
// until verify_merges runs once for the whole batch. Gating on Done alone stalled every
// successor for the entire batch, so a landed merge satisfies the edge.
var listId = await CreateListAsync();
var dependency = await SeedAsync(listId, status: TaskStatus.WaitingForReview);
await SeedWorktreeAsync(dependency.Id, WorktreeState.Merged);
var dependent = await SeedAsync(listId, dependsOn: [dependency.Id]);
var picked = await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None);
Assert.NotNull(picked);
Assert.Equal(dependent.Id, picked!.Id);
}
[Fact]
public async Task ClaimNextAsync_Skips_TaskWhoseDependsOnWasRejected()
{
// reject_rerun/reject_park never merge, so the worktree stays Active -- the successor
// must keep waiting even though the predecessor left WaitingForReview.
var listId = await CreateListAsync();
var dependency = await SeedAsync(listId, status: TaskStatus.Idle);
await SeedWorktreeAsync(dependency.Id, WorktreeState.Active);
await SeedAsync(listId, dependsOn: [dependency.Id]);
Assert.Null(await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None));
}
[Fact]
public async Task ClaimNextAsync_Skips_TaskWhoseDependsOnMergeWasReverted()
{
// revert_merge / abort_merge put the worktree back to Active -- the edge is unmet again.
var listId = await CreateListAsync();
var dependency = await SeedAsync(listId, status: TaskStatus.WaitingForReview);
await SeedWorktreeAsync(dependency.Id, WorktreeState.Active);
await SeedAsync(listId, dependsOn: [dependency.Id]);
Assert.Null(await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None));
}
[Fact]
public async Task ClaimNextAsync_Skips_TaskWhoseWorktreelessDependsOnIsNotDone()
{
// A handler task or an empty-diff run has no worktrees row at all: only 'done' can
// satisfy that edge, and the merged-branch must not accidentally match a missing row.
var listId = await CreateListAsync();
var dependency = await SeedAsync(listId, status: TaskStatus.WaitingForReview);
await SeedAsync(listId, dependsOn: [dependency.Id]);
Assert.Null(await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None));
}
[Fact]
public async Task ClaimNextAsync_Skips_TaskWhoseDependsOnFailed_ButKeepsClaimingOthers()
{
@@ -60,6 +60,8 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
try { Directory.Delete(d, true); } catch { /* best effort */ }
foreach (var id in _seededTaskIds)
try { Directory.Delete(Path.Combine(Paths.AppDataRoot(), "task-sessions", id), true); } catch { /* best effort */ }
foreach (var id in _paneIds)
try { Directory.Delete(InteractiveLaunchSpecService.PaneSessionDir(id), true); } catch { /* best effort */ }
}
private sealed class FakeSessionSkillRegistry : ISessionSkillRegistry
@@ -576,7 +578,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
public async Task BuildForDirectoryAsync_ExistingDirectory_ReturnsFreshStartSpec()
{
var svc = BuildService();
var spec = await svc.BuildForDirectoryAsync(_tempDir, CancellationToken.None);
var spec = await svc.BuildForDirectoryAsync(_tempDir, null, CancellationToken.None);
Assert.Equal(_tempDir, spec.Cwd);
Assert.Equal(_claudeStubPath, spec.Exe);
@@ -590,7 +592,171 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
{
var svc = BuildService();
await Assert.ThrowsAsync<InvalidOperationException>(
() => svc.BuildForDirectoryAsync(Path.Combine(_tempDir, "does-not-exist"), CancellationToken.None));
() => svc.BuildForDirectoryAsync(Path.Combine(_tempDir, "does-not-exist"), null, CancellationToken.None));
}
// ── Mission Control session handoff ──
private readonly List<string> _paneIds = new();
private string NewPaneId()
{
var id = Guid.NewGuid().ToString("n");
_paneIds.Add(id);
return id;
}
/// Asserts the spec leads with the per-tile MCP entry and the handoff hint: the entry's headers
/// name this pane and the session id the spec actually launches/resumes, and the hint names no
/// id at all (the model would guess it).
private static void AssertHandoffHint(LaunchSpec spec, string paneId, string sessionId)
{
Assert.Equal("--mcp-config", spec.Args[0]);
Assert.Equal("--append-system-prompt-file", spec.Args[2]);
using var mcp = System.Text.Json.JsonDocument.Parse(File.ReadAllText(spec.Args[1]));
var server = mcp.RootElement.GetProperty("mcpServers").GetProperty(InteractiveLaunchSpecService.PaneMcpServerName);
Assert.Equal($"http://127.0.0.1:{new WorkerConfig().ExternalMcpPort}/mcp", server.GetProperty("url").GetString());
var headers = server.GetProperty("headers");
Assert.Equal(paneId, headers.GetProperty(SessionHandoffMcpTools.PaneHeader).GetString());
Assert.Equal(sessionId, headers.GetProperty(SessionHandoffMcpTools.SessionHeader).GetString());
var hint = File.ReadAllText(spec.Args[3]);
Assert.Contains("mcp__claudedo_pane__handoff_session", hint);
Assert.DoesNotContain(paneId, hint);
Assert.DoesNotContain(sessionId, hint);
}
[Fact]
public async Task BuildForDirectoryAsync_ExternalMcpDisabled_NoPaneEntry()
{
var svc = new InteractiveLaunchSpecService(_db.CreateFactory(), _seeder, _registry,
new WorktreeManager(new GitService(), _db.CreateFactory(), new WorkerConfig(), NullLogger<WorktreeManager>.Instance),
new GitService(), new WorkerConfig { ClaudeBin = _claudeStubPath, ExternalMcpPort = 0 },
new HandoffRoundTracker(), NewRoundState());
var spec = await svc.BuildForDirectoryAsync(_tempDir, NewPaneId(), CancellationToken.None);
Assert.DoesNotContain("--mcp-config", spec.Args);
}
private static string ArgAfter(LaunchSpec spec, string flag) => spec.Args[spec.Args.ToList().IndexOf(flag) + 1];
[Fact]
public async Task BuildForDirectoryAsync_WithPaneId_PreAssignsSessionIdAndAppendsHint()
{
var paneId = NewPaneId();
var spec = await BuildService().BuildForDirectoryAsync(_tempDir, paneId, CancellationToken.None);
var sessionId = ArgAfter(spec, "--session-id");
Assert.True(Guid.TryParse(sessionId, out _));
AssertHandoffHint(spec, paneId, sessionId);
}
[Fact]
public async Task BuildForTaskAsync_WithPaneId_ResumedSession_HintNamesResumedId()
{
var listId = await SeedListAsync();
var taskId = Guid.NewGuid().ToString();
await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
var svc = BuildService();
var (_, _, _, firstSessionId) = ParseFreshTaskArgs(await svc.BuildForTaskAsync(taskId, CancellationToken.None));
var paneId = NewPaneId();
var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None, paneId);
AssertHandoffHint(spec, paneId, firstSessionId);
Assert.Equal(firstSessionId, ArgAfter(spec, "--resume"));
}
private static string WriteBrief(string paneId)
{
var dir = Path.Combine(InteractiveLaunchSpecService.PaneSessionDir(paneId), "handoffs", "h1");
Directory.CreateDirectory(dir);
var path = Path.Combine(dir, "brief.md");
File.WriteAllText(path, "brief");
return path;
}
[Fact]
public async Task BuildHandoffAsync_AdHoc_KicksOffFromBriefInSameCwd()
{
var paneId = NewPaneId();
var briefPath = WriteBrief(paneId);
var spec = await BuildService().BuildHandoffAsync(paneId, _tempDir + Path.DirectorySeparatorChar, briefPath, null, CancellationToken.None);
// A trailing separator on the cwd must not survive into the spec (ConPTY quoting trap).
Assert.Equal(_tempDir, spec.Cwd);
var sessionId = ArgAfter(spec, "--session-id");
AssertHandoffHint(spec, paneId, sessionId);
Assert.Equal(Path.GetDirectoryName(briefPath), ArgAfter(spec, "--add-dir"));
var kickoff = spec.Args[^1];
Assert.Contains(briefPath, kickoff);
Assert.DoesNotContain('\n', kickoff);
Assert.Equal("--session-id", spec.Args[^3]);
Assert.Equal(InteractiveLaunchSpecService.McpToolTimeoutMs, spec.Env["MCP_TOOL_TIMEOUT"]);
}
[Fact]
public async Task BuildHandoffAsync_Task_PersistsNewSessionIdForResume()
{
var listId = await SeedListAsync();
var taskId = Guid.NewGuid().ToString();
await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
var briefPath = WriteBrief(taskId);
_paneIds.Add(taskId);
var spec = await BuildService().BuildHandoffAsync(taskId, _tempDir, briefPath, taskId, CancellationToken.None);
Assert.Equal(ArgAfter(spec, "--session-id"), await ReadInteractiveSessionIdAsync(taskId));
}
[Fact]
public async Task RecentSession_AdHocThenHandoff_RecordsChainAndResumesInCwd()
{
var paneId = NewPaneId();
var svc = BuildService();
var origin = ArgAfter(await svc.BuildForDirectoryAsync(_tempDir, paneId, CancellationToken.None), "--session-id");
var dir = Path.Combine(InteractiveLaunchSpecService.PaneSessionDir(paneId), "handoffs", "h1");
Directory.CreateDirectory(dir);
var briefPath = Path.Combine(dir, "brief.md");
File.WriteAllText(briefPath, $"Handed off from session `{origin}`.\n\nbrief");
var handoff = ArgAfter(await svc.BuildHandoffAsync(paneId, _tempDir, briefPath, null, CancellationToken.None), "--session-id");
using (var ctx = _db.CreateContext())
{
var repo = new MissionControlSessionRepository(ctx);
var first = await repo.GetAsync(origin);
var second = await repo.GetAsync(handoff);
Assert.NotNull(first);
Assert.Null(first!.PredecessorSessionId);
Assert.Equal(origin, second!.PredecessorSessionId);
Assert.Equal(first.Title, second.Title);
Assert.Equal(briefPath, second.BriefPath);
}
var resumePane = NewPaneId();
var resume = await svc.BuildResumeAsync(handoff, resumePane, CancellationToken.None);
Assert.Equal(_tempDir, resume.Cwd);
Assert.Equal(handoff, ArgAfter(resume, "--resume"));
AssertHandoffHint(resume, resumePane, handoff);
}
[Fact]
public async Task BuildResumeAsync_UnknownSession_ThrowsKeyNotFound()
=> await Assert.ThrowsAsync<KeyNotFoundException>(
() => BuildService().BuildResumeAsync(Guid.NewGuid().ToString(), NewPaneId(), CancellationToken.None));
[Fact]
public async Task BuildHandoffAsync_BriefOutsidePaneDir_Throws()
{
var outside = Path.Combine(_tempDir, "brief.md");
File.WriteAllText(outside, "x");
await Assert.ThrowsAsync<InvalidOperationException>(
() => BuildService().BuildHandoffAsync(NewPaneId(), _tempDir, outside, null, CancellationToken.None));
}
// ── Merge helper ──
@@ -1482,7 +1648,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
var specs = new List<LaunchSpec>
{
await svc.BuildForTaskAsync(taskId, CancellationToken.None),
await svc.BuildForDirectoryAsync(_tempDir, CancellationToken.None),
await svc.BuildForDirectoryAsync(_tempDir, null, CancellationToken.None),
mergeHelperSpec,
handoffSpec,
svc.BuildPlanningStart(planningStartCtx),
@@ -374,6 +374,38 @@ public class TaskMergeServiceTests : IDisposable
Assert.Contains(targets.DefaultBranch, targets.LocalBranches);
}
[Fact]
public async Task GetTargetsAsync_UncommittedChangeInTarget_ReportsTargetDirty()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "dirty\n");
var (svc, _) = BuildService(db);
var targets = await svc.GetTargetsAsync(task.Id, CancellationToken.None);
Assert.True(targets.TargetDirty);
}
[Fact]
public async Task GetTargetsAsync_OnlyUntrackedFileInTarget_DoesNotReportTargetDirty()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.Done);
File.WriteAllText(Path.Combine(repo.RepoDir, "untracked.txt"), "stray\n");
var (svc, _) = BuildService(db);
var targets = await svc.GetTargetsAsync(task.Id, CancellationToken.None);
Assert.False(targets.TargetDirty);
}
[Fact]
public async Task MergeAsync_DirtyWorkingTree_ReturnsBlocked()
{