Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3ecb99492 | ||
|
|
a2496e056a | ||
|
|
44ec89b0e6 | ||
|
|
a7c3d18533 | ||
|
|
0d029717f8 | ||
|
|
2be8ba5d66 | ||
|
|
928e3905d9 | ||
|
|
60358100be | ||
|
|
2a41f7e641 | ||
|
|
bb92dc279f | ||
|
|
8a31a67c74 | ||
|
|
f2ff865dca | ||
|
|
5da8036b9a | ||
|
|
cf4b5c507c | ||
|
|
3e2d5bab6a | ||
|
|
27d2b1d8ef | ||
|
|
ccd11a8033 | ||
|
|
9991f1fc1f | ||
|
|
75b78cb2c0 | ||
|
|
05deda8343 | ||
|
|
52ba538339 | ||
|
|
2558e02ba3 | ||
|
|
81dcaebfba | ||
|
|
1396649519 | ||
|
|
f88d2a898e | ||
|
|
87dbb117d7 | ||
|
|
0f82fa67e1 | ||
|
|
9f2e63d337 | ||
|
|
3009ff0f8f | ||
|
|
0dac047c46 | ||
|
|
57dd676672 | ||
|
|
352aaa525b | ||
|
|
c8b2ad046a | ||
|
|
d3db4455fa | ||
|
|
216620c52e | ||
|
|
08d1797ead | ||
|
|
e0a1448f44 | ||
|
|
c9dd885fe0 | ||
|
|
31dfaffc8b | ||
|
|
09bcd696dd | ||
|
|
86cc309ba9 | ||
|
|
684b0115e5 | ||
|
|
1325ab9438 | ||
|
|
56161e4e9b | ||
|
|
4ff5383b35 | ||
|
|
5cdfb14ff1 | ||
|
|
7452f6cfe4 | ||
|
|
c874bf7e52 | ||
|
|
50b6b56f87 | ||
|
|
9a41bed0f5 | ||
|
|
103eb9a687 | ||
|
|
7ecb1fa0b5 | ||
|
|
18394dd6b2 | ||
|
|
251b70ce83 | ||
|
|
8e2d37e63e | ||
|
|
5be972564a | ||
|
|
ecb1cdbb06 |
@@ -1,5 +1,97 @@
|
||||
# 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
|
||||
|
||||
- dirty Ziel-Verzeichnis beim Oeffnen des Merge-Dialogs melden statt nach dem Fehlschlag (09bcd696)
|
||||
- preview_merge_set flags removed symbols and locale-key overlaps (684b0115)
|
||||
- Bilder aus der Zwischenablage ins interaktive Terminal einfuegen (56161e4e)
|
||||
- poll the OAuth usage endpoint as fast as its measured rate limit allows (50b6b56f)
|
||||
|
||||
### Fixes
|
||||
|
||||
- duplizierter Confirm-Dialog mit "Delete"-Knopf – zusammenfuehren und lokalisieren (86cc309b)
|
||||
- Worktrees-Uebersicht zeigt Task-Nummern, DIFF-Spalte trimmt statt abzuschneiden (1325ab94)
|
||||
- Diff-Viewer scrollt jetzt auch horizontal synchron (4ff5383b)
|
||||
- Ordner-Toggle in der Sidebar schluckt schnelle Klicks (5cdfb14f)
|
||||
- preview_merge braucht einen runVerify-Parameter wie preview_merge_set (7452f6cf)
|
||||
- depends_on-Nachfolger schon beim gelandeten Merge freigeben (c874bf7e)
|
||||
- Worker-Neustart trifft nun den tatsaechlich laufenden Prozess (9a41bed0)
|
||||
- Shutdown terminates the app even with secondary windows open (103eb9a6)
|
||||
|
||||
### Documentation
|
||||
|
||||
- update for v2.16.0 (31dfaffc)
|
||||
- update for v2.15.0 (18394dd6)
|
||||
|
||||
## v2.15.0 — 2026-09-11
|
||||
|
||||
### Features
|
||||
|
||||
- Stash-Option fuer uncommittete Aenderungen im Ziel-Tree (251b70ce)
|
||||
|
||||
### Documentation
|
||||
|
||||
- update for v2.14.0 (8e2d37e6)
|
||||
|
||||
## v2.14.0 — 2026-09-10
|
||||
|
||||
### Features
|
||||
|
||||
- Listen und Ordner in einer gemischten Sidebar-Reihenfolge (bfb1c652)
|
||||
- confirm before closing a running ConPTY session (eb623714)
|
||||
- worker-menu Shutdown ends worker + app after confirm (1bdf2027)
|
||||
- chain a normal parent's children on WaitingForChildren (d2648f96)
|
||||
- localize WorkConsole, DescriptionStepsCard and the ROADBLOCK label (8e6f0d16)
|
||||
- surface depends_on blocking in status chip, chain label, and detail pane (4cf601e4)
|
||||
- Worker-Menü "Update Claude CLI" führt claude update aus (dbee4a0d)
|
||||
- triage model picker (opus/fable) in the merge helper start modal (1177463a)
|
||||
- collapsible folders for the list sidebar (25c9a619)
|
||||
- list_folders table plus lists.folder_id (13e860f0)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Import broadcastet TaskUpdated fuer neue Tasks (1770b677)
|
||||
- ConnectedClientTracker in den externen MCP-Container teilen (3d09323e)
|
||||
- de.json-Konsistenz-Pass und restliche harte EN-Strings (d6696c33)
|
||||
- force English git output for locale-fragile stderr matches (5ecf6f75)
|
||||
- make diff-stat parsing locale-safe and dedupe the two parsers (29ea18c4)
|
||||
- report failed paste/drop in the interactive terminal (0cec0a77)
|
||||
- surface worker connect/restart failures instead of no-opting (b4b62254)
|
||||
- validate PowerUse window, explain effort and Prime Claude in Settings (951c2fe9)
|
||||
- start_list_handler ehrlich machen, wenn keine App verbunden ist (451c0184)
|
||||
- warn and block save when a list's working dir isn't a git repo (3348736e)
|
||||
- show a banner when a ConPTY session dies after startup (256bea5f)
|
||||
- merge duplicate top-level "checks" block in en/de.json (741f66fe)
|
||||
- translate max_turns failure text to English (0fcfe7c5)
|
||||
- route WeekReportService permission-mode through PermissionModeResolver (1c768529)
|
||||
- report startup port conflicts instead of crashing silently (c9d4823f)
|
||||
|
||||
### Documentation
|
||||
|
||||
- record list folders in the Data and Ui CLAUDE.md (5477ae53)
|
||||
- update for v2.13.6 (ec1e60be)
|
||||
|
||||
## v2.13.6 — 2026-09-07
|
||||
|
||||
### Features
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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** —
|
||||
|
||||
@@ -184,9 +184,9 @@ detached-HEAD scratch worktree under the OS temp dir (`GitService.WorktreeAddDet
|
||||
is always removed afterward (`finally`, non-cancellable cleanup). No verify command configured, or
|
||||
`runVerify=false`, reproduces the pre-existing preview exactly — no delay, no scratch worktree.
|
||||
|
||||
Wired into `ExternalMcpService`: `preview_merge` (single task) always requests a verify run when the
|
||||
list has a command configured; `preview_merge_set` only does when its `runVerify` parameter is
|
||||
explicitly set (default `false`) — a set preview never starts N builds unasked. The Hub's
|
||||
Wired into `ExternalMcpService`: both `preview_merge` (single task) and `preview_merge_set` only
|
||||
request a verify run when their `runVerify` parameter is explicitly set (default `false`) — a
|
||||
preview never starts a build unasked. The Hub's
|
||||
`PreviewMerge` (the UI's live mergeability indicator) always passes `runVerify: false`, since
|
||||
running a build on every poll would be a bad regression; `MergePreviewDto` carries the
|
||||
verify fields anyway so a future UI entry point can opt in. Result fields: `VerifyExitCode` (null =
|
||||
|
||||
@@ -21,6 +21,11 @@ authenticated with the Bearer access token Claude Code itself keeps fresh at
|
||||
Because the endpoint is undocumented and can change without notice, **every consumer
|
||||
fails open**. That is the single most important invariant here.
|
||||
|
||||
**Measured 2026-09-16** (clean burst against the live endpoint, Worker otherwise idle): exactly
|
||||
5 requests succeed, the 6th 429s with `Retry-After: 300` — a flat 5-requests-per-5-minute quota,
|
||||
no `ratelimit-*`/`x-ratelimit-*` headers on either the 200s or the 429. The quota is per-account,
|
||||
not per-process, so a manual refresh or another consumer of the same OAuth token shares it.
|
||||
|
||||
## Components (`Usage/`)
|
||||
|
||||
| Type | Role |
|
||||
@@ -28,7 +33,7 @@ fails open**. That is the single most important invariant here.
|
||||
| `UsageModels` | `UsageBucket` / `UsageLimitRow` / `UsageSnapshot`. `UsageBucket.Utilization` is already a 0–100 percent — compare directly against thresholds, don't rescale. |
|
||||
| `ClaudeOAuthUsageClient` | Reads the token, calls the endpoint. Defensive parsing: missing/null buckets → null, missing `limits` → empty list. |
|
||||
| `UsageState` | Threadsafe singleton. A failed poll **never** overwrites the last good snapshot — it only sets `LastError`. |
|
||||
| `UsageMonitorService` | `BackgroundService`; polls on `usage_poll_interval_seconds` (default 60, clamped to min 15 on config load), one poll at startup. Logs a failure at most once per distinct error message. Broadcasts `HubBroadcaster.UsageUpdated` after **every** tick, success or failure. |
|
||||
| `UsageMonitorService` | `BackgroundService`; polls on `usage_poll_interval_active_seconds` / `usage_poll_interval_idle_seconds` (default 120 each, clamped to min 90 on config load), one poll at startup. Logs a failure at most once per distinct error message. Broadcasts `HubBroadcaster.UsageUpdated` after **every** tick, success or failure. |
|
||||
| `UsageSnapshotBuilder` | Builds the Hub-facing `UsageSnapshotDto` from `UsageState` + `IUsageGate` + `AppSettings`. The one shared place for stale/threshold/gate logic — `WorkerHub.GetUsageSnapshot` and `UsageMonitorService` must not diverge. |
|
||||
| `UsageGate` | Hard pause decision → `UsageGateDecision(IsBlocked, Reason)`. |
|
||||
| `UsageThrottle` | Pure static staging of parallelism ahead of the gate. |
|
||||
|
||||
@@ -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 |
|
||||
@@ -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));
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -13,6 +13,7 @@ using ClaudeDo.Ui.ViewModels.Modals;
|
||||
using ClaudeDo.Ui.ViewModels.Modals.Settings;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Serilog;
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
@@ -20,6 +21,7 @@ using System.Linq;
|
||||
using System.Net.Http;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ClaudeDo.App;
|
||||
|
||||
@@ -34,6 +36,35 @@ sealed class Program
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
SetCurrentProcessExplicitAppUserModelID("ClaudeDo.App");
|
||||
|
||||
var logRoot = Path.Combine(Paths.AppDataRoot(), "logs");
|
||||
Directory.CreateDirectory(logRoot);
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Information()
|
||||
.WriteTo.File(
|
||||
Path.Combine(logRoot, "ui-.log"),
|
||||
rollingInterval: RollingInterval.Day,
|
||||
retainedFileCountLimit: 7,
|
||||
shared: true)
|
||||
.CreateLogger();
|
||||
|
||||
var version = Assembly.GetEntryAssembly()?
|
||||
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion
|
||||
?? "0.0.0";
|
||||
Log.Information("ClaudeDo.App starting (version: {version})", version);
|
||||
|
||||
// Last-resort net: any exception that misses every other handler still gets one
|
||||
// Fatal line in the log before the process dies.
|
||||
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
|
||||
{
|
||||
Log.Fatal(e.ExceptionObject as Exception, "Unhandled exception, terminating (is_terminating: {is_terminating})", e.IsTerminating);
|
||||
Log.CloseAndFlush();
|
||||
};
|
||||
TaskScheduler.UnobservedTaskException += (_, e) =>
|
||||
{
|
||||
Log.Error(e.Exception, "Unobserved task exception");
|
||||
e.SetObserved();
|
||||
};
|
||||
|
||||
var services = BuildServices();
|
||||
|
||||
using (var scope = services.CreateScope())
|
||||
@@ -42,6 +73,8 @@ sealed class Program
|
||||
ClaudeDoDbContext.MigrateAndConfigure(db);
|
||||
}
|
||||
|
||||
ClipboardImageTempStore.DeleteOlderThan(TimeSpan.FromHours(24), DateTime.UtcNow);
|
||||
|
||||
try
|
||||
{
|
||||
ConfigureAppBuilder(AppBuilder.Configure(() => new App(services)))
|
||||
@@ -60,6 +93,8 @@ sealed class Program
|
||||
{
|
||||
/* best effort on shutdown */
|
||||
}
|
||||
|
||||
Log.CloseAndFlush();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -424,14 +424,22 @@ public sealed class GitService
|
||||
return File.Exists(Path.Combine(gitDir, "MERGE_HEAD"));
|
||||
}
|
||||
|
||||
/// <param name="autostash">Stash the target working tree's uncommitted changes before the
|
||||
/// merge and restore them after it. Git owns the bookkeeping: on a conflict the stash stays
|
||||
/// pending and is re-applied by `merge --continue`/`--abort`, so a conflicted merge left in
|
||||
/// the tree doesn't lose it. A restore that itself conflicts leaves the stash in
|
||||
/// `git stash list` rather than dropping it.</param>
|
||||
public async Task<(int ExitCode, string Stderr)> MergeNoFfAsync(
|
||||
string repoDir, string sourceBranch, string message, CancellationToken ct = default)
|
||||
string repoDir, string sourceBranch, string message, CancellationToken ct = default,
|
||||
bool autostash = false)
|
||||
{
|
||||
// diff3 conflict style writes the merge base (|||||||) into conflict markers so the
|
||||
// in-app resolver can show a true three-way view. It only enriches conflicted hunks;
|
||||
// clean merges are unaffected.
|
||||
var (exitCode, _, stderr) = await RunGitAsync(repoDir,
|
||||
["-c", "merge.conflictStyle=diff3", "merge", "--no-ff", "-m", message, sourceBranch], ct);
|
||||
string[] args = autostash
|
||||
? ["-c", "merge.conflictStyle=diff3", "merge", "--no-ff", "--autostash", "-m", message, sourceBranch]
|
||||
: ["-c", "merge.conflictStyle=diff3", "merge", "--no-ff", "-m", message, sourceBranch];
|
||||
var (exitCode, _, stderr) = await RunGitAsync(repoDir, args, ct);
|
||||
return (exitCode, stderr);
|
||||
}
|
||||
|
||||
@@ -550,6 +558,16 @@ public sealed class GitService
|
||||
return exitCode == 0 ? stdout : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Content of <paramref name="path"/> at <paramref name="revision"/> via `git show revision:path`.
|
||||
/// Null when the path doesn't exist at that revision (new, deleted, or renamed file).
|
||||
/// </summary>
|
||||
public async Task<string?> ShowFileAsync(string repoDir, string revision, string path, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["show", $"{revision}:{path}"], ct, trimOutput: false);
|
||||
return exitCode == 0 ? stdout : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Non-destructive mergeability probe via `git merge-tree --write-tree`. Writes only
|
||||
/// loose objects — the working tree, index, and refs are left untouched.
|
||||
|
||||
+1137
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1152
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1156
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")
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -523,7 +523,7 @@ public static class PromptFiles
|
||||
- Clean merge → mergeStatus "merged_verify_pending" (expected: the task deliberately stays WaitingForReview until verify_merges passes) or "merged" (list has no verify command; the task is Done). Either way, move on.
|
||||
- Conflict (markers left in the working tree, repoPath returned) → resolve it below.
|
||||
|
||||
After each merge, main has moved — a clean preview_merge_set result from before this merge is now stale for the remaining tasks. If you are unsure whether an earlier overlap warning still matters, call preview_merge for the next task again before merging it.
|
||||
After each merge, main has moved — a clean preview_merge_set result from before this merge is now stale for the remaining tasks. If you are unsure whether an earlier overlap warning still matters, call preview_merge for the next task again before merging it (default runVerify=false — this is just a conflict recheck, not a build; the verify command still runs once via verify_merges after the last merge).
|
||||
|
||||
Every branch in this run forked from the same base, so conflicts between them are the NORMAL case, not a failure. Resolve them and keep going; do not abandon the run because a merge conflicted.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -72,7 +72,8 @@ public record WorktreeOverviewDto(
|
||||
WorktreeState State,
|
||||
string? DiffStat,
|
||||
DateTime CreatedAt,
|
||||
bool PathExistsOnDisk);
|
||||
bool PathExistsOnDisk,
|
||||
int TaskNumber = 0);
|
||||
|
||||
public record ForceRemoveResultDto(bool Removed, string? Reason);
|
||||
|
||||
@@ -91,7 +92,8 @@ public record MergePreviewDto(
|
||||
int? VerifyExitCode = null, long? VerifyDurationMs = null, string? VerifyOutputTail = null);
|
||||
|
||||
public record MergeTargetsDto(
|
||||
string DefaultBranch, IReadOnlyList<string> LocalBranches, string DefaultCommitMessage);
|
||||
string DefaultBranch, IReadOnlyList<string> LocalBranches, string DefaultCommitMessage,
|
||||
bool TargetDirty = false);
|
||||
|
||||
public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDocumentDto> Files);
|
||||
|
||||
|
||||
@@ -328,6 +328,7 @@
|
||||
"worktree": "Worktree",
|
||||
"reviewCombinedDiff": "Kombinierten Diff prüfen",
|
||||
"openDiffHint": "Diff öffnen, um Mergen freizuschalten",
|
||||
"targetDirty": "Ziel-Arbeitsverzeichnis hat uncommittete Änderungen — beim Mergen werden sie gestasht und danach wiederhergestellt.",
|
||||
"approveMerge": "Genehmigen & Mergen",
|
||||
"park": "Parken",
|
||||
"parkTip": "Zurückstellen — zurück auf Leerlauf, Worktree bleibt erhalten",
|
||||
@@ -377,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}",
|
||||
@@ -386,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}",
|
||||
@@ -452,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...",
|
||||
@@ -465,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",
|
||||
@@ -481,7 +495,8 @@
|
||||
"targetBranch": "Ziel-Branch",
|
||||
"removeWorktree": "Worktree nach dem Mergen entfernen",
|
||||
"commitMessage": "Commit-Nachricht",
|
||||
"conflictedFiles": "Konfliktdateien:"
|
||||
"conflictedFiles": "Konfliktdateien:",
|
||||
"stashAndMerge": "Änderungen stashen und mergen"
|
||||
},
|
||||
"diff": {
|
||||
"title": "DIFF",
|
||||
@@ -804,6 +819,14 @@
|
||||
"done": "Fertig"
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
"cancel": "Abbrechen",
|
||||
"confirm": "Bestätigen",
|
||||
"delete": "Löschen",
|
||||
"ok": "OK",
|
||||
"confirmTitle": "Bestätigen",
|
||||
"errorTitle": "Fehler"
|
||||
},
|
||||
"shell": {
|
||||
"menu": {
|
||||
"help": "Hilfe",
|
||||
@@ -850,7 +873,7 @@
|
||||
"tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "statusPill": "{0} laufend · {1} Prüfung", "openTaskOne": "1 offene Aufgabe", "openTasks": "{0} offene Aufgaben", "planningResumeFailed": "Planungssitzung konnte nicht fortgesetzt werden: {0}", "approveFailed": "Genehmigen & Mergen fehlgeschlagen: {0}", "cancelReviewFailed": "Prüfung abbrechen fehlgeschlagen: {0}", "sendToQueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "queuePlanBlockedInteractive": "Plan kann nicht in die Warteschlange gestellt werden — {0} hat eine offene interaktive Sitzung und muss zuerst geschlossen werden.", "moveRunningRejected": "Ein laufender Task kann nicht in eine andere Liste verschoben werden.", "moveWorktreeRejected": "Verschieben nicht möglich — dieser Task hat einen aktiven Worktree, der auf sein aktuelles Repo zeigt.", "moveRepoConfirm": "Unterschiedliche Repos — {0} → {1}. Task trotzdem verschieben?", "moveConfirmUnavailable": "Verschieben nicht möglich — der Bestätigungsdialog ist nicht verfügbar.", "quickClaudeNoWorkingDir": "Für diese Liste ist kein Arbeitsverzeichnis konfiguriert.", "quickClaudeDirMissing": "Arbeitsverzeichnis existiert nicht mehr: {0}", "setStatusFailed": "Status konnte nicht aktualisiert werden: {0}", "cancelFailed": "Abbrechen fehlgeschlagen: {0}", "rejectToQueueFailed": "Zurückweisen in die Warteschlange fehlgeschlagen: {0}", "rejectToIdleFailed": "Zurückweisen fehlgeschlagen: {0}", "deleteTaskConfirm": "\"{0}\" löschen? Dies kann nicht rückgängig gemacht werden.", "deleteTaskFailed": "Löschen fehlgeschlagen: {0}", "toggleDoneFailed": "Erledigt-Status konnte nicht aktualisiert werden: {0}", "removeFromQueueFailed": "Aus der Warteschlange entfernen fehlgeschlagen: {0}", "openWorktreeFailed": "Worktree konnte nicht geöffnet werden: {0}" },
|
||||
"diff": { "loadFailed": "Diff konnte nicht geladen werden: {0}", "noChanges": "Keine Änderungen anzuzeigen.", "unavailable": "Diff nicht mehr verfügbar — Commit-Bereich unvollständig." },
|
||||
"planningDiff": { "hubError": "Kombinierte Vorschau konnte nicht erstellt werden (Hub-Fehler).", "conflict": "Kombinierte Vorschau nicht möglich: Teilaufgabe {0} steht im Konflikt mit einer früheren Teilaufgabe ({1} Dateien).", "buildFailed": "Kombinierte Vorschau konnte nicht erstellt werden: {0}" },
|
||||
"merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Wird zusammengeführt…", "progressVerifying": "Verify-Kommando der Liste läuft… ({0})", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "blocked": "Blockiert: {0}", "verifyFailed": "Merge ist gelandet, aber das Verify-Kommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf Erledigt gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" },
|
||||
"merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Wird zusammengeführt…", "progressVerifying": "Verify-Kommando der Liste läuft… ({0})", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "blocked": "Blockiert: {0}", "verifyFailed": "Merge ist gelandet, aber das Verify-Kommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf Erledigt gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "dirtyTarget": "Merge abgelehnt — das Ziel-Arbeitsverzeichnis hat uncommittete Änderungen.", "targetDirty": "Das Ziel-Arbeitsverzeichnis hat bereits uncommittete Änderungen — stashen und mergen?", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" },
|
||||
"settingsModal": { "workerOffline": "Worker offline — Einstellungen schreibgeschützt.", "saveFailed": "Speichern fehlgeschlagen: {0}" },
|
||||
"onlineInbox": { "workerOffline": "Worker offline — Konfiguration kann nicht geladen werden.", "saved": "Konfiguration gespeichert.", "saveFailed": "Speichern fehlgeschlagen: {0}", "signedIn": "Erfolgreich angemeldet.", "signedInNoRole": "Angemeldet, aber diesem Konto fehlt die Rolle 'user' in Zitadel — die Online-Synchronisierung wird abgelehnt, bis die Rolle im ClaudeDo-Projekt zugewiesen wird.", "signInFailed": "Anmeldung fehlgeschlagen: {0}", "signedOut": "Abgemeldet.", "signOutFailed": "Abmeldung fehlgeschlagen: {0}" },
|
||||
"tickets": { "connectionOk": "Verbunden als {0}.", "connectionMissingScopes": "Verbunden als {0}, aber es fehlen Scopes: {1}", "tokenCleared": "Token entfernt.", "workerOffline": "Worker nicht erreichbar." },
|
||||
@@ -869,8 +892,8 @@
|
||||
"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." },
|
||||
"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.", "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}" },
|
||||
"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}" },
|
||||
"lists": { "localSuffix": "{0} / lokal", "smartMyDay": "Mein Tag", "smartImportant": "Wichtig", "smartPlanned": "Geplant", "smartNotes": "Notizen", "virtualQueue": "Warteschlange", "virtualRunning": "Läuft", "virtualReview": "Prüfung", "newList": "Neue Liste", "findingsNotFound": "Für diese Liste gibt es noch keine Findings.", "findingsOpenFailed": "Findings-Ordner konnte nicht geöffnet werden: {0}", "openInExplorerFailed": "Pfad konnte nicht geöffnet werden: {0}" },
|
||||
|
||||
@@ -328,6 +328,7 @@
|
||||
"worktree": "Worktree",
|
||||
"reviewCombinedDiff": "Review Combined Diff",
|
||||
"openDiffHint": "Open the diff to enable merge",
|
||||
"targetDirty": "Target working directory has uncommitted changes — merging will stash and restore them.",
|
||||
"approveMerge": "Approve & Merge",
|
||||
"park": "Park",
|
||||
"parkTip": "Set aside — back to Idle, keeps the worktree",
|
||||
@@ -377,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}",
|
||||
@@ -386,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}",
|
||||
@@ -452,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...",
|
||||
@@ -465,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",
|
||||
@@ -481,7 +495,8 @@
|
||||
"targetBranch": "Target branch",
|
||||
"removeWorktree": "Remove worktree after merge",
|
||||
"commitMessage": "Commit message",
|
||||
"conflictedFiles": "Conflicted files:"
|
||||
"conflictedFiles": "Conflicted files:",
|
||||
"stashAndMerge": "Stash changes and merge"
|
||||
},
|
||||
"diff": {
|
||||
"title": "DIFF",
|
||||
@@ -804,6 +819,14 @@
|
||||
"done": "Done"
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"delete": "Delete",
|
||||
"ok": "OK",
|
||||
"confirmTitle": "Confirm",
|
||||
"errorTitle": "Error"
|
||||
},
|
||||
"shell": {
|
||||
"menu": {
|
||||
"help": "Help",
|
||||
@@ -850,7 +873,7 @@
|
||||
"tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "statusPill": "{0} running · {1} review", "openTaskOne": "1 open task", "openTasks": "{0} open tasks", "planningResumeFailed": "Couldn't resume planning session: {0}", "approveFailed": "Approve & merge failed: {0}", "cancelReviewFailed": "Cancel review failed: {0}", "sendToQueueFailed": "Send to queue failed: {0}", "queuePlanBlockedInteractive": "Can't queue the plan — {0} has an open interactive session and must be closed first.", "moveRunningRejected": "Can't move a running task to another list.", "moveWorktreeRejected": "Can't move — this task has an active worktree pointing at its current repo.", "moveRepoConfirm": "Different repos — {0} → {1}. Move the task anyway?", "moveConfirmUnavailable": "Can't move — the confirmation dialog isn't available.", "quickClaudeNoWorkingDir": "This list has no working directory configured.", "quickClaudeDirMissing": "Working directory no longer exists: {0}", "setStatusFailed": "Failed to update status: {0}", "cancelFailed": "Cancel failed: {0}", "rejectToQueueFailed": "Reject to queue failed: {0}", "rejectToIdleFailed": "Reject failed: {0}", "deleteTaskConfirm": "Delete \"{0}\"? This cannot be undone.", "deleteTaskFailed": "Delete failed: {0}", "toggleDoneFailed": "Couldn't update done state: {0}", "removeFromQueueFailed": "Remove from queue failed: {0}", "openWorktreeFailed": "Couldn't open worktree: {0}" },
|
||||
"diff": { "loadFailed": "Failed to load diff: {0}", "noChanges": "No changes to show.", "unavailable": "Diff no longer available — commit range incomplete." },
|
||||
"planningDiff": { "hubError": "Could not build combined preview (hub error).", "conflict": "Cannot build combined preview: subtask {0} conflicts with an earlier subtask ({1} files).", "buildFailed": "Could not build combined preview: {0}" },
|
||||
"merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Merging…", "progressVerifying": "Running the list's verify command… ({0})", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "blocked": "Blocked: {0}", "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" },
|
||||
"merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Merging…", "progressVerifying": "Running the list's verify command… ({0})", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "blocked": "Blocked: {0}", "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "dirtyTarget": "Merge refused — the target working directory has uncommitted changes.", "targetDirty": "The target working directory already has uncommitted changes — stash them and merge?", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" },
|
||||
"settingsModal": { "workerOffline": "Worker offline — settings read-only.", "saveFailed": "Save failed: {0}" },
|
||||
"onlineInbox": { "workerOffline": "Worker offline — cannot load config.", "saved": "Config saved.", "saveFailed": "Save failed: {0}", "signedIn": "Signed in successfully.", "signedInNoRole": "Signed in, but this account is missing the 'user' role in Zitadel — online sync will be rejected until the role is granted in the ClaudeDo project.", "signInFailed": "Sign-in failed: {0}", "signedOut": "Signed out.", "signOutFailed": "Sign-out failed: {0}" },
|
||||
"tickets": { "connectionOk": "Connected as {0}.", "connectionMissingScopes": "Connected as {0}, but missing scopes: {1}", "tokenCleared": "Token removed.", "workerOffline": "Worker not reachable." },
|
||||
@@ -869,8 +892,8 @@
|
||||
"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." },
|
||||
"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.", "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}" },
|
||||
"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}" },
|
||||
"lists": { "localSuffix": "{0} / local", "smartMyDay": "My Day", "smartImportant": "Important", "smartPlanned": "Planned", "smartNotes": "Notes", "virtualQueue": "Queue", "virtualRunning": "Running", "virtualReview": "Review", "newList": "New list", "findingsNotFound": "No findings yet for this list.", "findingsOpenFailed": "Couldn't open findings folder: {0}", "openInExplorerFailed": "Couldn't open path: {0}" },
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -41,7 +41,7 @@ Design/ — Tokens.axaml (design tokens; merged before styles)
|
||||
|
||||
- **IslandsShellViewModel** — root coordinator. Owns the three island VMs and the `WorkerClient`, wires cross-island events (selection, notes/prep mode, conflict resolution), connection state, the update banner, the inline worker-log strip (clickable → Log Visualizer overlay; `FlashFooterError` surfaces UI-action failures + the worker's Warn/Error there), responsive-layout flags (`ShowLists`/`ShowDetails` by window width), `PrimeStatus` flash, the modal openers, and `RestartWorkerAsync`/`CheckForUpdatesAsync`. Hosts `UpdateCheckService`.
|
||||
- **ListsIslandViewModel** — smart lists (My Day, Important, Planned, virtual queued/running/review), user lists, selection, list CRUD, drag-reorder, badge counts, opens list settings / repo import / worktrees overview, `OpenInExplorer`/`OpenInTerminal`.
|
||||
- **Folders:** the sidebar's top level is **one mixed collection**, `TopLevel` (`ObservableCollection<object>`: `ListFolderNavItemViewModel` rows, each with its own `Lists`, interleaved with folder-less `ListNavItemViewModel` rows) — that's what lets a list sit *above* a folder. `Folders`/`RootLists` are `OfType` **projections** of it, not collections to keep in sync. `UserLists` stays the **flat superset** every count refresh, row refresh and the no-repo banner work over — a row lives in both. `ContainerOf(row)` resolves which collection renders it (non-generic `IList`, since the top level is mixed). The view is a single `ItemsControl` over `TopLevel` with **implicit, type-matched `DataTemplate`s in `UserControl.DataTemplates`** — no keyed row template, and the folder's child `ItemsControl` resolves the same list template by walking up to the UserControl. A folder is never selectable: tapping the header runs `ToggleFolderCommand` (persisted to `list_folders.is_collapsed`), and its badge is the sum of its children. `DropAsync(source, target, placeBelow)` handles all four source/target combinations and doubles as the move: a drop position implies a container, so dropping a list onto a row in another folder — or next to *anything* at the top level, folder headers included — reassigns `FolderId`. A folder dropped onto a row inside a folder is ignored; folders never nest. `MoveToFolderAsync` is the drop-into-the-header path (reaches collapsed/empty folders, bound to the middle half of the header — the outer quarters insert *next to* the folder instead); `RemoveFromFolderCommand` is its context-menu counterpart. Both end in `PersistOrderAsync` → `ListRepository.ReorderSidebarAsync`, one `sort_order` sequence spanning **both tables**. ⚠️ A **folder** drag starts from `PointerMoved` past a 4px threshold using the *stored* `PointerPressedEventArgs` (`DoDragDropAsync` only accepts those) — starting it on press would capture the pointer and swallow the `Tapped` that folds the folder.
|
||||
- **Folders:** the sidebar's top level is **one mixed collection**, `TopLevel` (`ObservableCollection<object>`: `ListFolderNavItemViewModel` rows, each with its own `Children`, interleaved with folder-less `ListNavItemViewModel` rows) — that's what lets a list sit *above* a folder. Folders nest arbitrarily deep — a folder's `Children` mixes lists and subfolders the same way `TopLevel` mixes folders and folder-less lists, via `ParentFolderId`. `Folders` is a **recursive** `OfType` projection over the whole tree (not just the top level); `RootLists` stays a `TopLevel`-only projection. `UserLists` stays the **flat superset** every count refresh, row refresh and the no-repo banner work over — a row lives in both. `ContainerOf(row-or-folder)` resolves which collection renders it (non-generic `IList`, since every container is mixed). The view is a single `ItemsControl` over `TopLevel` with **implicit, type-matched `DataTemplate`s in `UserControl.DataTemplates`** — no keyed row template, and the folder's child `ItemsControl` resolves the same templates recursively by walking up to the UserControl (which is also what indents nested folders: each level adds its template's fixed margin). A folder is never selectable: tapping the header runs `ToggleFolderCommand` (persisted to `list_folders.is_collapsed`), and its badge is the sum of all descendant lists, recursive. `DropAsync(source, target, placeBelow)` handles list and folder sources against any target and doubles as the move: a drop position implies a container, so dropping a row onto a row in another folder — or next to *anything* at the top level, folder headers included — reassigns `FolderId`/`ParentFolderId`. A folder dropped onto or next to a target inside its own subtree is silently ignored (self/descendant guard), never nested into itself. `MoveToFolderAsync` is the drop-into-the-header path (reaches collapsed/empty folders, bound to the middle half of the header — the outer quarters insert *next to* the folder instead; also takes a folder source now); `RemoveFromFolderCommand` is its context-menu counterpart. Both end in `PersistOrderAsync` → `ListRepository.ReorderSidebarAsync`/`SetFolderParentAsync`, one `sort_order` sequence spanning **both tables**, walked depth-first. ⚠️ A **folder** drag starts from `PointerMoved` past a 4px threshold using the *stored* `PointerPressedEventArgs` (`DoDragDropAsync` only accepts those) — starting it on press would capture the pointer and swallow the `Tapped` that folds the folder.
|
||||
- **TasksIslandViewModel** — open/overdue/completed groups for the selected list with hierarchy-aware regrouping; task CRUD, drag-reorder, toggle done/star, schedule, enqueue/dequeue, cancel; review actions; planning session lifecycle; `RefineTask`, `OpenConPtySessionRequested`, `ToggleManual`, `SyncInteractiveSessions`; MyDay extras (`IsMyDayList`, `ClearDayCommand`, `ShowPrepLogCommand`) and the pinned Notes pseudo-row. Raises `NotesRequested`/`PrepRequested` for the shell.
|
||||
- **DetailsIslandViewModel** — the detail pane for a bound `TaskRowViewModel`. Owns live-log streaming (`Log` via `StreamLineFormatter`), debounced title/description editing, subtasks, the session-outcome/roadblock split, the three-tab work console (`output`/`git`/`session`), child surfacing (`ChildOutcomes` + `ChildrenNeedingAttention` drive an attention band on the Session tab), attachments, and the modes `IsNotesMode`/`IsPrepMode`/computed `IsTaskDetailVisible`. Failures raise `ErrorReported`, wired by the shell into `FlashFooterError`.
|
||||
- Three concerns are extracted into section VMs exposed as properties: `AgentSettings` (`AgentConfigEditorViewModel`, scope=Task), `MergeSectionViewModel`, `PrepPanelViewModel`. Helper rows live in the same file.
|
||||
@@ -92,7 +92,7 @@ single-task and planning unit-merge conflicts. Full detail →
|
||||
The two surfaces are **not** variants of one control (read-only 2-way renderer over aligned rows
|
||||
vs. 3-way editor over a writable document), but they share their AvaloniaEdit host plumbing via
|
||||
`Views/Controls/DiffEditorSetup.cs`: the process-wide TextMate `Registry`, `InstallHighlighting`,
|
||||
`ApplyGrammar`, the `Brush` resource fallback, the `Seg` segment, and `VerticalScrollSync`. Put
|
||||
`ApplyGrammar`, the `Brush` resource fallback, the `Seg` segment, and `ScrollSync`. Put
|
||||
new editor boilerplate there rather than copying it a third time.
|
||||
|
||||
## Services
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -104,6 +104,16 @@
|
||||
<SolidColorBrush x:Key="RoadblockTintBrush" Color="#1FD4A574" />
|
||||
<SolidColorBrush x:Key="RoadblockTintBorderBrush" Color="#4CD4A574" />
|
||||
|
||||
<!-- Mission Control tile identity colours. Cool hues only, on purpose: the warm/green half of
|
||||
the wheel is taken by the status semantics above, and an identity strip must never read as
|
||||
"running"/"review"/"failed". -->
|
||||
<SolidColorBrush x:Key="Identity1Brush" Color="#5FA8C8" />
|
||||
<SolidColorBrush x:Key="Identity2Brush" Color="#4FA396" />
|
||||
<SolidColorBrush x:Key="Identity3Brush" Color="#7E88CC" />
|
||||
<SolidColorBrush x:Key="Identity4Brush" Color="#A57BB5" />
|
||||
<SolidColorBrush x:Key="Identity5Brush" Color="#C77FA0" />
|
||||
<SolidColorBrush x:Key="Identity6Brush" Color="#5C78B8" />
|
||||
|
||||
<!-- Merge editor (3-pane conflict resolver) block tints -->
|
||||
<SolidColorBrush x:Key="MergeOursTintBrush" Color="#1F7C9166" /> <!-- ours side (moss) -->
|
||||
<SolidColorBrush x:Key="MergeTheirsTintBrush" Color="#1FD4A574" /> <!-- theirs side (amber) -->
|
||||
@@ -148,6 +158,7 @@
|
||||
<Thickness x:Key="IslandHeaderPadding">18,16,18,12</Thickness>
|
||||
<Thickness x:Key="IslandBodyPadding">14</Thickness>
|
||||
<Thickness x:Key="WindowBodyPadding">14</Thickness>
|
||||
<Thickness x:Key="NestedRowIndent">18,0,0,0</Thickness> <!-- SpaceXl: a child row under its parent -->
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- CORNERS & BORDERS -->
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using ClaudeDo.Data;
|
||||
|
||||
namespace ClaudeDo.Ui.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Temp PNGs written for clipboard-image paste into the interactive terminal (see
|
||||
/// <see cref="ClaudeDo.Ui.Views.InteractiveTerminalView"/>) — dropped into the composer as a path,
|
||||
/// the same way a file drop is, then read by Claude like any other file.
|
||||
/// </summary>
|
||||
public static class ClipboardImageTempStore
|
||||
{
|
||||
public static string Directory => Path.Combine(Paths.AppDataRoot(), "tmp");
|
||||
|
||||
/// <summary>A fresh, collision-free path for one pasted image.</summary>
|
||||
public static string NewPngPath(DateTime utcNow) => NewPngPath(Directory, utcNow);
|
||||
|
||||
/// <summary>Deletes files older than <paramref name="maxAge"/> under the tmp dir — called once at
|
||||
/// app startup, not a running cleanup service.</summary>
|
||||
public static void DeleteOlderThan(TimeSpan maxAge, DateTime utcNow) => DeleteOlderThan(Directory, maxAge, utcNow);
|
||||
|
||||
/// <summary>Test seam for <see cref="NewPngPath(DateTime)"/> — takes the target dir explicitly
|
||||
/// instead of the real app data root.</summary>
|
||||
internal static string NewPngPath(string dir, DateTime utcNow) =>
|
||||
Path.Combine(dir, $"paste-{utcNow:yyyyMMdd-HHmmssfff}-{Guid.NewGuid():N}.png");
|
||||
|
||||
/// <summary>Test seam for <see cref="DeleteOlderThan(TimeSpan,DateTime)"/> — takes the target dir
|
||||
/// explicitly instead of the real app data root.</summary>
|
||||
internal static void DeleteOlderThan(string dir, TimeSpan maxAge, DateTime utcNow)
|
||||
{
|
||||
if (!System.IO.Directory.Exists(dir)) return;
|
||||
|
||||
foreach (var file in System.IO.Directory.EnumerateFiles(dir))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (utcNow - File.GetLastWriteTimeUtc(file) > maxAge)
|
||||
File.Delete(file);
|
||||
}
|
||||
catch (IOException) { /* best effort */ }
|
||||
catch (UnauthorizedAccessException) { /* best effort */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,9 @@ public interface IDialogService
|
||||
Task ShowConflictResolverAsync(ConflictResolverViewModel vm);
|
||||
Task ShowLogVisualizerAsync(LogVisualizerViewModel vm);
|
||||
|
||||
/// <summary>Modal yes/no confirmation. Returns true only when confirmed.</summary>
|
||||
Task<bool> ConfirmAsync(string message);
|
||||
/// <summary>Modal yes/no confirmation. Returns true only when confirmed. <paramref name="confirmLabel"/>
|
||||
/// overrides the default localized "Confirm" button text (e.g. a localized "Delete" for destructive actions).</summary>
|
||||
Task<bool> ConfirmAsync(string message, string? confirmLabel = null);
|
||||
|
||||
/// <summary>Modal error notice with a single dismiss button.</summary>
|
||||
Task ShowErrorAsync(string message);
|
||||
|
||||
@@ -30,6 +30,10 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
/// task to carry out nextPhase ("wait" | "merge" | "wait_final" | "merge_final").</summary>
|
||||
event Action<string, IReadOnlyList<string>, string>? HandoffRequestedEvent;
|
||||
|
||||
/// <summary>A Mission Control session called handoff_session: (paneId, briefPath). The UI replaces
|
||||
/// the tile whose IdentityKey is paneId with a fresh session starting from the brief.</summary>
|
||||
event Action<string, string>? SessionHandoffRequestedEvent;
|
||||
|
||||
/// <summary>start_list_handler was called through the external MCP: (listId, taskIds). The UI
|
||||
/// starts a list-handler run for them exactly like the "Let Claude handle it" button.</summary>
|
||||
event Action<string, IReadOnlyList<string>>? ListHandlerStartRequestedEvent;
|
||||
@@ -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;
|
||||
@@ -110,9 +117,11 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
/// and clears BlockedByTaskId in the same update) — call once per row (parent and each
|
||||
/// queued child) rather than cascading server-side.</summary>
|
||||
Task DequeueTaskAsync(string taskId);
|
||||
Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch);
|
||||
/// <param name="autostash">Retry flag for status "dirty_target": stash the target working
|
||||
/// tree's uncommitted changes before merging and restore them afterwards (git --autostash).</param>
|
||||
Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch, bool autostash = false);
|
||||
Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch);
|
||||
Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage);
|
||||
Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage, bool autostash = false);
|
||||
Task RejectReviewToQueueAsync(string taskId, string feedback);
|
||||
Task RejectReviewToIdleAsync(string taskId);
|
||||
Task CancelReviewAsync(string taskId);
|
||||
@@ -131,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
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -353,10 +380,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
await InvokeTimedAsync("ResetTask", () => _hub.InvokeAsync("ResetTask", taskId));
|
||||
}
|
||||
|
||||
public async Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage)
|
||||
public async Task<MergeResultDto> MergeTaskAsync(
|
||||
string taskId, string targetBranch, bool removeWorktree, string commitMessage, bool autostash = false)
|
||||
{
|
||||
return await InvokeTimedAsync<MergeResultDto>("MergeTask", () => _hub.InvokeAsync<MergeResultDto>(
|
||||
"MergeTask", taskId, targetBranch, removeWorktree, commitMessage));
|
||||
"MergeTask", taskId, targetBranch, removeWorktree, commitMessage, autostash));
|
||||
}
|
||||
|
||||
public Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch)
|
||||
@@ -553,10 +581,10 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
public Task DequeueTaskAsync(string taskId)
|
||||
=> InvokeTimedAsync("DequeueTask", () => _hub.InvokeAsync("DequeueTask", taskId));
|
||||
|
||||
public async Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch)
|
||||
public async Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch, bool autostash = false)
|
||||
{
|
||||
LastApproveTarget = targetBranch;
|
||||
return await InvokeTimedAsync<MergeResultDto>("ApproveReview", () => _hub.InvokeAsync<MergeResultDto>("ApproveReview", taskId, targetBranch));
|
||||
return await InvokeTimedAsync<MergeResultDto>("ApproveReview", () => _hub.InvokeAsync<MergeResultDto>("ApproveReview", taskId, targetBranch, autostash));
|
||||
}
|
||||
|
||||
public Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch)
|
||||
@@ -619,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));
|
||||
|
||||
@@ -145,7 +145,7 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
|
||||
{
|
||||
// Any non-conflict outcome here means nothing was left mid-merge for this dialog
|
||||
// to resolve -- surface why instead of quietly closing with no explanation.
|
||||
if (start.Status is "blocked" or "untracked_collision")
|
||||
if (start.Status is "blocked" or "untracked_collision" or "dirty_target")
|
||||
Error = start.ErrorMessage;
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -291,11 +291,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
// Set by shell so DeleteTaskCommand can remove from list
|
||||
public Func<TaskRowViewModel, System.Threading.Tasks.Task>? DeleteFromList { get; set; }
|
||||
|
||||
// Set by the view so DeleteTaskCommand can prompt yes/no before deleting
|
||||
public Func<string, System.Threading.Tasks.Task<bool>>? ConfirmAsync { get; set; }
|
||||
|
||||
// Set by the view so DeleteTaskCommand can show an error message
|
||||
public Func<string, System.Threading.Tasks.Task>? ShowErrorAsync { get; set; }
|
||||
// Set by the shell (mirrors ListsIslandViewModel.Dialogs) so confirm/error prompts go
|
||||
// through the one shared dialog implementation instead of a view-local copy.
|
||||
public IDialogService? Dialogs { get; set; }
|
||||
|
||||
// ── Review ──────────────────────────────────────────────────────────────
|
||||
[ObservableProperty]
|
||||
@@ -728,6 +726,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
Merge.MergeTargetBranches.Clear();
|
||||
foreach (var b in targets.LocalBranches) Merge.MergeTargetBranches.Add(b);
|
||||
Merge.SelectedMergeTarget = targets.DefaultBranch;
|
||||
Merge.TargetDirty = targets.TargetDirty;
|
||||
}
|
||||
}
|
||||
await Merge.RefreshMergePreviewAsync();
|
||||
@@ -778,6 +777,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
foreach (var b in targets.LocalBranches)
|
||||
Merge.MergeTargetBranches.Add(b);
|
||||
Merge.SelectedMergeTarget = targets.DefaultBranch;
|
||||
Merge.TargetDirty = targets.TargetDirty;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -824,6 +824,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
foreach (var b in targets.LocalBranches)
|
||||
Merge.MergeTargetBranches.Add(b);
|
||||
Merge.SelectedMergeTarget = targets.DefaultBranch;
|
||||
Merge.TargetDirty = targets.TargetDirty;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1042,9 +1043,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
if (Task == null) return;
|
||||
var row = Task;
|
||||
if (ConfirmAsync != null)
|
||||
if (Dialogs != null)
|
||||
{
|
||||
var ok = await ConfirmAsync($"Delete \"{row.Title}\"? This cannot be undone.");
|
||||
var ok = await Dialogs.ConfirmAsync($"Delete \"{row.Title}\"? This cannot be undone.", Loc.T("dialog.delete"));
|
||||
if (!ok) return;
|
||||
}
|
||||
// Routed through the worker (mirrors the MCP delete_task tool) so a deleted child
|
||||
@@ -1060,14 +1061,14 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
// Belt and braces: the connection can drop between the CanExecute check and
|
||||
// this call, so a stray throw here must surface, not vanish silently.
|
||||
if (ShowErrorAsync != null)
|
||||
await ShowErrorAsync(ex.Message);
|
||||
if (Dialogs != null)
|
||||
await Dialogs.ShowErrorAsync(ex.Message);
|
||||
return;
|
||||
}
|
||||
if (!deleted)
|
||||
{
|
||||
if (ShowErrorAsync != null)
|
||||
await ShowErrorAsync(error ?? "Delete failed.");
|
||||
if (Dialogs != null)
|
||||
await Dialogs.ShowErrorAsync(error ?? "Delete failed.");
|
||||
return;
|
||||
}
|
||||
if (DeleteFromList != null)
|
||||
@@ -1216,10 +1217,10 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
private async System.Threading.Tasks.Task ResetAndRetryAsync()
|
||||
{
|
||||
if (Task == null) return;
|
||||
if (ConfirmAsync == null) return;
|
||||
if (Dialogs == null) return;
|
||||
|
||||
var branchName = $"claudedo/{Task.Id.Replace("-", "")}";
|
||||
var ok = await ConfirmAsync(
|
||||
var ok = await Dialogs.ConfirmAsync(
|
||||
$"Reset and retry?\nThis discards branch {branchName} (and uncommitted changes), then queues the task to run from the beginning.");
|
||||
if (!ok) return;
|
||||
|
||||
@@ -1275,21 +1276,35 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
var hasChildren = Subtasks.Count > 0 || ChildOutcomes.Count > 0;
|
||||
var result = await _worker.ApproveReviewAsync(taskId, Merge.SelectedMergeTarget ?? "");
|
||||
if (!hasChildren && result?.Status == "conflict")
|
||||
|
||||
// Nothing landed -- the target working dir has uncommitted changes (often another
|
||||
// session's WIP in the shared main checkout). Offer the one-shot stash retry; git
|
||||
// restores the changes itself once the merge is through.
|
||||
if (result?.Status == "dirty_target" && Dialogs != null
|
||||
&& await Dialogs.ConfirmAsync(Loc.T("vm.detailsIsland.stashAndMergeConfirm")))
|
||||
{
|
||||
ApproveOp.Report(Loc.T("ops.merge.merging"));
|
||||
result = await _worker.ApproveReviewAsync(
|
||||
taskId, Merge.SelectedMergeTarget ?? "", autostash: true);
|
||||
}
|
||||
|
||||
if (result?.Status == "dirty_target" && Dialogs != null)
|
||||
await Dialogs.ShowErrorAsync(result.ErrorMessage ?? Loc.T("vm.detailsIsland.dirtyTarget"));
|
||||
else if (!hasChildren && result?.Status == "conflict")
|
||||
await _merge.ResolveConflictAsync(taskId, Merge.SelectedMergeTarget ?? "");
|
||||
// The merge itself already landed; the verify command failed, so the task stayed
|
||||
// out of Done. Surface that instead of silently looking like nothing happened.
|
||||
else if (!hasChildren && result?.Status == "verify_failed" && ShowErrorAsync != null)
|
||||
await ShowErrorAsync(result.ErrorMessage ?? Loc.T("vm.detailsIsland.verifyFailed"));
|
||||
else if (!hasChildren && result?.Status == "verify_failed" && Dialogs != null)
|
||||
await Dialogs.ShowErrorAsync(result.ErrorMessage ?? Loc.T("vm.detailsIsland.verifyFailed"));
|
||||
// Nothing landed -- the merge was refused before touching the repo because the
|
||||
// branch would have overwritten an untracked file in the target working directory.
|
||||
else if (!hasChildren && result?.Status == "untracked_collision" && ShowErrorAsync != null)
|
||||
await ShowErrorAsync(result.ErrorMessage ?? Loc.T("vm.detailsIsland.untrackedCollision"));
|
||||
else if (!hasChildren && result?.Status == "untracked_collision" && Dialogs != null)
|
||||
await Dialogs.ShowErrorAsync(result.ErrorMessage ?? Loc.T("vm.detailsIsland.untrackedCollision"));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (ShowErrorAsync != null)
|
||||
await ShowErrorAsync(ex.Message);
|
||||
if (Dialogs != null)
|
||||
await Dialogs.ShowErrorAsync(ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -1334,7 +1349,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
try { await _worker.SubmitTaskForReviewAsync(Task.Id); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (ShowErrorAsync != null) await ShowErrorAsync(ex.Message);
|
||||
if (Dialogs != null) await Dialogs.ShowErrorAsync(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1365,9 +1380,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
[RelayCommand]
|
||||
private async System.Threading.Tasks.Task ResetReviewAsync()
|
||||
{
|
||||
if (Task is null || !_worker.IsConnected || ConfirmAsync is null) return;
|
||||
if (Task is null || !_worker.IsConnected || Dialogs is null) return;
|
||||
var branchName = $"claudedo/{Task.Id.Replace("-", "")}";
|
||||
var ok = await ConfirmAsync(
|
||||
var ok = await Dialogs.ConfirmAsync(
|
||||
$"Reset working tree?\nThis discards branch {branchName} (and all changes) and returns the task to Idle.");
|
||||
if (!ok) return;
|
||||
try { await _worker.ResetTaskAsync(Task.Id); }
|
||||
|
||||
@@ -6,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;
|
||||
}
|
||||
@@ -622,16 +719,33 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
await MoveToFolderAsync(row, null);
|
||||
}
|
||||
|
||||
private readonly Dictionary<string, CancellationTokenSource> _folderCollapseCts = new();
|
||||
|
||||
// Synchronous on purpose: the fold is pure UI state and must never miss a click waiting on
|
||||
// the AsyncRelayCommand's own CanExecute lock while the DB write below is in flight. The
|
||||
// write is fire-and-forget with a per-folder cancellation token so a second toggle before the
|
||||
// first one lands cancels it — the DB always ends up with the last-clicked state, never a
|
||||
// stale one racing ahead of it.
|
||||
[RelayCommand]
|
||||
private async Task ToggleFolderAsync(ListFolderNavItemViewModel? folder)
|
||||
private void ToggleFolder(ListFolderNavItemViewModel? folder)
|
||||
{
|
||||
if (folder is null) return;
|
||||
folder.IsCollapsed = !folder.IsCollapsed;
|
||||
if (_folderCollapseCts.TryGetValue(folder.Id, out var previous)) previous.Cancel();
|
||||
var cts = new CancellationTokenSource();
|
||||
_folderCollapseCts[folder.Id] = cts;
|
||||
_ = PersistFolderCollapsedAsync(folder.Id, folder.IsCollapsed, cts.Token);
|
||||
}
|
||||
|
||||
private async Task PersistFolderCollapsedAsync(string folderId, bool isCollapsed, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
await new ListRepository(ctx).SetFolderCollapsedAsync(folder.Id, folder.IsCollapsed);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
await new ListRepository(ctx).SetFolderCollapsedAsync(folderId, isCollapsed);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception ex)
|
||||
{
|
||||
// The fold itself already happened in the UI; only its persistence failed.
|
||||
@@ -645,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.
|
||||
@@ -654,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) =>
|
||||
@@ -715,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();
|
||||
}
|
||||
@@ -761,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,
|
||||
@@ -788,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);
|
||||
|
||||
@@ -28,6 +28,10 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
|
||||
[ObservableProperty] private ObservableCollection<string> _mergeTargetBranches = new();
|
||||
[ObservableProperty] private string? _selectedMergeTarget;
|
||||
|
||||
// Mirrors MergeTargetsDto.TargetDirty — surfaced next to Approve so a dirty target working
|
||||
// dir is visible before anyone clicks, not just after the merge modal refuses it.
|
||||
[ObservableProperty] private bool _targetDirty;
|
||||
|
||||
// No CanExecute to gate here — RefreshMergePreviewAsync is triggered from the Bind path,
|
||||
// not a [RelayCommand], so PreviewOp only drives the indicator, never locks a button.
|
||||
public OperationStatus PreviewOp { get; } = new();
|
||||
@@ -141,6 +145,7 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
|
||||
MergePreviewText = "";
|
||||
MergeIsClean = false;
|
||||
MergeIsConflict = false;
|
||||
TargetDirty = false;
|
||||
SyncWorktree(null, null, null, null, null);
|
||||
SyncTaskContext(null, null, false);
|
||||
SyncChildOutcomes(false, 0);
|
||||
@@ -152,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);
|
||||
}
|
||||
@@ -182,7 +187,7 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
|
||||
}
|
||||
else return;
|
||||
|
||||
await vm.LoadAsync();
|
||||
_ = vm.LoadAsync();
|
||||
DiffViewed?.Invoke();
|
||||
await ShowDiffViewer(vm);
|
||||
}
|
||||
|
||||
@@ -769,12 +769,16 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
|
||||
// A predecessor missing from both rowsById and the external cache counts as unmet -- mirrors
|
||||
// QueuePicker/Worker's TaskBlocking.Unmet: an edge pointing at an unresolved/vanished task
|
||||
// keeps blocking rather than being silently treated as satisfied.
|
||||
// keeps blocking rather than being silently treated as satisfied. A landed merge satisfies
|
||||
// the edge too (skipVerify parks a merged task in WaitingForReview until the batch verify);
|
||||
// the external cache carries no worktree state, so a cross-list predecessor still shows as
|
||||
// waiting until it reaches Done.
|
||||
private static bool IsUnmet(
|
||||
string id, Dictionary<string, TaskRowViewModel> rowsById,
|
||||
IReadOnlyDictionary<string, (int Number, string Title, TaskStatus Status)> external)
|
||||
{
|
||||
if (rowsById.TryGetValue(id, out var p)) return p.Status != TaskStatus.Done;
|
||||
if (rowsById.TryGetValue(id, out var p))
|
||||
return p.Status != TaskStatus.Done && p.WorktreeState != WorktreeState.Merged;
|
||||
if (external.TryGetValue(id, out var e)) return e.Status != TaskStatus.Done;
|
||||
return true;
|
||||
}
|
||||
@@ -1169,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;
|
||||
@@ -1205,9 +1209,9 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
// block removing a completed planning parent together with its done children.
|
||||
var toDelete = completed.OrderByDescending(r => r.IsChild).ToList();
|
||||
|
||||
if (ConfirmAsync is not null)
|
||||
if (Dialogs is not null)
|
||||
{
|
||||
var ok = await ConfirmAsync($"Clear {toDelete.Count} completed task(s)? This cannot be undone.");
|
||||
var ok = await Dialogs.ConfirmAsync($"Clear {toDelete.Count} completed task(s)? This cannot be undone.", Loc.T("dialog.delete"));
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
@@ -1233,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;
|
||||
@@ -1335,9 +1339,9 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
private async Task DeleteTaskAsync(TaskRowViewModel? row)
|
||||
{
|
||||
if (row is null || row.HasInteractiveSession || _worker is null) return;
|
||||
if (ConfirmAsync is not null)
|
||||
if (Dialogs is not null)
|
||||
{
|
||||
var ok = await ConfirmAsync(Loc.T("vm.tasksIsland.deleteTaskConfirm", row.Title));
|
||||
var ok = await Dialogs.ConfirmAsync(Loc.T("vm.tasksIsland.deleteTaskConfirm", row.Title), Loc.T("dialog.delete"));
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
@@ -1399,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;
|
||||
@@ -1649,8 +1653,8 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
|
||||
if (outcome.Result == DiscardPlanningResult.BlockedByQueuedChildren)
|
||||
{
|
||||
if (ConfirmAsync is null) return;
|
||||
var ok = await ConfirmAsync(
|
||||
if (Dialogs is null) return;
|
||||
var ok = await Dialogs.ConfirmAsync(
|
||||
$"{outcome.QueuedChildrenCount} child task(s) are queued.\n" +
|
||||
"Dequeue them and discard the planning session?");
|
||||
if (!ok) return;
|
||||
@@ -1659,18 +1663,13 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
}
|
||||
else if (outcome.Result == DiscardPlanningResult.BlockedByRunningChildren)
|
||||
{
|
||||
if (ConfirmAsync is null) return;
|
||||
await ConfirmAsync(
|
||||
if (Dialogs is null) return;
|
||||
await Dialogs.ConfirmAsync(
|
||||
$"{outcome.RunningChildrenCount} child task(s) are still running.\n" +
|
||||
"Cancel them first, then try again.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wired by the view via <see cref="ShowConfirmAsync"/>. Returns true when the user confirms.
|
||||
/// </summary>
|
||||
public Func<string, Task<bool>>? ConfirmAsync { get; set; }
|
||||
|
||||
private bool CanRunPlanningOperation() => !PlanningOperation.IsRunning;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanRunPlanningOperation))]
|
||||
@@ -1701,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;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Threading;
|
||||
using System;
|
||||
using System.Threading;
|
||||
@@ -66,7 +68,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
// Set by MainWindow so a reveal can bring the main window to the foreground.
|
||||
public Action? BringToFront { get; set; }
|
||||
|
||||
// Single dialog seam (set by MainWindow); propagated to the lists island.
|
||||
// Single dialog seam (set by MainWindow); propagated to the islands.
|
||||
private IDialogService? _dialogs;
|
||||
public IDialogService? Dialogs
|
||||
{
|
||||
@@ -76,6 +78,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
_dialogs = value;
|
||||
if (Lists is not null) Lists.Dialogs = value;
|
||||
if (Tasks is not null) Tasks.Dialogs = value;
|
||||
if (Details is not null) Details.Dialogs = value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,6 +208,9 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
|
||||
private void FlashFooter(string message, WorkerLogLevel level)
|
||||
{
|
||||
if (level == WorkerLogLevel.Error)
|
||||
Serilog.Log.Warning("Footer error: {message}", message);
|
||||
|
||||
WorkerLogText = $"{DateTime.Now:HH:mm} · {message}";
|
||||
WorkerLogLevel = level;
|
||||
IsWorkerLogVisible = true;
|
||||
@@ -655,7 +661,15 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
|
||||
private void RestartWorkerService()
|
||||
{
|
||||
var exe = _workerLocator.Find();
|
||||
// Prefer the exe path of the worker that's actually running: _workerLocator.Find()
|
||||
// walks up from AppContext.BaseDirectory looking for install.json and falls back to
|
||||
// the registry's InstallLocation otherwise — on a debug build launched from the repo
|
||||
// there's no install.json above it, so it returns the *installed* worker's path. That
|
||||
// never matches the running debug process, so KillMatchingWorkerProcesses silently
|
||||
// skips it (it only kills a process whose path equals the given exe) and this either
|
||||
// launches a second worker that immediately dies on the already-bound port, or leaves
|
||||
// the real one running untouched while nothing visibly changes.
|
||||
var exe = RunningWorkerExePath() ?? _workerLocator.Find();
|
||||
if (exe is null) throw new InvalidOperationException("Worker executable not found.");
|
||||
|
||||
KillMatchingWorkerProcesses(exe);
|
||||
@@ -665,6 +679,20 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
throw new InvalidOperationException($"worker process exited immediately (code {started.ExitCode})");
|
||||
}
|
||||
|
||||
private static string? RunningWorkerExePath()
|
||||
{
|
||||
foreach (var p in System.Diagnostics.Process.GetProcessesByName("ClaudeDo.Worker"))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (p.MainModule?.FileName is { } path) return path;
|
||||
}
|
||||
catch { /* inaccessible */ }
|
||||
finally { p.Dispose(); }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only kill the worker belonging to THIS installation — not any other
|
||||
// ClaudeDo.Worker on the machine (e.g. a second install).
|
||||
private static void KillMatchingWorkerProcesses(string exe)
|
||||
@@ -686,10 +714,6 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
// Set by MainWindow: force-closes the window without re-triggering its own close
|
||||
// confirmation (the shutdown dialog above already confirmed).
|
||||
public Action? CloseWindow { get; set; }
|
||||
|
||||
// Test seam: overrides the real kill-worker + close-window body.
|
||||
internal Action? ShutdownOperation { get; set; }
|
||||
|
||||
@@ -706,7 +730,14 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
var exe = _workerLocator.Find();
|
||||
if (exe is not null) KillMatchingWorkerProcesses(exe);
|
||||
CloseWindow?.Invoke();
|
||||
|
||||
// Closing just the MainWindow relies on ShutdownMode.OnMainWindowClose, which asks
|
||||
// every open window to close and backs off if any of them cancels — and
|
||||
// MissionControlWindow always cancels its own close (it hides instead, see
|
||||
// MissionControlWindow.OnClosing), so the process never actually exited. Shutdown()
|
||||
// force-closes every window, ignoring that cancellation.
|
||||
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
desktop.Shutdown();
|
||||
}
|
||||
|
||||
private bool _updatingClaudeCli;
|
||||
|
||||
@@ -19,6 +19,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;
|
||||
|
||||
@@ -31,6 +31,10 @@ public sealed partial class MergeModalViewModel : ViewModelBase
|
||||
[ObservableProperty] private string? _warningMessage;
|
||||
[ObservableProperty] private string? _successMessage;
|
||||
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(SubmitCommand))] private bool _hasConflict;
|
||||
|
||||
/// The merge was refused because the target working dir is dirty — offer the stash retry
|
||||
/// instead of dead-ending on the error text.
|
||||
[ObservableProperty] private bool _showStashRetry;
|
||||
[ObservableProperty] private IReadOnlyList<string> _conflictFiles = Array.Empty<string>();
|
||||
|
||||
public Action? CloseAction { get; set; }
|
||||
@@ -72,6 +76,14 @@ public sealed partial class MergeModalViewModel : ViewModelBase
|
||||
SelectedBranch = Branches.Contains(targets.DefaultBranch)
|
||||
? targets.DefaultBranch
|
||||
: Branches.FirstOrDefault();
|
||||
|
||||
// Known dirty before anyone clicks Merge — offer the stash retry right away instead
|
||||
// of making the user hit the "dirty_target" refusal first.
|
||||
if (targets.TargetDirty)
|
||||
{
|
||||
WarningMessage = Loc.T("vm.merge.targetDirty");
|
||||
ShowStashRetry = true;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -84,13 +96,21 @@ public sealed partial class MergeModalViewModel : ViewModelBase
|
||||
!IsBusy && !HasConflict && !string.IsNullOrWhiteSpace(SelectedBranch);
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanSubmit))]
|
||||
private async Task SubmitAsync()
|
||||
private Task SubmitAsync() => RunMergeAsync(autostash: false);
|
||||
|
||||
/// Retry after "dirty_target": git stashes the target working tree's uncommitted changes,
|
||||
/// merges, and restores them afterwards.
|
||||
[RelayCommand(CanExecute = nameof(CanSubmit))]
|
||||
private Task StashAndMergeAsync() => RunMergeAsync(autostash: true);
|
||||
|
||||
private async Task RunMergeAsync(bool autostash)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(SelectedBranch)) return;
|
||||
IsBusy = true;
|
||||
ErrorMessage = null;
|
||||
WarningMessage = null;
|
||||
SuccessMessage = null;
|
||||
ShowStashRetry = false;
|
||||
// Subscribed only for the duration of the call: the worker's broadcast reaches every
|
||||
// client, and a transient VM left on that event would outlive its window.
|
||||
ProgressMessage = Loc.T("vm.merge.progressMerging");
|
||||
@@ -98,7 +118,7 @@ public sealed partial class MergeModalViewModel : ViewModelBase
|
||||
try
|
||||
{
|
||||
var result = await _worker.MergeTaskAsync(
|
||||
TaskId, SelectedBranch!, RemoveWorktree, CommitMessage);
|
||||
TaskId, SelectedBranch!, RemoveWorktree, CommitMessage, autostash);
|
||||
|
||||
switch (result.Status)
|
||||
{
|
||||
@@ -130,6 +150,12 @@ public sealed partial class MergeModalViewModel : ViewModelBase
|
||||
// point of the gate.
|
||||
ErrorMessage = result.ErrorMessage ?? Loc.T("vm.merge.verifyFailed");
|
||||
break;
|
||||
case "dirty_target":
|
||||
// Nothing landed -- the target working dir has uncommitted changes. Offer the
|
||||
// stash retry; git restores the changes after the merge.
|
||||
ErrorMessage = result.ErrorMessage ?? Loc.T("vm.merge.dirtyTarget");
|
||||
ShowStashRetry = true;
|
||||
break;
|
||||
case "untracked_collision":
|
||||
// Nothing landed -- the merge was refused before touching the repo. Show the
|
||||
// real message (it names the colliding path and size); the locale string is
|
||||
|
||||
@@ -16,6 +16,7 @@ public enum BatchMergeOutcome { None, Merging, Merged, Conflict, Blocked, Verify
|
||||
public sealed partial class WorktreeOverviewRowViewModel : ViewModelBase
|
||||
{
|
||||
[ObservableProperty] private string _taskId = "";
|
||||
[ObservableProperty] private int _taskNumber;
|
||||
[ObservableProperty] private string _taskTitle = "";
|
||||
[ObservableProperty][NotifyPropertyChangedFor(nameof(IsRunning))] private TaskStatus _taskStatus;
|
||||
[ObservableProperty] private string _listId = "";
|
||||
@@ -49,6 +50,9 @@ public sealed partial class WorktreeOverviewRowViewModel : ViewModelBase
|
||||
public bool IsKept => State == WorktreeState.Kept;
|
||||
public bool IsRunning => TaskStatus == TaskStatus.Running;
|
||||
|
||||
// 0 = row without an allocated number (test seeds); a bare "#0" would be meaningless.
|
||||
public bool ShowNumberBadge => TaskNumber > 0;
|
||||
|
||||
private static string FormatAge(TimeSpan ts)
|
||||
{
|
||||
if (ts.TotalDays >= 1) return $"{(int)ts.TotalDays}d ago";
|
||||
@@ -366,7 +370,7 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
|
||||
|
||||
private static WorktreeOverviewRowViewModel Map(WorktreeOverviewDto d) => new()
|
||||
{
|
||||
TaskId = d.TaskId, TaskTitle = d.TaskTitle, TaskStatus = d.TaskStatus,
|
||||
TaskId = d.TaskId, TaskNumber = d.TaskNumber, TaskTitle = d.TaskTitle, TaskStatus = d.TaskStatus,
|
||||
ListId = d.ListId, ListName = d.ListName,
|
||||
Path = d.Path, BranchName = d.BranchName, BaseCommit = d.BaseCommit, State = d.State,
|
||||
DiffStat = d.DiffStat, CreatedAt = d.CreatedAt, PathExistsOnDisk = d.PathExistsOnDisk,
|
||||
@@ -415,7 +419,8 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
|
||||
private bool CanMergeAll() => !BatchMergeStatus.IsRunning && SelectedCount > 0 && !string.IsNullOrWhiteSpace(SelectedTarget);
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanMergeAll))]
|
||||
private Task MergeAll() => MergeSelectedAsync(_worker.MergeTaskAsync);
|
||||
private Task MergeAll() => MergeSelectedAsync(
|
||||
(taskId, target, removeWorktree, message) => _worker.MergeTaskAsync(taskId, target, removeWorktree, message));
|
||||
|
||||
[RelayCommand]
|
||||
private void ResolveConflict(WorktreeOverviewRowViewModel? row)
|
||||
|
||||
@@ -78,8 +78,8 @@ public partial class ConflictResolverView : Window
|
||||
|
||||
// Panes are whole files here, not aligned rows, so raw pixel offsets line up and the
|
||||
// gutter buttons only have to be repositioned once per scroll.
|
||||
new VerticalScrollSync(new[] { OursEditor, ResultEditor, TheirsEditor },
|
||||
afterSync: PositionGutters);
|
||||
new ScrollSync(new[] { OursEditor, ResultEditor, TheirsEditor },
|
||||
afterSync: PositionGutters);
|
||||
|
||||
var conflict = DiffEditorSetup.Brush(this, "MergeConflictTintBrush", Color.Parse("#28C87060"));
|
||||
var resolved = DiffEditorSetup.Brush(this, "MergeResolvedTintBrush", Color.Parse("#206FA86B"));
|
||||
|
||||
@@ -75,9 +75,11 @@ internal static class DiffEditorSetup
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keeps a set of editors scrolled to the same vertical position. Construction wires the panes;
|
||||
/// there is nothing to dispose, and nothing to keep a reference to — the event subscriptions on
|
||||
/// the text views own the instance.
|
||||
/// Keeps a set of editors scrolled to the same position, vertically always and horizontally
|
||||
/// whenever the panes share the same content width (no per-pane offset conversion, unlike
|
||||
/// <see cref="TargetOffset"/> for Y). Construction wires the panes; there is nothing to dispose,
|
||||
/// and nothing to keep a reference to — the event subscriptions on the text views own the
|
||||
/// instance.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -96,7 +98,7 @@ internal static class DiffEditorSetup
|
||||
/// leaves the ScrollViewer's own offset (and therefore the scrollbar thumb) behind.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class VerticalScrollSync
|
||||
internal sealed class ScrollSync
|
||||
{
|
||||
private readonly TextEditor[] _editors;
|
||||
private readonly ScrollViewer?[] _viewers;
|
||||
@@ -108,8 +110,8 @@ internal sealed class VerticalScrollSync
|
||||
/// <param name="isActive">Gate for layouts where the panes are not side by side.</param>
|
||||
/// <param name="anchorByLine">True while lines wrap — see <see cref="TargetOffset"/>.</param>
|
||||
/// <param name="afterSync">Runs once per user scroll, not per echo.</param>
|
||||
public VerticalScrollSync(TextEditor[] editors, Func<bool>? isActive = null,
|
||||
Func<bool>? anchorByLine = null, Action? afterSync = null)
|
||||
public ScrollSync(TextEditor[] editors, Func<bool>? isActive = null,
|
||||
Func<bool>? anchorByLine = null, Action? afterSync = null)
|
||||
{
|
||||
_editors = editors;
|
||||
_viewers = new ScrollViewer?[editors.Length];
|
||||
@@ -136,15 +138,18 @@ internal sealed class VerticalScrollSync
|
||||
_syncing = true;
|
||||
try
|
||||
{
|
||||
var x = _editors[sourceIndex].TextArea.TextView.ScrollOffset.X;
|
||||
for (var i = 0; i < _editors.Length; i++)
|
||||
{
|
||||
if (i == sourceIndex) continue;
|
||||
if (Viewer(i) is not { } viewer) continue;
|
||||
if (TargetOffset(_editors[sourceIndex], _editors[i]) is not { } y) continue;
|
||||
// The echo from the target's own ScrollOffsetChanged lands after _syncing is
|
||||
// cleared again, so this comparison — not the flag — is what ends the loop.
|
||||
if (Math.Abs(viewer.Offset.Y - y) > 0.5)
|
||||
viewer.Offset = new Vector(viewer.Offset.X, y);
|
||||
// cleared again, so this comparison — not the flag — is what ends the loop. Both
|
||||
// axes must be checked: writing only when Y moved left X's own echo unguarded,
|
||||
// which is how it looped forever once X was written below.
|
||||
if (Math.Abs(viewer.Offset.X - x) > 0.5 || Math.Abs(viewer.Offset.Y - y) > 0.5)
|
||||
viewer.Offset = new Vector(x, y);
|
||||
}
|
||||
}
|
||||
finally { _syncing = false; }
|
||||
|
||||
@@ -85,8 +85,8 @@ public partial class DiffTextView : UserControl
|
||||
_leftTm = DiffEditorSetup.InstallHighlighting(LeftEditor);
|
||||
_rightTm = DiffEditorSetup.InstallHighlighting(RightEditor);
|
||||
ReloadFile();
|
||||
new VerticalScrollSync(new[] { LeftEditor, RightEditor },
|
||||
isActive: () => IsSplit, anchorByLine: () => WrapLines);
|
||||
new ScrollSync(new[] { LeftEditor, RightEditor },
|
||||
isActive: () => IsSplit, anchorByLine: () => WrapLines);
|
||||
}
|
||||
|
||||
/// Brushes and the mono typeface only resolve once the control is in the visual tree —
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Platform.Storage;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels;
|
||||
|
||||
namespace ClaudeDo.Ui.Views;
|
||||
@@ -43,13 +45,54 @@ public partial class InteractiveTerminalView : UserControl
|
||||
if (DataContext is not InteractiveTerminalViewModel vm) return;
|
||||
|
||||
e.Handled = true;
|
||||
_ = PasteAndReportAsync(vm);
|
||||
_ = PasteAndReportAsync(this, vm);
|
||||
}
|
||||
|
||||
private static async System.Threading.Tasks.Task PasteAndReportAsync(InteractiveTerminalViewModel vm)
|
||||
// A clipboard image (e.g. a screenshot) can't be typed into the child process, so it's written
|
||||
// to a temp PNG and its path is inserted the same way a dropped file is — Claude reads the file
|
||||
// itself. Plain-text clipboard content keeps going through the existing bracketed-paste flow.
|
||||
// Once a bitmap is confirmed present, any failure (dir/file write, send) is a real paste
|
||||
// failure — never silently fall back to a text paste of the same clipboard.
|
||||
private static async System.Threading.Tasks.Task PasteAndReportAsync(InteractiveTerminalView view, InteractiveTerminalViewModel vm)
|
||||
{
|
||||
if (!await vm.PasteAsync())
|
||||
Avalonia.Media.Imaging.Bitmap? bitmap = null;
|
||||
try
|
||||
{
|
||||
bitmap = await TryGetClipboardBitmapAsync(TopLevel.GetTopLevel(view));
|
||||
if (bitmap is null)
|
||||
{
|
||||
if (!await vm.PasteAsync()) vm.ReportSendFailed();
|
||||
return;
|
||||
}
|
||||
|
||||
var path = ClipboardImageTempStore.NewPngPath(DateTime.UtcNow);
|
||||
Directory.CreateDirectory(ClipboardImageTempStore.Directory);
|
||||
bitmap.Save(path);
|
||||
if (!await vm.SendTextAsync(FormatDroppedPaths(new[] { path })))
|
||||
vm.ReportSendFailed();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
vm.ReportSendFailed();
|
||||
}
|
||||
finally
|
||||
{
|
||||
bitmap?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static async System.Threading.Tasks.Task<Avalonia.Media.Imaging.Bitmap?> TryGetClipboardBitmapAsync(TopLevel? topLevel)
|
||||
{
|
||||
if (topLevel?.Clipboard is not { } clipboard) return null;
|
||||
try
|
||||
{
|
||||
using var data = await clipboard.TryGetDataAsync();
|
||||
return data is null ? null : await Avalonia.Input.AsyncDataTransferExtensions.TryGetBitmapAsync(data);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsFilesDrop(DragEventArgs e) => e.DataTransfer?.Contains(DataFormat.File) == true;
|
||||
|
||||
@@ -327,6 +327,10 @@
|
||||
<ComboBox ItemsSource="{Binding Merge.MergeTargetBranches}"
|
||||
SelectedItem="{Binding Merge.SelectedMergeTarget, Mode=TwoWay}"
|
||||
HorizontalAlignment="Stretch" />
|
||||
<TextBlock Classes="meta" Text="{loc:Tr details.workConsole.targetDirty}"
|
||||
TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource AmberBrush}"
|
||||
IsVisible="{Binding Merge.TargetDirty}" />
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="0">
|
||||
|
||||
@@ -173,8 +173,6 @@ public partial class DetailsIslandView : UserControl
|
||||
await modal.ShowDialog(owner);
|
||||
};
|
||||
|
||||
vm.ConfirmAsync = ShowConfirmAsync;
|
||||
vm.ShowErrorAsync = ShowErrorDialogAsync;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,87 +211,4 @@ public partial class DetailsIslandView : UserControl
|
||||
_descriptionHeights[id] = DetailBodyGrid.RowDefinitions[0].Height.Value;
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task ShowErrorDialogAsync(string message)
|
||||
{
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
if (owner == null) return;
|
||||
|
||||
var ok = new Button { Content = "OK", MinWidth = 90 };
|
||||
|
||||
var dialog = new Window
|
||||
{
|
||||
Title = "Error",
|
||||
Width = 360,
|
||||
SizeToContent = SizeToContent.Height,
|
||||
CanResize = false,
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner,
|
||||
ShowInTaskbar = false,
|
||||
Background = this.FindResource("SurfaceBrush") as IBrush,
|
||||
Content = new StackPanel
|
||||
{
|
||||
Spacing = 16,
|
||||
Margin = new Thickness(20),
|
||||
Children =
|
||||
{
|
||||
new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },
|
||||
new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
Spacing = 8,
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
Children = { ok }
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ok.Click += (_, _) => dialog.Close();
|
||||
|
||||
await dialog.ShowDialog(owner);
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task<bool> ShowConfirmAsync(string message)
|
||||
{
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
if (owner == null) return false;
|
||||
|
||||
var tcs = new TaskCompletionSource<bool>();
|
||||
|
||||
var cancel = new Button { Content = "Cancel", MinWidth = 90 };
|
||||
var confirm = new Button { Content = "Delete", MinWidth = 90, Classes = { "danger" } };
|
||||
|
||||
var dialog = new Window
|
||||
{
|
||||
Title = "Confirm",
|
||||
Width = 360,
|
||||
SizeToContent = SizeToContent.Height,
|
||||
CanResize = false,
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner,
|
||||
ShowInTaskbar = false,
|
||||
Background = this.FindResource("SurfaceBrush") as IBrush,
|
||||
Content = new StackPanel
|
||||
{
|
||||
Spacing = 16,
|
||||
Margin = new Thickness(20),
|
||||
Children =
|
||||
{
|
||||
new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },
|
||||
new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
Spacing = 8,
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
Children = { cancel, confirm }
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
cancel.Click += (_, _) => { tcs.TrySetResult(false); dialog.Close(); };
|
||||
confirm.Click += (_, _) => { tcs.TrySetResult(true); dialog.Close(); };
|
||||
dialog.Closed += (_, _) => tcs.TrySetResult(false);
|
||||
|
||||
_ = dialog.ShowDialog(owner);
|
||||
return await tcs.Task;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -55,7 +55,6 @@ public partial class TasksIslandView : UserControl
|
||||
await modal.ShowDialog(owner);
|
||||
// ShowDialog completes once the window is closed (CloseAction or OS close).
|
||||
};
|
||||
vm.ConfirmAsync = ShowConfirmAsync;
|
||||
vm.SelectionChanged += (_, _) => ScrollSelectedIntoView();
|
||||
}
|
||||
};
|
||||
@@ -71,50 +70,6 @@ public partial class TasksIslandView : UserControl
|
||||
Dispatcher.UIThread.Post(() => RowsListBox.ScrollIntoView(target), DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task<bool> ShowConfirmAsync(string message)
|
||||
{
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
if (owner is null) return false;
|
||||
|
||||
var tcs = new TaskCompletionSource<bool>();
|
||||
var cancel = new Button { Content = "Cancel", MinWidth = 90 };
|
||||
var confirm = new Button { Content = "Confirm", MinWidth = 90, Classes = { "danger" } };
|
||||
|
||||
var dialog = new Window
|
||||
{
|
||||
Title = "Confirm",
|
||||
Width = 380,
|
||||
SizeToContent = SizeToContent.Height,
|
||||
CanResize = false,
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner,
|
||||
ShowInTaskbar = false,
|
||||
Background = this.FindResource("SurfaceBrush") as IBrush,
|
||||
Content = new StackPanel
|
||||
{
|
||||
Margin = new Thickness(20),
|
||||
Spacing = 16,
|
||||
Children =
|
||||
{
|
||||
new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap },
|
||||
new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
Spacing = 8,
|
||||
Children = { cancel, confirm },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
cancel.Click += (_, _) => { tcs.TrySetResult(false); dialog.Close(); };
|
||||
confirm.Click += (_, _) => { tcs.TrySetResult(true); dialog.Close(); };
|
||||
dialog.Closed += (_, _) => tcs.TrySetResult(false);
|
||||
|
||||
_ = dialog.ShowDialog(owner);
|
||||
return await tcs.Task;
|
||||
}
|
||||
|
||||
// ── Custom ghost drag ────────────────────────────────────────────────────
|
||||
// Replaces both the OLE DoDragDropAsync reorder and the OLE drop-to-queue path: a hand-built
|
||||
// drag (pointer capture + a transparent topmost ghost window) is the only way to get a
|
||||
|
||||
@@ -80,11 +80,6 @@ public partial class MainWindow : Window
|
||||
WindowState = WindowState.Normal;
|
||||
Activate();
|
||||
};
|
||||
vm.CloseWindow = () =>
|
||||
{
|
||||
_forceClose = true;
|
||||
Close();
|
||||
};
|
||||
if (vm.Tasks is not null)
|
||||
vm.Tasks.IsWindowVisible = WindowState != WindowState.Minimized;
|
||||
}
|
||||
|
||||
@@ -4,11 +4,16 @@
|
||||
xmlns:views="using:ClaudeDo.Ui.Views"
|
||||
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
||||
xmlns:conv="using:Avalonia.Data.Converters"
|
||||
xmlns:cconv="using:ClaudeDo.Ui.Converters"
|
||||
x:DataType="vm:ConPtyPaneViewModel"
|
||||
x:Class="ClaudeDo.Ui.Views.MissionControl.ConPtyPaneView">
|
||||
<Border Classes="monitor-pane" BorderThickness="1" CornerRadius="10" ClipToBounds="True">
|
||||
<DockPanel LastChildFill="True">
|
||||
|
||||
<!-- Identity strip: same colour for the same session across handoffs and restarts. -->
|
||||
<Border DockPanel.Dock="Left" Width="3"
|
||||
Background="{Binding IdentityColorKey, Converter={x:Static cconv:DotBrushConverter.Instance}}" />
|
||||
|
||||
<!-- Header: title + close -->
|
||||
<Border DockPanel.Dock="Top"
|
||||
Background="{DynamicResource Surface2Brush}"
|
||||
|
||||
@@ -48,6 +48,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>
|
||||
|
||||
@@ -8,6 +8,7 @@ using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Avalonia.VisualTree;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using ClaudeDo.Ui.ViewModels;
|
||||
|
||||
namespace ClaudeDo.Ui.Views.MissionControl;
|
||||
@@ -28,10 +29,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()
|
||||
{
|
||||
@@ -73,20 +80,21 @@ public partial class MissionControlView : UserControl
|
||||
UpdateQueueColumn();
|
||||
}
|
||||
|
||||
// Abandon-run confirmation -- same TaskCompletionSource-backed plain Window as
|
||||
// TasksIslandView.ShowConfirmAsync (no shared control for this shape yet).
|
||||
// Abandon-run confirmation -- own plain-Window TaskCompletionSource dialog, not routed
|
||||
// through WindowDialogService: it's owned by the main window, so showing it while Mission
|
||||
// Control (a separate Window) is focused would put it behind Mission Control.
|
||||
private async Task<bool> ShowConfirmAsync(string message)
|
||||
{
|
||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||
if (owner is null) return false;
|
||||
|
||||
var tcs = new TaskCompletionSource<bool>();
|
||||
var cancel = new Button { Content = "Cancel", MinWidth = 90 };
|
||||
var confirm = new Button { Content = "Confirm", MinWidth = 90, Classes = { "danger" } };
|
||||
var cancel = new Button { Content = Loc.T("dialog.cancel"), MinWidth = 90 };
|
||||
var confirm = new Button { Content = Loc.T("dialog.confirm"), MinWidth = 90, Classes = { "danger" } };
|
||||
|
||||
var dialog = new Window
|
||||
{
|
||||
Title = "Confirm",
|
||||
Title = Loc.T("dialog.confirmTitle"),
|
||||
Width = 380,
|
||||
SizeToContent = SizeToContent.Height,
|
||||
CanResize = false,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -70,6 +70,16 @@
|
||||
TextWrapping="Wrap"
|
||||
IsVisible="{Binding ErrorMessage, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||
|
||||
<TextBlock Text="{Binding WarningMessage}"
|
||||
Foreground="{DynamicResource AmberBrush}"
|
||||
TextWrapping="Wrap"
|
||||
IsVisible="{Binding WarningMessage, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||
|
||||
<Button Classes="btn" HorizontalAlignment="Left"
|
||||
Content="{loc:Tr modals.merge.stashAndMerge}"
|
||||
Command="{Binding StashAndMergeCommand}"
|
||||
IsVisible="{Binding ShowStashRetry}" />
|
||||
|
||||
<Border Classes="danger-box"
|
||||
IsVisible="{Binding HasConflict}">
|
||||
<StackPanel Spacing="4">
|
||||
|
||||
@@ -58,13 +58,18 @@
|
||||
CommandParameter="{Binding}"/>
|
||||
</ContextMenu>
|
||||
</Border.ContextMenu>
|
||||
<Grid ColumnDefinitions="Auto,*,90,90,80,80">
|
||||
<Grid ColumnDefinitions="Auto,*,90,90,110,80">
|
||||
<CheckBox Grid.Column="0" VerticalAlignment="Center" Margin="0,0,8,0"
|
||||
IsChecked="{Binding IsChecked, Mode=TwoWay}"
|
||||
IsEnabled="{Binding IsActive}"
|
||||
IsVisible="{Binding IsActive}"/>
|
||||
<StackPanel Grid.Column="1" Orientation="Vertical" Spacing="2">
|
||||
<TextBlock Classes="title" Text="{Binding TaskTitle}"/>
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<TextBlock Grid.Column="0" Classes="meta" Text="{Binding TaskNumber, StringFormat='#{0}'}"
|
||||
IsVisible="{Binding ShowNumberBadge}"
|
||||
VerticalAlignment="Center" Margin="0,0,6,0"/>
|
||||
<TextBlock Grid.Column="1" Classes="title" Text="{Binding TaskTitle}"/>
|
||||
</Grid>
|
||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||
<TextBlock Classes="meta" Text="{Binding TaskStatus}"/>
|
||||
<TextBlock Classes="meta" Text="•"
|
||||
@@ -85,7 +90,8 @@
|
||||
HorizontalAlignment="Left" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding State}"/>
|
||||
</Border>
|
||||
<TextBlock Grid.Column="4" Classes="meta" Text="{Binding DiffStat}" VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="4" Classes="meta" Text="{Binding DiffStat}" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" ToolTip.Tip="{Binding DiffStat}"/>
|
||||
<TextBlock Grid.Column="5" Classes="meta" Text="{Binding AgeText}" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
@@ -146,8 +152,11 @@
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:WorktreeOverviewRowViewModel">
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="0,2">
|
||||
<TextBlock Grid.Column="0" Classes="meta" VerticalAlignment="Center"
|
||||
Text="{Binding TaskTitle}"/>
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="4" VerticalAlignment="Center">
|
||||
<TextBlock Classes="meta" Text="{Binding TaskNumber, StringFormat='#{0}'}"
|
||||
IsVisible="{Binding ShowNumberBadge}"/>
|
||||
<TextBlock Classes="meta" Text="{Binding TaskTitle}"/>
|
||||
</StackPanel>
|
||||
<Button Grid.Column="1" Classes="btn"
|
||||
Content="{loc:Tr modals.worktreesOverview.resolve}"
|
||||
Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).ResolveConflictCommand}"
|
||||
@@ -159,7 +168,7 @@
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<!-- Column headers -->
|
||||
<Grid ColumnDefinitions="Auto,*,90,90,80,80" Margin="12,0,12,4">
|
||||
<Grid ColumnDefinitions="Auto,*,90,90,110,80" Margin="12,0,12,4">
|
||||
<TextBlock Grid.Column="1" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnTask}"/>
|
||||
<TextBlock Grid.Column="2" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnOutcome}"/>
|
||||
<TextBlock Grid.Column="3" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnState}"/>
|
||||
|
||||
@@ -6,6 +6,7 @@ using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels;
|
||||
using ClaudeDo.Ui.ViewModels.Conflicts;
|
||||
@@ -71,7 +72,7 @@ public sealed class WindowDialogService : IDialogService
|
||||
|
||||
public async Task ShowSettingsAsync(SettingsModalViewModel vm)
|
||||
{
|
||||
vm.Worktrees.ConfirmAsync = ConfirmAsync;
|
||||
vm.Worktrees.ConfirmAsync = message => ConfirmAsync(message);
|
||||
var dlg = new SettingsModalView { DataContext = vm };
|
||||
vm.RequestRepoImport = () =>
|
||||
{
|
||||
@@ -90,7 +91,7 @@ public sealed class WindowDialogService : IDialogService
|
||||
{
|
||||
var dlg = new ListSettingsModalView { DataContext = vm };
|
||||
vm.CloseAction = () => dlg.Close();
|
||||
vm.ConfirmAsync = ConfirmAsync;
|
||||
vm.ConfirmAsync = message => ConfirmAsync(message);
|
||||
vm.ShowErrorAsync = ShowErrorAsync;
|
||||
await dlg.ShowDialog(_owner);
|
||||
}
|
||||
@@ -144,7 +145,7 @@ public sealed class WindowDialogService : IDialogService
|
||||
_diffViewerWindow = diffDlg;
|
||||
diffDlg.Show(_owner);
|
||||
};
|
||||
vm.ConfirmAction = ConfirmAsync;
|
||||
vm.ConfirmAction = message => ConfirmAsync(message);
|
||||
if (Shell is { } shell)
|
||||
{
|
||||
vm.ResolveMergeVm = shell.ResolveMergeVm;
|
||||
@@ -206,13 +207,13 @@ public sealed class WindowDialogService : IDialogService
|
||||
_cheatSheets.Activate();
|
||||
}
|
||||
|
||||
public Task<bool> ConfirmAsync(string message)
|
||||
public Task<bool> ConfirmAsync(string message, string? confirmLabel = null)
|
||||
{
|
||||
var tcs = new TaskCompletionSource<bool>();
|
||||
var cancel = new Button { Content = "Cancel", MinWidth = 90 };
|
||||
var confirm = new Button { Content = "Confirm", MinWidth = 90, Classes = { "danger" } };
|
||||
var cancel = new Button { Content = Loc.T("dialog.cancel"), MinWidth = 90 };
|
||||
var confirm = new Button { Content = confirmLabel ?? Loc.T("dialog.confirm"), MinWidth = 90, Classes = { "danger" } };
|
||||
|
||||
var dialog = NoticeWindow("Confirm", 380, message,
|
||||
var dialog = NoticeWindow(Loc.T("dialog.confirmTitle"), 380, message,
|
||||
new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
@@ -231,8 +232,8 @@ public sealed class WindowDialogService : IDialogService
|
||||
|
||||
public Task ShowErrorAsync(string message)
|
||||
{
|
||||
var ok = new Button { Content = "OK", MinWidth = 90 };
|
||||
var dialog = NoticeWindow("Error", 360, message,
|
||||
var ok = new Button { Content = Loc.T("dialog.ok"), MinWidth = 90 };
|
||||
var dialog = NoticeWindow(Loc.T("dialog.errorTitle"), 360, message,
|
||||
new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
|
||||
@@ -51,11 +51,11 @@ subfolder within their area; the namespace stays the area namespace.
|
||||
|
||||
- **Program.cs** — loads config, inits schema, registers DI, configures SignalR on `/hub`, binds to `127.0.0.1:47821`
|
||||
- **TaskStateService** — the **only** component that writes `Status`, `PlanningPhase`, `BlockedByTaskId`, and the `task_dependencies` edges. All transitions return a `TransitionResult` (no exceptions on invalid moves). Wakes the queue and advances the planning chain on child terminal transitions. `SubmitForChildrenAsync` (Running → WaitingForChildren) also chains and immediately queues a normal parent's own Idle/Queued children via `PlanningChainCoordinator.SetupChainAsync(enqueue: true)` — unlike a planning parent (`PlanningPhase != None`), whose children stay untouched here and wait for the user's explicit "Queue plan" (`QueuePlanAsync`); a child already `Running` makes `SetupChainAsync` throw, which is caught and logged rather than failing the parent's own transition. Every transition broadcasts via a private `NotifyAsync(taskId)` — never call `_broadcaster.TaskUpdated` directly from this class, `NotifyAsync` also drives `TicketStatusSync.SyncAsync` (see Tickets below) and a direct call skips that transition's ticket write-back. `SetDependsOnAsync` replaces the task's whole dependency **set** (empty list clears it) and rejects a self-reference, an unknown dependency id, or any link that would create a cycle — a BFS over the whole `task_dependencies` edge set from every proposed predecessor, so a multi-edge loop (A→B→C→A) is caught too, and one bad edge leaves the entire set unwritten.
|
||||
- **IQueueWaker / IQueuePicker / QueueService** — waker is a singleton `SemaphoreSlim`; picker performs the atomic `Queued → Running` claim filtered by `BlockedByTaskId IS NULL`, `is_manual = 0`, schedule, and "no `task_dependencies` edge whose predecessor isn't `done`" (doubly negated on purpose: zero edges stays eligible, all edges must be done, and an edge pointing at a vanished task keeps blocking); QueueService is a thin `BackgroundService` looping on the waker, dispatching via `TaskRunner`. Per tick it also applies the usage throttle and gate → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md).
|
||||
- **IQueueWaker / IQueuePicker / QueueService** — waker is a singleton `SemaphoreSlim`; picker performs the atomic `Queued → Running` claim filtered by `BlockedByTaskId IS NULL`, `is_manual = 0`, schedule, and "no `task_dependencies` edge whose predecessor isn't satisfied" — satisfied = `status = 'done'` **or** a landed merge (`worktrees.state = 'merged'`), so a predecessor merged with `skipVerify` and parked in `WaitingForReview` until the batch `verify_merges` no longer stalls its successors (doubly negated on purpose: zero edges stays eligible, all edges must be satisfied, and an edge pointing at a vanished task keeps blocking); QueueService is a thin `BackgroundService` looping on the waker, dispatching via `TaskRunner`. Per tick it also applies the usage throttle and gate → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md).
|
||||
- **RunCancellationRegistry** — taskId → running-run CTS. Lets `TaskStateService.CancelAsync` kill a cancelled task's process without a DI cycle.
|
||||
- **OverrideSlotService** — owns `RunNow` / `ContinueTask`; goes through `TaskStateService.StartRunningAsync` (caller-driven, serialized by slot lock).
|
||||
- **StaleTaskRecovery** — startup-only; calls `TaskStateService.RecoverStaleRunningAsync` to flip orphaned `Running` rows to `Failed`.
|
||||
- **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
|
||||
|
||||
@@ -67,7 +67,7 @@ not conflated.
|
||||
| `Status` | `Idle`, `Queued`, `Running`, `WaitingForChildren`, `WaitingForReview`, `Done`, `Failed`, `Cancelled` | Lifecycle only. `WaitingForChildren` = parent's own work done, waiting on children. |
|
||||
| `PlanningPhase` | `None`, `Active`, `Finalized` | Parent-only marker. `Active` ≈ legacy `Planning`; `Finalized` ≈ legacy `Planned`. |
|
||||
| `BlockedByTaskId` | nullable FK | Replaces legacy `Waiting`. A queued row with a non-null value is skipped by the picker. Internal to `PlanningChainCoordinator` — resolves (or cascades) on ANY terminal state of the predecessor. Because it self-resolves, `wait_for_task_change` deliberately keeps **waiting** through it instead of reporting `"Blocked"`. |
|
||||
| `Dependencies` | 0..n rows in `task_dependencies` | User/MCP-declared predecessors (`add_task`/`update_task`), separate from `BlockedByTaskId` because the semantics differ: the picker skips a queued row until **every** dependency reached `Done` -- a Failed/Cancelled dependency does **not** cascade or auto-resolve, the dependent just stays blocked (see `QueuePicker`, `TaskStateService.SetDependsOnAsync`). `get_task`/`list_tasks`/`batch_get_tasks` surface this as `dependsOnTaskIds` + `blocked`/`blockedReason` (the reason names **all** open predecessors, via `External/TaskBlocking`); `wait_for_task_change` reports `"Blocked"` instead of silently running out its timeout. |
|
||||
| `Dependencies` | 0..n rows in `task_dependencies` | User/MCP-declared predecessors (`add_task`/`update_task`), separate from `BlockedByTaskId` because the semantics differ: the picker skips a queued row until **every** dependency's merge has landed — `Done`, or `worktrees.state = 'merged'` while the batch `verify_merges` is still pending. A Failed/Cancelled dependency does **not** cascade or auto-resolve, the dependent just stays blocked; `revert_merge`/`abort_merge` put the worktree back to `Active` and re-block the successors; a failing `verify_merges` does **not** (the code is on the target branch either way). See `QueuePicker`, `TaskStateService.SetDependsOnAsync`. `get_task`/`list_tasks`/`batch_get_tasks` surface this as `dependsOnTaskIds` + `blocked`/`blockedReason` (the reason names **all** open predecessors, via `External/TaskBlocking`); `wait_for_task_change` reports `"Blocked"` instead of silently running out its timeout. |
|
||||
| `IsManual` | bool | Reminder only the user can do. `EnqueueAsync`/`StartRunningAsync` refuse it, the picker skips it, `GetDailyPrepCandidates` never offers it. An interactive ConPTY session is still allowed. |
|
||||
| `ReviewFeedback` | nullable string | Reviewer's rejection comment; consumed and cleared by `QueueService` on the next re-run. |
|
||||
|
||||
@@ -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
|
||||
@@ -254,7 +254,10 @@ non-obvious, behaviour-changing. A fixed bug is git history, not a finding.
|
||||
Both edit the DI-singleton `WorkerConfig` in place *and* persist via their own `SaveKey` call
|
||||
(`SaveClaudeBin`/`SaveTicketApiBaseUrl`), so each applies without a restart. Every other field
|
||||
here is hand-edit only.
|
||||
- `usage_poll_interval_seconds` (60, clamped to min 15 on load)
|
||||
- `usage_poll_interval_active_seconds` / `usage_poll_interval_idle_seconds` (120 each, clamped to
|
||||
min 90 on load) — measured 2026-09-16: the endpoint allows exactly 5 requests per 5-minute
|
||||
window, then 429s with `Retry-After: 300`; 120s leaves margin for manual refreshes and other
|
||||
consumers of the same OAuth token, 60s eats the whole quota (see [usage-monitoring](../../docs/explore-notes/usage-monitoring.md)).
|
||||
- `online_inbox` — `enabled` (false by default; when false the entire `Online/` stack is not registered), `api_base_url` (must be HTTPS or loopback, validated at startup), `poll_interval_seconds` (60), `zitadel.authority`/`client_id`/`scopes`. The refresh token is **not** in this file — DPAPI-encrypted at `~/.claudeDo/online-inbox.token`.
|
||||
|
||||
Per-list config (`list_config` in DB) provides defaults for `model`, `system_prompt`,
|
||||
@@ -306,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.
|
||||
|
||||
@@ -46,16 +46,25 @@ public sealed class WorkerConfig
|
||||
|
||||
/// <summary>
|
||||
/// Usage-monitor poll interval while at least one task is Running. Clamped to a minimum
|
||||
/// of 60s on load — the endpoint rate-limits (429) on tighter polling.
|
||||
/// of 90s on load. Measured 2026-09-16 against the live endpoint: a clean burst allows
|
||||
/// exactly 5 requests before a 429, with <c>Retry-After: 300</c> (a flat 5-req/5-min quota,
|
||||
/// no <c>ratelimit-*</c> headers). 120s leaves ~2.5 requests of headroom per 5-minute window
|
||||
/// for manual refreshes and any other consumer of the same OAuth token — 60s consumes the
|
||||
/// whole quota by itself with zero margin, which is what made the old flat-60s cadence 429
|
||||
/// (see commit 2700c3d8). Same default as <see cref="UsagePollIntervalIdleSeconds"/>: the
|
||||
/// quota is a flat per-account limit, not tied to ClaudeDo's own activity, so an active/idle
|
||||
/// split buys nothing safety-wise. The two config keys stay separate for compatibility.
|
||||
/// </summary>
|
||||
[JsonPropertyName("usage_poll_interval_active_seconds")]
|
||||
public int UsagePollIntervalActiveSeconds { get; set; } = 300;
|
||||
public int UsagePollIntervalActiveSeconds { get; set; } = 120;
|
||||
|
||||
/// <summary>
|
||||
/// Usage-monitor poll interval while nothing is running. Clamped to a minimum of 60s on load.
|
||||
/// Usage-monitor poll interval while nothing is running. Clamped to a minimum of 90s on
|
||||
/// load. Same default as <see cref="UsagePollIntervalActiveSeconds"/> — see that doc for the
|
||||
/// measured rate limit this is sized against.
|
||||
/// </summary>
|
||||
[JsonPropertyName("usage_poll_interval_idle_seconds")]
|
||||
public int UsagePollIntervalIdleSeconds { get; set; } = 900;
|
||||
public int UsagePollIntervalIdleSeconds { get; set; } = 120;
|
||||
|
||||
/// <summary>Base URL of the Bandel ticket-system API. Null = ticket integration off entirely.</summary>
|
||||
[JsonPropertyName("ticket_api_base_url")]
|
||||
@@ -88,8 +97,8 @@ public sealed class WorkerConfig
|
||||
cfg.SandboxRoot = Paths.Expand(cfg.SandboxRoot);
|
||||
cfg.LogRoot = Paths.Expand(cfg.LogRoot);
|
||||
cfg.CentralWorktreeRoot = Paths.Expand(cfg.CentralWorktreeRoot);
|
||||
cfg.UsagePollIntervalActiveSeconds = Math.Max(60, cfg.UsagePollIntervalActiveSeconds);
|
||||
cfg.UsagePollIntervalIdleSeconds = Math.Max(60, cfg.UsagePollIntervalIdleSeconds);
|
||||
cfg.UsagePollIntervalActiveSeconds = Math.Max(90, cfg.UsagePollIntervalActiveSeconds);
|
||||
cfg.UsagePollIntervalIdleSeconds = Math.Max(90, cfg.UsagePollIntervalIdleSeconds);
|
||||
|
||||
return cfg;
|
||||
}
|
||||
|
||||
+20
-11
@@ -216,14 +216,19 @@ public sealed class ExternalMcpService
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
var dependencyStatuses = new Dictionary<string, TaskStatus>();
|
||||
var predecessors = new Dictionary<string, (TaskStatus Status, bool Merged)>();
|
||||
if (dependencyIds.Count > 0)
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
dependencyStatuses = await ctx.Tasks.AsNoTracking()
|
||||
predecessors = await ctx.Tasks.AsNoTracking()
|
||||
.Where(t => dependencyIds.Contains(t.Id))
|
||||
.Select(t => new { t.Id, t.Status })
|
||||
.ToDictionaryAsync(t => t.Id, t => t.Status, ct);
|
||||
.Select(t => new
|
||||
{
|
||||
t.Id,
|
||||
t.Status,
|
||||
Merged = t.Worktree != null && t.Worktree.State == WorktreeState.Merged,
|
||||
})
|
||||
.ToDictionaryAsync(t => t.Id, t => (t.Status, t.Merged), ct);
|
||||
}
|
||||
|
||||
var result = new Dictionary<string, (bool Blocked, string? Reason)>();
|
||||
@@ -239,7 +244,7 @@ public sealed class ExternalMcpService
|
||||
}
|
||||
else
|
||||
{
|
||||
var unmet = TaskBlocking.Unmet(t.DependsOnTaskIds, dependencyStatuses);
|
||||
var unmet = TaskBlocking.Unmet(t.DependsOnTaskIds, predecessors);
|
||||
result[t.Id] = unmet.Count == 0 ? (false, null) : (true, TaskBlocking.Describe(unmet));
|
||||
}
|
||||
}
|
||||
@@ -290,13 +295,14 @@ public sealed class ExternalMcpService
|
||||
"for normal coding, 'opus' only for complex or cross-cutting work. null inherits the " +
|
||||
"list/global default (normally sonnet).")]
|
||||
string? model = null,
|
||||
[Description("Id of a task that must reach Done (i.e. be merged) before the picker will claim this one, " +
|
||||
"even once queued. Shorthand for a one-element dependsOnTaskIds; ignored when that is given. " +
|
||||
[Description("Id of a task whose merge must have landed before the picker will claim this one, even once " +
|
||||
"queued (Done, or merged and still awaiting the batch verify). Shorthand for a " +
|
||||
"one-element dependsOnTaskIds; ignored when that is given. " +
|
||||
"Rejected if it doesn't exist, is this task's own id, or would create a " +
|
||||
"dependency cycle. If that predecessor instead ends up Failed or Cancelled, this task simply " +
|
||||
"stays blocked rather than starving silently -- check get_task/list_tasks' blocked field.")]
|
||||
string? dependsOnTaskId = null,
|
||||
[Description("Ids of ALL tasks that must reach Done before the picker will claim this one. Use this when a " +
|
||||
[Description("Ids of ALL tasks whose merges must have landed before the picker will claim this one. Use this when a " +
|
||||
"task wires together, or verifies, the results of several independent tasks -- one waiting on " +
|
||||
"A and B is the normal shape of a wave-style plan. Same rejection rules as dependsOnTaskId.")]
|
||||
string[]? dependsOnTaskIds = null,
|
||||
@@ -460,12 +466,13 @@ public sealed class ExternalMcpService
|
||||
string? title = null,
|
||||
string? description = null,
|
||||
string? commitType = null,
|
||||
[Description("Id of a task that must reach Done before the picker will claim this one. Shorthand for a " +
|
||||
[Description("Id of a task whose merge must have landed (Done, or merged and still awaiting the batch " +
|
||||
"verify) before the picker will claim this one. Shorthand for a " +
|
||||
"one-element dependsOnTaskIds; ignored when that is given. Pass an empty " +
|
||||
"string to clear all existing links; null leaves them unchanged. Rejected if it doesn't exist, " +
|
||||
"is this task's own id, or would create a dependency cycle.")]
|
||||
string? dependsOnTaskId = null,
|
||||
[Description("Ids of ALL tasks that must reach Done before the picker will claim this one, replacing the " +
|
||||
[Description("Ids of ALL tasks whose merges must have landed before the picker will claim this one, replacing the " +
|
||||
"task's current set. Pass an empty array to clear it; null leaves it unchanged. Use this when " +
|
||||
"a task wires together, or verifies, the results of several independent tasks.")]
|
||||
string[]? dependsOnTaskIds = null,
|
||||
@@ -708,7 +715,9 @@ public sealed class ExternalMcpService
|
||||
else
|
||||
{
|
||||
var r = await _merge.ApproveAndMergeAsync(taskId, targetBranch ?? "", leaveConflictsInTree, cancellationToken, progress, skipVerify);
|
||||
if (r.Status == TaskMergeService.StatusBlocked)
|
||||
// dirty_target is a refusal like blocked -- only the UI turns it into a stash
|
||||
// retry; an MCP session must not read it as an approve that went through.
|
||||
if (r.Status is TaskMergeService.StatusBlocked or TaskMergeService.StatusDirtyTarget)
|
||||
throw new InvalidOperationException(r.ErrorMessage ?? "approve failed");
|
||||
mergeStatus = r.Status;
|
||||
mergeConflicts = r.ConflictFiles;
|
||||
|
||||
@@ -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);
|
||||
|
||||
+309
-43
@@ -1,6 +1,8 @@
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Git;
|
||||
using ClaudeDo.Data.Models;
|
||||
@@ -61,7 +63,14 @@ public sealed record MergePreviewSetEntryDto(
|
||||
// Mirrors TaskEntity.ExpectsNoDiff -- lets the merge helper tell a deliberately empty diff
|
||||
// (verification/audit/research task) apart from a task that silently failed to commit, both
|
||||
// of which otherwise show IsEmpty=true with no other distinguishing signal.
|
||||
bool ExpectsNoDiff = false);
|
||||
bool ExpectsNoDiff = false,
|
||||
// Top-level C# declarations (methods/properties/fields/types/events) this task's diff removes
|
||||
// -- a line-pattern scan over `-` lines, not a parser: false positives are possible, a missed
|
||||
// removal is not the intent. Skips .axaml/.json. Null for an entry with Error set. A name
|
||||
// another task in the same set still touches on a `+` line surfaces in SymbolWarnings below.
|
||||
IReadOnlyList<RemovedSymbolDto>? RemovedSymbols = null);
|
||||
|
||||
public sealed record RemovedSymbolDto(string Name, string File);
|
||||
|
||||
public sealed record FileOverlapDto(string File, IReadOnlyList<string> TaskIds, IReadOnlyList<int> Numbers);
|
||||
|
||||
@@ -71,9 +80,16 @@ public sealed record FileOverlapDto(string File, IReadOnlyList<string> TaskIds,
|
||||
// signal that TaskId's work may already be entirely covered by SupersetTaskId.
|
||||
public sealed record SubsetRelationDto(string TaskId, int Number, string SupersetTaskId, int SupersetNumber);
|
||||
|
||||
// RemovedByTaskId's diff removes Symbol (in RemovedInFile); ReferencedByTaskId's diff still touches
|
||||
// that name on a `+` line elsewhere -- no textual file conflict, so Overlaps alone misses it (e.g.
|
||||
// one task deletes a method, a sibling task's edit in a different file still calls it).
|
||||
public sealed record SymbolReferenceWarningDto(
|
||||
string Symbol, string RemovedInFile, string RemovedByTaskId, int RemovedByNumber,
|
||||
string ReferencedByTaskId, int ReferencedByNumber, string ReferencedInFile);
|
||||
|
||||
public sealed record MergePreviewSetResultDto(
|
||||
IReadOnlyList<MergePreviewSetEntryDto> Tasks, IReadOnlyList<FileOverlapDto> Overlaps,
|
||||
IReadOnlyList<SubsetRelationDto> Subsets);
|
||||
IReadOnlyList<SubsetRelationDto> Subsets, IReadOnlyList<SymbolReferenceWarningDto> SymbolWarnings);
|
||||
|
||||
public sealed record RevertMergeResultDto(
|
||||
bool Reverted, string? RevertCommit, IReadOnlyList<string> Conflicts, string? Message);
|
||||
@@ -446,26 +462,30 @@ public sealed class MergeMcpTools
|
||||
"untracked, in the target working directory — merge-tree can't see the working tree at all, so this is the " +
|
||||
"only way to catch it before a real merge either refuses or, if that path became tracked in the meantime, " +
|
||||
"silently overwrites it); behind counts commits on targetBranch not yet on this branch, which flags a stale " +
|
||||
"branch even when the preview is clean. If the list has a verify command configured, a clean preview is " +
|
||||
"additionally built/tested in a scratch worktree (still without touching the real working tree) — " +
|
||||
"verifyExitCode 0 means it built clean, non-zero or -1 (timeout/failed to start) means it doesn't, with the " +
|
||||
"tail of its output in verifyOutputTail; verifyExitCode stays null when no verify command is configured. " +
|
||||
"isEmpty=true means the task's review range contributed nothing; check that flag rather than reading a " +
|
||||
"small changedFileCount as empty. staleFiles lists files this branch touches that the target branch ALSO " +
|
||||
"changed since this branch's fork point — a more honest staleness signal than `behind` alone, since a " +
|
||||
"branch can be far behind yet touch nothing the target changed, or barely behind yet collide on the one " +
|
||||
"file that matters (always empty for a worktree-less handler task, which has no fork point). Throws if the " +
|
||||
"task has neither an active worktree nor a handler commit range, or the list's working directory is missing " +
|
||||
"from disk." + McpToolDocs.ProgressHint)]
|
||||
"branch even when the preview is clean. runVerify=false (default) never builds. runVerify=true additionally " +
|
||||
"builds/tests a clean preview in a scratch worktree (still without touching the real working tree), if the " +
|
||||
"list has a verify command configured — verifyExitCode 0 means it built clean, non-zero or -1 " +
|
||||
"(timeout/failed to start) means it doesn't, with the tail of its output in verifyOutputTail; " +
|
||||
"verifyExitCode/verifyDurationMs/verifyOutputTail stay null without runVerify=true or without a configured " +
|
||||
"verify command. isEmpty=true means the task's review range contributed nothing; check that flag rather " +
|
||||
"than reading a small changedFileCount as empty. staleFiles lists files this branch touches that the " +
|
||||
"target branch ALSO changed since this branch's fork point — a more honest staleness signal than `behind` " +
|
||||
"alone, since a branch can be far behind yet touch nothing the target changed, or barely behind yet " +
|
||||
"collide on the one file that matters (always empty for a worktree-less handler task, which has no fork " +
|
||||
"point). Throws if the task has neither an active worktree nor a handler commit range, or the list's " +
|
||||
"working directory is missing from disk." + McpToolDocs.ProgressHint)]
|
||||
public async Task<MergePreviewToolDto> PreviewMerge(
|
||||
string taskId,
|
||||
[Description("Branch to preview against; defaults to the repo's current branch.")]
|
||||
string? targetBranch = null,
|
||||
[Description("true: also run the verify command (if configured), building in a scratch worktree. " +
|
||||
"false (default): no build.")]
|
||||
bool runVerify = false,
|
||||
CancellationToken cancellationToken = default,
|
||||
IProgress<ProgressNotificationValue>? progress = null)
|
||||
{
|
||||
taskId = await TaskIdResolver.ResolveAsync(_tasks, taskId, cancellationToken);
|
||||
var (preview, behind, _, isEmpty, staleFiles, _, _) = await PreviewMergeCoreAsync(taskId, targetBranch, runVerify: true, cancellationToken, progress);
|
||||
var (preview, behind, _, isEmpty, staleFiles, _, _, _, _, _) = await PreviewMergeCoreAsync(taskId, targetBranch, runVerify, cancellationToken, progress);
|
||||
return new MergePreviewToolDto(preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, isEmpty,
|
||||
preview.VerifyExitCode, preview.VerifyDurationMs, preview.VerifyOutputTail, staleFiles);
|
||||
}
|
||||
@@ -473,16 +493,21 @@ public sealed class MergeMcpTools
|
||||
[McpServerTool, Description(
|
||||
"Plan a batch merge: preview_merge for several tasks against the same targetBranch, plus a file-overlap " +
|
||||
"check between them. Per entry you get preview_merge's fields, or error instead when that task could not " +
|
||||
"be previewed (it is then left out of the overlap/subset computation). overlaps names, for each file " +
|
||||
"touched by MORE THAN ONE of the given tasks, which tasks touch it — a single taskId always yields no " +
|
||||
"overlaps. subsets flags a stronger relation: TaskId's changed files are a PROPER subset of " +
|
||||
"be previewed (it is then left out of the overlap/subset/symbol computation). overlaps names, for each " +
|
||||
"file touched by MORE THAN ONE of the given tasks, which tasks touch it — a single taskId always yields " +
|
||||
"no overlaps; for a .json file this is narrowed to nested-key-path level (parsing both the base commit " +
|
||||
"and the task branch's head), so two tasks adding different keys to the same locale file are not flagged " +
|
||||
"and only a genuinely shared key is — unparsable JSON on either side falls back to the plain file-level " +
|
||||
"check. subsets flags a stronger relation: TaskId's changed files are a PROPER subset of " +
|
||||
"SupersetTaskId's — the strongest hint you get post-hoc that TaskId may be redundant with SupersetTaskId, " +
|
||||
"worth checking before merging both. IMPORTANT: neither overlap nor subset is a safety guarantee — two " +
|
||||
"tasks touching entirely different files (one deleting a symbol, another still referencing it) can still " +
|
||||
"collide unflagged, and as with preview_merge a clean result does not mean the merge builds. " +
|
||||
"runVerify=false (default) never builds — set it true to also run each task's list's verify command in a " +
|
||||
"scratch worktree per entry (same fields as preview_merge); this can take a long time across many tasks, " +
|
||||
"since builds run one at a time." + McpToolDocs.ProgressHint)]
|
||||
"worth checking before merging both. removedSymbols (per task entry) lists C# methods/properties/fields/" +
|
||||
"types/events that task's diff deletes (a line-pattern scan, not a parser — false positives possible, " +
|
||||
"skips .axaml/.json); symbolWarnings then cross-references those names against every OTHER task's added " +
|
||||
"lines in the same set, catching a same-symbol collision even when the two tasks touch entirely different " +
|
||||
"files. IMPORTANT: none of overlaps/subsets/symbolWarnings is a safety guarantee, and as with preview_merge " +
|
||||
"a clean result does not mean the merge builds. runVerify=false (default) never builds — set it true to " +
|
||||
"also run each task's list's verify command in a scratch worktree per entry (same fields as preview_merge); " +
|
||||
"this can take a long time across many tasks, since builds run one at a time." + McpToolDocs.ProgressHint)]
|
||||
public async Task<MergePreviewSetResultDto> PreviewMergeSet(
|
||||
IReadOnlyList<string> taskIds,
|
||||
[Description("Branch to preview every task against; defaults to the repo's current branch.")]
|
||||
@@ -501,18 +526,37 @@ public sealed class MergeMcpTools
|
||||
var entries = new List<MergePreviewSetEntryDto>();
|
||||
var filesByTask = new Dictionary<string, IReadOnlyList<string>>();
|
||||
var numbersByTask = new Dictionary<string, int>();
|
||||
var repoPathByTask = new Dictionary<string, string>();
|
||||
var baseRefByTask = new Dictionary<string, string>();
|
||||
var headRefByTask = new Dictionary<string, string?>();
|
||||
var diffLinesByTask = new Dictionary<string, List<DiffLine>>();
|
||||
var removedSymbolsByTask = new Dictionary<string, IReadOnlyList<RemovedSymbolDto>>();
|
||||
|
||||
for (var i = 0; i < taskIds.Count; i++)
|
||||
{
|
||||
var taskId = taskIds[i];
|
||||
try
|
||||
{
|
||||
var (preview, behind, changedFiles, isEmpty, staleFiles, number, expectsNoDiff) = await PreviewMergeCoreAsync(taskId, targetBranch, runVerify, cancellationToken, progress);
|
||||
entries.Add(new MergePreviewSetEntryDto(
|
||||
taskId, preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, null, isEmpty,
|
||||
preview.VerifyExitCode, preview.VerifyDurationMs, preview.VerifyOutputTail, staleFiles, number, expectsNoDiff));
|
||||
var (preview, behind, changedFiles, isEmpty, staleFiles, number, expectsNoDiff, repoPath, baseRef, headRef) =
|
||||
await PreviewMergeCoreAsync(taskId, targetBranch, runVerify, cancellationToken, progress);
|
||||
filesByTask[taskId] = changedFiles;
|
||||
numbersByTask[taskId] = number;
|
||||
repoPathByTask[taskId] = repoPath;
|
||||
baseRefByTask[taskId] = baseRef;
|
||||
headRefByTask[taskId] = headRef;
|
||||
|
||||
var diff = headRef is null
|
||||
? await _git.GetBranchDiffAsync(repoPath, baseRef, ct: cancellationToken)
|
||||
: await _git.GetCommitRangeDiffAsync(repoPath, baseRef, headRef, ct: cancellationToken);
|
||||
var diffLines = ParseDiffLines(diff);
|
||||
diffLinesByTask[taskId] = diffLines;
|
||||
var removedSymbols = ExtractRemovedSymbols(diffLines);
|
||||
removedSymbolsByTask[taskId] = removedSymbols;
|
||||
|
||||
entries.Add(new MergePreviewSetEntryDto(
|
||||
taskId, preview.Status, preview.ConflictFiles, preview.ChangedFileCount, behind, null, isEmpty,
|
||||
preview.VerifyExitCode, preview.VerifyDurationMs, preview.VerifyOutputTail, staleFiles, number,
|
||||
expectsNoDiff, removedSymbols));
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
@@ -525,23 +569,243 @@ public sealed class MergeMcpTools
|
||||
ProgressReporter.ReportItem(progress, "Previewing merges", i + 1, taskIds.Count);
|
||||
}
|
||||
|
||||
var overlaps = filesByTask
|
||||
.SelectMany(kv => kv.Value.Select(f => (File: f, TaskId: kv.Key)))
|
||||
.GroupBy(x => x.File, StringComparer.OrdinalIgnoreCase)
|
||||
.Where(g => g.Select(x => x.TaskId).Distinct().Count() > 1)
|
||||
.Select(g =>
|
||||
{
|
||||
var ids = g.Select(x => x.TaskId).Distinct().ToList();
|
||||
return new FileOverlapDto(g.Key, ids, ids.Select(id => numbersByTask[id]).ToList());
|
||||
})
|
||||
.OrderBy(o => o.File, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
var overlaps = await ComputeOverlapsAsync(filesByTask, numbersByTask, repoPathByTask, baseRefByTask, headRefByTask, cancellationToken);
|
||||
var subsets = FindSubsetRelations(filesByTask, numbersByTask);
|
||||
var symbolWarnings = FindSymbolWarnings(removedSymbolsByTask, numbersByTask, diffLinesByTask);
|
||||
|
||||
return new MergePreviewSetResultDto(entries, overlaps, subsets);
|
||||
return new MergePreviewSetResultDto(entries, overlaps, subsets, symbolWarnings);
|
||||
}
|
||||
|
||||
// Cross-references each task's removed symbols (Teil 1) against every OTHER task's added
|
||||
// lines in the same set -- a same-named symbol collision that overlaps/subsets (both purely
|
||||
// file-based) can never catch, since the two tasks may not share a single file.
|
||||
private static List<SymbolReferenceWarningDto> FindSymbolWarnings(
|
||||
Dictionary<string, IReadOnlyList<RemovedSymbolDto>> removedSymbolsByTask,
|
||||
Dictionary<string, int> numbersByTask,
|
||||
Dictionary<string, List<DiffLine>> diffLinesByTask)
|
||||
{
|
||||
var warnings = new List<SymbolReferenceWarningDto>();
|
||||
foreach (var (taskId, removed) in removedSymbolsByTask)
|
||||
{
|
||||
foreach (var symbol in removed)
|
||||
{
|
||||
foreach (var (otherTaskId, diffLines) in diffLinesByTask)
|
||||
{
|
||||
if (otherTaskId == taskId) continue;
|
||||
var hit = diffLines.FirstOrDefault(l => l.Sign == '+' && ContainsWord(l.Text, symbol.Name));
|
||||
if (hit.File is not null)
|
||||
warnings.Add(new SymbolReferenceWarningDto(
|
||||
symbol.Name, symbol.File, taskId, numbersByTask[taskId],
|
||||
otherTaskId, numbersByTask[otherTaskId], hit.File));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return warnings
|
||||
.OrderBy(w => w.Symbol, StringComparer.Ordinal)
|
||||
.ThenBy(w => w.RemovedByTaskId, StringComparer.Ordinal)
|
||||
.ThenBy(w => w.ReferencedByTaskId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
// File-level overlap for everything except .json; for .json, narrows to nested-key-path
|
||||
// collisions between the tasks that touch it (Teil 2) -- unless either side fails to parse,
|
||||
// in which case it falls back to the plain file-level check for that file.
|
||||
private async Task<List<FileOverlapDto>> ComputeOverlapsAsync(
|
||||
Dictionary<string, IReadOnlyList<string>> filesByTask, Dictionary<string, int> numbersByTask,
|
||||
Dictionary<string, string> repoPathByTask, Dictionary<string, string> baseRefByTask,
|
||||
Dictionary<string, string?> headRefByTask, CancellationToken ct)
|
||||
{
|
||||
var overlaps = new List<FileOverlapDto>();
|
||||
|
||||
var byFile = filesByTask
|
||||
.SelectMany(kv => kv.Value.Select(f => (File: f, TaskId: kv.Key)))
|
||||
.GroupBy(x => x.File, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var group in byFile)
|
||||
{
|
||||
var taskIds = group.Select(x => x.TaskId).Distinct().ToList();
|
||||
if (taskIds.Count <= 1) continue;
|
||||
|
||||
if (!group.Key.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
overlaps.Add(MakeOverlap(group.Key, taskIds, numbersByTask));
|
||||
continue;
|
||||
}
|
||||
|
||||
var colliding = await FindJsonKeyCollisionTasksAsync(
|
||||
group.Key, taskIds, repoPathByTask, baseRefByTask, headRefByTask, ct);
|
||||
if (colliding is null)
|
||||
overlaps.Add(MakeOverlap(group.Key, taskIds, numbersByTask)); // unparsable -- fall back
|
||||
else if (colliding.Count > 1)
|
||||
overlaps.Add(MakeOverlap(group.Key, colliding, numbersByTask));
|
||||
}
|
||||
|
||||
return overlaps.OrderBy(o => o.File, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
}
|
||||
|
||||
private static FileOverlapDto MakeOverlap(string file, IReadOnlyList<string> taskIds, Dictionary<string, int> numbersByTask)
|
||||
=> new(file, taskIds, taskIds.Select(id => numbersByTask[id]).ToList());
|
||||
|
||||
// Null = give up and fall back to file-level (JSON unparsable on some side for some task).
|
||||
// Otherwise: the subset of taskIds whose changed/added/removed key paths in `file` intersect
|
||||
// with at least one other task's -- two tasks touching disjoint keys in the same file never appear.
|
||||
private async Task<List<string>?> FindJsonKeyCollisionTasksAsync(
|
||||
string file, List<string> taskIds,
|
||||
Dictionary<string, string> repoPathByTask, Dictionary<string, string> baseRefByTask,
|
||||
Dictionary<string, string?> headRefByTask, CancellationToken ct)
|
||||
{
|
||||
var keysByTask = new Dictionary<string, HashSet<string>>();
|
||||
foreach (var taskId in taskIds)
|
||||
{
|
||||
var baseContent = await ReadFileContentAsync(repoPathByTask[taskId], baseRefByTask[taskId], file, ct);
|
||||
var headContent = await ReadFileContentAsync(repoPathByTask[taskId], headRefByTask[taskId], file, ct);
|
||||
if (!TryDiffJsonKeyPaths(baseContent, headContent, out var keys))
|
||||
return null;
|
||||
keysByTask[taskId] = keys;
|
||||
}
|
||||
|
||||
var colliding = new HashSet<string>();
|
||||
foreach (var a in taskIds)
|
||||
foreach (var b in taskIds)
|
||||
{
|
||||
if (a == b) continue;
|
||||
if (keysByTask[a].Overlaps(keysByTask[b]))
|
||||
{
|
||||
colliding.Add(a);
|
||||
colliding.Add(b);
|
||||
}
|
||||
}
|
||||
|
||||
return taskIds.Where(colliding.Contains).ToList();
|
||||
}
|
||||
|
||||
// gitRef null = read the live working tree off disk (mirrors GetBranchDiffAsync's own base-vs-
|
||||
// working-tree comparison); non-null = `git show ref:path` (handler task's fixed commit range).
|
||||
private async Task<string?> ReadFileContentAsync(string repoPath, string? gitRef, string relativePath, CancellationToken ct)
|
||||
{
|
||||
if (gitRef is null)
|
||||
{
|
||||
var full = Path.Combine(repoPath, relativePath);
|
||||
return File.Exists(full) ? await File.ReadAllTextAsync(full, ct) : null;
|
||||
}
|
||||
return await _git.ShowFileAsync(repoPath, gitRef, relativePath, ct);
|
||||
}
|
||||
|
||||
private static bool TryDiffJsonKeyPaths(string? baseContent, string? headContent, out HashSet<string> changedPaths)
|
||||
{
|
||||
changedPaths = new HashSet<string>(StringComparer.Ordinal);
|
||||
if (!TryFlattenJson(baseContent, out var baseMap) || !TryFlattenJson(headContent, out var headMap))
|
||||
return false;
|
||||
|
||||
foreach (var (path, value) in headMap)
|
||||
if (!baseMap.TryGetValue(path, out var baseValue) || baseValue != value)
|
||||
changedPaths.Add(path);
|
||||
foreach (var path in baseMap.Keys)
|
||||
if (!headMap.ContainsKey(path))
|
||||
changedPaths.Add(path);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Missing content (new/deleted file, either side) is an empty object, not a parse failure --
|
||||
// only actually-malformed JSON triggers the file-level fallback.
|
||||
private static bool TryFlattenJson(string? content, out Dictionary<string, string> flattened)
|
||||
{
|
||||
flattened = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
return true;
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(content);
|
||||
FlattenJson(doc.RootElement, "", flattened);
|
||||
return true;
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void FlattenJson(JsonElement element, string prefix, Dictionary<string, string> into)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var prop in element.EnumerateObject())
|
||||
FlattenJson(prop.Value, prefix.Length == 0 ? prop.Name : $"{prefix}.{prop.Name}", into);
|
||||
}
|
||||
else
|
||||
{
|
||||
into[prefix] = element.GetRawText();
|
||||
}
|
||||
}
|
||||
|
||||
// One line of a unified diff, tagged with the file it belongs to ('-'/'+' only; context lines
|
||||
// are dropped). File is set from the `diff --git a/<path> b/<path>` header, which (unlike
|
||||
// `+++ b/...`) is present and names the real path even for a deleted file (`+++ /dev/null`).
|
||||
private readonly record struct DiffLine(string? File, char Sign, string Text);
|
||||
|
||||
private static List<DiffLine> ParseDiffLines(string diff)
|
||||
{
|
||||
var result = new List<DiffLine>();
|
||||
string? currentFile = null;
|
||||
foreach (var raw in diff.Split('\n'))
|
||||
{
|
||||
var line = raw.TrimEnd('\r');
|
||||
if (line.StartsWith("diff --git a/", StringComparison.Ordinal))
|
||||
{
|
||||
var rest = line["diff --git a/".Length..];
|
||||
var sep = rest.IndexOf(" b/", StringComparison.Ordinal);
|
||||
currentFile = sep >= 0 ? rest[..sep] : rest;
|
||||
continue;
|
||||
}
|
||||
if (currentFile is null) continue;
|
||||
if (line.StartsWith("---", StringComparison.Ordinal) || line.StartsWith("+++", StringComparison.Ordinal) ||
|
||||
line.StartsWith("@@", StringComparison.Ordinal) || line.StartsWith("index ", StringComparison.Ordinal))
|
||||
continue;
|
||||
|
||||
if (line.Length > 0 && line[0] == '-')
|
||||
result.Add(new DiffLine(currentFile, '-', line[1..]));
|
||||
else if (line.Length > 0 && line[0] == '+')
|
||||
result.Add(new DiffLine(currentFile, '+', line[1..]));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Deliberately a line-pattern scan, not a Roslyn parse: requires an explicit access modifier
|
||||
// (this codebase's style never omits one on a real member/type), which is also what keeps a
|
||||
// removed local variable or statement inside a method body from matching.
|
||||
private static readonly Regex RemovedDeclarationRegex = new(
|
||||
@"^\s*(?:\[[^\]]*\]\s*)*(?:public|private|protected|internal)(?:\s+(?:static|readonly|sealed|abstract|virtual|override|async|partial|const|new|extern|unsafe|required))*\s+" +
|
||||
@"(?:(?:class|struct|interface|enum|record)\s+(?<name>[A-Za-z_]\w*)" +
|
||||
@"|event\s+[\w<>\[\],.?]+\s+(?<name>[A-Za-z_]\w*)" +
|
||||
@"|[\w<>\[\],.?]+\??\s+(?<name>[A-Za-z_]\w*)\s*(?:\(|\{|=>|;|=))",
|
||||
RegexOptions.Compiled);
|
||||
|
||||
private static List<RemovedSymbolDto> ExtractRemovedSymbols(List<DiffLine> diffLines)
|
||||
{
|
||||
var seen = new HashSet<(string Name, string File)>();
|
||||
var result = new List<RemovedSymbolDto>();
|
||||
foreach (var line in diffLines)
|
||||
{
|
||||
if (line.Sign != '-' || line.File is null) continue;
|
||||
if (line.File.EndsWith(".axaml", StringComparison.OrdinalIgnoreCase) ||
|
||||
line.File.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
var match = RemovedDeclarationRegex.Match(line.Text);
|
||||
if (!match.Success) continue;
|
||||
|
||||
if (seen.Add((match.Groups["name"].Value, line.File)))
|
||||
result.Add(new RemovedSymbolDto(match.Groups["name"].Value, line.File));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool ContainsWord(string text, string word)
|
||||
=> Regex.IsMatch(text, $@"\b{Regex.Escape(word)}\b");
|
||||
|
||||
// A task's changed-file set is flagged only against another task's STRICTLY LARGER set that
|
||||
// contains every one of its files -- an empty set (isEmpty task) never qualifies, since that
|
||||
// case is already surfaced via IsEmpty and would otherwise match every other task trivially.
|
||||
@@ -577,7 +841,7 @@ public sealed class MergeMcpTools
|
||||
// (its commits already sit on the list's working dir) — falls back to the fixed
|
||||
// HandlerBaseCommit..HandlerHeadCommit range, reporting a synthetic "clean" preview of that
|
||||
// range's own diff-stat instead of throwing "has no worktree".
|
||||
private async Task<(MergePreviewResult Preview, int Behind, IReadOnlyList<string> ChangedFiles, bool IsEmpty, IReadOnlyList<string> StaleFiles, int Number, bool ExpectsNoDiff)> PreviewMergeCoreAsync(
|
||||
private async Task<(MergePreviewResult Preview, int Behind, IReadOnlyList<string> ChangedFiles, bool IsEmpty, IReadOnlyList<string> StaleFiles, int Number, bool ExpectsNoDiff, string RepoPath, string BaseRef, string? HeadRef)> PreviewMergeCoreAsync(
|
||||
string taskId, string? targetBranch, bool runVerify, CancellationToken ct,
|
||||
IProgress<ProgressNotificationValue>? progress = null)
|
||||
{
|
||||
@@ -619,7 +883,8 @@ public sealed class MergeMcpTools
|
||||
.Intersect(targetChangedSinceFork, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
return (preview, behind, changedFiles, changedFiles.Count == 0, staleFiles, task.Number, task.ExpectsNoDiff);
|
||||
return (preview, behind, changedFiles, changedFiles.Count == 0, staleFiles, task.Number, task.ExpectsNoDiff,
|
||||
wt.Path, wt.BaseCommit, null);
|
||||
}
|
||||
|
||||
if (task.HandlerBaseCommit is { Length: > 0 } handlerBase && task.HandlerHeadCommit is { Length: > 0 } handlerHead)
|
||||
@@ -636,7 +901,8 @@ public sealed class MergeMcpTools
|
||||
// No fork point to diff against: a handler task commits straight onto the list's
|
||||
// working dir instead of a branch, so there is nothing else that could have "changed
|
||||
// in the target since the fork".
|
||||
return (preview, 0, changedFiles, isEmpty, Array.Empty<string>(), task.Number, task.ExpectsNoDiff);
|
||||
return (preview, 0, changedFiles, isEmpty, Array.Empty<string>(), task.Number, task.ExpectsNoDiff,
|
||||
list.WorkingDir, handlerBase, handlerHead);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Task {taskId} has no worktree.");
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.ComponentModel;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Runner;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace ClaudeDo.Worker.External;
|
||||
|
||||
/// <summary>Confirmation of a handoff_session call: where the brief was written.</summary>
|
||||
public sealed record SessionHandoffResult(bool Requested, string BriefPath);
|
||||
|
||||
/// <summary>Generic Mission Control handoff. Reached only through the per-tile <c>claudedo_pane</c>
|
||||
/// MCP entry, whose headers identify the tile and session — the model never passes an id.</summary>
|
||||
[McpServerToolType]
|
||||
public sealed class SessionHandoffMcpTools
|
||||
{
|
||||
/// <summary>Header naming the calling tile (ConPtyPaneViewModel.IdentityKey).</summary>
|
||||
public const string PaneHeader = "X-ClaudeDo-Pane";
|
||||
|
||||
/// <summary>Header naming the calling session's claude session id.</summary>
|
||||
public const string SessionHeader = "X-ClaudeDo-Session";
|
||||
|
||||
/// <summary>The only tool the <c>claudedo_pane</c> entry lists, and the one the plain entry hides.</summary>
|
||||
public const string ToolName = "handoff_session";
|
||||
|
||||
private readonly HubBroadcaster _broadcaster;
|
||||
private readonly IHttpContextAccessor _http;
|
||||
|
||||
public SessionHandoffMcpTools(HubBroadcaster broadcaster, IHttpContextAccessor http)
|
||||
{
|
||||
_broadcaster = broadcaster;
|
||||
_http = http;
|
||||
}
|
||||
|
||||
/// <summary>Writes the brief under the tile's session dir and asks the UI to replace the tile.</summary>
|
||||
[McpServerTool(Name = ToolName), Description(
|
||||
"Hand this session off to a fresh session in the same Mission Control tile and directory, when the " +
|
||||
"user asks for it (e.g. the context is full). The brief is the ONLY context the fresh session gets -- " +
|
||||
"make it self-contained. This tile closes right after the call; end your turn immediately.")]
|
||||
public async Task<SessionHandoffResult> HandoffSession(
|
||||
[Description("Markdown brief for the fresh session: goal, done, decisions, current state, next steps, open questions, file paths.")] string brief,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var headers = _http.HttpContext?.Request.Headers;
|
||||
var paneId = headers?[PaneHeader].ToString();
|
||||
if (string.IsNullOrEmpty(paneId))
|
||||
throw new InvalidOperationException("handoff_session only works from a ClaudeDo Mission Control tile.");
|
||||
if (string.IsNullOrWhiteSpace(brief))
|
||||
throw new InvalidOperationException("brief must not be empty.");
|
||||
|
||||
var dir = Path.Combine(InteractiveLaunchSpecService.PaneSessionDir(paneId), "handoffs", Guid.NewGuid().ToString("n"));
|
||||
Directory.CreateDirectory(dir);
|
||||
var briefPath = Path.Combine(dir, "brief.md");
|
||||
var fromSession = headers![SessionHeader].ToString();
|
||||
var header = fromSession.Length == 0 ? "" : $"{HandedOffFromPrefix}{fromSession}`.\n\n";
|
||||
await File.WriteAllTextAsync(briefPath, header + brief.Trim() + "\n", cancellationToken);
|
||||
|
||||
await _broadcaster.SessionHandoffRequested(paneId, briefPath);
|
||||
return new SessionHandoffResult(true, briefPath);
|
||||
}
|
||||
|
||||
/// <summary>Start of a brief's first line; the handing-off session id and a closing backtick follow.</summary>
|
||||
private const string HandedOffFromPrefix = "Handed off from session `";
|
||||
|
||||
/// <summary>The session id a brief was handed off from, or null when its first line carries none.</summary>
|
||||
public static string? ReadHandedOffFrom(string briefPath)
|
||||
{
|
||||
var first = File.ReadLines(briefPath).FirstOrDefault();
|
||||
if (first is null || !first.StartsWith(HandedOffFromPrefix, StringComparison.Ordinal)) return null;
|
||||
var end = first.IndexOf('`', HandedOffFromPrefix.Length);
|
||||
return end > HandedOffFromPrefix.Length ? first[HandedOffFromPrefix.Length..end] : null;
|
||||
}
|
||||
|
||||
/// <summary>List-tools filter: a request carrying <see cref="PaneHeader"/> (the per-tile entry) sees
|
||||
/// only handoff_session; every other request sees everything but it.</summary>
|
||||
public static McpRequestHandler<ListToolsRequestParams, ListToolsResult> FilterByPane(
|
||||
McpRequestHandler<ListToolsRequestParams, ListToolsResult> next) =>
|
||||
async (request, cancellationToken) =>
|
||||
{
|
||||
var result = await next(request, cancellationToken);
|
||||
var http = request.Services?.GetService<IHttpContextAccessor>()?.HttpContext;
|
||||
result.Tools = result.Tools.Where(t => IsVisible(t.Name, http)).ToList();
|
||||
return result;
|
||||
};
|
||||
|
||||
/// <summary>Visibility rule behind <see cref="FilterByPane"/>.</summary>
|
||||
internal static bool IsVisible(string toolName, HttpContext? http) =>
|
||||
(toolName == ToolName) == !string.IsNullOrEmpty(http?.Request.Headers[PaneHeader].ToString());
|
||||
}
|
||||
+9
-6
@@ -10,19 +10,22 @@ namespace ClaudeDo.Worker.External;
|
||||
internal static class TaskBlocking
|
||||
{
|
||||
/// <summary>
|
||||
/// The predecessors of <paramref name="dependencyIds"/> that have not reached Done — an id
|
||||
/// missing from <paramref name="statuses"/> counts as unmet (the picker treats a dangling
|
||||
/// edge as blocking too).
|
||||
/// The predecessors of <paramref name="dependencyIds"/> the picker would still skip on: not
|
||||
/// Done <i>and</i> without a landed merge. Mirrors <c>QueuePicker</c>'s SQL predicate — a
|
||||
/// task merged with <c>skipVerify</c> sits in WaitingForReview until the batch verify runs,
|
||||
/// yet its code is on the target branch, so it no longer blocks. An id missing from
|
||||
/// <paramref name="predecessors"/> counts as unmet (a dangling edge blocks there too).
|
||||
/// </summary>
|
||||
public static List<(string Id, TaskStatus? Status)> Unmet(
|
||||
IEnumerable<string> dependencyIds, IReadOnlyDictionary<string, TaskStatus> statuses)
|
||||
IEnumerable<string> dependencyIds,
|
||||
IReadOnlyDictionary<string, (TaskStatus Status, bool Merged)> predecessors)
|
||||
{
|
||||
var unmet = new List<(string Id, TaskStatus? Status)>();
|
||||
foreach (var id in dependencyIds)
|
||||
{
|
||||
if (statuses.TryGetValue(id, out var status))
|
||||
if (predecessors.TryGetValue(id, out var p))
|
||||
{
|
||||
if (status != TaskStatus.Done) unmet.Add((id, status));
|
||||
if (p.Status != TaskStatus.Done && !p.Merged) unmet.Add((id, p.Status));
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
+11
-5
@@ -1,5 +1,6 @@
|
||||
using System.ComponentModel;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ModelContextProtocol;
|
||||
@@ -152,12 +153,17 @@ public sealed class TaskWaitMcpTools
|
||||
.SelectMany(r => r.DependsOnTaskIds)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var dependencyStatuses = dependencyIds.Count == 0
|
||||
? new Dictionary<string, TaskStatus>()
|
||||
var predecessors = dependencyIds.Count == 0
|
||||
? new Dictionary<string, (TaskStatus Status, bool Merged)>()
|
||||
: await ctx.Tasks.AsNoTracking()
|
||||
.Where(t => dependencyIds.Contains(t.Id))
|
||||
.Select(t => new { t.Id, t.Status })
|
||||
.ToDictionaryAsync(t => t.Id, t => t.Status, ct);
|
||||
.Select(t => new
|
||||
{
|
||||
t.Id,
|
||||
t.Status,
|
||||
Merged = t.Worktree != null && t.Worktree.State == WorktreeState.Merged,
|
||||
})
|
||||
.ToDictionaryAsync(t => t.Id, t => (t.Status, t.Merged), ct);
|
||||
|
||||
var result = new List<TaskStatusChangeDto>();
|
||||
foreach (var id in taskIds)
|
||||
@@ -175,7 +181,7 @@ public sealed class TaskWaitMcpTools
|
||||
// every chained child and turn the wait into a one-turn-per-poll busy loop.
|
||||
if (row.Status == TaskStatus.Queued && row.BlockedByTaskId is null)
|
||||
{
|
||||
var unmet = TaskBlocking.Unmet(row.DependsOnTaskIds, dependencyStatuses);
|
||||
var unmet = TaskBlocking.Unmet(row.DependsOnTaskIds, predecessors);
|
||||
if (unmet.Count > 0)
|
||||
{
|
||||
result.Add(new TaskStatusChangeDto(
|
||||
|
||||
@@ -37,6 +37,11 @@ public sealed class HubBroadcaster : IPrimeBroadcaster, IRefineBroadcaster
|
||||
public Task HandoffRequested(string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase) =>
|
||||
_hub.Clients.All.SendAsync("HandoffRequested", taskId, survivingTaskIds, nextPhase);
|
||||
|
||||
/// <summary>A Mission Control session called handoff_session -- the UI replaces the tile whose
|
||||
/// IdentityKey is paneId with a fresh session that starts from the brief.</summary>
|
||||
public Task SessionHandoffRequested(string paneId, string briefPath) =>
|
||||
_hub.Clients.All.SendAsync("SessionHandoffRequested", paneId, briefPath);
|
||||
|
||||
// start_list_handler (External MCP) -- the App creates the handler task and opens the triage
|
||||
// tile, exactly like the "Let Claude handle it" button.
|
||||
public Task ListHandlerStartRequested(string listId, IReadOnlyList<string> taskIds) =>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -277,8 +277,11 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
// TryAdvanceParentAsync side effect — a direct-repo delete from the details pane
|
||||
// used to skip it, permanently wedging a WaitingForChildren parent whose last
|
||||
// child was deleted from there.
|
||||
public async Task DeleteTask(string taskId, CancellationToken cancellationToken)
|
||||
// No CancellationToken parameter: SignalR can't bind one on a non-streaming method and fails
|
||||
// every invocation with a generic server error before the body runs.
|
||||
public async Task DeleteTask(string taskId)
|
||||
{
|
||||
var cancellationToken = CancellationToken.None;
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
|
||||
var repo = new TaskRepository(ctx);
|
||||
var task = await repo.GetByIdAsync(taskId, cancellationToken)
|
||||
@@ -449,7 +452,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
var rows = await _wtMaintenance.GetOverviewAsync(listId, Context.ConnectionAborted);
|
||||
return rows.Select(r => new WorktreeOverviewDto(
|
||||
r.TaskId, r.TaskTitle, r.TaskStatus, r.ListId, r.ListName,
|
||||
r.Path, r.BranchName, r.BaseCommit, r.State, r.DiffStat, r.CreatedAt, r.PathExistsOnDisk)).ToList();
|
||||
r.Path, r.BranchName, r.BaseCommit, r.State, r.DiffStat, r.CreatedAt, r.PathExistsOnDisk,
|
||||
r.TaskNumber)).ToList();
|
||||
}
|
||||
|
||||
public async Task<bool> SetWorktreeState(string taskId, WorktreeState newState)
|
||||
@@ -478,7 +482,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
}
|
||||
|
||||
public Task<MergeResultDto> MergeTask(
|
||||
string taskId, string targetBranch, bool removeWorktree, string commitMessage)
|
||||
string taskId, string targetBranch, bool removeWorktree, string commitMessage, bool autostash = false)
|
||||
=> HubGuard(async () =>
|
||||
{
|
||||
// A blank message is handed through deliberately — TaskMergeService builds the
|
||||
@@ -488,7 +492,9 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
targetBranch ?? "",
|
||||
removeWorktree,
|
||||
commitMessage,
|
||||
CancellationToken.None);
|
||||
leaveConflictsInTree: false,
|
||||
CancellationToken.None,
|
||||
autostash: autostash);
|
||||
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
|
||||
});
|
||||
|
||||
@@ -496,7 +502,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
=> HubGuard(async () =>
|
||||
{
|
||||
var t = await _mergeService.GetTargetsAsync(taskId, CancellationToken.None);
|
||||
return new MergeTargetsDto(t.DefaultBranch, t.LocalBranches, t.DefaultCommitMessage);
|
||||
return new MergeTargetsDto(t.DefaultBranch, t.LocalBranches, t.DefaultCommitMessage, t.TargetDirty);
|
||||
});
|
||||
|
||||
public Task<MergePreviewDto> PreviewMerge(string taskId, string targetBranch)
|
||||
@@ -683,7 +689,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
if (!result.Ok) throw new HubException(result.Reason ?? "dequeue failed");
|
||||
}
|
||||
|
||||
public Task<MergeResultDto> ApproveReview(string taskId, string targetBranch)
|
||||
public Task<MergeResultDto> ApproveReview(string taskId, string targetBranch, bool autostash = false)
|
||||
=> HubGuard(async () =>
|
||||
{
|
||||
bool hasChildren;
|
||||
@@ -698,7 +704,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
return new MergeResultDto(unitResult.Status, Array.Empty<string>(), unitResult.Reason);
|
||||
}
|
||||
|
||||
var r = await _mergeService.ApproveAndMergeAsync(taskId, targetBranch ?? "", CancellationToken.None);
|
||||
var r = await _mergeService.ApproveAndMergeAsync(taskId, targetBranch ?? "", CancellationToken.None, autostash);
|
||||
if (r.Status == TaskMergeService.StatusBlocked)
|
||||
throw new HubException(r.ErrorMessage ?? "approve failed");
|
||||
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
|
||||
@@ -772,16 +778,35 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
{
|
||||
if (_interactiveLaunchSpec is null)
|
||||
throw new InvalidOperationException("Interactive launch spec service is not configured.");
|
||||
return _interactiveLaunchSpec.BuildForTaskAsync(taskId, Context.ConnectionAborted);
|
||||
_broadcaster.WorktreeUpdated(taskId);
|
||||
// A task tile's paneId is its task id (ConPtyPaneViewModel.IdentityKey).
|
||||
return _interactiveLaunchSpec.BuildForTaskAsync(taskId, Context.ConnectionAborted, paneId: taskId);
|
||||
});
|
||||
|
||||
// Builds the launch spec for an ad-hoc interactive session in an arbitrary directory --
|
||||
// no task, no worktree, no session-skills seeding.
|
||||
public Task<LaunchSpec> GetAdHocLaunchSpec(string directory) => HubGuard(() =>
|
||||
// no task, no worktree, no session-skills seeding. paneId is the tile's IdentityKey, rendered
|
||||
// into the handoff hint.
|
||||
public Task<LaunchSpec> GetAdHocLaunchSpec(string directory, string paneId) => HubGuard(() =>
|
||||
{
|
||||
if (_interactiveLaunchSpec is null)
|
||||
throw new InvalidOperationException("Interactive launch spec service is not configured.");
|
||||
return _interactiveLaunchSpec.BuildForDirectoryAsync(directory, Context.ConnectionAborted);
|
||||
return _interactiveLaunchSpec.BuildForDirectoryAsync(directory, paneId, Context.ConnectionAborted);
|
||||
});
|
||||
|
||||
/// <summary>Launch spec for the fresh session a tile hands off to after handoff_session.</summary>
|
||||
public Task<LaunchSpec> GetSessionHandoffLaunchSpec(string paneId, string cwd, string briefPath, string? taskId) => HubGuard(() =>
|
||||
{
|
||||
if (_interactiveLaunchSpec is null)
|
||||
throw new InvalidOperationException("Interactive launch spec service is not configured.");
|
||||
return _interactiveLaunchSpec.BuildHandoffAsync(paneId, cwd, briefPath, taskId, Context.ConnectionAborted);
|
||||
});
|
||||
|
||||
/// <summary>Launch spec resuming a recorded Mission Control session ("Recent sessions") in a new tile.</summary>
|
||||
public Task<LaunchSpec> GetResumeSessionLaunchSpec(string sessionId, string paneId) => HubGuard(() =>
|
||||
{
|
||||
if (_interactiveLaunchSpec is null)
|
||||
throw new InvalidOperationException("Interactive launch spec service is not configured.");
|
||||
return _interactiveLaunchSpec.BuildResumeAsync(sessionId, paneId, Context.ConnectionAborted);
|
||||
});
|
||||
|
||||
// Builds the launch spec for an embedded ConPTY "merge helper" session that drives the given
|
||||
|
||||
@@ -2,6 +2,7 @@ using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Git;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Runner;
|
||||
using ClaudeDo.Worker.State;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -20,17 +21,20 @@ public sealed class InteractiveReviewSubmissionService
|
||||
private readonly GitService _git;
|
||||
private readonly ITaskStateService _state;
|
||||
private readonly WorktreeManager? _worktreeManager;
|
||||
private readonly HubBroadcaster? _broadcaster;
|
||||
|
||||
public InteractiveReviewSubmissionService(
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
GitService git,
|
||||
ITaskStateService state,
|
||||
WorktreeManager? worktreeManager = null)
|
||||
WorktreeManager? worktreeManager = null,
|
||||
HubBroadcaster? broadcaster = null)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_git = git;
|
||||
_state = state;
|
||||
_worktreeManager = worktreeManager;
|
||||
_broadcaster = broadcaster;
|
||||
}
|
||||
|
||||
public async Task SubmitAsync(string taskId, CancellationToken ct)
|
||||
@@ -86,5 +90,14 @@ public sealed class InteractiveReviewSubmissionService
|
||||
var result = await _state.SubmitInteractiveForReviewAsync(taskId, DateTime.UtcNow, ct);
|
||||
if (!result.Ok)
|
||||
throw new InvalidOperationException(result.Reason ?? "Could not submit for review.");
|
||||
|
||||
// The transition itself only broadcasts TaskUpdated, and the detail pane answers that by
|
||||
// refreshing the task row alone -- its whole REVIEW/MERGE block (Open diff, branch picker,
|
||||
// change summary) is gated on the worktree it last read, refreshed only on WorktreeUpdated.
|
||||
// An interactive session creates its worktree at launch and gets it committed HERE, both
|
||||
// outside the autonomous pipeline that would normally announce it, so without this the task
|
||||
// arrives in WaitingForReview with the diff button missing until it is re-selected.
|
||||
if (_broadcaster is not null)
|
||||
await _broadcaster.WorktreeUpdated(taskId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,8 @@ public sealed record MergeResult(
|
||||
public sealed record MergeTargets(
|
||||
string DefaultBranch,
|
||||
IReadOnlyList<string> LocalBranches,
|
||||
string DefaultCommitMessage);
|
||||
string DefaultCommitMessage,
|
||||
bool TargetDirty = false);
|
||||
|
||||
// VerifyExitCode/VerifyDurationMs/VerifyOutputTail are only populated when the caller asked for
|
||||
// a verify run (runVerify=true) AND the list has a verify command configured AND the preview came
|
||||
@@ -70,6 +71,10 @@ public sealed class TaskMergeService
|
||||
public const string StatusMergedVerifyPending = "merged_verify_pending";
|
||||
public const string StatusVerifyPassed = "verify_passed";
|
||||
public const string StatusUntrackedCollision = "untracked_collision";
|
||||
// Nothing landed -- the target working dir has uncommitted changes. Its own status rather
|
||||
// than a Blocked reason string so callers can offer the autostash retry (MergeAsync's
|
||||
// autostash: true) without matching on prose.
|
||||
public const string StatusDirtyTarget = "dirty_target";
|
||||
|
||||
public const string StatusReverted = "reverted";
|
||||
public const string StatusConflictAborted = "conflict_aborted";
|
||||
@@ -375,7 +380,8 @@ public sealed class TaskMergeService
|
||||
bool leaveConflictsInTree,
|
||||
CancellationToken ct,
|
||||
IProgress<ProgressNotificationValue>? progress = null,
|
||||
bool skipVerify = false)
|
||||
bool skipVerify = false,
|
||||
bool autostash = false)
|
||||
{
|
||||
var (task, list, wt, verifyCommand) = await LoadMergeContextAsync(taskId, ct);
|
||||
|
||||
@@ -400,8 +406,10 @@ public sealed class TaskMergeService
|
||||
return Blocked("working directory is not a git repository");
|
||||
if (await _git.IsMidMergeAsync(list.WorkingDir, ct))
|
||||
return Blocked("target working directory is mid-merge");
|
||||
if (await _git.HasChangesAsync(list.WorkingDir, includeUntracked: false, ct))
|
||||
return Blocked("target working tree has uncommitted changes");
|
||||
// autostash hands the dirty tree to git itself (merge --autostash) instead of refusing.
|
||||
if (!autostash && await _git.HasChangesAsync(list.WorkingDir, includeUntracked: false, ct))
|
||||
return new MergeResult(StatusDirtyTarget, Array.Empty<string>(),
|
||||
"target working tree has uncommitted changes");
|
||||
|
||||
var currentBranch = await _git.GetCurrentBranchAsync(list.WorkingDir, ct);
|
||||
if (!string.Equals(currentBranch, targetBranch, StringComparison.Ordinal))
|
||||
@@ -417,7 +425,7 @@ public sealed class TaskMergeService
|
||||
var message = string.IsNullOrWhiteSpace(commitMessage)
|
||||
? DefaultMergeMessage(task, list)
|
||||
: commitMessage;
|
||||
var (exitCode, stderr) = await _git.MergeNoFfAsync(list.WorkingDir, wt.BranchName, message, ct);
|
||||
var (exitCode, stderr) = await _git.MergeNoFfAsync(list.WorkingDir, wt.BranchName, message, ct, autostash);
|
||||
if (exitCode != 0)
|
||||
{
|
||||
List<string> files;
|
||||
@@ -868,7 +876,8 @@ public sealed class TaskMergeService
|
||||
|
||||
var current = await _git.GetCurrentBranchAsync(list.WorkingDir, ct);
|
||||
var branches = await _git.ListLocalBranchesAsync(list.WorkingDir, ct);
|
||||
return new MergeTargets(current, branches, defaultMessage);
|
||||
var dirty = await _git.HasChangesAsync(list.WorkingDir, includeUntracked: false, ct);
|
||||
return new MergeTargets(current, branches, defaultMessage, dirty);
|
||||
}
|
||||
|
||||
/// The commit message a merge uses when the caller passes none. Built here rather than in the
|
||||
@@ -973,13 +982,15 @@ public sealed class TaskMergeService
|
||||
}
|
||||
}
|
||||
|
||||
public Task<MergeResult> ApproveAndMergeAsync(string taskId, string targetBranch, CancellationToken ct)
|
||||
=> ApproveAndMergeAsync(taskId, targetBranch, leaveConflictsInTree: false, ct);
|
||||
public Task<MergeResult> ApproveAndMergeAsync(
|
||||
string taskId, string targetBranch, CancellationToken ct, bool autostash = false)
|
||||
=> ApproveAndMergeAsync(taskId, targetBranch, leaveConflictsInTree: false, ct, autostash: autostash);
|
||||
|
||||
public async Task<MergeResult> ApproveAndMergeAsync(
|
||||
string taskId, string targetBranch, bool leaveConflictsInTree, CancellationToken ct,
|
||||
IProgress<ProgressNotificationValue>? progress = null,
|
||||
bool skipVerify = false)
|
||||
bool skipVerify = false,
|
||||
bool autostash = false)
|
||||
{
|
||||
var (task, list, wt, verifyCommand) = await LoadMergeContextAsync(taskId, ct);
|
||||
|
||||
@@ -1070,7 +1081,7 @@ public sealed class TaskMergeService
|
||||
// MergeAsync transitions the task WaitingForReview -> Done on a successful merge.
|
||||
// Remove the worktree on approve (matching the unit-merge path) so merged
|
||||
// worktrees don't pile up; the merge commit on the target branch is the record.
|
||||
return await MergeAsync(taskId, target, removeWorktree: true, commitMessage: "", leaveConflictsInTree, ct, progress, skipVerify);
|
||||
return await MergeAsync(taskId, target, removeWorktree: true, commitMessage: "", leaveConflictsInTree, ct, progress, skipVerify, autostash);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -21,11 +21,19 @@ public sealed class QueuePicker : IQueuePicker
|
||||
// Atomic queue claim: UPDATE + RETURNING in a single statement prevents TOCTOU races.
|
||||
// Raw SQL because EF cannot express UPDATE...RETURNING.
|
||||
// Eligible task must be Queued, unblocked (chain and depends-on), not manual, and due
|
||||
// (or unscheduled). A task_dependencies edge only gates on the dependency's Status='done'
|
||||
// -- unlike blocked_by_task_id there is no cascade on the dependency failing, so a task
|
||||
// with a Failed dependency simply stays here, skipped, until someone intervenes. The
|
||||
// depends-on clause is doubly negated on purpose: "no edge whose predecessor isn't done"
|
||||
// means a task with zero edges is eligible, ALL edges must be done, and an edge pointing
|
||||
// (or unscheduled). A task_dependencies edge is satisfied once the predecessor is 'done'
|
||||
// OR its merge has landed (worktrees.state = 'merged'), because review_task(skipVerify)
|
||||
// deliberately parks a successfully merged task in WaitingForReview until the batch
|
||||
// verify_merges runs -- gating on 'done' alone stalled every successor for the whole
|
||||
// batch. Consequences, all deliberate: a failing verify_merges leaves the merges on the
|
||||
// target branch, so successors do run (the predecessor's code is there either way);
|
||||
// revert_merge/abort_merge flip the worktree back to 'active' and re-block; a rejected
|
||||
// task (reject_rerun/reject_park) never reached 'merged' and stays unmet; a task with no
|
||||
// worktree (handler task, empty diff) is only ever satisfied via 'done'. Unlike
|
||||
// blocked_by_task_id there is no cascade on the dependency failing, so a task with a
|
||||
// Failed dependency simply stays here, skipped, until someone intervenes. The depends-on
|
||||
// clause is doubly negated on purpose: "no edge whose predecessor isn't satisfied" means
|
||||
// a task with zero edges is eligible, ALL edges must be satisfied, and an edge pointing
|
||||
// at a vanished task keeps blocking (same as the old single-column form did).
|
||||
// EF SQLite stores DateTime as "yyyy-MM-dd HH:mm:ss.fffffff" — same format used here for comparison.
|
||||
var rows = await ctx.Tasks.FromSqlRaw("""
|
||||
@@ -41,7 +49,11 @@ public sealed class QueuePicker : IQueuePicker
|
||||
WHERE td.task_id = t.id
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM tasks d
|
||||
WHERE d.id = td.depends_on_task_id AND d.status = 'done'
|
||||
WHERE d.id = td.depends_on_task_id
|
||||
AND (d.status = 'done' OR EXISTS (
|
||||
SELECT 1 FROM worktrees w
|
||||
WHERE w.task_id = d.id AND w.state = 'merged'
|
||||
))
|
||||
)
|
||||
)
|
||||
ORDER BY t.sort_order ASC, t.created_at ASC
|
||||
|
||||
@@ -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.",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ public class IslandsShellViewModelShutdownTests
|
||||
|
||||
private sealed class FakeDialogService(bool confirmResult) : IDialogService
|
||||
{
|
||||
public Task<bool> ConfirmAsync(string message) => Task.FromResult(confirmResult);
|
||||
public Task<bool> ConfirmAsync(string message, string? confirmLabel = null) => Task.FromResult(confirmResult);
|
||||
|
||||
public Task ShowAboutAsync(AboutModalViewModel vm) => throw new NotImplementedException();
|
||||
public Task ShowWeeklyReportAsync(WeeklyReportModalViewModel vm) => throw new NotImplementedException();
|
||||
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using ClaudeDo.Ui.Services;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests.Services;
|
||||
|
||||
public class ClipboardImageTempStoreTests : IDisposable
|
||||
{
|
||||
private readonly string _dir;
|
||||
|
||||
public ClipboardImageTempStoreTests()
|
||||
{
|
||||
_dir = Path.Combine(Path.GetTempPath(), "ClaudeDo.Ui.Tests-" + Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(_dir);
|
||||
}
|
||||
|
||||
public void Dispose() { try { Directory.Delete(_dir, true); } catch { } }
|
||||
|
||||
[Fact]
|
||||
public void NewPngPath_IsUniqueAndUnderTheGivenDir()
|
||||
{
|
||||
var now = new DateTime(2026, 9, 16, 10, 30, 0, DateTimeKind.Utc);
|
||||
|
||||
var a = ClipboardImageTempStore.NewPngPath(_dir, now);
|
||||
var b = ClipboardImageTempStore.NewPngPath(_dir, now);
|
||||
|
||||
Assert.NotEqual(a, b);
|
||||
Assert.Equal(_dir, Path.GetDirectoryName(a));
|
||||
Assert.Equal(".png", Path.GetExtension(a));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeleteOlderThan_RemovesOnlyFilesPastTheMaxAge()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var oldFile = Path.Combine(_dir, "old.png");
|
||||
var freshFile = Path.Combine(_dir, "fresh.png");
|
||||
File.WriteAllText(oldFile, "x");
|
||||
File.WriteAllText(freshFile, "x");
|
||||
File.SetLastWriteTimeUtc(oldFile, now - TimeSpan.FromHours(25));
|
||||
File.SetLastWriteTimeUtc(freshFile, now - TimeSpan.FromHours(1));
|
||||
|
||||
ClipboardImageTempStore.DeleteOlderThan(_dir, TimeSpan.FromHours(24), now);
|
||||
|
||||
Assert.False(File.Exists(oldFile));
|
||||
Assert.True(File.Exists(freshFile));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeleteOlderThan_NonExistentDir_DoesNotThrow()
|
||||
{
|
||||
ClipboardImageTempStore.DeleteOlderThan(Path.Combine(_dir, "missing"), TimeSpan.FromHours(24), DateTime.UtcNow);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -109,9 +113,9 @@ public abstract class StubWorkerClient : IWorkerClient
|
||||
public virtual Task SetTaskDoneAsync(string taskId) => Task.CompletedTask;
|
||||
public virtual Task UnsetTaskDoneAsync(string taskId) => Task.CompletedTask;
|
||||
public virtual Task DequeueTaskAsync(string taskId) => Task.CompletedTask;
|
||||
public virtual Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) => Task.FromResult<MergeResultDto?>(null);
|
||||
public virtual Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch, bool autostash = false) => Task.FromResult<MergeResultDto?>(null);
|
||||
public virtual Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch) => Task.FromResult<MergePreviewDto?>(null);
|
||||
public virtual Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));
|
||||
public virtual Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage, bool autostash = false) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));
|
||||
public virtual Task RejectReviewToQueueAsync(string taskId, string feedback) => Task.CompletedTask;
|
||||
public virtual Task RejectReviewToIdleAsync(string taskId) => Task.CompletedTask;
|
||||
public virtual Task CancelReviewAsync(string taskId) => Task.CompletedTask;
|
||||
@@ -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(
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.Services.Interfaces;
|
||||
using ClaudeDo.Ui.ViewModels;
|
||||
using ClaudeDo.Ui.ViewModels.Conflicts;
|
||||
using ClaudeDo.Ui.ViewModels.Modals;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests;
|
||||
@@ -31,3 +34,43 @@ public sealed class StubNotesApi : INotesApi
|
||||
|
||||
/// <summary><see cref="StubWorkerClient"/> is abstract — this is the concrete no-op subclass.</summary>
|
||||
public sealed class DefaultStub : StubWorkerClient { }
|
||||
|
||||
/// <summary>
|
||||
/// Records the message/label of the last Confirm/Error call and returns a configurable
|
||||
/// Confirm result. Covers the ViewModels (DetailsIsland, TasksIsland, ...) that take their
|
||||
/// dialog seam via a settable <c>Dialogs</c> property instead of a per-call constructor arg.
|
||||
/// </summary>
|
||||
public sealed class RecordingDialogService : IDialogService
|
||||
{
|
||||
public bool ConfirmResult { get; set; } = true;
|
||||
public string? LastConfirmMessage { get; private set; }
|
||||
public string? LastConfirmLabel { get; private set; }
|
||||
public string? LastErrorMessage { get; private set; }
|
||||
|
||||
public Task<bool> ConfirmAsync(string message, string? confirmLabel = null)
|
||||
{
|
||||
LastConfirmMessage = message;
|
||||
LastConfirmLabel = confirmLabel;
|
||||
return Task.FromResult(ConfirmResult);
|
||||
}
|
||||
|
||||
public Task ShowErrorAsync(string message)
|
||||
{
|
||||
LastErrorMessage = message;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task ShowAboutAsync(AboutModalViewModel vm) => throw new NotImplementedException();
|
||||
public Task ShowWeeklyReportAsync(WeeklyReportModalViewModel vm) => throw new NotImplementedException();
|
||||
public Task ShowUsageMonitorAsync(UsageMonitorModalViewModel vm) => throw new NotImplementedException();
|
||||
public Task ShowSettingsAsync(SettingsModalViewModel vm) => throw new NotImplementedException();
|
||||
public Task ShowListSettingsAsync(ListSettingsModalViewModel vm) => throw new NotImplementedException();
|
||||
public Task ShowRepoImportAsync(RepoImportModalViewModel vm) => throw new NotImplementedException();
|
||||
public Task ShowWorktreesOverviewAsync(WorktreesOverviewModalViewModel vm) => throw new NotImplementedException();
|
||||
public Task<IReadOnlyList<string>?> ShowMergeHelperSelectionAsync(MergeHelperSelectionModalViewModel vm) => throw new NotImplementedException();
|
||||
public Task ShowWorkerConnectionAsync(WorkerConnectionModalViewModel vm) => throw new NotImplementedException();
|
||||
public Task ShowConflictResolverAsync(ConflictResolverViewModel vm) => throw new NotImplementedException();
|
||||
public Task ShowLogVisualizerAsync(LogVisualizerViewModel vm) => throw new NotImplementedException();
|
||||
public void ShowMissionControl(MissionControlViewModel vm) => throw new NotImplementedException();
|
||||
public void ShowCheatSheets(CheatSheetsViewModel vm) => throw new NotImplementedException();
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ public class DetailsIslandConflictSeamTests : IDisposable
|
||||
private sealed class ConflictApproveWorkerClient : StubWorkerClient
|
||||
{
|
||||
public override bool IsConnected => true;
|
||||
public override Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) =>
|
||||
public override Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch, bool autostash = false) =>
|
||||
Task.FromResult<MergeResultDto?>(new MergeResultDto("conflict", new[] { "a.cs" }, null));
|
||||
}
|
||||
|
||||
|
||||
@@ -90,12 +90,12 @@ public class DetailsIslandDeleteTaskTests : IDisposable
|
||||
vm.DeleteFromList = _ => { deleteFromListCalled = true; return Task.CompletedTask; };
|
||||
var closed = false;
|
||||
vm.CloseDetail = () => closed = true;
|
||||
string? reportedError = null;
|
||||
vm.ShowErrorAsync = msg => { reportedError = msg; return Task.CompletedTask; };
|
||||
var dialogs = new RecordingDialogService();
|
||||
vm.Dialogs = dialogs;
|
||||
|
||||
await vm.DeleteTaskCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal(fkMessage, reportedError);
|
||||
Assert.Equal(fkMessage, dialogs.LastErrorMessage);
|
||||
Assert.False(deleteFromListCalled);
|
||||
Assert.False(closed);
|
||||
}
|
||||
@@ -136,12 +136,12 @@ public class DetailsIslandDeleteTaskTests : IDisposable
|
||||
vm.DeleteFromList = _ => { deleteFromListCalled = true; return Task.CompletedTask; };
|
||||
var closed = false;
|
||||
vm.CloseDetail = () => closed = true;
|
||||
string? reportedError = null;
|
||||
vm.ShowErrorAsync = msg => { reportedError = msg; return Task.CompletedTask; };
|
||||
var dialogs = new RecordingDialogService();
|
||||
vm.Dialogs = dialogs;
|
||||
|
||||
await vm.DeleteTaskCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal("Connection is not active.", reportedError);
|
||||
Assert.Equal("Connection is not active.", dialogs.LastErrorMessage);
|
||||
Assert.False(deleteFromListCalled);
|
||||
Assert.False(closed);
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ public class DetailsIslandErrorFeedbackTests : IDisposable
|
||||
var vm = BuildVm(worker);
|
||||
vm.Bind(new TaskRowViewModel { Id = "task-reset-throw", Status = TaskStatus.Failed });
|
||||
vm.Monitor.ApplyState(TaskStatus.Failed);
|
||||
vm.ConfirmAsync = _ => Task.FromResult(true);
|
||||
vm.Dialogs = new RecordingDialogService { ConfirmResult = true };
|
||||
|
||||
string? reportedError = null;
|
||||
vm.ErrorReported += msg => reportedError = msg;
|
||||
|
||||
@@ -55,7 +55,7 @@ public class DetailsIslandPlanningTests : IDisposable
|
||||
private sealed class ThrowingReviewWorkerClient : StubWorkerClient
|
||||
{
|
||||
public override bool IsConnected => true;
|
||||
public override Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) =>
|
||||
public override Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch, bool autostash = false) =>
|
||||
Task.FromException<MergeResultDto?>(new InvalidOperationException("Task is not waiting for review; cannot approve."));
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user