Compare commits

..
40 Commits
Author SHA1 Message Date
Mika Kuns f3ecb99492 chore(claude-do): merge Customizable Spalten Anzahl in MIssion controll
Changelog / changelog (push) Successful in 2s
Release / release (push) Successful in 43s
ClaudeDo-Task: 99bfb985efb64c02ba3f27b53eae790d
2026-09-30 10:50:21 +02:00
Mika Kuns a2496e056a chore(claude-do): merge Worktrees sollten nach dem Task benannt werden
ClaudeDo-Task: 63f08d944d3341c081267158ef4bbb3e
2026-09-30 10:50:09 +02:00
Mika Kuns 44ec89b0e6 chore(claude-do): Worktrees sollten nach dem Task benannt werden
Durch die GUID namen kann ein User die Worktrees keinem Task zuordnen, und in Rider Müllen mir die Worktrees die BRanches voll. Also sollten wir einfach den Task titel oder Nummer stattdessen nehmen

ClaudeDo-Task: 63f08d944d3341c081267158ef4bbb3e
2026-09-30 10:49:07 +02:00
Mika Kuns a7c3d18533 feat(mission-control): column picker for the overview grid
Auto keeps the pane-count heuristic; 1-4 fixes the column count (capped at
the pane count). Persisted to ui.config.json as MissionControlColumns.
2026-09-30 10:39:23 +02:00
mika kuns 0d029717f8 style(ui): folder chevron and icon take the folder colour 2026-09-30 10:28:14 +02:00
mika kuns 2be8ba5d66 feat(ui): folders slide open and closed, rows below follow 2026-09-30 10:25:17 +02:00
mika kuns 928e3905d9 feat(ui): animate folder expand in the sidebar 2026-09-30 10:19:35 +02:00
mika kuns 60358100be fix(ui): Recent-Sessions Resume/Brief-Buttons fuehren ihren Command wieder aus
Der Click-Handler schloss das Flyout synchron; Button liest Command erst nach dem ClickEvent, das Hide hatte die Bindung da schon geloest.
2026-09-30 10:14:55 +02:00
mika kuns 2a41f7e641 style(ui): smart list rows match the user list row height 2026-09-30 10:13:51 +02:00
mika kuns bb92dc279f feat(lists): assign a colour to a sidebar folder
Tints the folder name and its guide line; picked from a swatch row in the folder settings.
2026-09-30 10:12:41 +02:00
mika kuns 8a31a67c74 feat(report): write weekly report as short bullets with haiku 2026-09-30 10:05:24 +02:00
mika kuns f2ff865dca feat(ui): searchable Material Design icon library for lists and folders 2026-09-30 09:52:34 +02:00
mika kuns 5da8036b9a style(ui): only top-level lists align to the folder chevron 2026-09-30 08:48:36 +02:00
mika kuns cf4b5c507c style(ui): lists start at the folder chevron's edge 2026-09-30 08:43:10 +02:00
mika kuns 3e2d5bab6a style(ui): keep child row highlight clear of the folder guide line 2026-09-30 08:39:20 +02:00
mika kuns 27d2b1d8ef fix(tickets): convert ticket HTML to markdown on import 2026-09-30 08:39:17 +02:00
mika kuns ccd11a8033 style(ui): tighter sidebar tree rows and shallower child indent 2026-09-30 08:34:48 +02:00
mika kuns 9991f1fc1f feat(ui): coloured dots for icon-less lists, guide line under chevron
Lists without an icon show a dot in a stable identity colour (same
palette/hash as Mission Control tiles); "Dot" is the new picker default.
2026-09-30 08:29:46 +02:00
mika kuns 75b78cb2c0 feat(ui): make sidebar folder hierarchy readable
Lists default to the Text icon instead of the folder icon, rows align
across depths with a guide line per open folder, folder names are
semibold, folder counts only show while collapsed and zero counts hide.
2026-09-30 08:21:34 +02:00
mika kuns 05deda8343 docs: add visual verification session notes 2026-09-16 2026-09-30 08:14:26 +02:00
mika kuns 52ba538339 test(worker): cover WorktreeUpdated broadcast on interactive review submission 2026-09-30 08:14:26 +02:00
mika kuns 2558e02ba3 fix(ui): stop row buttons flashing disabled while a row command runs
Shared AsyncRelayCommands disabled every row's button during execution.
2026-09-30 08:13:26 +02:00
mika kuns 81dcaebfba Merge branch 'claudedo/f518b71f990643cfaf73174e8617b2b5'
Drop the unused deferredUntil parameter the branch added to HandoffRequested: the worker still sends three args, so the four-arg client handler would never bind. The reset time already arrives via HandlerHandoffDeferred.
2026-09-29 08:54:14 +02:00
mika kuns 1396649519 Merge branch 'claudedo/ff26e146286f409aacc453247dd86155' 2026-09-29 08:52:02 +02:00
ClaudeDo CI f88d2a898e docs(changelog): update for v2.17.0 2026-09-29 06:24:37 +00:00
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 kuns 5be972564a feat(ui): mission-control pane title shows usage limit reset time
When a list-handler handoff is deferred waiting for the 5h usage limit to
reset, the Mission Control pane title is augmented with ⏳ waiting for
limit reset (HH:mm) so the wait is visible at a glance. Extends
HubBroadcaster.HandoffRequested with an optional deferredUntil DateTime
parameter, wired through SignalR to MissionControlViewModel, which formats
it into the pane title via Loc.T(missionControl.waitingForLimitReset).
2026-09-10 13:18:09 +02:00
Mika Kuns ecb1cdbb06 fix(ui): show diff window before parsing so ParseOp spinner is visible 2026-08-12 13:23:03 +02:00
76 changed files with 6270 additions and 246 deletions
+36 -12
View File
@@ -1,24 +1,48 @@
# Changelog
## v2.17.0 — 2026-09-28
### Features
- allow nested sidebar folders via drag and drop (0dac047c)
- UI-Prozess loggt auf Platte (ui-.log) + fehlgeschlagene Hub- (57dd6766)
- Mission Control Recent Sessions mit verschachtelten Handoffs (d3db4455)
- Mission-Control-Kacheln behalten Position und Identitaetsfarbe ueber Handoffs (e0a1448f)
### Fixes
- Interactive sessions no longer Instantly start working (87dbb117)
- snake_case Serilog tokens + no Warning-log on cancelled hub calls (3009ff0f)
- DeleteTask aus der UI schlug immer mit generischem Serverfehler fehl (352aaa52)
### Documentation
- update for v2.16.0 (c9dd885f)
## v2.16.0 — 2026-09-16
### Features
- Bilder aus der Zwischenablage ins interaktive Terminal einfuegen (81d835c0)
- preview_merge_set flags removed symbols and locale-key overlaps (fb7dddb3)
- poll the OAuth usage endpoint as fast as its measured rate limit allows (6b6d9895)
- dirty Ziel-Verzeichnis beim Oeffnen des Merge-Dialogs melden statt nach dem Fehlschlag (0805540a)
- 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
- depends_on-Nachfolger schon beim gelandeten Merge freigeben (21591a5d)
- preview_merge braucht einen runVerify-Parameter wie preview_merge_set (d725e3f1)
- Shutdown beendet die App auch mit offenen Nebenfenstern (d7898240)
- Worker-Neustart trifft nun den tatsaechlich laufenden Prozess (c486b85b)
- Ordner-Toggle in der Sidebar schluckt schnelle Klicks (0cd31411)
- Diff-Viewer scrollt jetzt auch horizontal synchron (f4af0b98)
- Worktrees-Uebersicht zeigt Task-Nummern, DIFF-Spalte trimmt statt abzuschneiden (62349c4f)
- duplizierter Confirm-Dialog mit "Delete"-Knopf zusammengefuehrt und lokalisiert (a0ad4d0f)
- 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
+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
+2 -2
View File
@@ -25,8 +25,8 @@ is wired through `TaskIdResolver`, which resolves:
This is **not** ambiguous: a GUID is never all-digits, so a pure-integer parameter is always a
number, not a partial GUID. All task-returning tools (`GetTask`, `ListTasks`, `AddTask`,
`BatchGetTasks`, etc.) stamp the resolved task's `Number` in the DTO — both `TaskDto` and the
lean `TaskRefDto` carry an `int Number` field. **Branch names and worktree paths** (`claudedo/{id}`)
continue to use the GUID; the number is a display alias, never the identity.
lean `TaskRefDto` carry an `int Number` field. **Task worktree branches and folders** are `claudedo/{number}-{title-slug}`;
otherwise the number is a display alias, never the identity.
Every tool description carries a shared boilerplate clause (defined in `McpToolDocs.TaskNumberHint`)
instructing the agent to **refer to tasks as `#<number>` when reporting results to the user** —
+161
View File
@@ -0,0 +1,161 @@
# Sichtprüfungs-Session 2026-09-16
Build: Debug 2.15.0 (App 08:05, Worker 09:40 — enthält den uncommitteten `WorktreeUpdated`-Fix).
Sandkasten: Liste **ClaudeDoTests** → `C:\TestRepos\ClaudeDoTests` (sauber, alle Alt-Tasks Done).
Vorgeseedet: **#334** (README.md) und **#335** (docs/notes.md) — laufen, landen in `WaitingForReview`.
Abgedeckt wird alles seit dem letzten Sichtprüfungs-Pass (06.08.), also v2.13.x–v2.15.
**Bewusst draußen:** Installer/SystemCheckPage (braucht echte Installation) und die
Ticketsystem-Anbindung (braucht ein zugewiesenes Ticket) — beide aus `docs/open.md`.
Notation: `[ ]` offen · `[x]` ok · `[!]` Fehler (Text dahinter = Fix-Kandidat)
---
## Pass 1 — Sidebar: Listen & Ordner (~8 min) · **ERLEDIGT** — 1.1–1.8 ok bis auf 1.2 (→ F1)
> 1.7 verhält sich gut: Ordner löschen schiebt die enthaltene Liste raus statt sie mitzunehmen.
- [ ] **1.1** Rechtsklick in die Listen-Sidebar → „Neue Liste": im Modal den Ordner-Toggle
einschalten → es entsteht ein **Ordner**, nicht eine Liste.
- [ ] **1.2** Ordner anklicken → klappt auf/zu, Pfeil dreht sich, Zustand bleibt nach
Listenwechsel erhalten.
- [ ] **1.3** Eine bestehende Liste per Drag in den Ordner ziehen → landet drin, Ordner zeigt sie.
- [ ] **1.4** Liste wieder aus dem Ordner heraus auf die oberste Ebene ziehen → geht zurück.
- [ ] **1.5** **Gemischte Reihenfolge** (das ist der Kern von 2.14): eine Liste **über** einen
Ordner ziehen → Reihenfolge hält. Ordner per Drag zwischen zwei Listen schieben → hält auch.
- [ ] **1.6** App neu starten (oder Worker-Menü → Reconnect) → die gemischte Reihenfolge ist
noch dieselbe. *Das ist der Test, der `ReorderSidebarAsync` wirklich prüft.*
- [ ] **1.7** Ordner löschen, während eine Liste drin liegt → was passiert mit der Liste?
(Erwartung unklar — notieren, was es tut.)
- [ ] **1.8** Rechtsklick auf einen Task → „In Liste verschieben" → Ziel wählen → Task ist weg
aus der Quelle, da in der Zielliste.
## Pass 2 — Review, Merge, Stash (~15 min) · der wichtigste Pass
Braucht #334/#335 in `WaitingForReview`. Sag mir Bescheid, falls sie noch laufen.
- [ ] **2.1** ⚠️ **Der uncommittete Fix.** Auf einem Task „Open ConPTY session" → im Terminal
irgendeine Kleinigkeit ändern → in der Mission-Control-Kachel „Submit for review".
**Erwartung:** Der Task landet in `WaitingForReview` und die REVIEW-Karte im Detail-Pane
zeigt **sofort** „Open diff" + Änderungs-Zusammenfassung — **ohne** dass du den Task neu
anklicken musst. (Vorher fehlte der ganze Block bis zur Neuauswahl.)
- [ ] **2.2** #334 anklicken → „Open diff" → Side-by-side-Diff zeigt die README-Änderung,
Zeilen/Farben stimmen, kein Abschneiden unten.
- [ ] **2.3** Approve-Gate: **ohne** geöffneten Diff ist Approve gesperrt, nach dem Diff aktiv.
- [ ] **2.4** Approve auf #334 → merged, Task geht auf **Done**, Chip wechselt, Worker-Log-Zeile
im Footer erscheint (mit `#334` im Text — deckt gleich Pass 5.1 mit ab).
- [ ] **2.5** 🔥 **Stash-Option (neuester Commit, komplett ungetestet).** Ich mache dir das
Ziel-Verzeichnis absichtlich dirty (sag „mach dirty"), dann auf #335 **Approve & Merge**:
Erwartung = **Bestätigungsdialog** „Änderungen stashen und mergen?" statt der alten
`blocked`-Sackgasse. Bestätigen → Merge läuft, und deine uncommitteten Änderungen in
`C:\TestRepos\ClaudeDoTests` sind danach **wieder da**.
- [ ] **2.6** Derselbe Fall über das **Merge-Modal** (statt Approve): Button
„Änderungen stashen und mergen" ist da und tut dasselbe.
- [ ] **2.7** Abbrechen im Stash-Dialog → nichts passiert, Task bleibt `WaitingForReview`,
Arbeitsverzeichnis unverändert.
- [ ] **2.8** Worktrees-Übersicht öffnen → die gemergten Worktrees stehen als `Merged` drin,
Cleanup funktioniert, keine WARN-Flut im Footer.
## Pass 3 — Interaktive Sessions & Mission Control (~12 min)
- [ ] **3.1** ConPTY-Session läuft → auf das **X** klicken → **Rückfrage** erscheint
(„Session läuft wirklich schließen?"), Abbrechen lässt sie laufen.
- [ ] **3.2** Im interaktiven Terminal **Text einfügen** (Ctrl+V) → landet im Prompt.
- [ ] **3.3** Eine **Datei ins Terminal droppen** → Pfad wird eingefügt.
Und: ein Drop, der scheitern muss (z.B. Ordner statt Datei) → Fehler wird **sichtbar**
gemeldet, nicht still geschluckt.
- [ ] **3.4** **Session stirbt nach dem Start** (im Terminal `exit` tippen) → Banner erscheint
in der Kachel, nicht einfach eine tote schwarze Fläche.
- [ ] **3.5** Mission Control: **Home-Ordner als Sitzungsziel** — neue Session ohne Liste/Task
starten → läuft im User-Verzeichnis, nicht irgendwo.
- [ ] **3.6** Mission Control ist **minimiert** → Handler-Run/Session startet → Fenster wird
aus dem Minimiert-Zustand geholt, nicht nur in der Taskleiste blinkend.
- [ ] **3.7** Handler-Run per Knopf **abbrechen** → Run endet sauber, Kachel verschwindet/
wechselt, kein Geister-Pane.
## Pass 4 — Menüs, Settings, Fenster-Layout (~12 min)
- [ ] **4.1** Hilfe-Menü → **Cheat-Sheets** → Fenster öffnet, sechs Sheets (git, Conventional
Commits, Claude CLI, Claude-Config, dotnet CLI, PowerShell).
- [ ] **4.2** Im Cheat-Sheet: Sektionen sind Expander, Befehle sind Mono-Chips mit
**Copy-Button** → Klick kopiert wirklich (irgendwo einfügen und prüfen).
- [ ] **4.3** Cheat-Sheet-Fenster **anpinnen** (Topmost) → bleibt über der App, App bleibt
bedienbar (modelos).
- [ ] **4.4** Hilfe-Menü → „Nach Updates suchen" und „Über" → Version zeigt **2.15.0**
(aus der FileVersion, nicht hartkodiert).
- [ ] **4.5** Worker-Menü → **„Update Claude CLI"** → führt sichtbar `claude update` aus,
Ausgabe landet irgendwo lesbar. *(Achtung: ändert wirklich deine CLI — überspring das,
wenn du gerade keine CLI-Version riskieren willst.)*
- [ ] **4.6** Worker-Menü → **Shutdown** → Rückfrage → beendet Worker **und** App.
*Ganz am Ende machen, sonst ist die Session vorbei.*
- [ ] **4.7** Settings → **Ausführung**: die PowerUse-Einstellungen sind hier (nicht mehr im
Usage-Monitor), Erklärtexte zu Effort und „Prime Claude" sind da und verständlich.
Ungültiges PowerUse-Zeitfenster eingeben → wird abgelehnt/erklärt, nicht still gespeichert.
- [ ] **4.8** **Modal-Layout** (Fix vom 02.09., Sichtprüfung offen): Über-Dialog,
Merge-Modal, Repo-Import, Worktrees-Übersicht, Merge-Helper-Auswahl **einmal öffnen** und
prüfen, dass unten **nichts abgeschnitten** ist (letzte Zeile/Button voll sichtbar).
- [ ] **4.9** Listeneinstellungen einer Liste öffnen → Modal erscheint **sofort** (lädt sichtbar
nach), und: Arbeitsverzeichnis auf einen Nicht-Git-Ordner setzen → **Warnung + Speichern
blockiert**, nicht stillschweigend akzeptiert.
- [ ] **4.10** Merge-Helper starten → Start-Modal hat den **Triage-Modell-Picker (opus/fable)**.
*(Nur das Modal ansehen, Lauf nicht starten — kostet sonst die halbe Stunde.)*
## Pass 5 — Reste & bekannte Nits (~8 min)
- [ ] **5.1** **Task-Nummern:** `#334` steht dimmed vor dem Titel in der Zeile, der Detail-Chip
zeigt `#334` (nicht mehr GUID), Footer-Worker-Log schreibt `#334 …`.
- [ ] **5.2** **depends_on sichtbar:** zwei Tasks verketten → der blockierte zeigt einen
Blocking-Chip, ein Ketten-Label („wartet auf …") und im Detail-Pane den Grund.
In der Queue-Zeile hat auch der **blockierte** Task ein Dequeue-**X**.
- [ ] **5.3** **Usage-Monitor-Modal → Models-Tab:** die bekannten Nits — kollidierende
Spaltenköpfe (`OTHER CACHISHARE`), Zahlen linksbündig ohne Tausendertrenner, Task-Titel
hart abgeschnitten. Nur bestätigen, dass sie noch da sind → geht auf die Fix-Liste.
- [ ] **5.4** **Deutsch:** einmal die Sprache auf DE stellen und durch Sidebar, Detail-Pane,
WorkConsole, ROADBLOCK-Karte und die Merge-Dialoge gehen → keine englischen Reste.
*(Der de.json-Konsistenz-Pass vom 10.09. ist nie angeschaut worden.)*
- [ ] **5.5** **Worker-Verbindung:** Worker-Menü → Reconnect bei laufendem Worker → Erfolg
sichtbar. Und einmal Worker beenden, dann Reconnect → **Fehlschlag wird gemeldet**
(Footer-Strip), nicht still no-op.
---
## Ergebnis
Alle fünf Pässe durchlaufen. **11 Befunde** aus ~45 Checks; Pass 4 (Menüs/Settings/Modal-Layout)
war komplett sauber. Die drei schweren (F9/F10/F11) sind alle erst ganz am Ende aufgetaucht —
Lebenszyklus der App, nicht Features.
Nicht getestet: Installer/SystemCheckPage, Ticketsystem-Anbindung (beide brauchen echtes Setup),
3.7 Handler-Abbruch nur oberflächlich.
## Delegiert als Tasks (Liste „Claude do", alle Idle)
| Task | Deckt ab |
|---|---|
| **#339** Shutdown beendet den Prozess nicht, Worker-Neustart tut nichts | F9 + F11 |
| **#340** App-Speicherverbrauch messen — nur Diagnose | F10 |
| **#341** duplizierter Confirm-Dialog mit „Delete"-Knopf | F4 + F5 |
| **#342** Ordner-Toggle schluckt schnelle Klicks | F1 |
| **#343** Diff-Viewer horizontal nicht synchron | F3 |
| **#344** dirty Ziel früh melden statt nach dem Fehlschlag | F6 |
| **#345** Bilder aus der Zwischenablage ins Terminal | F8 |
| **#346** Worktrees-Übersicht: Nummern + Truncation | F7 |
Nicht delegiert: **F2** (Modell für interaktive Sessions erzwingen) — Entscheidung Mika offen.
## Fix-Liste
| # | Pass | Befund | Schwere |
|---|------|--------|---------|
| F9 | 4.6 | **„Shutdown" beendet die App nicht — der Prozess überlebt.** `IslandsShellViewModel.cs:705` `ShutdownWorkerAndCloseApp` killt den Worker und ruft dann nur `CloseWindow` für das Hauptfenster. Mission Control, Cheat-Sheets und der Diff-Viewer sind eigene `Window`-Instanzen, bleiben offen und halten den Avalonia-Prozess am Leben (bestätigt: alle Nebenfenster standen nach dem „Shutdown" noch offen, `ClaudeDo.App` weiterhin im Task-Manager). Fix: alle Fenster schließen bzw. `IClassicDesktopStyleApplicationLifetime.Shutdown()` statt `CloseWindow`. | **hoch** |
| F10 | — | **App-Speicherverbrauch ~1,2 GB.** Gemessen 2026-09-16 am laufenden Debug-Build: `ClaudeDo.App` 1227 MB, `ClaudeDo.Worker` 209 MB. Der Worker wurde 2026-09-02 (`c347e4fc`) bewusst auf Workstation-GC umgestellt, die App offenbar nicht — erster Verdacht, aber ungeprüft. Braucht eine echte Messung (Server-GC? gehaltene Log-/Diff-Puffer? ConPTY-Scrollback?), keinen Blindfix. | **hoch** |
| F11 | 5.5 | **Worker-Neustart über den Button funktioniert nicht.** `IslandsShellViewModel.cs:656` `RestartWorkerService` beginnt mit `_workerLocator.Find()` und wirft „Worker executable not found", wenn nichts passt. **Hypothese, nicht verifiziert:** der Locator findet den *installierten* Worker (`C:\Program Files\ClaudeDo`), während der Debug-Build aus dem Repo läuft — dann wird der falsche oder gar kein Prozess gekillt/gestartet. Erst reproduzieren und die Footer-Meldung lesen, dann fixen. | **hoch** |
| F8 | 3.2 | **Bilder aus der Zwischenablage lassen sich nicht ins interaktive Terminal einfügen** (Text geht). `InteractiveTerminalViewModel.PasteAsync` → `PtyTerminalSession.PasteAsync` liest nur Text. Lazy Fix: Bild-Clipboard in eine temporäre PNG schreiben und deren Pfad über den bereits funktionierenden Drop-Pfad (`SendTextAsync`) einfügen — Claude liest die Datei dann selbst. Kein neuer Mechanismus nötig. | mittel |
| F6 | 2.6 | **Dirty-Target wird zu spät gemeldet.** `dirty_target` kommt erst als Fehlerergebnis *nachdem* der Merge gedrückt und abgelehnt wurde — der Zustand des Ziel-Trees ist aber schon beim Öffnen des Merge-Modals bekannt. Wunsch (Mika): beim Laden des Modals prüfen, Hinweis + „Änderungen stashen und mergen" direkt anbieten; ggf. schon im Diff-Viewer signalisieren. Betrifft beide Pfade (Approve + Merge-Modal). | mittel |
| F7 | 2.8 | **Worktrees-Übersicht zeigt keine Task-Nummern.** TASK-Spalte listet nur den Titel; die Task-Numbers-Feature hat Zeile, Detail-Chip und Worker-Log abgedeckt, diese Tabelle nicht. Zusätzlich ist die DIFF-Spalte hart abgeschnitten ohne Ellipse (`docs/notes.m`, `1 file chan`) — gleiches Truncation-Muster wie im Usage-Monitor (F-Nit 5.3). | niedrig |
| F4 | 2.5 | **Stash-Bestätigung trägt „Delete" als Bestätigungsknopf.** `DetailsIslandView.axaml.cs:262-263` ist eine zweite, kopierte Confirm-Dialog-Implementierung mit hartkodiertem `Content = "Delete"` (+ `Classes = { "danger" }`); `DetailsIslandViewModel.ConfirmAsync` hängt daran statt am gemeinsamen `WindowDialogService.ConfirmAsync:209` (dort korrekt „Confirm"). Der Stash-Dialog erbt damit die Beschriftung eines Lösch-Dialogs — irreführend bis gefährlich. Fix: Duplikat löschen, VM an den Service-Dialog hängen. | **hoch** |
| F5 | 2.5 | **Confirm-/Error-Dialoge sind nicht lokalisiert.** Beide Implementierungen setzen `"Cancel"`/`"Confirm"`/`"Delete"`/`"OK"` und den Fenstertitel `"Confirm"`/`"Error"` als String-Literal, kein `Loc.T`. Fällt mit F4 zusammen erledigen. | mittel |
| F3 | 2.2 | **Diff-Viewer scrollt nur vertikal synchron.** `DiffEditorSetup.cs` — die Sync-Klasse heißt `VerticalScrollSync`, Zeile 147 schreibt `new Vector(viewer.Offset.X, y)` und behält das X der Zielspalte bei; horizontal wurde nie verdrahtet, beide Seiten scrollen seitwärts unabhängig. Fix: X 1:1 mitspiegeln (gleiche Schrift/Breite in beiden Panes), Klasse entsprechend umbenennen. | mittel |
| F2 | 2.1 | **Feature-Wunsch (kein Bug):** interaktive ConPTY-Sessions starten unter dem Modell aus Mikas Claude-Config, nicht unter dem im Task gewählten. Bewusste Entscheidung, siehe `docs/open.md:35` (`--effort` ja, `--model` nein) — dort schon als möglicher Folge-Task vermerkt. Entscheidung Mika offen. | Wunsch |
| F1 | 1.2 | **Ordner-Toggle schluckt schnelle Klicks.** `ListsIslandViewModel.cs:626` `ToggleFolderAsync` ist ein async `[RelayCommand]` → `AsyncRelayCommand` setzt `CanExecute=false` für die Dauer des DB-Writes (`SetFolderCollapsedAsync`); jeder Klick in diesem Fenster wird verworfen. Fix: Toggle synchron (UI-State sofort), Persistierung fire-and-forget. Gleiches Muster in der Sidebar auf andere async RelayCommands gegenprüfen. | mittel |
+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 -1
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())
@@ -62,6 +93,8 @@ sealed class Program
{
/* best effort on shutdown */
}
Log.CloseAndFlush();
}
}
@@ -181,7 +214,8 @@ sealed class Program
new MissionControlViewModel(
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
sp.GetRequiredService<IWorkerClient>(),
sp.GetRequiredService<UsagePillViewModel>()));
sp.GetRequiredService<UsagePillViewModel>(),
sp.GetRequiredService<AppSettings>()));
sc.AddSingleton<IslandsShellViewModel>(sp =>
{
var shell = ActivatorUtilities.CreateInstance<IslandsShellViewModel>(sp);
+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));
@@ -15,9 +15,17 @@ public class ListFolderEntityConfiguration : IEntityTypeConfiguration<ListFolder
builder.Property(f => f.Name).HasColumnName("name").IsRequired();
builder.Property(f => f.CreatedAt).HasColumnName("created_at").IsRequired();
builder.Property(f => f.Icon).HasColumnName("icon");
builder.Property(f => f.Color).HasColumnName("color");
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);
}
}
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");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddListFolderColor : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "color",
table: "list_folders",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "color",
table: "list_folders");
}
}
}
@@ -370,6 +370,10 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("Color")
.HasColumnType("TEXT")
.HasColumnName("color");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
@@ -389,6 +393,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 +405,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 +1038,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")
+6 -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
{
@@ -13,8 +13,13 @@ public sealed class ListFolderEntity
public required DateTime CreatedAt { get; init; }
// Icon key into the UI's icon library, same set as ListEntity.Icon; null = default folder icon.
public string? Icon { get; set; }
/// <summary>Hex colour (#RRGGBB) for the sidebar name and guide line; null = theme default.</summary>
public string? Color { get; set; }
public int SortOrder { get; set; }
// 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; }
}
+12 -11
View File
@@ -697,20 +697,21 @@ public static class PromptFiles
""";
private const string WeeklyReportDefault = """
You are generating a concise weekly standup report for a software developer,
covering {start} to {end}.
You are writing memory-jogger notes for a developer's weekly standup,
covering {start} to {end}. They read them aloud in their own words, so write
keywords, not sentences.
Rules:
- Write the ENTIRE report in German.
- Group by day. One "## <Wochentag>, <dd.MM.yyyy>" section per day that has
- Write in German.
- Group by day. One "## <Wochentag>, <dd.MM.>" heading per day that has
activity (German weekday names). Omit days with no activity.
- Within each day: 3–5 first-person, past-tense bullets ("- Habe X umgesetzt",
"- Y behoben"). Merge related small work into one bullet.
- Drop trivia: typo fixes, pure exploration, false starts, tooling/log noise.
- Blend the developer's own notes and the derived activity into ONE deduplicated
bullet list per day. The notes are authoritative — never omit or contradict them.
- Name the project/repo when it adds clarity.
- Output ONLY the dated sections. No preamble, no intro, no closing remarks.
- Per day: 1–3 bullets, each at most ~6 words, noun style
("- LagerApp: Etikettendruck gefixt", "- ClaudeDo Listen-Ordner"). No "Habe …".
- One bullet per topic — merge everything about the same feature/bug.
- Drop trivia: typo fixes, exploration, false starts, tooling/log noise.
- Merge the developer's notes with the derived activity, deduplicated.
The notes are authoritative — never omit or contradict them.
- Output ONLY the day sections. No preamble, no summary, no closing remarks.
Two sections follow below: an activity log derived from Claude session history,
and the developer's own notes. Base the report on both; the notes are
@@ -109,16 +109,17 @@ public sealed class ListRepository
await _context.SaveChangesAsync(ct);
}
/// <summary>Patches just the two editable fields — a whole-entity Update would need
/// <summary>Patches just the editable fields — a whole-entity Update would need
/// <c>CreatedAt</c>/<c>SortOrder</c>/<c>IsCollapsed</c> carried along and silently reset
/// whichever the caller forgot (see the SetConfigAsync note in CLAUDE.md).</summary>
public async Task RenameFolderAsync(string folderId, string name, string? icon, CancellationToken ct = default)
public async Task RenameFolderAsync(string folderId, string name, string? icon, string? color, CancellationToken ct = default)
{
await _context.ListFolders
.Where(f => f.Id == folderId)
.ExecuteUpdateAsync(s => s
.SetProperty(f => f.Name, name)
.SetProperty(f => f.Icon, icon), ct);
.SetProperty(f => f.Icon, icon)
.SetProperty(f => f.Color, color), ct);
}
/// <summary>Deletes the folder only — its lists survive and fall back to the top level via
@@ -135,6 +136,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);
}
+14 -1
View File
@@ -378,8 +378,16 @@
"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",
"columns": "Spalten",
"columnsAuto": "Auto",
"closeSession": "Sitzung hart beenden",
"closeSessionConfirm": "Laufende Sitzung beenden? Der Claude-Prozess wird abgebrochen.",
"conptyLaunchFailed": "Interaktive Sitzung konnte nicht geöffnet werden: {0}",
@@ -387,6 +395,7 @@
"conptyExited": "Sitzung beendet (Code {0}).",
"usageWarningNotDelivered": "Die Listen-Handler-Sitzung konnte nicht vor dem Usage-Limit gewarnt werden.",
"terminalSendFailed": "Konnte nicht an die Terminal-Sitzung gesendet werden.",
"deferredHandoffTitleSuffix": "warte auf Limit-Zurücksetzen ({0})",
"mergeHelperTitle": "Merge-Helfer",
"mergeHelperHandoffTitleSuffix": " (Übergabe)",
"mergeHelperTaskTitle": "Listen-Handler: {0}",
@@ -453,6 +462,8 @@
"createAsFolderHint": "Ein Ordner gruppiert nur Listen in der Seitenleiste — er enthält keine Aufgaben und führt nichts aus. Listen per Drag-and-Drop hineinziehen.",
"sectionGeneral": "ALLGEMEIN",
"name": "Name",
"folderColor": "Farbe",
"folderColorHint": "Färbt den Ordnernamen und seine Führungslinie in der Seitenleiste.",
"workingDirectory": "Arbeitsverzeichnis",
"workingDirectoryPlaceholder": "(keines)",
"browse": "Durchsuchen...",
@@ -466,6 +477,8 @@
"sectionIcon": "ICON",
"icon": "Listen-Icon",
"iconHint": "Wird in der Seitenleiste neben dem Listennamen angezeigt.",
"iconSearchWatermark": "Alle Icons durchsuchen (Englisch, z.B. rocket, car, bug)",
"iconSearchTruncated": "Die ersten 200 Treffer werden angezeigt — Suche verfeinern.",
"sectionAgent": "AGENT",
"resetAgentSettings": "Agent-Einstellungen zurücksetzen",
"sectionVerify": "VERIFIKATION",
@@ -879,7 +892,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}" },
+14 -1
View File
@@ -378,8 +378,16 @@
"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",
"columns": "Columns",
"columnsAuto": "Auto",
"closeSession": "Kill session",
"closeSessionConfirm": "End the running session? The Claude process will be aborted.",
"conptyLaunchFailed": "Couldn't open interactive session: {0}",
@@ -387,6 +395,7 @@
"conptyExited": "Session ended (code {0}).",
"usageWarningNotDelivered": "Couldn't warn the list-handler session about the usage limit.",
"terminalSendFailed": "Couldn't send that to the terminal session.",
"deferredHandoffTitleSuffix": "waiting for limit reset ({0})",
"mergeHelperTitle": "Merge Helper",
"mergeHelperHandoffTitleSuffix": " (Handoff)",
"mergeHelperTaskTitle": "List handler: {0}",
@@ -453,6 +462,8 @@
"createAsFolderHint": "A folder just groups lists in the sidebar — it holds no tasks and runs nothing. Drag lists onto it to fill it.",
"sectionGeneral": "GENERAL",
"name": "Name",
"folderColor": "Colour",
"folderColorHint": "Tints the folder name and its guide line in the sidebar.",
"workingDirectory": "Working directory",
"workingDirectoryPlaceholder": "(none)",
"browse": "Browse...",
@@ -466,6 +477,8 @@
"sectionIcon": "ICON",
"icon": "List icon",
"iconHint": "Shown next to the list name in the sidebar.",
"iconSearchWatermark": "Search all icons (English, e.g. rocket, car, bug)",
"iconSearchTruncated": "Showing the first 200 matches — refine the search.",
"sectionAgent": "AGENT",
"resetAgentSettings": "Reset agent settings",
"sectionVerify": "VERIFICATION",
@@ -879,7 +892,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}" },
+3
View File
@@ -16,6 +16,9 @@ public sealed class AppSettings
public string DiffViewMode { get; set; } = "unified";
public bool DiffWrapLines { get; set; }
/// <summary>Mission Control overview column count; 0 = auto (derived from the pane count).</summary>
public int MissionControlColumns { get; set; }
private static readonly string DefaultConfigPath = Paths.Expand("~/.claudeDo/ui.config.json");
/// Where this instance persists. Instance-level (not static) so tests can redirect it —
+1 -1
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.
+2
View File
@@ -10,6 +10,7 @@
<PackageReference Include="Avalonia" Version="12.0.4" />
<PackageReference Include="Avalonia.AvaloniaEdit" Version="12.0.0" />
<PackageReference Include="AvaloniaEdit.TextMate" Version="12.0.0" />
<PackageReference Include="Material.Icons" Version="3.0.2" />
<PackageReference Include="TextMateSharp.Grammars" Version="2.0.3" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" />
@@ -17,6 +18,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>
+23 -1
View File
@@ -2,27 +2,49 @@ using System.Globalization;
using Avalonia;
using Avalonia.Data.Converters;
using Avalonia.Media;
using Material.Icons;
namespace ClaudeDo.Ui.Converters;
/// <summary>
/// Converts an icon key string (e.g. "Sun") to the matching StreamGeometry resource "Icon.Sun".
/// Converts an icon key to its geometry: "Sun" → the "Icon.Sun" StreamGeometry resource,
/// "mdi:RocketLaunch" → the Material Design Icons path of that <see cref="MaterialIconKind"/>.
/// </summary>
public class IconKeyConverter : IValueConverter
{
/// <summary>Key prefix marking a Material Design Icons kind.</summary>
public const string MdiPrefix = "mdi:";
public static IconKeyConverter Instance { get; } = new();
private static readonly Dictionary<string, Geometry?> MdiCache = new();
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is not string key || string.IsNullOrEmpty(key))
return null;
if (key.StartsWith(MdiPrefix, StringComparison.Ordinal))
return MdiGeometry(key[MdiPrefix.Length..]);
var resourceKey = $"Icon.{key}";
if (Application.Current?.TryGetResource(resourceKey, null, out var res) == true)
return res as StreamGeometry;
return null;
}
/// <summary>Parses (and caches) the geometry for an MDI kind name; null for unknown names.</summary>
private static Geometry? MdiGeometry(string name)
{
if (MdiCache.TryGetValue(name, out var cached))
return cached;
var geometry = Enum.TryParse<MaterialIconKind>(name, out var kind)
? StreamGeometry.Parse(MaterialIconDataProvider.GetData(kind))
: null;
MdiCache[name] = geometry;
return geometry;
}
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
@@ -9,6 +9,24 @@ namespace ClaudeDo.Ui.Converters;
// instantiate a generic FuncValueConverter as a resource; they stay one per type so the
// x:Key names in App.axaml keep resolving. Anything with real logic gets its own file.
/// <summary>"#RRGGBB" to a brush; anything else falls back to the app resource named by the parameter.</summary>
public sealed class HexBrushConverter : OneWayConverter
{
public static HexBrushConverter Instance { get; } = new();
public override object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value is string s && Color.TryParse(s, out var c)
? new SolidColorBrush(c)
: Avalonia.Application.Current?.TryGetResource(parameter as string ?? "", null, out var res) == true ? res : null;
}
/// <summary>True when the value's string form equals the parameter (segmented-switch active state).</summary>
public sealed class EqualsParameterConverter : OneWayConverter
{
public static EqualsParameterConverter Instance { get; } = new();
public override object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> Equals(value?.ToString(), parameter as string);
}
public sealed class UpperCaseConverter : OneWayConverter
{
public static UpperCaseConverter Instance { get; } = new();
+8 -1
View File
@@ -54,6 +54,9 @@
The old stroke version filled into a solid slab. -->
<StreamGeometry x:Key="Icon.Inbox">F0 M3 5 H21 V19 H3 Z M5 7 H19 V12 H5 Z M5 14 H19 V17 H5 Z M11 7.6 H13 V9.4 H15 L12 12 L9 9.4 H11 Z</StreamGeometry>
<!-- Icon.Dot — picker preview for "no icon"; the sidebar renders it as a coloured Ellipse instead. -->
<StreamGeometry x:Key="Icon.Dot">M12 7a5 5 0 1 0 0 10a5 5 0 1 0 0-10z</StreamGeometry>
<!-- Icon.Folder -->
<StreamGeometry x:Key="Icon.Folder">M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z</StreamGeometry>
@@ -854,7 +857,7 @@
<!-- LIST NAV ITEM -->
<!-- ============================================================ -->
<Style Selector="Border.list-item">
<Setter Property="Padding" Value="10,7" />
<Setter Property="Padding" Value="10,4" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="Background" Value="Transparent" />
@@ -863,6 +866,10 @@
<Setter Property="BorderThickness" Value="1.5" />
<Setter Property="BorderBrush" Value="Transparent" />
</Style>
<!-- Folder tree rows sit tighter than the smart lists — a deep tree gets long fast. -->
<Style Selector="Border.list-item.tree-row">
<Setter Property="Padding" Value="4,4,10,4" />
</Style>
<Style Selector="Border.list-item:pointerover">
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
</Style>
+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 -->
@@ -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;
@@ -37,6 +41,9 @@ public interface IWorkerClient : INotifyPropertyChanged
/// (handlerTaskId, message). Mission Control types the message into that task's ConPTY pane so
/// the session can hand off cleanly; no pane for the task means nothing happens.</summary>
event Action<string, string>? HandlerUsageWarningEvent;
/// <summary>A list-handler handoff is deferred because the 5h usage window is exhausted:
/// (handlerTaskId, dueAt). Mission Control updates the pane title to show the reset time.</summary>
event Action<string, DateTimeOffset>? HandlerHandoffDeferredEvent;
/// <summary>An agent added a note through the MCP tool — an open notes editor reloads.</summary>
event Action? NotesUpdatedEvent;
@@ -133,8 +140,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
+35 -2
View File
@@ -49,8 +49,10 @@ 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<string, DateTimeOffset>? HandlerHandoffDeferredEvent;
public event Action? ConnectionRestoredEvent;
public event Action<string>? WorktreeUpdatedEvent;
public event Action<string>? ListUpdatedEvent;
@@ -160,6 +162,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));
@@ -170,6 +177,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
Dispatcher.UIThread.Post(() => HandlerUsageWarningEvent?.Invoke(taskId, message));
});
_hub.On<string, DateTimeOffset>("HandlerHandoffDeferred", (taskId, dueAt) =>
{
Dispatcher.UIThread.Post(() => HandlerHandoffDeferredEvent?.Invoke(taskId, dueAt));
});
_hub.On<string>("WorktreeUpdated", taskId =>
{
Dispatcher.UIThread.Post(() => WorktreeUpdatedEvent?.Invoke(taskId));
@@ -298,6 +310,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 +329,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 +348,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 +647,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));
@@ -6,16 +6,28 @@ 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.
[ObservableProperty] private int _count;
/// <summary>Hex colour for name + guide line; null = theme default.</summary>
[ObservableProperty] private string? _color;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowCount))]
private bool _isCollapsed;
// Sum of the descendant lists' counts, recursive — a collapsed folder still shows what's inside it.
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowCount))]
private int _count;
/// <summary>Badge only while collapsed — expanded, the children show their own counts.</summary>
public bool ShowCount => IsCollapsed && Count > 0;
// Set while a dragged list row hovers over the middle of this folder header (move-into-folder).
[ObservableProperty] private bool _isDropTarget;
// Insert-position hints for a drop next to the folder instead of into it. Drawn around the
@@ -23,5 +35,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();
}
@@ -1,4 +1,5 @@
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.ViewModels.MissionControl;
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClaudeDo.Ui.ViewModels.Islands;
@@ -8,7 +9,12 @@ public sealed partial class ListNavItemViewModel : ViewModelBase
public required string Id { get; init; }
public required ListKind Kind { get; init; }
[ObservableProperty] private string _name = "";
[ObservableProperty] private int _count;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasCount))]
private int _count;
/// <summary>Hides a "0" badge — an empty list reads as noise next to the real counts.</summary>
public bool HasCount => Count > 0;
[ObservableProperty] private bool _isActive;
[ObservableProperty] private string? _workingDir;
[ObservableProperty] private string _defaultCommitType = CommitTypeRegistry.DefaultType;
@@ -25,7 +31,15 @@ public sealed partial class ListNavItemViewModel : ViewModelBase
// DropHintAbove/Below, which mark an insertion point for list reordering.
[ObservableProperty] private bool _isTaskDropTarget;
// Settable (not init) so a list-settings icon change refreshes the row in place.
[ObservableProperty] private string? _iconKey;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsDot))]
private string? _iconKey;
/// <summary>No icon picked — the row shows a coloured dot instead of a glyph.</summary>
public bool IsDot => IconKey is null or ListsIslandViewModel.ListIcon;
/// <summary>Identity colour of the dot, stable per list id across restarts.</summary>
public string DotColorKey => $"Identity{ConPtyPaneViewModel.StableIndex(Id, 6) + 1}";
// The sidebar folder this row sits in (ListEntity.FolderId); null = top level.
[ObservableProperty] private string? _folderId;
}
@@ -30,6 +30,9 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
/// <summary>Icon key a folder falls back to when it has none of its own.</summary>
public const string ListFolderIcon = "Folder";
/// <summary>Icon key a list falls back to — deliberately not the folder icon, so the two stay distinguishable.</summary>
public const string ListIcon = ListSettingsModalViewModel.DefaultIcon;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly IServiceProvider? _services;
private readonly IWorkerClient? _worker;
@@ -215,10 +218,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 +333,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
@@ -331,10 +352,14 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
Id = f.Id,
Name = f.Name,
IconKey = f.Icon ?? ListFolderIcon,
Color = f.Color,
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" });
@@ -347,7 +372,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
Id = $"user:{l.Id}",
Name = l.Name,
Kind = ListKind.User,
IconKey = l.Icon ?? "Folder",
IconKey = l.Icon ?? ListIcon,
WorkingDir = l.WorkingDir,
DefaultCommitType = l.DefaultCommitType,
IsManual = l.IsManual,
@@ -357,16 +382,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); }
@@ -447,7 +477,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
Id = $"user:{entity.Id}",
Name = entity.Name,
Kind = ListKind.User,
IconKey = "Folder",
IconKey = ListIcon,
WorkingDir = entity.WorkingDir,
DefaultCommitType = entity.DefaultCommitType,
};
@@ -517,10 +547,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 +574,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 +586,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 +635,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 +694,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;
}
@@ -662,7 +759,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
if (folder is null || Dialogs is null || _services is null) return;
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
vm.ErrorReported += msg => ErrorReported?.Invoke(msg);
vm.BeginLoadFolder(folder.Id, folder.Name, folder.IconKey);
vm.BeginLoadFolder(folder.Id, folder.Name, folder.IconKey, folder.Color);
await Dialogs.ShowListSettingsAsync(vm);
// Rename, re-icon and delete all change the folder header (and delete releases its lists
// back to the top level) — one reload covers every outcome.
@@ -671,7 +768,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) =>
@@ -732,7 +844,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
row.DefaultCommitType = entity.DefaultCommitType;
row.IsManual = entity.IsManual;
row.FindingsTracked = entity.FindingsTracked;
row.IconKey = entity.Icon ?? "Folder";
row.IconKey = entity.Icon ?? ListIcon;
row.HasTicketProject = hasTicketProject;
RecomputeHasNoLinkedRepo();
}
@@ -778,7 +890,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
Id = $"user:{entity.Id}",
Name = entity.Name,
Kind = ListKind.User,
IconKey = entity.Icon ?? "Folder",
IconKey = entity.Icon ?? ListIcon,
WorkingDir = entity.WorkingDir,
DefaultCommitType = entity.DefaultCommitType,
IsManual = entity.IsManual,
@@ -805,8 +917,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);
@@ -157,7 +157,7 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
if (TaskId is null || ShowDiffViewer is null) return;
var vm = _services.GetRequiredService<DiffViewerViewModel>();
vm.ConfigurePlanning(TaskId, SelectedMergeTarget ?? "main");
await vm.LoadAsync();
_ = vm.LoadAsync();
DiffViewed?.Invoke();
await ShowDiffViewer(vm);
}
@@ -187,7 +187,7 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
}
else return;
await vm.LoadAsync();
_ = vm.LoadAsync();
DiffViewed?.Invoke();
await ShowDiffViewer(vm);
}
@@ -1173,7 +1173,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
TasksChanged?.Invoke(this, EventArgs.Empty);
}
[RelayCommand]
[RelayCommand(AllowConcurrentExecutions = true)]
private async Task ToggleDoneAsync(TaskRowViewModel row)
{
if (_worker is null) return;
@@ -1237,7 +1237,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
TasksChanged?.Invoke(this, EventArgs.Empty);
}
[RelayCommand]
[RelayCommand(AllowConcurrentExecutions = true)]
private async Task ToggleStarAsync(TaskRowViewModel row)
{
row.IsStarred = !row.IsStarred;
@@ -1403,7 +1403,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.sendToQueueFailed", ex.Message)); }
}
[RelayCommand]
[RelayCommand(AllowConcurrentExecutions = true)]
private async Task RemoveFromQueueAsync(TaskRowViewModel? row)
{
if (row is null || _worker is null) return;
@@ -1700,7 +1700,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
Regroup();
}
[RelayCommand]
[RelayCommand(AllowConcurrentExecutions = true)]
private async Task RefineTask(TaskRowViewModel row)
{
if (row is null || !row.CanRefine) return;
@@ -208,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;
@@ -19,6 +19,26 @@ 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.
/// <summary>Process-stable bucket for <paramref name="value"/>; also colours the sidebar list dots.</summary>
internal 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 +80,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 +90,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 +133,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 +151,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;
@@ -16,6 +18,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
{
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly IWorkerClient _worker;
private readonly AppSettings? _settings;
private readonly Action<string, string, DateTime> _onTaskStarted;
private readonly Action<string, string, string, DateTime> _onTaskFinished;
private readonly Action<string> _onTaskUpdated;
@@ -23,6 +26,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
private readonly Action<string, IReadOnlyList<string>, string> _onHandoffRequested;
private readonly Action<string, IReadOnlyList<string>> _onListHandlerStartRequested;
private readonly Action<string, string> _onHandlerUsageWarning;
private readonly Action<string, DateTimeOffset> _onHandlerHandoffDeferred;
// Embedded ConPTY sessions (task-based only) — a manual cockpit detached from the
// review/merge/status machinery.
@@ -41,6 +45,9 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
[ObservableProperty] private int _columnCount = 1;
/// <summary>User-picked column count for the overview grid; 0 = auto (derived from the pane count).</summary>
[ObservableProperty] private int _columnPreference;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(LayoutToggleLabel))]
private bool _isFocusMode;
@@ -75,6 +82,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; }
@@ -85,11 +98,13 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
public UsagePillViewModel UsagePill { get; }
public MissionControlViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient worker, UsagePillViewModel usagePill)
public MissionControlViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient worker, UsagePillViewModel usagePill, AppSettings? settings = null)
{
_dbFactory = dbFactory;
_worker = worker;
UsagePill = usagePill;
_settings = settings;
_columnPreference = Math.Clamp(settings?.MissionControlColumns ?? 0, 0, MaxColumns);
ConPtySessions.CollectionChanged += OnConPtySessionsChanged;
Panes.CollectionChanged += OnPanesChanged;
@@ -108,11 +123,14 @@ 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;
_onHandlerUsageWarning = (taskId, message) => { _ = WarnHandlerSessionAsync(taskId, message); };
_worker.HandlerUsageWarningEvent += _onHandlerUsageWarning;
_onHandlerHandoffDeferred = (taskId, dueAt) => UpdateHandlerSessionTitleAsync(taskId, dueAt);
_worker.HandlerHandoffDeferredEvent += _onHandlerHandoffDeferred;
_ = RefreshQueueAsync();
_ = RefreshSessionTargetsAsync();
@@ -214,6 +232,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
@@ -244,6 +340,32 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
[RelayCommand]
private void ToggleLayout() => IsFocusMode = !IsFocusMode;
/// <summary>Upper bound of the column picker.</summary>
public const int MaxColumns = 4;
/// <summary>Sets the column preference from the header picker ("0" = auto) and persists it.</summary>
[RelayCommand]
private void SetColumns(string value) => ColumnPreference = Math.Clamp(int.Parse(value), 0, MaxColumns);
partial void OnColumnPreferenceChanged(int value)
{
UpdateColumnCount();
if (_settings is null) return;
_settings.MissionControlColumns = value;
try { _settings.Save(); }
catch (Exception ex) { ErrorReported?.Invoke(ex.Message); }
}
// Never more columns than panes, so a wide preference doesn't leave empty cells.
private void UpdateColumnCount() => ColumnCount = ColumnPreference > 0
? Math.Max(1, Math.Min(ColumnPreference, Panes.Count))
: Panes.Count switch
{
<= 1 => 1,
<= 4 => 2,
_ => 3,
};
// Fetches the launch spec for a task's worktree and hosts an embedded ConPTY session as a
// Command Center pane (task-based only).
public async System.Threading.Tasks.Task OpenConPtySessionAsync(string taskId)
@@ -322,11 +444,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 +550,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 +575,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
@@ -444,16 +591,27 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
ErrorReported?.Invoke(Loc.T("missionControl.usageWarningNotDelivered"));
}
// A handler session's handoff is deferred due to usage limits: update the pane title to show
// the reset time. No pane = nothing to update, not an error.
private void UpdateHandlerSessionTitleAsync(string taskId, DateTimeOffset dueAt)
{
if (string.IsNullOrEmpty(taskId)) return;
if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is not { } pane) return;
var suffix = Loc.T("missionControl.deferredHandoffTitleSuffix", dueAt.LocalDateTime.ToString("HH:mm"));
pane.DisplayTitle += $" — {suffix}";
}
// 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();
}
@@ -590,12 +748,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
private void OnPanesChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
ColumnCount = Panes.Count switch
{
<= 1 => 1,
<= 4 => 2,
_ => 3,
};
UpdateColumnCount();
OnPropertyChanged(nameof(HasPanes));
if (e.Action == NotifyCollectionChangedAction.Add && e.NewItems?[0] is ConPtyPaneViewModel added)
FocusedPane = added;
@@ -610,8 +763,10 @@ 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;
_worker.HandlerHandoffDeferredEvent -= _onHandlerHandoffDeferred;
ConPtySessions.CollectionChanged -= OnConPtySessionsChanged;
Panes.CollectionChanged -= OnPanesChanged;
foreach (var c in ConPtySessions.ToList())
@@ -647,6 +802,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
{
@@ -2,12 +2,14 @@ using System.Collections.ObjectModel;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui.Converters;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Agent;
using ClaudeDo.Ui.ViewModels.Modals.Settings;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Material.Icons;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Ui.ViewModels.Modals;
@@ -111,7 +113,17 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
public ObservableCollection<string> CommitTypeOptions { get; } = new(CommitTypeRegistry.Types);
public const string DefaultIcon = "Folder";
public const string DefaultIcon = "Dot";
/// <summary>Folder colour swatches; "" = no colour (theme default).</summary>
public static readonly string[] FolderColorOptions =
["", "#D4A574", "#C87060", "#D9C27A", "#8B9D7A", "#6FB3A8", "#7FA7C9", "#A38BC9"];
/// <summary>Selected folder colour, "" for none.</summary>
[ObservableProperty] private string _folderColor = "";
/// <summary>Default icon for folders, kept apart from <see cref="DefaultIcon"/> so lists never look like folders.</summary>
public const string DefaultFolderIcon = "Folder";
// Curated subset of the Icon.* geometry library in IslandStyles.axaml — window chrome and
// pure stroke icons (PathIcon fills, so those render invisible) are deliberately left out.
@@ -120,7 +132,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
public static readonly string[] IconOptions =
[
// general
"Folder", "Star", "Sun", "Calendar", "Clock", "Inbox", "Mail", "Text", "Search", "Grid",
"Dot", "Folder", "Star", "Sun", "Calendar", "Clock", "Inbox", "Mail", "Text", "Search", "Grid",
// dev
"Code", "Terminal", "Database", "Server", "Chip", "Bolt", "Wand", "Flask", "Shield",
// status / flow
@@ -129,7 +141,66 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
"Box", "Truck", "MapPin", "Camera", "Image", "Barcode", "Skull",
];
public ObservableCollection<string> Icons { get; } = new(IconOptions);
/// <summary>Cap on search hits so the picker's WrapPanel stays responsive.</summary>
public const int MaxIconResults = 200;
// One entry per distinct MDI icon; the enum carries aliases, all of which stay searchable.
private static readonly Lazy<(string Key, string Names)[]> MdiIndex = new(() =>
Enum.GetNames<MaterialIconKind>()
.GroupBy(Enum.Parse<MaterialIconKind>)
.Select(g => (IconKeyConverter.MdiPrefix + g.First(), string.Join(' ', g)))
.ToArray());
/// <summary>Picker items: the curated set, or MDI search hits once the search has 2+ characters.</summary>
[ObservableProperty] private IReadOnlyList<string> _icons = IconOptions;
/// <summary>Free-text filter over the MDI icon names (English).</summary>
[ObservableProperty] private string _iconSearch = "";
/// <summary>True when the search hit more than <see cref="MaxIconResults"/> icons.</summary>
[ObservableProperty] private bool _iconResultsTruncated;
// Separate from Icon: swapping Icons makes the ListBox push null here, which must not wipe the choice.
[ObservableProperty] private string? _selectedIconOption = DefaultIcon;
/// <summary>True for a curated key or an existing "mdi:" kind.</summary>
public static bool IsKnownIcon(string? key) =>
key is not null && (IconOptions.Contains(key)
|| (key.StartsWith(IconKeyConverter.MdiPrefix, StringComparison.Ordinal)
&& Enum.TryParse<MaterialIconKind>(key[IconKeyConverter.MdiPrefix.Length..], out _)));
partial void OnIconSearchChanged(string value)
{
var q = value.Trim();
if (q.Length < 2)
{
IconResultsTruncated = false;
// Keep a chosen MDI icon visible (and selected) in the curated view.
Icons = IconOptions.Contains(Icon) ? IconOptions : [Icon, .. IconOptions];
}
else
{
var hits = MdiIndex.Value
.Where(e => e.Names.Contains(q, StringComparison.OrdinalIgnoreCase))
.Select(e => e.Key)
.Take(MaxIconResults + 1)
.ToList();
IconResultsTruncated = hits.Count > MaxIconResults;
Icons = hits.Take(MaxIconResults).ToList();
}
SelectedIconOption = Icons.Contains(Icon) ? Icon : null;
}
partial void OnSelectedIconOptionChanged(string? value)
{
if (value is not null) Icon = value;
}
partial void OnIconChanged(string value)
{
if (!Icons.Contains(value)) OnIconSearchChanged(IconSearch);
else SelectedIconOption = value;
}
// The shared agent-config editor (Model / MaxTurns / SystemPrompt / AgentFile),
// scoped to this list (list → global inheritance).
@@ -165,13 +236,14 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
/// Opens the modal on an existing folder: name + icon only, no list config to load and
/// nothing to fetch from the worker, so this needs no async counterpart.
/// </summary>
public void BeginLoadFolder(string folderId, string name, string? icon)
public void BeginLoadFolder(string folderId, string name, string? icon, string? color = null)
{
FolderColor = color ?? "";
_folderId = folderId;
IsFolder = true;
CanToggleFolder = false;
Name = name;
Icon = IconOptions.Contains(icon) ? icon! : DefaultIcon;
Icon = IsKnownIcon(icon) ? icon! : DefaultFolderIcon;
}
public async Task LoadAsync(
@@ -195,7 +267,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
FindingsTracked = findingsTracked;
// An unknown/legacy key would leave the picker with no selection and silently reset the
// icon on the next save — fall back to the default instead.
Icon = IconOptions.Contains(icon) ? icon! : DefaultIcon;
Icon = IsKnownIcon(icon) ? icon! : DefaultIcon;
WorkingDir = workingDir ?? "";
DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? CommitTypeRegistry.DefaultType : defaultCommitType;
@@ -261,6 +333,13 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
// A manual list without a WorkingDir is untouched (empty is always valid) — this only fires
// for the "picked a folder that turns out not to be a repo" case the audit found: it would
// otherwise only surface as a Failed task the first time the worker tries to use it.
// The create-flow toggle swaps the untouched default so a new folder doesn't get the list icon.
partial void OnIsFolderChanged(bool value)
{
if (Icon == (value ? DefaultIcon : DefaultFolderIcon))
Icon = value ? DefaultFolderIcon : DefaultIcon;
}
partial void OnWorkingDirChanged(string value)
{
if (string.IsNullOrWhiteSpace(value)) { WorkingDirWarning = ""; return; }
@@ -304,6 +383,8 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
CloseAction?.Invoke();
}
private static string? NullIfEmpty(string s) => s.Length == 0 ? null : s;
private async Task SaveFolderAsync()
{
var name = string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name;
@@ -317,6 +398,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
Id = Guid.NewGuid().ToString("N"),
Name = name,
Icon = Icon,
Color = NullIfEmpty(FolderColor),
CreatedAt = DateTime.UtcNow,
});
// The create flow pre-creates a list row for this modal to save against; a folder
@@ -327,7 +409,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
}
else
{
await lists.RenameFolderAsync(_folderId, name, Icon);
await lists.RenameFolderAsync(_folderId, name, Icon, NullIfEmpty(FolderColor));
}
Saved = true;
@@ -0,0 +1,99 @@
using System;
using System.Threading;
using Avalonia;
using Avalonia.Animation;
using Avalonia.Animation.Easings;
using Avalonia.Controls;
using Avalonia.Styling;
namespace ClaudeDo.Ui.Views.Controls;
/// <summary>
/// Accordion host: animates its height between 0 and the child's natural height, so content
/// below slides instead of jumping. The first value (from the binding) applies without animation.
/// </summary>
public sealed class ExpandPanel : Decorator
{
private static readonly TimeSpan Duration = TimeSpan.FromMilliseconds(180);
public static readonly StyledProperty<bool> IsExpandedProperty =
AvaloniaProperty.Register<ExpandPanel, bool>(nameof(IsExpanded));
/// <summary>0 = collapsed, 1 = fully open; drives the measured height.</summary>
public static readonly StyledProperty<double> ProgressProperty =
AvaloniaProperty.Register<ExpandPanel, double>(nameof(Progress));
private CancellationTokenSource? _cts;
private bool _arranged;
static ExpandPanel()
{
AffectsMeasure<ExpandPanel>(ProgressProperty);
ClipToBoundsProperty.OverrideDefaultValue<ExpandPanel>(true);
}
/// <summary>Whether the content is shown.</summary>
public bool IsExpanded
{
get => GetValue(IsExpandedProperty);
set => SetValue(IsExpandedProperty, value);
}
/// <summary>Current open fraction, animated between 0 and 1.</summary>
public double Progress
{
get => GetValue(ProgressProperty);
set => SetValue(ProgressProperty, value);
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == IsExpandedProperty)
Animate(IsExpanded ? 1 : 0);
else if (change.Property == ProgressProperty && Child is { } child)
child.IsVisible = Progress > 0;
}
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnAttachedToVisualTree(e);
if (Child is { } child) child.IsVisible = Progress > 0;
}
private void Animate(double to)
{
_cts?.Cancel();
var from = Progress;
Progress = to;
if (!_arranged || from == to) return;
_cts = new CancellationTokenSource();
var animation = new Animation
{
Duration = Duration,
Easing = new CubicEaseOut(),
Children =
{
new KeyFrame { Cue = new Cue(0), Setters = { new Setter(ProgressProperty, from) } },
new KeyFrame { Cue = new Cue(1), Setters = { new Setter(ProgressProperty, to) } },
},
};
_ = animation.RunAsync(this, _cts.Token);
}
protected override Size MeasureOverride(Size availableSize)
{
if (Child is not { } child) return default;
child.Measure(availableSize.WithHeight(double.PositiveInfinity));
return new Size(child.DesiredSize.Width, child.DesiredSize.Height * Progress);
}
protected override Size ArrangeOverride(Size finalSize)
{
_arranged = true;
// Full natural height, top-aligned; ClipToBounds cuts off what isn't revealed yet.
Child?.Arrange(new Rect(0, 0, finalSize.Width, Child.DesiredSize.Height));
return finalSize;
}
}
@@ -3,11 +3,22 @@
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
xmlns:converters="using:ClaudeDo.Ui.Converters"
xmlns:loc="using:ClaudeDo.Ui.Localization"
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
x:Class="ClaudeDo.Ui.Views.Islands.ListsIslandView"
x:DataType="vm:ListsIslandViewModel">
<!-- Implicit, type-matched templates: the sidebar's top level is one mixed ItemsControl over
TopLevel (folders and folder-less lists interleaved), and each folder's children resolve the
list template the same way. -->
<!-- Glyph alignment is a style, not a local value, so the nested class can override it. -->
<UserControl.Styles>
<Style Selector="Panel.list-glyph > :is(Control)">
<Setter Property="HorizontalAlignment" Value="Left"/>
</Style>
<Style Selector="Panel.list-glyph.nested > :is(Control)">
<Setter Property="HorizontalAlignment" Value="Center"/>
</Style>
</UserControl.Styles>
<UserControl.DataTemplates>
<!-- One user-list row. Shared by the top level and by each folder's children, so the
row (context menu, drag handlers, drop indicators) exists once. -->
@@ -19,7 +30,7 @@
Background="{DynamicResource MossBrush}" CornerRadius="1"
IsVisible="{Binding DropHintAbove}"/>
<Border Grid.Row="1" Classes="list-item" Classes.active="{Binding IsActive}"
<Border Grid.Row="1" Classes="list-item tree-row" Classes.active="{Binding IsActive}"
Classes.drop-target="{Binding IsTaskDropTarget}"
Tapped="OnItemTapped"
DragDrop.AllowDrop="True"
@@ -57,9 +68,11 @@
CommandParameter="{Binding}"/>
</ContextMenu>
</Border.ContextMenu>
<Grid ColumnDefinitions="20,*,Auto">
<!-- Inside a folder: a spacer for the chevron slot, so list and subfolder icons line up.
At the top level: no spacer, glyph left-aligned to where a folder's chevron starts. -->
<Grid ColumnDefinitions="Auto,20,*,Auto">
<!-- Left accent bar for active state -->
<Border Grid.Column="0" Grid.ColumnSpan="3"
<Border Grid.Column="0" Grid.ColumnSpan="4"
Background="Transparent"
CornerRadius="8" IsHitTestVisible="False"
IsVisible="{Binding IsActive}">
@@ -67,21 +80,29 @@
Background="{DynamicResource AccentBrush}"
CornerRadius="1"
HorizontalAlignment="Left" VerticalAlignment="Center"
Margin="-8,0,0,0"/>
Margin="-2,0,0,0"/>
</Border>
<!-- Icon (list-settings → Icon tab; "Folder" when unset) -->
<PathIcon Grid.Column="0" Classes="list-icon"
Width="14" Height="14"
Data="{Binding IconKey, Converter={StaticResource IconKey}}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
<Border Grid.Column="0" Width="12"
IsVisible="{Binding FolderId, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<Panel Grid.Column="1" Classes="list-glyph"
Classes.nested="{Binding FolderId, Converter={x:Static StringConverters.IsNotNullOrEmpty}}">
<PathIcon Classes="list-icon"
Width="14" Height="14" IsVisible="{Binding !IsDot}"
Data="{Binding IconKey, Converter={StaticResource IconKey}}"
VerticalAlignment="Center"/>
<Ellipse Width="8" Height="8" IsVisible="{Binding IsDot}"
Fill="{Binding DotColorKey, Converter={StaticResource DotBrush}}"
VerticalAlignment="Center"/>
</Panel>
<!-- Name -->
<TextBlock Grid.Column="1" Classes="list-label"
Text="{Binding Name}"
<TextBlock Grid.Column="2" Classes="list-label"
Text="{Binding Name}" ToolTip.Tip="{Binding Name}"
VerticalAlignment="Center" Margin="8,0"
Foreground="{DynamicResource TextDimBrush}" FontSize="{StaticResource FontSizeBody}"/>
<!-- Count -->
<TextBlock Grid.Column="2" Classes="list-count"
Text="{Binding Count}"/>
<TextBlock Grid.Column="3" Classes="list-count"
Text="{Binding Count}" IsVisible="{Binding HasCount}"/>
</Grid>
</Border>
@@ -102,7 +123,7 @@
IsVisible="{Binding DropHintAbove}"/>
<StackPanel Grid.Row="1">
<Border Classes="list-item"
<Border Classes="list-item tree-row"
Classes.drop-target="{Binding IsDropTarget}"
Tapped="OnFolderTapped"
DragDrop.AllowDrop="True"
@@ -116,32 +137,40 @@
</ContextMenu>
</Border.ContextMenu>
<Grid ColumnDefinitions="12,20,*,Auto">
<PathIcon Grid.Column="0" Classes="list-icon"
<PathIcon Grid.Column="0" Classes="list-icon" Foreground="{Binding Color, Converter={x:Static converters:HexBrushConverter.Instance}, ConverterParameter=TextMuteBrush}"
Width="9" Height="9"
Data="{StaticResource Icon.ChevronRight}"
IsVisible="{Binding IsCollapsed}"
HorizontalAlignment="Left" VerticalAlignment="Center"/>
<PathIcon Grid.Column="0" Classes="list-icon"
<PathIcon Grid.Column="0" Classes="list-icon" Foreground="{Binding Color, Converter={x:Static converters:HexBrushConverter.Instance}, ConverterParameter=TextMuteBrush}"
Width="9" Height="9"
Data="{StaticResource Icon.ChevronDown}"
IsVisible="{Binding !IsCollapsed}"
HorizontalAlignment="Left" VerticalAlignment="Center"/>
<PathIcon Grid.Column="1" Classes="list-icon"
<PathIcon Grid.Column="1" Classes="list-icon" Foreground="{Binding Color, Converter={x:Static converters:HexBrushConverter.Instance}, ConverterParameter=TextMuteBrush}"
Width="14" Height="14"
Data="{Binding IconKey, Converter={StaticResource IconKey}}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
<TextBlock Grid.Column="2" Classes="list-label"
Text="{Binding Name}"
VerticalAlignment="Center" Margin="8,0"
Foreground="{DynamicResource TextDimBrush}" FontSize="{StaticResource FontSizeBody}"/>
Text="{Binding Name}" ToolTip.Tip="{Binding Name}"
VerticalAlignment="Center" Margin="8,0" FontWeight="SemiBold"
Foreground="{Binding Color, Converter={x:Static converters:HexBrushConverter.Instance}, ConverterParameter=TextDimBrush}"
FontSize="{StaticResource FontSizeBody}"/>
<TextBlock Grid.Column="3" Classes="list-count"
Text="{Binding Count}"/>
Text="{Binding Count}" IsVisible="{Binding ShowCount}"/>
</Grid>
</Border>
<ItemsControl ItemsSource="{Binding Lists}"
IsVisible="{Binding !IsCollapsed}"
Margin="14,0,0,0"/>
<!-- Guide line drops from under the chevron's centre (tree-row padding 4 + half the 9px
glyph); children start past it so their highlight never touches the line. -->
<ctl:ExpandPanel IsExpanded="{Binding !IsCollapsed}">
<Panel>
<Border Width="1" HorizontalAlignment="Left" Margin="8,0,0,4"
Background="{Binding Color, Converter={x:Static converters:HexBrushConverter.Instance}, ConverterParameter=LineBrush}"
IsHitTestVisible="False"/>
<ItemsControl ItemsSource="{Binding Children}" Margin="14,0,0,0"/>
</Panel>
</ctl:ExpandPanel>
</StackPanel>
<Border Grid.Row="2" Height="2" VerticalAlignment="Center" Margin="4,0"
@@ -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();
@@ -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,81 @@
</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>
<Border Classes="segmented" VerticalAlignment="Center"
IsVisible="{Binding !IsFocusMode}"
ToolTip.Tip="{loc:Tr missionControl.columns}">
<StackPanel Orientation="Horizontal">
<Button Classes="segment" Content="{loc:Tr missionControl.columnsAuto}"
Classes.active="{Binding ColumnPreference, Converter={x:Static conv:EqualsParameterConverter.Instance}, ConverterParameter=0}"
Command="{Binding SetColumnsCommand}" CommandParameter="0"/>
<Button Classes="segment" Content="1"
Classes.active="{Binding ColumnPreference, Converter={x:Static conv:EqualsParameterConverter.Instance}, ConverterParameter=1}"
Command="{Binding SetColumnsCommand}" CommandParameter="1"/>
<Button Classes="segment" Content="2"
Classes.active="{Binding ColumnPreference, Converter={x:Static conv:EqualsParameterConverter.Instance}, ConverterParameter=2}"
Command="{Binding SetColumnsCommand}" CommandParameter="2"/>
<Button Classes="segment" Content="3"
Classes.active="{Binding ColumnPreference, Converter={x:Static conv:EqualsParameterConverter.Instance}, ConverterParameter=3}"
Command="{Binding SetColumnsCommand}" CommandParameter="3"/>
<Button Classes="segment" Content="4"
Classes.active="{Binding ColumnPreference, Converter={x:Static conv:EqualsParameterConverter.Instance}, ConverterParameter=4}"
Command="{Binding SetColumnsCommand}" CommandParameter="4"/>
</StackPanel>
</Border>
<Button Classes="icon-btn"
Command="{Binding ToggleLayoutCommand}"
ToolTip.Tip="{Binding LayoutToggleLabel}">
@@ -84,7 +159,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>
@@ -29,10 +29,16 @@ 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. Deferred: Click fires before
// Button runs its Command, and a synchronous Hide unbinds that Command first.
RecentSessionsList.AddHandler(Button.ClickEvent,
(_, _) => Avalonia.Threading.Dispatcher.UIThread.Post(() => 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()
{
@@ -3,6 +3,7 @@
xmlns:vm="clr-namespace:ClaudeDo.Ui.ViewModels.Modals"
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
xmlns:loc="using:ClaudeDo.Ui.Localization"
xmlns:conv="using:ClaudeDo.Ui.Converters"
x:Class="ClaudeDo.Ui.Views.Modals.ListSettingsModalView"
x:DataType="vm:ListSettingsModalViewModel"
Title="{loc:Tr modals.listSettings.title}"
@@ -88,6 +89,30 @@
<TextBox Text="{Binding Name, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
<StackPanel Spacing="4" IsVisible="{Binding IsFolder}">
<TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.folderColor}"/>
<ListBox ItemsSource="{x:Static vm:ListSettingsModalViewModel.FolderColorOptions}"
SelectedItem="{Binding FolderColor, Mode=TwoWay}"
Background="Transparent" BorderThickness="0"
HorizontalAlignment="Left">
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel/>
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="x:String">
<!-- "" renders as an empty ring: the "no colour" swatch. -->
<Border Width="18" Height="18" Margin="3" CornerRadius="9"
BorderThickness="1" BorderBrush="{DynamicResource LineBrightBrush}"
Background="{Binding Converter={x:Static conv:HexBrushConverter.Instance}, ConverterParameter=Surface2Brush}"/>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Text="{loc:Tr modals.listSettings.folderColorHint}"
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
</StackPanel>
<StackPanel Spacing="4" IsVisible="{Binding !IsFolder}">
<TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.workingDirectory}"/>
<Grid ColumnDefinitions="*,Auto">
@@ -143,8 +168,13 @@
<ScrollViewer>
<StackPanel Spacing="8" Margin="0,8,0,0">
<TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.icon}"/>
<TextBox Text="{Binding IconSearch}"
Watermark="{loc:Tr modals.listSettings.iconSearchWatermark}"/>
<TextBlock Text="{loc:Tr modals.listSettings.iconSearchTruncated}"
IsVisible="{Binding IconResultsTruncated}"
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
<ListBox ItemsSource="{Binding Icons}"
SelectedItem="{Binding Icon, Mode=TwoWay}"
SelectedItem="{Binding SelectedIconOption, Mode=TwoWay}"
Background="Transparent" BorderThickness="0"
HorizontalAlignment="Left">
<ListBox.ItemsPanel>
@@ -157,6 +187,7 @@
<!-- No Foreground here on purpose: the icon inherits the container's, so the
selected/hover states of ListBoxItem reach it (see Ui/CLAUDE.md). -->
<PathIcon Width="20" Height="20" Margin="6"
ToolTip.Tip="{Binding}"
Data="{Binding Converter={StaticResource IconKey}}"/>
</DataTemplate>
</ListBox.ItemTemplate>
+3 -3
View File
@@ -55,7 +55,7 @@ subfolder within their area; the namespace stays the area namespace.
- **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`.
- **External/*** — always-on MCP tools for general Claude sessions, scoped to *starting* and *observing* sessions (no multi-turn, planning internals, or app-settings writes). Auth via optional `X-ClaudeDo-Key`. **Task-ID resolution:** every tool parameter that accepts a task id (`taskId`, `parentId`, `taskIds`, etc.) is wired through `TaskIdResolver`, which accepts both `#123` (short number-based handle) and bare GUIDs. A bare all-digits string is never a GUID and is unambiguous. Branch names and worktree paths continue to use the GUID (`claudedo/{id}`) — the `Number` is a display alias, never identity. Every task-returning tool stamps the task's `Number` in its DTO payload and the MCP tool descriptions carry a shared clause instructing the agent to **refer to tasks as `#<number>` when reporting to the user** (otherwise the number sits unused in the payload). **Hard convention (test-enforced):** every optional/filter parameter needs a C# default value — the MCP schema only marks a parameter optional when one exists; `ExternalMcpToolSchemaTests` guards this. **Other conventions (not test-enforced):** no tool returns bare `Task`/a nullable payload directly (write tools return a confirmation record like `RunTaskNowResult`; read tools use an explicit `Found`/`Available` flag); tool descriptions follow rules documented in `External/McpToolDocs.cs`. Full tool inventory + per-tool behaviour → [external-mcp](../../docs/explore-notes/external-mcp.md).
- **External/*** — always-on MCP tools for general Claude sessions, scoped to *starting* and *observing* sessions (no multi-turn, planning internals, or app-settings writes). Auth via optional `X-ClaudeDo-Key`. **Task-ID resolution:** every tool parameter that accepts a task id (`taskId`, `parentId`, `taskIds`, etc.) is wired through `TaskIdResolver`, which accepts both `#123` (short number-based handle) and bare GUIDs. A bare all-digits string is never a GUID and is unambiguous. Task worktree branches and folders are named `{number}-{title-slug}` (`claudedo/{number}-{title-slug}`; stored in the `worktrees` row, never re-derived) — the `Number` is otherwise a display alias, never identity. Every task-returning tool stamps the task's `Number` in its DTO payload and the MCP tool descriptions carry a shared clause instructing the agent to **refer to tasks as `#<number>` when reporting to the user** (otherwise the number sits unused in the payload). **Hard convention (test-enforced):** every optional/filter parameter needs a C# default value — the MCP schema only marks a parameter optional when one exists; `ExternalMcpToolSchemaTests` guards this. **Other conventions (not test-enforced):** no tool returns bare `Task`/a nullable payload directly (write tools return a confirmation record like `RunTaskNowResult`; read tools use an explicit `Found`/`Available` flag); tool descriptions follow rules documented in `External/McpToolDocs.cs`. Full tool inventory + per-tool behaviour → [external-mcp](../../docs/explore-notes/external-mcp.md).
## Status Model
@@ -130,7 +130,7 @@ Full flow, invariants, and model/effort/max-turns resolution (including the low-
- **ClaudeProcess** — spawns `claude -p --output-format stream-json --verbose --permission-mode auto` (or whatever app settings specify). Prompt via stdin, NDJSON from stdout. CancellationToken kills the process tree. Resolves `_cfg.ClaudeBin` via `ExecutableResolver` (`ClaudeDo.Data`, shared with `ClaudeCliPreflight` and the Installer's checks) before spawning — a `.cmd`/`.bat` shim (e.g. an npm-installed `claude`) is launched through `cmd.exe /c` since `UseShellExecute = false` can't exec a shim directly; a resolved `.exe` starts exactly as before. Throws if nothing resolves. *(Not yet on `main` — see `Environment Checks` in `ClaudeDo.Installer/CLAUDE.md`.)*
- **ClaudeArgsBuilder** — `--model`, `--effort`, `--max-turns`, `--append-system-prompt`, `--agents`, `--json-schema`, `--resume`
- **StreamAnalyzer** — parses NDJSON; extracts session_id, token counts, turn counts, result text, structured output. Replaced MessageParser.
- **WorktreeManager** — worktrees on `claudedo/{id}` branches (full task GUID, dashes stripped — a prefix isn't enough to rule out collisions); commits with semantic messages, updates DB with head commit + diff stats
- **WorktreeManager** — worktrees on `claudedo/{number}-{title-slug}` branches, same leaf as the folder (`NameFor`; the unique task number rules out collisions, slug capped at 40 chars); commits with semantic messages, updates DB with head commit + diff stats
- **CommitMessageBuilder** — `{commitType}(slug): title\n\ndescription\n\nClaudeDo-Task: taskId`; `BuildMerge` is the merge-commit variant (`{commitType}(slug): merge title` + trailer). **Every merge caller passes a blank commit message on purpose** — `TaskMergeService` fills in `BuildMerge` from the task's commit type and its list's name, which is the only place that knows both. Don't reintroduce a caller-side literal.
- **TaskResetService** — discards a failed task's worktree, resets the row to Idle, preserves run history
- **AgentFileService** — manages `~/.claudeDo/agents/*.md`; list/refresh via SignalR
@@ -309,6 +309,6 @@ distinguish the two singletons). The token never touches `worker.config.json` or
- The worker runs standalone — start it separately from the UI. Loopback only (127.0.0.1).
- `--permission-mode auto` by default; legacy `bypassPermissions` settings map to `auto` at dispatch time. `acceptEdits`, `plan`, `default` pass through unchanged.
- Worktree branches follow `claudedo/{id}`.
- Task worktree branches follow `claudedo/{number}-{title-slug}`; planning worktrees stay `claudedo/planning/{guid}` (their path is re-derived from the id on resume).
- **Both MCP servers run `Stateless = true`** (`Program.cs`). No `Mcp-Session-Id`, so a worker restart can't 404 a session that outlives it, and a 2026-07-28 client isn't refused back to the initialize handshake. The trade: **no** sampling, elicitation, resource subscriptions, unsolicited notifications, or legacy SSE endpoint — don't reach for those. In-tool progress notifications ride the POST's own response stream and work in both modes. All three consumers (`TaskRunner`, `PlanningSessionManager`, the installer's `RegisterMcpStep`) already use `type: "http"`.
- **An MCP tool that can run longer than ~5s reports progress.** Staying silent lets the MCP client abort after 300s idle while the worker keeps working — the caller sees an abort even though the operation is still running. `Lifecycle/ProgressReporter` is the one implementation (elapsed-time reporting via `RunAsync`, per-item `i/n` via `ReportItem`) — thread an `IProgress<ProgressNotificationValue>? progress = null` parameter through instead of writing another polling loop.
+2
View File
@@ -92,6 +92,8 @@ public sealed class HandoffMcpTools
taskId, survivingTaskIds, round.EffectivePhase, cancellationToken);
if (deferredUntil is null)
await _broadcaster.HandoffRequested(taskId, survivingTaskIds, round.EffectivePhase);
else
await _broadcaster.HandlerHandoffDeferred(taskId, deferredUntil.Value);
return new HandoffListHandlerResult(
true, taskId, task.Number, survivingTaskIds.Count, round.EffectivePhase, round.Coerced, deferredUntil);
+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());
}
+10
View File
@@ -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) =>
@@ -47,6 +52,11 @@ public sealed class HubBroadcaster : IPrimeBroadcaster, IRefineBroadcaster
public Task HandlerUsageWarning(string handlerTaskId, string message) =>
_hub.Clients.All.SendAsync("HandlerUsageWarning", handlerTaskId, message);
// A list-handler handoff is deferred because the 5h usage window is exhausted. Mission Control
// updates the pane title to show the reset time, so the user sees when the session will resume.
public Task HandlerHandoffDeferred(string handlerTaskId, DateTimeOffset dueAt) =>
_hub.Clients.All.SendAsync("HandlerHandoffDeferred", handlerTaskId, dueAt);
/// <summary>An agent wrote a note through MCP — the open notes editor reloads.</summary>
public Task NotesUpdated() =>
_hub.Clients.All.SendAsync("NotesUpdated");
@@ -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;
}
}
}
+26 -5
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)
@@ -776,16 +779,34 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
if (_interactiveLaunchSpec is null)
throw new InvalidOperationException("Interactive launch spec service is not configured.");
_broadcaster.WorktreeUpdated(taskId);
return _interactiveLaunchSpec.BuildForTaskAsync(taskId, Context.ConnectionAborted);
// 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
+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>()
@@ -40,14 +40,12 @@ public sealed class WeekReportService
public async Task<string> GenerateAsync(DateOnly start, DateOnly end, CancellationToken ct = default)
{
string[] excludes;
string model;
IReadOnlyList<Data.Models.DailyNoteEntity> noteRows;
await using (var ctx = await _dbFactory.CreateDbContextAsync(ct))
{
var settings = await new AppSettingsRepository(ctx).GetAsync(ct);
excludes = ParseExcludes(settings.ReportExcludedPaths);
model = string.IsNullOrWhiteSpace(settings.DefaultModel) ? "sonnet" : settings.DefaultModel;
noteRows = await new DailyNoteRepository(ctx).ListBetweenAsync(start, end, ct);
}
@@ -65,15 +63,13 @@ public sealed class WeekReportService
else
{
var prompt = WeekReportPromptBuilder.Build(start, end, activity, notesByDay);
// Guard against argument injection via the model setting: model aliases/ids are
// alphanumerics, dashes and dots only.
var safeModel = new string(model.Where(c => char.IsLetterOrDigit(c) || c is '-' or '.').ToArray());
if (safeModel.Length == 0) safeModel = "sonnet";
// Summarising notes into bullets doesn't need the user's default model.
const string model = "haiku";
IReadOnlyList<string> args =
[
"-p", "--output-format", "stream-json", "--verbose",
"--permission-mode", PermissionModeResolver.Resolve(safeModel, "auto"),
"--model", safeModel,
"--permission-mode", PermissionModeResolver.Resolve(model, "auto"),
"--model", model,
];
var result = await _claude.RunAsync(args, prompt, Path.GetTempPath(), _ => Task.CompletedTask, ct);
if (!result.IsSuccess)
@@ -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.",
};
}
+23 -8
View File
@@ -38,6 +38,14 @@ public sealed class WorktreeManager
private Task BroadcastCreating(string taskId) =>
_broadcaster?.OperationProgress(taskId, PhaseCreatingWorktree, 0, 0) ?? Task.CompletedTask;
/// <summary>Branch leaf and folder name for a task worktree: <c>{number}-{title-slug}</c>, unique via the task number.</summary>
public static string NameFor(TaskEntity task)
{
var titleSlug = CommitMessageBuilder.ToSlug(task.Title);
if (titleSlug.Length > 40) titleSlug = titleSlug[..40].TrimEnd('-');
return titleSlug.Length == 0 ? task.Number.ToString() : $"{task.Number}-{titleSlug}";
}
public async Task<WorktreeContext> CreateAsync(TaskEntity task, ListEntity list, CancellationToken ct)
{
var workingDir = list.WorkingDir
@@ -51,10 +59,7 @@ public sealed class WorktreeManager
_logger.LogInformation("Repository at {Dir} had no commits; created an empty bootstrap commit", workingDir);
var baseCommit = await ResolveBaseCommitAsync(task, workingDir, ct);
// Use the full task id (dashes stripped) in the branch name so
// two GUIDs sharing an 8-char prefix cannot collide on the same branch.
var idForBranch = task.Id.Replace("-", "");
var branchName = $"claudedo/{idForBranch}";
var name = NameFor(task);
var slug = CommitMessageBuilder.ToSlug(list.Name);
string strategy;
@@ -68,11 +73,21 @@ public sealed class WorktreeManager
: _cfg.CentralWorktreeRoot;
}
var worktreePath = strategy.Equals("central", StringComparison.OrdinalIgnoreCase)
? Path.Combine(centralRoot ?? _cfg.CentralWorktreeRoot, slug, task.Id)
: Path.Combine(WorktreeRootResolver.ResolveSiblingRoot(workingDir), ".claudedo-worktrees", slug, task.Id);
var parentDir = strategy.Equals("central", StringComparison.OrdinalIgnoreCase)
? Path.Combine(centralRoot ?? _cfg.CentralWorktreeRoot, slug)
: Path.Combine(WorktreeRootResolver.ResolveSiblingRoot(workingDir), ".claudedo-worktrees", slug);
worktreePath = Path.GetFullPath(worktreePath);
// Numbers restart after a DB reset: a taken name may be another task's unmerged branch,
// which the self-heal below would force-delete — the id suffix keeps it untouched.
if (Directory.Exists(Path.Combine(parentDir, name))
|| (await _git.ListLocalBranchesAsync(workingDir, ct)).Contains($"claudedo/{name}"))
{
var id = task.Id.Replace("-", "");
name = $"{name}-{id[..Math.Min(8, id.Length)]}";
}
var branchName = $"claudedo/{name}";
var worktreePath = Path.GetFullPath(Path.Combine(parentDir, name));
WorktreeRootResolver.EnsureOutsideWorkingDir(worktreePath, workingDir);
// Ensure parent directory exists.
@@ -1,3 +1,5 @@
using System.Net;
using System.Text.RegularExpressions;
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
@@ -73,7 +75,7 @@ public sealed class TicketImportService
{
Id = taskId,
ListId = listId,
Title = ticket.Title,
Title = WebUtility.HtmlDecode(ticket.Title).Trim(),
Description = BuildDescription(ticket),
Status = TaskStatus.Idle,
CreatedAt = DateTime.UtcNow,
@@ -102,7 +104,43 @@ public sealed class TicketImportService
private static string BuildDescription(TicketBoardItem ticket)
{
var body = string.IsNullOrWhiteSpace(ticket.Description) ? "" : ticket.Description.Trim() + "\n\n";
return body + $"Ticket #{ticket.Id}";
var body = string.IsNullOrWhiteSpace(ticket.Description) ? "" : HtmlToMarkdown(ticket.Description);
return (body.Length > 0 ? body + "\n\n" : "") + $"Ticket #{ticket.Id}";
}
private const RegexOptions Rx = RegexOptions.IgnoreCase | RegexOptions.Singleline;
/// <summary>
/// Wandelt das Rich-Text-HTML des Ticketsystems in das Markdown-Subset von <c>MarkdownView</c>.
/// Zeilenumbrüche werden zu Absätzen, weil <c>MarkdownView</c> einfache Umbrüche zusammenzieht.
/// </summary>
// ponytail: Regex statt HTML-Parser — verschachtelte Listen und Tabellen werden flach; AngleSharp, falls die Tickets das brauchen
internal static string HtmlToMarkdown(string html)
{
var s = Regex.Replace(html, @"\s*\r?\n\s*", " ");
s = Regex.Replace(s, @"<(script|style)\b.*?</\1>", "", Rx);
s = Regex.Replace(s, @"<h([1-6])\b[^>]*>(.*?)</h\1>",
m => $"\n\n{new string('#', Math.Min(int.Parse(m.Groups[1].Value), 3))} {m.Groups[2].Value}\n\n", Rx);
s = Regex.Replace(s, @"<(b|strong)\b[^>]*>(.*?)</\1>", m => Wrap(m.Groups[2].Value, "**"), Rx);
s = Regex.Replace(s, @"<(i|em)\b[^>]*>(.*?)</\1>", m => Wrap(m.Groups[2].Value, "*"), Rx);
s = Regex.Replace(s, @"<code\b[^>]*>(.*?)</code>", m => Wrap(m.Groups[1].Value, "`"), Rx);
s = Regex.Replace(s, @"<a\b[^>]*href=""([^""]*)""[^>]*>(.*?)</a>",
m => m.Groups[2].Value == m.Groups[1].Value ? m.Groups[1].Value : $"{m.Groups[2].Value} ({m.Groups[1].Value})", Rx);
s = Regex.Replace(s, @"<ol\b[^>]*>(.*?)</ol>", m =>
{
var n = 0;
return "\n\n" + Regex.Replace(m.Groups[1].Value, @"<li\b[^>]*>", _ => $"\n{++n}. ", Rx) + "\n\n";
}, Rx);
s = Regex.Replace(s, @"<li\b[^>]*>", "\n- ", Rx);
s = Regex.Replace(s, @"</?(ul|ol)\b[^>]*>", "\n\n", Rx);
s = Regex.Replace(s, @"<br\s*/?>|</?(p|div|blockquote|tr)\b[^>]*>", "\n\n", Rx);
s = Regex.Replace(s, @"<[^>]+>", "");
s = WebUtility.HtmlDecode(s).Replace((char)0xA0, ' ');
var lines = s.Split('\n').Select(l => Regex.Replace(l, @"[ \t]+", " ").Trim());
return Regex.Replace(string.Join('\n', lines), @"\n{3,}", "\n\n").Trim();
static string Wrap(string inner, string mark)
=> string.IsNullOrWhiteSpace(inner) ? inner : $"{mark}{inner.Trim()}{mark}";
}
}
+47 -1
View File
@@ -67,13 +67,14 @@ public sealed class ListFolderTests : IDisposable
}
await using (var ctx = Open())
await new ListRepository(ctx).RenameFolderAsync("f2", "Renamed", "Star");
await new ListRepository(ctx).RenameFolderAsync("f2", "Renamed", "Star", "#7FA7C9");
await using (var ctx = Open())
{
var folder = await ctx.ListFolders.AsNoTracking().FirstAsync(f => f.Id == "f2");
Assert.Equal("Renamed", folder.Name);
Assert.Equal("Star", folder.Icon);
Assert.Equal("#7FA7C9", folder.Color);
Assert.True(folder.IsCollapsed);
Assert.Equal(1, folder.SortOrder);
}
@@ -144,4 +145,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);
}
}
}
@@ -0,0 +1,32 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.MissionControl;
using Xunit;
namespace ClaudeDo.Ui.Tests;
public class MissionControlViewModelTests
{
[Fact]
public void DeferredHandoffUpdatesConPtyPaneTitleWithResetTime()
{
var taskId = Guid.NewGuid().ToString();
var title = "Test Handler";
var dueAt = new DateTimeOffset(2026, 9, 10, 14, 30, 0, TimeSpan.Zero);
var pane = new ConPtyPaneViewModel(taskId, title, () => Task.FromResult(
new TerminalLaunchDescriptor(".", "claude", Array.Empty<string>(), new Dictionary<string, string>())));
var originalTitle = pane.DisplayTitle;
// Simulate the update that would happen on HandlerHandoffDeferredEvent
var suffix = $"waiting for limit reset ({dueAt.LocalDateTime:HH:mm})";
pane.DisplayTitle += $" — {suffix}";
Assert.NotEqual(originalTitle, pane.DisplayTitle);
Assert.Contains("waiting for limit reset", pane.DisplayTitle);
Assert.Contains(dueAt.LocalDateTime.ToString("HH:mm"), pane.DisplayTitle);
}
}
+9 -1
View File
@@ -26,8 +26,10 @@ 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<string, DateTimeOffset>? HandlerHandoffDeferredEvent;
public event Action? NotesUpdatedEvent;
public event Action? PrepStartedEvent;
public event Action<string>? PrepLineEvent;
@@ -59,7 +61,9 @@ 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 RaiseHandlerHandoffDeferred(string taskId, DateTimeOffset dueAt) => HandlerHandoffDeferredEvent?.Invoke(taskId, dueAt);
public void RaisePlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList<string> files, bool externallyDriven)
=> PlanningMergeConflictEvent?.Invoke(planningTaskId, subtaskId, files, externallyDriven);
public void RaiseOperationProgress(string opKey, string phase, int current, int total) => OperationProgressEvent?.Invoke(opKey, phase, current, total);
@@ -124,8 +128,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(
@@ -59,6 +59,42 @@ public class ListSettingsModalViewModelTests
}
}
[Fact]
public void FolderToggle_SwapsUntouchedDefaultIcon_KeepsCustomChoice()
{
var vm = new ListSettingsModalViewModel(new CountingWorker(), NewFactory());
vm.IsFolder = true;
Assert.Equal(ListSettingsModalViewModel.DefaultFolderIcon, vm.Icon);
vm.IsFolder = false;
Assert.Equal(ListSettingsModalViewModel.DefaultIcon, vm.Icon);
vm.Icon = "Star";
vm.IsFolder = true;
Assert.Equal("Star", vm.Icon);
}
[Fact]
public void IconSearch_FindsMdiIcons_AndClearingKeepsTheChoice()
{
var vm = new ListSettingsModalViewModel(new CountingWorker(), NewFactory());
vm.IconSearch = "rocket";
Assert.Contains("mdi:RocketLaunch", vm.Icons);
Assert.DoesNotContain("Star", vm.Icons);
vm.SelectedIconOption = "mdi:RocketLaunch";
vm.SelectedIconOption = null; // what the ListBox pushes when Icons is swapped
Assert.Equal("mdi:RocketLaunch", vm.Icon);
vm.IconSearch = "";
Assert.Equal("mdi:RocketLaunch", vm.Icons[0]);
Assert.Equal("mdi:RocketLaunch", vm.SelectedIconOption);
Assert.True(ListSettingsModalViewModel.IsKnownIcon("mdi:RocketLaunch"));
Assert.False(ListSettingsModalViewModel.IsKnownIcon("mdi:NoSuchIcon"));
}
[Fact]
public async Task LoadAsync_CallsGetListConfig_ExactlyOnce()
{
@@ -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]
@@ -308,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);
@@ -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
@@ -474,6 +491,33 @@ public class MissionControlViewModelTests : IDisposable
Assert.Equal(2, vm.ColumnCount);
}
[Fact]
public async Task SetColumns_OverridesAutoCapsAtPaneCountAndPersists()
{
var path = Path.Combine(Path.GetTempPath(), $"claudedo_mc_cfg_{Guid.NewGuid():N}.json");
try
{
var worker = new FakeWorker();
using var vm = new MissionControlViewModel(new TestDbFactory(NewContext), worker,
new UsagePillViewModel(worker), new AppSettings { ConfigPath = path });
for (var i = 1; i <= 4; i++) await vm.OpenConPtySessionAsync($"t{i}");
Assert.Equal(2, vm.ColumnCount); // auto: 4 panes -> 2x2
vm.SetColumnsCommand.Execute("3");
Assert.Equal(3, vm.ColumnCount);
vm.SetColumnsCommand.Execute("4");
vm.Panes[0].CloseCommand.Execute(null);
Assert.Equal(3, vm.ColumnCount); // never more columns than panes
Assert.Equal(4, AppSettings.Load(path).MissionControlColumns);
vm.SetColumnsCommand.Execute("0");
Assert.Equal(2, vm.ColumnCount);
}
finally { try { File.Delete(path); } catch { } }
}
[Fact]
public async Task CloseConPtySession_RemovesFromConPtySessionsAndPanes()
{
@@ -490,7 +534,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 +791,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()
{
@@ -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",
@@ -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);
}
}
@@ -0,0 +1,125 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.Lifecycle;
// The detail pane gates its whole REVIEW/MERGE block (Open diff included) on the worktree row it
// last read, and it only re-reads that on WorktreeUpdated -- TaskUpdated refreshes the task row
// alone. An interactive session creates/commits its worktree entirely outside the autonomous
// pipeline, so without a WorktreeUpdated here the task lands in WaitingForReview with no diff
// button at all until it is re-selected.
public sealed class InteractiveReviewSubmissionServiceTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly List<GitRepoFixture> _repos = new();
private readonly CapturingHubContext _hub = new();
public InteractiveReviewSubmissionServiceTests() => _ctx = _db.CreateContext();
public void Dispose()
{
foreach (var r in _repos) r.Dispose();
_ctx.Dispose();
_db.Dispose();
}
private GitRepoFixture CreateRepo()
{
var f = new GitRepoFixture();
_repos.Add(f);
return f;
}
private InteractiveReviewSubmissionService BuildSut()
{
var cfg = new WorkerConfig
{
SandboxRoot = Path.Combine(Path.GetTempPath(), $"cd_{Guid.NewGuid():N}"),
LogRoot = Path.Combine(Path.GetTempPath(), $"cdl_{Guid.NewGuid():N}"),
};
var git = new GitService();
var dbFactory = _db.CreateFactory();
var wtManager = new WorktreeManager(git, dbFactory, cfg, NullLogger<WorktreeManager>.Instance);
var state = TaskStateServiceBuilder.Build(dbFactory).State;
return new InteractiveReviewSubmissionService(
dbFactory, git, state, wtManager, new HubBroadcaster(_hub));
}
private async Task<(TaskEntity Task, ListEntity List)> SeedAsync(string workingDir, string? handlerBaseCommit)
{
var lists = new ListRepository(_ctx);
var list = new ListEntity { Id = Guid.NewGuid().ToString(), Name = "L", WorkingDir = workingDir, CreatedAt = DateTime.UtcNow };
await lists.AddAsync(list);
var task = new TaskEntity
{
Id = Guid.NewGuid().ToString(), ListId = list.Id, Title = "t",
Status = TaskStatus.Idle, HandlerBaseCommit = handlerBaseCommit,
CreatedAt = DateTime.UtcNow, CommitType = "chore",
};
await new TaskRepository(_ctx).AddAsync(task);
return (task, list);
}
private bool BroadcastWorktreeUpdatedFor(string taskId) =>
_hub.Proxy.Calls.Any(c => c.Method == "WorktreeUpdated" && c.Args.Length > 0 && (string?)c.Args[0] == taskId);
[Fact]
public async Task Submit_WorktreeTask_BroadcastsWorktreeUpdated()
{
if (!GitRepoFixture.IsGitAvailable()) { Assert.True(true, "git not available -- skipping"); return; }
var repo = CreateRepo();
var (task, list) = await SeedAsync(repo.RepoDir, handlerBaseCommit: null);
var sut = BuildSut();
var cfg = new WorkerConfig
{
SandboxRoot = Path.Combine(Path.GetTempPath(), $"cd_{Guid.NewGuid():N}"),
LogRoot = Path.Combine(Path.GetTempPath(), $"cdl_{Guid.NewGuid():N}"),
};
var wtManager = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
var wt = await wtManager.CreateAsync(task, list, CancellationToken.None);
File.WriteAllText(Path.Combine(wt.WorktreePath, "hand-driven.txt"), "content");
await sut.SubmitAsync(task.Id, CancellationToken.None);
Assert.True(BroadcastWorktreeUpdatedFor(task.Id));
}
[Fact]
public async Task Submit_WorktreeLessHandlerTask_BroadcastsWorktreeUpdated()
{
if (!GitRepoFixture.IsGitAvailable()) { Assert.True(true, "git not available -- skipping"); return; }
var repo = CreateRepo();
var (task, _) = await SeedAsync(repo.RepoDir, handlerBaseCommit: repo.BaseCommit);
File.WriteAllText(Path.Combine(repo.RepoDir, "handled.txt"), "content");
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "handled");
await BuildSut().SubmitAsync(task.Id, CancellationToken.None);
Assert.True(BroadcastWorktreeUpdatedFor(task.Id));
}
[Fact]
public async Task Submit_RejectedTask_DoesNotBroadcastWorktreeUpdated()
{
var (task, _) = await SeedAsync(Path.GetTempPath(), handlerBaseCommit: null);
await Assert.ThrowsAsync<InvalidOperationException>(() =>
BuildSut().SubmitAsync(task.Id, CancellationToken.None));
Assert.False(BroadcastWorktreeUpdatedFor(task.Id));
}
}
@@ -25,7 +25,7 @@ public class WeekReportPromptBuilderTests
new DateOnly(2026, 5, 28), new DateOnly(2026, 6, 3),
new[] { repoA }, notes);
Assert.Contains("Write the ENTIRE report in German", prompt);
Assert.Contains("Write in German", prompt);
var idxJun1 = prompt.IndexOf("2026-06-01", StringComparison.Ordinal);
var idxJun2 = prompt.IndexOf("2026-06-02", StringComparison.Ordinal);
Assert.True(idxJun1 >= 0 && idxJun2 > idxJun1);
@@ -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),
@@ -65,7 +65,9 @@ public class WorktreeManagerTests : IDisposable
Assert.NotNull(ctx);
Assert.True(Directory.Exists(ctx.WorktreePath));
Assert.Equal($"claudedo/{task.Id.Replace("-", "")}", ctx.BranchName);
// StartsWith: a folder leaked into the shared %TEMP% root by another test forces the id suffix.
Assert.StartsWith($"claudedo/{task.Number}-test-task", ctx.BranchName);
Assert.StartsWith($"{task.Number}-test-task", Path.GetFileName(ctx.WorktreePath));
Assert.Equal(repo.BaseCommit, ctx.BaseCommit);
using var readCtx = db.CreateContext();
@@ -77,6 +79,15 @@ public class WorktreeManagerTests : IDisposable
Assert.Null(row.HeadCommit);
}
[Theory]
[InlineData("Fix: Löschen des Tasks!", "7-fix-lschen-des-tasks")]
[InlineData("äöü", "7")]
[InlineData("a very long title that goes well beyond the forty char cap", "7-a-very-long-title-that-goes-well-beyond")]
public void NameFor_NumberPlusCappedTitleSlug(string title, string expected)
{
Assert.Equal(expected, WorktreeManager.NameFor(new TaskEntity { Id = "x", ListId = "y", Title = title, Number = 7, CreatedAt = DateTime.UtcNow }));
}
[Fact]
public async Task CreateAsync_BroadcastsCreatingWorktreePhase()
{
@@ -107,6 +118,28 @@ public class WorktreeManagerTests : IDisposable
Assert.Equal(WorktreeManager.PhaseCreatingWorktree, (string?)progressCalls[0].Args[1]);
}
[Fact]
public async Task CreateAsync_NameTakenByForeignBranch_FallsBackToIdSuffixAndLeavesBranchIntact()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = CreateRepo();
var (task, list) = MakeEntities(repo.RepoDir);
var (mgr, _) = await CreateManagerAsync(task, list);
// A leftover from an earlier DB whose task happened to get the same number and title.
var foreign = $"claudedo/{WorktreeManager.NameFor(task)}";
GitRepoFixture.RunGit(repo.RepoDir, "branch", foreign, repo.BaseCommit);
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
var suffixed = $"{WorktreeManager.NameFor(task)}-{task.Id.Replace("-", "")[..8]}";
Assert.Equal($"claudedo/{suffixed}", ctx.BranchName);
Assert.Equal(suffixed, Path.GetFileName(ctx.WorktreePath));
Assert.False(string.IsNullOrWhiteSpace(GitRepoFixture.RunGit(repo.RepoDir, "branch", "--list", foreign)));
}
[Fact]
public async Task CreateAsync_SelfHealPath_BroadcastsCreatingWorktreePhaseForEachSection()
{
@@ -122,9 +155,11 @@ public class WorktreeManagerTests : IDisposable
await new TaskRepository(seedCtx).AddAsync(task);
}
// Pre-create the branch so `git worktree add -b` hits the "already exists" self-heal path.
var branchName = $"claudedo/{task.Id.Replace("-", "")}";
// Occupy the plain name (-> id-suffixed fallback) and the fallback too, so
// `git worktree add -b` hits the "already exists" self-heal path.
var branchName = $"claudedo/{WorktreeManager.NameFor(task)}";
GitRepoFixture.RunGit(repo.RepoDir, "branch", branchName, repo.BaseCommit);
GitRepoFixture.RunGit(repo.RepoDir, "branch", $"{branchName}-{task.Id.Replace("-", "")[..8]}", repo.BaseCommit);
var fakeHub = new CapturingHubContext();
var broadcaster = new HubBroadcaster(fakeHub);
@@ -276,13 +311,13 @@ public class WorktreeManagerTests : IDisposable
await Assert.ThrowsAnyAsync<Exception>(
() => mgr.CreateAsync(task, list, CancellationToken.None));
var branchName = $"claudedo/{task.Id.Replace("-", "")}";
var branchName = $"claudedo/{WorktreeManager.NameFor(task)}";
var branchList = GitRepoFixture.RunGit(repo.RepoDir, "branch", "--list", branchName);
Assert.True(string.IsNullOrWhiteSpace(branchList),
$"orphaned branch {branchName} should be cleaned up, got: {branchList}");
var worktreeList = GitRepoFixture.RunGit(repo.RepoDir, "worktree", "list");
Assert.DoesNotContain(task.Id, worktreeList);
Assert.DoesNotContain(WorktreeManager.NameFor(task), worktreeList);
}
[Fact]
@@ -0,0 +1,30 @@
using ClaudeDo.Worker.Tickets;
namespace ClaudeDo.Worker.Tests.Tickets;
public sealed class TicketHtmlToMarkdownTests
{
[Fact]
public void Paragraphs_breaks_and_entities_become_markdown_paragraphs()
{
var md = TicketImportService.HtmlToMarkdown(
"<p>Lager -&gt; Adressprüfung&nbsp;aus</p>Zeile 1<br>Zeile 2<div><br></div><div>Ende</div>");
Assert.Equal("Lager -> Adressprüfung aus\n\nZeile 1\n\nZeile 2\n\nEnde", md);
}
[Fact]
public void Lists_and_inline_formatting_map_to_the_MarkdownView_subset()
{
var md = TicketImportService.HtmlToMarkdown(
"<p>Offene Punkte:</p><ul><li class=\"MsoNormal\"><b>Band</b> sync</li><li>Waage</li></ul>" +
"<ol><li>Erst</li><li><em>Dann</em></li></ol><a href=\"http://x\">Link</a>");
Assert.Equal(
"Offene Punkte:\n\n- **Band** sync\n- Waage\n\n1. Erst\n2. *Dann*\n\nLink (http://x)", md);
}
[Fact]
public void Plain_text_passes_through()
=> Assert.Equal("Ticket ohne HTML", TicketImportService.HtmlToMarkdown(" Ticket ohne HTML "));
}