Compare commits
137
Commits
v2.13.1
...
f3ecb99492
| 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 | ||
|
|
edcaee77dc | ||
|
|
bfb1c6523c | ||
|
|
1770b67713 | ||
|
|
3d09323ee1 | ||
|
|
7c399b26ba | ||
|
|
d6696c3317 | ||
|
|
73e038edd9 | ||
|
|
9d22c775f2 | ||
|
|
407bc30554 | ||
|
|
5ecf6f750a | ||
|
|
eb62371430 | ||
|
|
395874d834 | ||
|
|
29ea18c427 | ||
|
|
1bdf20272d | ||
|
|
7eba0affbe | ||
|
|
d2648f965c | ||
|
|
897f287ee1 | ||
|
|
1a5728749b | ||
|
|
e628d97d29 | ||
|
|
0cec0a77ee | ||
|
|
acff815706 | ||
|
|
8e6f0d1605 | ||
|
|
bd393a0599 | ||
|
|
b4b6225432 | ||
|
|
46a0a27620 | ||
|
|
423d39e9fb | ||
|
|
951c2fe92a | ||
|
|
6f1fbe5b93 | ||
|
|
cfda3e9044 | ||
|
|
2a34d2453f | ||
|
|
451c01848c | ||
|
|
0235b43bad | ||
|
|
3348736e9d | ||
|
|
752cf0926b | ||
|
|
4cf601e423 | ||
|
|
c1b133ff90 | ||
|
|
256bea5ffa | ||
|
|
2b0562b2ed | ||
|
|
741f66fe14 | ||
|
|
2d67ca3171 | ||
|
|
0fcfe7c524 | ||
|
|
5be972564a | ||
|
|
1c76852957 | ||
|
|
c9d4823f06 | ||
|
|
dbee4a0de9 | ||
|
|
1177463a82 | ||
|
|
5477ae5302 | ||
|
|
a0a85c5c24 | ||
|
|
25c9a619dc | ||
|
|
13e860f06b | ||
|
|
ec1e60be1d | ||
|
|
f231f08a27 | ||
|
|
23491d3b49 | ||
|
|
0db9fadee9 | ||
|
|
7c7c33d677 | ||
|
|
c7392e085f | ||
|
|
c16c7b97ef | ||
|
|
cccfe2eca1 | ||
|
|
a8ec550740 | ||
|
|
40d2537486 | ||
|
|
f70503d038 | ||
|
|
9360cd2fe6 | ||
|
|
ee502883f8 | ||
|
|
e64d570554 | ||
|
|
fc25ee45d1 | ||
|
|
51ee505057 | ||
|
|
06bc3d2105 | ||
|
|
bd5aa265d1 | ||
|
|
b298594a2c | ||
|
|
62855ac575 | ||
|
|
18b4dcd56f | ||
|
|
b02e0f80f0 | ||
|
|
df3e7ba0c7 | ||
|
|
a1b20d9f27 | ||
|
|
e0c6b670cb | ||
|
|
2bd48ea1c7 | ||
|
|
77c70d74cb | ||
|
|
f24654dfb8 | ||
|
|
b05b6fbaec | ||
|
|
eb3aee41a4 | ||
|
|
2b8733227d | ||
|
|
ecb1cdbb06 |
+220
@@ -1,5 +1,225 @@
|
||||
# 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
|
||||
|
||||
- Home-Ordner als Sitzungsziel in Mission Control (f231f08a)
|
||||
- Einfuegen und Datei-Drop im interaktiven Terminal (23491d3b)
|
||||
- Cheat-Sheet-Fenster im Hilfe-Menue (0db9fade)
|
||||
- PowerUse-Einstellungen nach Settings -> Ausfuehrung verschieben (7c7c33d6)
|
||||
|
||||
### Documentation
|
||||
|
||||
- update for v2.13.5 (c7392e08)
|
||||
|
||||
## v2.13.5 — 2026-09-03
|
||||
|
||||
### Fixes
|
||||
|
||||
- Version im About-Dialog aus der FileVersion lesen (cccfe2ec)
|
||||
|
||||
### Refactoring
|
||||
|
||||
- "Nach Updates suchen" ins Hilfe-Menue verschieben (c16c7b97)
|
||||
|
||||
### Documentation
|
||||
|
||||
- update for v2.13.4 (a8ec5507)
|
||||
|
||||
## v2.13.4 — 2026-09-03
|
||||
|
||||
### Fixes
|
||||
|
||||
- interaktive Session ohne Arbeitsverzeichnis im User-Verzeichnis starten (f70503d0)
|
||||
|
||||
### Documentation
|
||||
|
||||
- update for v2.13.3 (9360cd2f)
|
||||
|
||||
## v2.13.3 — 2026-09-03
|
||||
|
||||
### Fixes
|
||||
|
||||
- raise SignalR MaximumParallelInvocationsPerClient (e64d5705)
|
||||
|
||||
### Documentation
|
||||
|
||||
- update for v2.13.2 (fc25ee45)
|
||||
|
||||
## v2.13.2 — 2026-09-03
|
||||
|
||||
### Features
|
||||
|
||||
- Handler-Run per Knopf abbrechen (bd5aa265)
|
||||
|
||||
### Fixes
|
||||
|
||||
- strip CLAUDE_CODE_CHILD_SESSION/CLAUDECODE before every ConPTY launch (51ee5050)
|
||||
- Dequeue-X auf blockierten Queue-Zeilen anzeigen und Kette konsistent halten (df3e7ba0)
|
||||
- report and gate ListSettingsModal load failures instead of swallowing them (a1b20d9f)
|
||||
- move ScrollViewer padding to inner content in modal bodies (e0c6b670)
|
||||
- Mission Control oeffnen, wenn ein Handler-Run per MCP startet oder ein Handoff eintrifft (b05b6fba)
|
||||
|
||||
### Performance
|
||||
|
||||
- show list settings modal before its seven-round-trip load finishes (f24654df)
|
||||
|
||||
### Documentation
|
||||
|
||||
- remove stale session-skills empty-state entry (2bd48ea1)
|
||||
- update for v2.13.1 (eb3aee41)
|
||||
- update for v2.13.0 (2b873322)
|
||||
|
||||
## v2.13.1 — 2026-09-02
|
||||
|
||||
### Fixes
|
||||
|
||||
- Mission Control oeffnen, wenn ein Handler-Run per MCP startet oder ein Handoff eintrifft (9f64ba50)
|
||||
|
||||
## v2.13.0 — 2026-09-02
|
||||
|
||||
### Features
|
||||
|
||||
- Handler-Session vor dem Usage-Limit warnen und Handoff bis zum Reset zurueckhalten (fe818651)
|
||||
- start_list_handler MCP-Tool startet einen List-Handler-Run wie der Button (c2166a0d)
|
||||
- Handler-Session nach Usage-Limit-Reset automatisch neu starten (016b9203)
|
||||
- PowerUse-Zeitfenster + Handoff-Schwellwert als Settings (69898be5)
|
||||
- PowerUse-Toggle im Usage-Monitor, Opt-in pro Liste (19375254)
|
||||
- PowerUse-Addendum an die Handler-Prompts, Cap aus den Settings (453d61ca)
|
||||
- PowerUse-Flags und konfigurierbares Handoff-Budget (3cf9628f)
|
||||
- depends_on als Menge statt Einzelfeld (c25732cf)
|
||||
- surface the handoff round budget in the merge-helper handoff brief (81a5fc5d)
|
||||
- add ExpectsNoDiff flag for verification/audit tasks (0b506f52)
|
||||
- add dependency audit and diff-review threshold to list-handler prompts (9e805646)
|
||||
- add "move to list" to the task row context menu (80fe188a)
|
||||
- notes get their own list, a day log and an Enter chain (0f04b796)
|
||||
- add_note MCP tool with a per-day agent cap (ae0c7d37)
|
||||
|
||||
### Fixes
|
||||
|
||||
- carry ExpectsNoDiff into the relocated merge MCP tools (4ee1d05f)
|
||||
- stop Ui.Tests order-dependent flakiness on shared Loc.Current (dc1e67d0)
|
||||
- add failureReason field to batch_get_tasks (91220177)
|
||||
- make locale-parity test able to detect a malformed locale file (4c78a5d6)
|
||||
- don't leave an orphaned list behind on cancel (56bc1651)
|
||||
- stop treating git worktrees and submodules as repos (b6e5876b)
|
||||
- fix Bandel ticket sync and reactivate the integration (30d0611b)
|
||||
- Shift+Enter in the capture box, not just in the note rows (f6405804)
|
||||
- let Shift+Enter break the line, and clear the leftovers around the editor (b90c5855)
|
||||
- auto-bootstrap commit-less repos before creating worktrees (e0748ba3)
|
||||
- bootstrap commit-less repos and survive missing git identity (de44196b)
|
||||
|
||||
### Performance
|
||||
|
||||
- switch to workstation GC to cut idle memory (c347e4fc)
|
||||
|
||||
### Refactoring
|
||||
|
||||
- share StubWorkerClient between Ui.Tests and Worker.Tests (9f90e75a)
|
||||
- remove the stale-worker banner (6be62f02)
|
||||
|
||||
### Documentation
|
||||
|
||||
- Handler-Prompt praezisieren (Wave-Plan, Task-Split, review_task-Aufruf) (20b3b4ce)
|
||||
- Test-Scoping fuer Implementer-Tasks, PowerUse in den Explore-Notes (416997d7)
|
||||
- plan for multi-dependency depends_on relation table (84d955df)
|
||||
- empty-repo hardening notes + verification item (cf093e31)
|
||||
- spec + plan for empty-repo (unborn HEAD) hardening (fea8236e)
|
||||
- update for v2.12.0 (1d6cd49b)
|
||||
|
||||
## v2.12.0 — 2026-08-28
|
||||
|
||||
### 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
|
||||
@@ -121,7 +163,7 @@ per call and `Register` clamps to min 1, so 0 can never mean "unbounded"). Past
|
||||
`wait`/`merge` request is silently coerced to its `_final` counterpart — check the result's
|
||||
`coerced` flag — and any handoff after a `merge_final` round throws.
|
||||
|
||||
**PowerUse.** When `AppSettingsEntity.PowerUseEnabled` (toggled on the usage monitor) **and** the
|
||||
**PowerUse.** When `AppSettingsEntity.PowerUseEnabled` (Settings → Execution) **and** the
|
||||
list's own `ListConfigEntity.PowerUseAllowed` are both set, `InteractiveLaunchSpecService.
|
||||
ComposeHandlerPromptAsync` appends `PromptKind.PowerUseAddendum` to whichever handler prompt the
|
||||
phase resolved to. The addendum is written as an *override* of the base prompt's interactive rules
|
||||
|
||||
@@ -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** —
|
||||
@@ -98,6 +98,7 @@ Daily prep: `GetDailyPrepCandidates`, `SetMyDay`.
|
||||
| `TaskWaitMcpTools` | `WaitForTaskChange` |
|
||||
| `QueueStateMcpTools` | `GetQueueState` |
|
||||
| `AttachmentMcpTools` | `AddTaskAttachment`, `ListTaskAttachments`, `RemoveTaskAttachment` |
|
||||
| `ListHandlerMcpTools` | `StartListHandler` |
|
||||
|
||||
## Per-tool behaviour worth knowing
|
||||
|
||||
@@ -235,6 +236,13 @@ task already outside those two (unknown ids reported as status `"NotFound"`, als
|
||||
**`AttachmentMcpTools`** — re-attaching the same `fileName` overwrites. Add/remove refuse on a
|
||||
`Running` task.
|
||||
|
||||
**`StartListHandler`** — the worker only broadcasts `ListHandlerStartRequested`; the connected
|
||||
ClaudeDo App does the actual work (creates the handler task, opens the triage ConPTY tile). Before
|
||||
broadcasting it checks `ConnectedClientTracker` (a `WorkerHub.OnConnectedAsync`/`OnDisconnectedAsync`-driven
|
||||
singleton count) — with zero connected clients the broadcast would reach nobody, so the tool
|
||||
returns `Requested: false` plus a `Reason` instead of claiming success. Does not queue or retry the
|
||||
request for a later connect.
|
||||
|
||||
**`GetDailyPrepCandidates`** — Idle, non-blocked tasks in a git repo **not** excluded by
|
||||
`AppSettings.ReportExcludedPaths` and not already `IsMyDay`, plus the current Idle MyDay tasks
|
||||
and `maxTasks` (= `DailyPrepMaxTasks`). Repo-exclusion logic lives in the `DailyPrepFilter`
|
||||
|
||||
@@ -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. |
|
||||
@@ -254,6 +259,7 @@ degrades instead of throwing. Worker config: `usage_poll_interval_active_seconds
|
||||
`usage_poll_interval_idle_seconds`.
|
||||
|
||||
The gate percentages are editable in **two** places that both write the same `app_settings` row:
|
||||
Settings → General (typed) and the usage-monitor gauges (dragged). The throttle stages are
|
||||
Settings → Execution (typed) and the usage-monitor gauges (dragged). The throttle stages are
|
||||
gauge-only — `SettingsModalViewModel` therefore carries them load→save verbatim so saving Settings
|
||||
can't reset a dragged value.
|
||||
can't reset a dragged value. PowerUse (switch, time window, handoff %) used to be usage-monitor-only
|
||||
and is now typed on the same Settings tab, so it needs no such round-trip.
|
||||
|
||||
+1
-3
@@ -15,9 +15,7 @@ Checkliste lebt in `docs/verification-handoff.md`.
|
||||
|
||||
- **Planning-aktiver Parent zeigt weiter „Idle":** Parent in `planning_phase=active` hat `Status=Idle` (korrekt im Modell), aber `TaskRowViewModel.StatusLabel` (Zeile 132) kennt nur `HasInteractiveSession`/`IsParked` als Overrides — der `PlanningBadge` ersetzt den Chip nicht sichtbar → liest sich wie ein normaler Idle-Task. Wunsch: klarer „Planning/Draft aktiv"-Zustand, der Idle überschreibt. (Kinder zeigen korrekt „Draft".)
|
||||
- **Blocked-by-Kette nicht sichtbar:** Nach Finalize ist die sequentielle Kette korrekt gesetzt (child[i] blocked-by child[i-1]), aber die UI stellt Reihenfolge/Abhängigkeit nicht dar — es gibt nur den „waiting"-Chip. Wunsch: Kette visualisieren (z.B. „wartet auf <Vorgänger>").
|
||||
- **Dequeue-„X" fehlt auf wartenden (blockierten) Kettengliedern:** `CanRemoveFromQueue = IsQueued || HasQueuedSubtasks` (TaskRowViewModel.cs:99), `IsQueued` verlangt leeres `blocked_by`. Ein gequeuetes, aber blockiertes Kind (`IsWaiting`) bekommt daher kein Remove-from-queue-X — nur Parent + erstes (entsperrtes) Kind.
|
||||
- **Session-Skills-Tab hat keinen Empty-State:** Bei 0 installierten Skills zeigt der Skills-Tab (Settings) nur eine nackte leere Fläche unter der Install-Zeile — kein erklärender Hinweis (z.B. „Noch keine Skills installiert — GitHub-URL oben einfügen"). Im `sessionSkillsTab`-Locale-Namespace (en.json:674) gibt es keinen `empty`-Key.
|
||||
- **Modal-Bodies sind unten abgeschnitten** (Sichtprüfung 2026-08-06, beobachtet in den Listen-Settings: der Hinweistext unter „Verify command" ist mitten in der Zeile vom Footer weggeschnitten und lässt sich nicht herunterscrollen; laut Mika „fast überall" in den Settings-Modals). Gemeinsames Muster: `<ScrollViewer Padding="20,16">` als Modal-Body — in `ListSettingsModalView:36`, `MergeModalView:33`, `WorktreesOverviewModalView:131`, `AboutModalView:20`, `MergeHelperSelectionModal:47`, `RepoImportModalView:45`, `LogVisualizerView:45` sowie `WorkConsole:295/395`. **Vermutete** Ursache (nicht verifiziert): das untere `Padding` des ScrollViewers zählt nicht zum scrollbaren Extent, die letzten Pixel sind also unerreichbar. Erst am echten Fall nachmessen, dann ggf. einheitlich das Padding vom ScrollViewer auf ein `Margin` des inneren Inhalts umziehen.
|
||||
- **Modal-Bodies unten abgeschnitten — gefixt, Sichtprüfung offen** (Ursache 2026-09-02 verifiziert: `ScrollViewer.Padding` zählt in Avalonia 12 nicht zum Extent; Fix ist das Padding auf ein `Margin` des inneren Inhalts umzuziehen). Betraf `AboutModalView`, `MergeHelperSelectionModal`, `MergeModalView`, `RepoImportModalView`, `WorktreesOverviewModalView` — alle fünf umgestellt. `ListSettingsModalView`, `LogVisualizerView` und `WorkConsole` hatten zu diesem Zeitpunkt bereits kein ScrollViewer-Padding mehr.
|
||||
- **Usage-Monitor-Modal: Analyse-Tabellen ausbaufähig** (Sichtprüfung 2026-08-06; Gauges, Info-Bänder, Presets/Custom-Range und Refresh-Button sind in Ordnung). Models-Tab: die Spaltenköpfe „OTHER CACHE" und „SHARE" kollidieren zu `OTHER CACHISHARE`; `claude-haiku-4-5-20251001` läuft in die IN-Spalte; alle Zahlen linksbündig und ohne Tausendertrenner (`506830708`). Tasks-Tab: Task-Titel wird ohne Ellipse hart an der LIST-Spalte abgeschnitten, der LIST-Text ebenfalls; MODEL ist bei Runs von vor der `task_runs.model`-Spalte leer (besser „—"). Wunsch: Zahlen rechtsbündig + `#,##0` (oder k/M), Spaltenbreiten/Truncation fixen.
|
||||
- **Conflict-Resolver: mehrere Konfliktdateien schlecht erkennbar:** Beim 2-Datei-Konflikt schwer zu sehen, dass zwei Dateien betroffen sind (File-Switcher/Anzahl zu unauffällig). Prominentere Datei-Liste / „x von y Dateien".
|
||||
- **Attachments: erster Drag&Drop der Session schlug einmalig fehl („An error occurred"):** Beim allerersten Drop-to-attach einer UI-Session zeigte die `DropStatus`-Zeile inline einen generischen Fehler und es wurde nichts persistiert (kein File, keine DB-Row); alle folgenden Drops derselben Session + der „Add file…"-Picker + Remove funktionierten fehlerfrei. Nicht reproduzierbar nach dem ersten Mal. Kandidaten: transiente SQLite-Contention (der UI-Prozess schreibt `todo.db` direkt via `new TaskAttachmentRepository`, während der Worker dieselbe DB hält) ODER ein Fehler im Drop-Stream-Pfad (`IStorageFile.OpenReadAsync` im Code-Behind, außerhalb des `try` in `AddFilesAsync`). Falls es erneut auftritt: `AddFilesAsync`/`OnDrop` mit robusterem Error-Logging versehen.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -12,7 +12,9 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
|
||||
- `InteractiveSessionId` = the claude session id an embedded ConPTY interactive task session runs under, persisted by `InteractiveLaunchSpecService` before launch so a closed/aborted session can be resumed → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md).
|
||||
- `Number` (INTEGER NOT NULL, unique index `idx_tasks_number`) — a global, monotonically increasing display alias for tasks. **Never reused**: deleting a task leaves a gap in numbering, ensuring that a task number that appears in a log or report always points to the same task (if it exists). Allocated by `TaskNumberAllocator.AddWithNumberAsync` on every insert via a persistent counter (`app_settings.next_task_number`). **Why not `MAX(number) + 1`:** deleting the highest-numbered task would free its number for reuse, breaking the immutability guarantee. The allocator uses an `UPDATE…RETURNING` statement to claim numbers atomically, retrying up to 5 times if a uniqueness collision occurs (the counter advances on each attempt, so retries always allocate fresh numbers). The one-time backfill for pre-existing rows lived in the `AddTaskNumbers` migration, squashed away 2026-08-26.
|
||||
- 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`)
|
||||
- **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, 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`)
|
||||
@@ -22,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
|
||||
@@ -39,10 +42,10 @@ Beyond the basics it carries:
|
||||
| `ReportExcludedPaths` | `report_excluded_paths` | null | JSON array of excluded path prefixes. |
|
||||
| `StandupWeekday` | `standup_weekday` | Wednesday | int `DayOfWeek`. |
|
||||
| `NextTaskNumber` | `next_task_number` | 1 | Persistent counter for `TaskEntity.Number` allocation. Incremented atomically in `TaskNumberAllocator.AddWithNumberAsync` on every task insert. **Monotonic only:** the counter itself advances across all tasks (no per-list numbering), and it never resets. A deleted task leaves a gap. |
|
||||
| `PowerUseEnabled` | `power_use_enabled` | false | PowerUse master switch, toggled on the **usage monitor** (not Settings). ⚠️ Never sufficient alone — a handler run is in PowerUse only when `list_config.power_use_allowed` is also set. |
|
||||
| `PowerUseEnabled` | `power_use_enabled` | false | PowerUse master switch, toggled in **Settings → Execution**. ⚠️ Never sufficient alone — a handler run is in PowerUse only when `list_config.power_use_allowed` is also set. |
|
||||
| `ListHandlerMaxHandoffs` | `list_handler_max_handoffs` | 4 | Cap on a list-handler run's wait↔merge handoff chain (`HandoffRoundTracker`). 4 = two merge rounds. Independent of PowerUse — applies to interactive runs too. Clamped to min 1 in `WorkerHub.UpdateAppSettings` and again in `Register`, because 0 would remove the only bound on a runaway chain. |
|
||||
| `PowerUseWindowStart` / `PowerUseWindowEnd` | `power_use_window_*` | null | Optional local-time window (`HH:mm`) narrowing when PowerUse applies. Both null (or only one set) = round the clock, i.e. byte-identical to the pre-window behaviour; end before start wraps over midnight. Semantics live in the pure `PowerUseWindow`; checked in `InteractiveLaunchSpecService.ComposeHandlerPromptAsync` **per phase** at prompt-build time, so a chain handing off past the end time drops the addendum from there on. Unparseable input is stored as null, not rejected. |
|
||||
| `PowerUseHandoffPct` | `power_use_handoff_pct` | 95 | 5h-usage percentage at which a PowerUse handler session should hand off. Clamped 50..99. Stored + shown on the usage monitor; the consumer is the handoff prompt. |
|
||||
| `PowerUseHandoffPct` | `power_use_handoff_pct` | 95 | 5h-usage percentage at which a PowerUse handler session should hand off. Clamped 50..99. Typed in Settings → Execution; the consumer is the handoff prompt. |
|
||||
|
||||
All four usage percentages are clamped 0..100 by `AppSettingsRepository.UpdateAsync`.
|
||||
Gate/throttle semantics → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md).
|
||||
@@ -65,7 +68,8 @@ All use EF Core LINQ via `ClaudeDoDbContext`. The atomic `Queued → Running` cl
|
||||
Worker's `QueuePicker` (`FromSqlRaw`), **not** here.
|
||||
|
||||
- **TaskRepository** — CRUD, planning helpers (`CreateChildAsync`, `SetPlanningStartedAsync`, `DiscardPlanningAsync`, `UpdateChildAsync`), `UpdateAgentSettingsAsync`. ⚠️ Status-mutation primitives (`MarkRunningAsync`/`MarkDoneAsync`/`MarkFailedAsync`/`FlipAllRunningToFailedAsync`) are **`internal`** — only the Worker's `TaskStateService` may call them. `CreateChildAsync` produces children with `Status=Idle, PlanningPhase=None`. **Task-number allocation:** `AddAsync` (line 20) and `CreateChildAsync` (line 276) are the two sole task-creation paths; both call `TaskNumberAllocator.AddWithNumberAsync` to allocate a number atomically during the same transaction as the insert. Every other creation path (UI, MCP `add_task`, `batch_add_tasks`, Online Inbox sync, merge-helper handler tasks, planning children) routes through one of these two.
|
||||
- **ListRepository** — CRUD, `GetConfigAsync`/`SetConfigAsync` (upsert)/`DeleteConfigAsync` for `list_config`
|
||||
- **ListRepository** — CRUD, `GetConfigAsync`/`SetConfigAsync` (upsert)/`DeleteConfigAsync` for `list_config`, plus the `list_folders` set: `GetFoldersAsync`, `AddFolderAsync` (appends), `RenameFolderAsync`, `DeleteFolderAsync` (folder only — the FK releases its lists), `SetFolderCollapsedAsync`, `SetListFolderAsync`. The folder writers are `ExecuteUpdate` patches, not entity `Update()`s, so a rename can't reset `SortOrder`/`IsCollapsed` the way `SetConfigAsync`'s verbatim copy resets forgotten columns.
|
||||
- ⚠️ **`lists.sort_order` and `list_folders.sort_order` share one number space** — the sidebar interleaves folders and top-level lists in a single order, so `ReorderSidebarAsync` numbers the whole visual sequence (each folder followed by its children) across both tables, and `AddAsync`/`AddFolderAsync` append via `MAX(sort_order)+1` over **both**. Never renumber one table on its own.
|
||||
- **WorktreeRepository** — CRUD, `UpdateHeadAsync`, `SetStateAsync`, `SetMergedAsync` (atomically sets State=Merged **and** stamps MergeCommit in one update — the only writer of MergeCommit)
|
||||
- **TaskAttachmentRepository** — `AddAsync`, `UpdateAsync`, `GetAsync(taskId, fileName)`, `ListByTaskIdAsync`, `DeleteAsync(taskId, fileName)`. Bulk delete-per-task is the `ON DELETE CASCADE` on `task_attachments`, not a repository method.
|
||||
- **DailyNoteRepository**, **WeekReportRepository**, **TaskRunRepository**, **SubtaskRepository**, **AppSettingsRepository**
|
||||
@@ -116,6 +120,8 @@ Tables (one per line so parallel migrations don't collide on the same line):
|
||||
- `week_reports`
|
||||
- `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,
|
||||
|
||||
@@ -43,6 +43,7 @@ public class ClaudeDoDbContext : DbContext
|
||||
public DbSet<TaskEntity> Tasks => Set<TaskEntity>();
|
||||
public DbSet<TaskDependencyEntity> TaskDependencies => Set<TaskDependencyEntity>();
|
||||
public DbSet<ListEntity> Lists => Set<ListEntity>();
|
||||
public DbSet<ListFolderEntity> ListFolders => Set<ListFolderEntity>();
|
||||
public DbSet<ListConfigEntity> ListConfigs => Set<ListConfigEntity>();
|
||||
public DbSet<WorktreeEntity> Worktrees => Set<WorktreeEntity>();
|
||||
public DbSet<TaskRunEntity> TaskRuns => Set<TaskRunEntity>();
|
||||
@@ -53,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));
|
||||
|
||||
@@ -20,8 +20,17 @@ public class ListEntityConfiguration : IEntityTypeConfiguration<ListEntity>
|
||||
builder.Property(l => l.IsManual).HasColumnName("is_manual").IsRequired().HasDefaultValue(false);
|
||||
builder.Property(l => l.FindingsTracked).HasColumnName("findings_tracked").IsRequired().HasDefaultValue(false);
|
||||
builder.Property(l => l.Icon).HasColumnName("icon");
|
||||
builder.Property(l => l.FolderId).HasColumnName("folder_id");
|
||||
|
||||
builder.HasIndex(l => l.SortOrder).HasDatabaseName("idx_lists_sort");
|
||||
builder.HasIndex(l => l.FolderId).HasDatabaseName("idx_lists_folder");
|
||||
|
||||
// No navigation property on either side: nothing needs the graph, and a nav would have to
|
||||
// be Include'd (or silently come back empty) in every list query.
|
||||
builder.HasOne<ListFolderEntity>()
|
||||
.WithMany()
|
||||
.HasForeignKey(l => l.FolderId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
builder.HasOne(l => l.Config)
|
||||
.WithOne(c => c.List)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ClaudeDo.Data.Configuration;
|
||||
|
||||
public class ListFolderEntityConfiguration : IEntityTypeConfiguration<ListFolderEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<ListFolderEntity> builder)
|
||||
{
|
||||
builder.ToTable("list_folders");
|
||||
|
||||
builder.HasKey(f => f.Id);
|
||||
builder.Property(f => f.Id).HasColumnName("id");
|
||||
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.
|
||||
@@ -704,6 +722,11 @@ public sealed class GitService
|
||||
// (mktree "input format error") and would prepend to stdin commit messages.
|
||||
StandardInputEncoding = stdinData is not null ? new UTF8Encoding(false) : null,
|
||||
};
|
||||
// Force English stderr/stdout so the English-only substring matches below
|
||||
// (e.g. "ambiguous argument 'HEAD'", "tell me who you are") work regardless
|
||||
// of the OS locale git for Windows picks up.
|
||||
psi.Environment["LC_ALL"] = "C";
|
||||
psi.Environment["LANGUAGE"] = "C";
|
||||
psi.ArgumentList.Add("-C");
|
||||
psi.ArgumentList.Add(workDir);
|
||||
foreach (var a in args) psi.ArgumentList.Add(a);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,74 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddListFolders : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "folder_id",
|
||||
table: "lists",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "list_folders",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<string>(type: "TEXT", nullable: false),
|
||||
name = table.Column<string>(type: "TEXT", nullable: false),
|
||||
created_at = table.Column<DateTime>(type: "TEXT", nullable: false),
|
||||
icon = table.Column<string>(type: "TEXT", nullable: true),
|
||||
sort_order = table.Column<int>(type: "INTEGER", nullable: false, defaultValue: 0),
|
||||
is_collapsed = table.Column<bool>(type: "INTEGER", nullable: false, defaultValue: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_list_folders", x => x.id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "idx_lists_folder",
|
||||
table: "lists",
|
||||
column: "folder_id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "idx_list_folders_sort",
|
||||
table: "list_folders",
|
||||
column: "sort_order");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_lists_list_folders_folder_id",
|
||||
table: "lists",
|
||||
column: "folder_id",
|
||||
principalTable: "list_folders",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_lists_list_folders_folder_id",
|
||||
table: "lists");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "list_folders");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "idx_lists_folder",
|
||||
table: "lists");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "folder_id",
|
||||
table: "lists");
|
||||
}
|
||||
}
|
||||
}
|
||||
+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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -324,6 +324,10 @@ namespace ClaudeDo.Data.Migrations
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("findings_tracked");
|
||||
|
||||
b.Property<string>("FolderId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("folder_id");
|
||||
|
||||
b.Property<string>("Icon")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("icon");
|
||||
@@ -351,12 +355,104 @@ namespace ClaudeDo.Data.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("FolderId")
|
||||
.HasDatabaseName("idx_lists_folder");
|
||||
|
||||
b.HasIndex("SortOrder")
|
||||
.HasDatabaseName("idx_lists_sort");
|
||||
|
||||
b.ToTable("lists", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListFolderEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("Color")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("color");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("Icon")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("icon");
|
||||
|
||||
b.Property<bool>("IsCollapsed")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_collapsed");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<string>("ParentFolderId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("parent_folder_id");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("sort_order");
|
||||
|
||||
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")
|
||||
@@ -934,6 +1030,22 @@ namespace ClaudeDo.Data.Migrations
|
||||
b.Navigation("List");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.ListFolderEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("FolderId")
|
||||
.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")
|
||||
|
||||
@@ -62,8 +62,7 @@ public sealed class AppSettingsEntity
|
||||
public bool AutoContinueOnUsageLimit { get; set; }
|
||||
|
||||
// Master switch for PowerUse: list-handler runs go unattended (never ask the user) and harvest
|
||||
// new work when a round would otherwise starve. Toggled from the usage monitor, because it is a
|
||||
// deliberate "spend the remaining window" decision rather than a normal default. Off by default,
|
||||
// new work when a round would otherwise starve. Toggled in Settings → Execution. Off by default,
|
||||
// and on its own it changes nothing — a run is only in PowerUse when the LIST also opted in via
|
||||
// ListConfigEntity.PowerUseAllowed, so leaving this on cannot make an unrelated list's handler
|
||||
// run unattended. Composition lives in InteractiveLaunchSpecService.
|
||||
|
||||
@@ -16,6 +16,9 @@ public sealed class ListEntity
|
||||
public bool FindingsTracked { get; set; }
|
||||
// Icon key into the UI's icon library (e.g. "Star"); null = the default folder icon.
|
||||
public string? Icon { get; set; }
|
||||
// Sidebar folder this list sits in (ListFolderEntity.Id); null = top level. ON DELETE SET NULL,
|
||||
// so deleting a folder moves its lists back to the top level instead of deleting them.
|
||||
public string? FolderId { get; set; }
|
||||
|
||||
// Navigation properties
|
||||
public ListConfigEntity? Config { get; set; }
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace ClaudeDo.Data.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// Folders nest arbitrarily deep via <see cref="ParentFolderId"/>.
|
||||
/// </summary>
|
||||
public sealed class ListFolderEntity
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Name { get; set; }
|
||||
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
|
||||
|
||||
@@ -17,10 +17,20 @@ public sealed class ListRepository
|
||||
public async Task AddAsync(ListEntity entity, CancellationToken ct = default)
|
||||
{
|
||||
NormalizeWorkingDir(entity);
|
||||
if (entity.SortOrder == 0) entity.SortOrder = await NextSortOrderAsync(ct);
|
||||
_context.Lists.Add(entity);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>One past the highest <c>sort_order</c> in use — across <b>both</b> tables, because
|
||||
/// the sidebar interleaves folders and top-level lists in a single order sequence.</summary>
|
||||
private async Task<int> NextSortOrderAsync(CancellationToken ct = default)
|
||||
{
|
||||
var maxList = await _context.Lists.MaxAsync(l => (int?)l.SortOrder, ct) ?? -1;
|
||||
var maxFolder = await _context.ListFolders.MaxAsync(f => (int?)f.SortOrder, ct) ?? -1;
|
||||
return Math.Max(maxList, maxFolder) + 1;
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(ListEntity entity, CancellationToken ct = default)
|
||||
{
|
||||
NormalizeWorkingDir(entity);
|
||||
@@ -56,18 +66,92 @@ public sealed class ListRepository
|
||||
return await _context.Lists.OrderBy(l => l.SortOrder).ThenBy(l => l.CreatedAt).ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task ReorderAsync(IReadOnlyList<string> orderedListIds, CancellationToken ct = default)
|
||||
/// <summary>
|
||||
/// Writes the sidebar's visual order as one sequence spanning both tables: folders and lists
|
||||
/// share the <c>sort_order</c> number space, which is what lets a top-level list sit between
|
||||
/// two folders. A folder's own children follow it in the sequence, so ordering folders by
|
||||
/// <c>sort_order</c> and lists by <c>sort_order</c> independently still reproduces the sidebar.
|
||||
/// </summary>
|
||||
public async Task ReorderSidebarAsync(IReadOnlyList<(string Id, bool IsFolder)> ordered, CancellationToken ct = default)
|
||||
{
|
||||
var idSet = orderedListIds.ToHashSet();
|
||||
var entities = await _context.Lists.Where(l => idSet.Contains(l.Id)).ToListAsync(ct);
|
||||
for (int i = 0; i < orderedListIds.Count; i++)
|
||||
var listIds = ordered.Where(o => !o.IsFolder).Select(o => o.Id).ToHashSet();
|
||||
var folderIds = ordered.Where(o => o.IsFolder).Select(o => o.Id).ToHashSet();
|
||||
var lists = await _context.Lists.Where(l => listIds.Contains(l.Id)).ToListAsync(ct);
|
||||
var folders = await _context.ListFolders.Where(f => folderIds.Contains(f.Id)).ToListAsync(ct);
|
||||
|
||||
for (int i = 0; i < ordered.Count; i++)
|
||||
{
|
||||
var e = entities.FirstOrDefault(x => x.Id == orderedListIds[i]);
|
||||
if (e is not null) e.SortOrder = i;
|
||||
var (id, isFolder) = ordered[i];
|
||||
if (isFolder)
|
||||
{
|
||||
if (folders.FirstOrDefault(f => f.Id == id) is { } folder) folder.SortOrder = i;
|
||||
}
|
||||
else if (lists.FirstOrDefault(l => l.Id == id) is { } list) list.SortOrder = i;
|
||||
}
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
// ---- Sidebar folders (list_folders) ----
|
||||
// Folders live here rather than in their own repository because they exist only as a grouping
|
||||
// of these lists — every caller that touches one already holds a ListRepository.
|
||||
|
||||
public async Task<List<ListFolderEntity>> GetFoldersAsync(CancellationToken ct = default)
|
||||
{
|
||||
return await _context.ListFolders
|
||||
.OrderBy(f => f.SortOrder).ThenBy(f => f.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task AddFolderAsync(ListFolderEntity folder, CancellationToken ct = default)
|
||||
{
|
||||
folder.SortOrder = await NextSortOrderAsync(ct);
|
||||
_context.ListFolders.Add(folder);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <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, 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)
|
||||
.SetProperty(f => f.Color, color), ct);
|
||||
}
|
||||
|
||||
/// <summary>Deletes the folder only — its lists survive and fall back to the top level via
|
||||
/// the ON DELETE SET NULL on <c>lists.folder_id</c>.</summary>
|
||||
public async Task DeleteFolderAsync(string folderId, CancellationToken ct = default)
|
||||
{
|
||||
await _context.ListFolders.Where(f => f.Id == folderId).ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
public async Task SetFolderCollapsedAsync(string folderId, bool collapsed, CancellationToken ct = default)
|
||||
{
|
||||
await _context.ListFolders
|
||||
.Where(f => f.Id == folderId)
|
||||
.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)
|
||||
{
|
||||
await _context.Lists
|
||||
.Where(l => l.Id == listId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(l => l.FolderId, folderId), ct);
|
||||
}
|
||||
|
||||
public async Task<ListConfigEntity?> GetConfigAsync(string listId, CancellationToken ct = default)
|
||||
{
|
||||
return await _context.ListConfigs.FirstOrDefaultAsync(c => c.ListId == listId, ct);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -37,13 +37,12 @@ public record AppSettingsDto(
|
||||
int UsageThrottleSevenDaySoftPct = 50,
|
||||
int UsageThrottleSevenDayHardPct = 65,
|
||||
bool AutoContinueOnUsageLimit = false,
|
||||
// PowerUse master switch — toggled on the usage monitor, not typed in Settings. Only takes
|
||||
// effect for a list that also set PowerUseAllowed.
|
||||
// PowerUse master switch — Settings → Execution. Only takes effect for a list that also set
|
||||
// PowerUseAllowed.
|
||||
bool PowerUseEnabled = false,
|
||||
int ListHandlerMaxHandoffs = 4,
|
||||
// PowerUse time window (`HH:mm`, local, null = round the clock) + the 5h-usage percentage at
|
||||
// which a PowerUse handler session should hand off. Also usage-monitor-owned, so Settings has
|
||||
// to round-trip them too.
|
||||
// which a PowerUse handler session should hand off. Same Settings tab.
|
||||
string? PowerUseWindowStart = null,
|
||||
string? PowerUseWindowEnd = null,
|
||||
int PowerUseHandoffPct = 95);
|
||||
@@ -73,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);
|
||||
|
||||
@@ -92,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);
|
||||
|
||||
@@ -217,6 +218,10 @@ public sealed record LaunchSpec(
|
||||
IReadOnlyList<string> Args,
|
||||
IReadOnlyDictionary<string, string> Env);
|
||||
|
||||
// A handler round's still-open (Queued/Running) task, for the Mission Control "abandon run"
|
||||
// action -- confirms with a count, then cancels each one before closing the pane.
|
||||
public record HandlerOpenTaskDto(string Id, string Title);
|
||||
|
||||
public record TicketSettingsDto(string? ApiBaseUrl, bool TokenSet);
|
||||
public record TicketConnectionDto(bool Ok, string? UserName, IReadOnlyList<string> Scopes, string? Error);
|
||||
public record TicketProjectDto(int Id, string Title, string DepartmentName);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using ClaudeDo.Installer.Core;
|
||||
using ClaudeDo.Installer.Localization;
|
||||
|
||||
namespace ClaudeDo.Installer.Checks;
|
||||
|
||||
@@ -23,11 +24,12 @@ public sealed class ClaudeAuthCheck : IEnvironmentCheck
|
||||
|
||||
public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
|
||||
{
|
||||
var loc = TrExtension.Localizer;
|
||||
var lookup = await _lookup.ResolveAsync(ctx, ct);
|
||||
if (lookup.Resolved is null)
|
||||
{
|
||||
return CheckResult.Unknown(Id, Severity, "checks.claudeAuth.title",
|
||||
"The claude CLI was not found — login could not be checked.");
|
||||
loc?["checks.claudeAuth.notFound"] ?? "The claude CLI was not found — login could not be checked.");
|
||||
}
|
||||
|
||||
var (fileName, arguments) = ProcessCommand.For(lookup.Resolved, "auth", "status", "--json");
|
||||
@@ -36,19 +38,19 @@ public sealed class ClaudeAuthCheck : IEnvironmentCheck
|
||||
if (exitCode != 0)
|
||||
{
|
||||
return CheckResult.Unknown(Id, Severity, "checks.claudeAuth.title",
|
||||
"Login status could not be determined.", output);
|
||||
loc?["checks.claudeAuth.undetermined"] ?? "Login status could not be determined.", output);
|
||||
}
|
||||
|
||||
var loggedIn = TryParseLoggedIn(output);
|
||||
if (loggedIn is null)
|
||||
{
|
||||
return CheckResult.Unknown(Id, Severity, "checks.claudeAuth.title",
|
||||
"Login status could not be determined.", output);
|
||||
loc?["checks.claudeAuth.undetermined"] ?? "Login status could not be determined.", output);
|
||||
}
|
||||
|
||||
return loggedIn.Value
|
||||
? CheckResult.Ok(Id, Severity, "checks.claudeAuth.title", "Logged in.")
|
||||
: CheckResult.Fail(Id, Severity, "checks.claudeAuth.title", "Not logged in.", "checks.claudeAuth.hint");
|
||||
? CheckResult.Ok(Id, Severity, "checks.claudeAuth.title", loc?["checks.claudeAuth.loggedIn"] ?? "Logged in.")
|
||||
: CheckResult.Fail(Id, Severity, "checks.claudeAuth.title", loc?["checks.claudeAuth.notLoggedIn"] ?? "Not logged in.", "checks.claudeAuth.hint");
|
||||
}
|
||||
|
||||
private static bool? TryParseLoggedIn(string output)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.IO;
|
||||
using ClaudeDo.Data.Environment;
|
||||
using ClaudeDo.Installer.Core;
|
||||
using ClaudeDo.Installer.Localization;
|
||||
|
||||
namespace ClaudeDo.Installer.Checks;
|
||||
|
||||
@@ -21,20 +22,21 @@ public sealed class ClaudeCliCheck : IEnvironmentCheck
|
||||
|
||||
public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
|
||||
{
|
||||
var loc = TrExtension.Localizer;
|
||||
var lookup = await _lookup.ResolveAsync(ctx, ct);
|
||||
|
||||
if (lookup.Resolved is null)
|
||||
{
|
||||
var searched = string.Join(", ", SearchedLocations());
|
||||
return CheckResult.Fail(Id, Severity, "checks.claudeCli.title",
|
||||
$"'{ctx.ClaudeBin}' was not found on PATH. Searched: {searched}.",
|
||||
loc?.Get("checks.claudeCli.notFound", ctx.ClaudeBin, searched) ?? $"'{ctx.ClaudeBin}' was not found on PATH. Searched: {searched}.",
|
||||
"checks.claudeCli.hint");
|
||||
}
|
||||
|
||||
if (lookup.ExitCode != 0)
|
||||
{
|
||||
return CheckResult.Fail(Id, Severity, "checks.claudeCli.title",
|
||||
$"'{lookup.Resolved.Path}' exited with code {lookup.ExitCode}.",
|
||||
loc?.Get("checks.claudeCli.exitCode", lookup.Resolved.Path, lookup.ExitCode) ?? $"'{lookup.Resolved.Path}' exited with code {lookup.ExitCode}.",
|
||||
"checks.claudeCli.hint", detail: lookup.Output);
|
||||
}
|
||||
|
||||
@@ -42,7 +44,8 @@ public sealed class ClaudeCliCheck : IEnvironmentCheck
|
||||
var detail = lookup.Resolved.IsShim
|
||||
? "Resolved via a shim (.cmd/.bat/.ps1), not a native .exe."
|
||||
: null;
|
||||
return CheckResult.Ok(Id, Severity, "checks.claudeCli.title", $"{lookup.Resolved.Path} — {version}", detail);
|
||||
return CheckResult.Ok(Id, Severity, "checks.claudeCli.title",
|
||||
loc?.Get("checks.claudeCli.resolvedFormat", lookup.Resolved.Path, version) ?? $"{lookup.Resolved.Path} — {version}", detail);
|
||||
}
|
||||
|
||||
private IEnumerable<string> SearchedLocations()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ClaudeDo.Installer.Core;
|
||||
using ClaudeDo.Installer.Localization;
|
||||
|
||||
namespace ClaudeDo.Installer.Checks;
|
||||
|
||||
@@ -27,28 +28,29 @@ public sealed class ClaudeVersionCheck : IEnvironmentCheck
|
||||
|
||||
public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
|
||||
{
|
||||
var loc = TrExtension.Localizer;
|
||||
var lookup = await _lookup.ResolveAsync(ctx, ct);
|
||||
|
||||
if (lookup.Resolved is null)
|
||||
{
|
||||
return CheckResult.Unknown(Id, Severity, "checks.claudeVersion.title",
|
||||
"The claude CLI was not found — version could not be checked.");
|
||||
loc?["checks.claudeVersion.notFound"] ?? "The claude CLI was not found — version could not be checked.");
|
||||
}
|
||||
|
||||
if (lookup.ExitCode != 0 || lookup.ParsedVersion is null)
|
||||
{
|
||||
return CheckResult.Unknown(Id, Severity, "checks.claudeVersion.title",
|
||||
$"Could not parse a version from '{lookup.Output.Trim()}'.");
|
||||
loc?.Get("checks.claudeVersion.parseFailed", lookup.Output.Trim()) ?? $"Could not parse a version from '{lookup.Output.Trim()}'.");
|
||||
}
|
||||
|
||||
if (lookup.ParsedVersion < MinimumVersion)
|
||||
{
|
||||
return CheckResult.Fail(Id, Severity, "checks.claudeVersion.title",
|
||||
$"claude {lookup.ParsedVersion} is older than the required {MinimumVersion}.",
|
||||
loc?.Get("checks.claudeVersion.tooOld", lookup.ParsedVersion, MinimumVersion) ?? $"claude {lookup.ParsedVersion} is older than the required {MinimumVersion}.",
|
||||
"checks.claudeVersion.hint");
|
||||
}
|
||||
|
||||
return CheckResult.Ok(Id, Severity, "checks.claudeVersion.title",
|
||||
$"claude {lookup.ParsedVersion} meets the minimum ({MinimumVersion}).");
|
||||
loc?.Get("checks.claudeVersion.ok", lookup.ParsedVersion, MinimumVersion) ?? $"claude {lookup.ParsedVersion} meets the minimum ({MinimumVersion}).");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Text.RegularExpressions;
|
||||
using ClaudeDo.Data.Environment;
|
||||
using ClaudeDo.Installer.Core;
|
||||
using ClaudeDo.Installer.Core.Interfaces;
|
||||
using ClaudeDo.Installer.Localization;
|
||||
|
||||
namespace ClaudeDo.Installer.Checks;
|
||||
|
||||
@@ -27,10 +28,12 @@ public sealed class GitCheck : IEnvironmentCheck
|
||||
|
||||
public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
|
||||
{
|
||||
var loc = TrExtension.Localizer;
|
||||
var resolved = ExecutableResolver.Resolve("git", _pathOverride, _pathExtOverride);
|
||||
if (resolved is null)
|
||||
{
|
||||
return CheckResult.Fail(Id, Severity, "checks.git.title", "git was not found on PATH.",
|
||||
return CheckResult.Fail(Id, Severity, "checks.git.title",
|
||||
loc?["checks.git.notFound"] ?? "git was not found on PATH.",
|
||||
"checks.git.hint", "https://git-scm.com/downloads");
|
||||
}
|
||||
|
||||
@@ -39,11 +42,13 @@ public sealed class GitCheck : IEnvironmentCheck
|
||||
|
||||
if (exitCode != 0)
|
||||
{
|
||||
return CheckResult.Fail(Id, Severity, "checks.git.title", $"'{resolved.Path}' exited with code {exitCode}.",
|
||||
return CheckResult.Fail(Id, Severity, "checks.git.title",
|
||||
loc?.Get("checks.git.exitCode", resolved.Path, exitCode) ?? $"'{resolved.Path}' exited with code {exitCode}.",
|
||||
"checks.git.hint", "https://git-scm.com/downloads", output);
|
||||
}
|
||||
|
||||
var version = VersionPattern.Match(output) is { Success: true } match ? match.Value : output.Trim();
|
||||
return CheckResult.Ok(Id, Severity, "checks.git.title", $"{resolved.Path} — {version}");
|
||||
return CheckResult.Ok(Id, Severity, "checks.git.title",
|
||||
loc?.Get("checks.git.resolvedFormat", resolved.Path, version) ?? $"{resolved.Path} — {version}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using ClaudeDo.Data.Environment;
|
||||
using ClaudeDo.Installer.Core;
|
||||
using ClaudeDo.Installer.Core.Interfaces;
|
||||
using ClaudeDo.Installer.Localization;
|
||||
|
||||
namespace ClaudeDo.Installer.Checks;
|
||||
|
||||
@@ -25,10 +26,12 @@ public sealed class GitIdentityCheck : IEnvironmentCheck
|
||||
|
||||
public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
|
||||
{
|
||||
var loc = TrExtension.Localizer;
|
||||
var resolved = ExecutableResolver.Resolve("git", _pathOverride, _pathExtOverride);
|
||||
if (resolved is null)
|
||||
{
|
||||
return CheckResult.Unknown(Id, Severity, "checks.gitIdentity.title", "git was not found — identity could not be checked.");
|
||||
return CheckResult.Unknown(Id, Severity, "checks.gitIdentity.title",
|
||||
loc?["checks.gitIdentity.notFound"] ?? "git was not found — identity could not be checked.");
|
||||
}
|
||||
|
||||
var name = await ReadConfigAsync(resolved, "user.name", ct);
|
||||
@@ -48,7 +51,8 @@ public sealed class GitIdentityCheck : IEnvironmentCheck
|
||||
_ => "user.email",
|
||||
};
|
||||
|
||||
return CheckResult.Fail(Id, Severity, "checks.gitIdentity.title", $"Missing git identity: {missing}.", "checks.gitIdentity.hint");
|
||||
return CheckResult.Fail(Id, Severity, "checks.gitIdentity.title",
|
||||
loc?.Get("checks.gitIdentity.missing", missing) ?? $"Missing git identity: {missing}.", "checks.gitIdentity.hint");
|
||||
}
|
||||
|
||||
private async Task<string> ReadConfigAsync(ResolvedExecutable resolved, string key, CancellationToken ct)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using ClaudeDo.Installer.Core;
|
||||
using ClaudeDo.Installer.Localization;
|
||||
|
||||
namespace ClaudeDo.Installer.Checks;
|
||||
|
||||
@@ -28,11 +29,12 @@ public sealed class PermissionModeAutoCheck : IEnvironmentCheck
|
||||
|
||||
public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
|
||||
{
|
||||
var loc = TrExtension.Localizer;
|
||||
var lookup = await _lookup.ResolveAsync(ctx, ct);
|
||||
if (lookup.Resolved is null)
|
||||
{
|
||||
return CheckResult.Unknown(Id, Severity, "checks.permissionModeAuto.title",
|
||||
"The claude CLI was not found — permission-mode support could not be checked.");
|
||||
loc?["checks.permissionModeAuto.notFound"] ?? "The claude CLI was not found — permission-mode support could not be checked.");
|
||||
}
|
||||
|
||||
var (fileName, arguments) = ProcessCommand.For(lookup.Resolved, "--help");
|
||||
@@ -41,17 +43,17 @@ public sealed class PermissionModeAutoCheck : IEnvironmentCheck
|
||||
if (exitCode != 0 || string.IsNullOrWhiteSpace(output))
|
||||
{
|
||||
return CheckResult.Unknown(Id, Severity, "checks.permissionModeAuto.title",
|
||||
"Could not determine whether --permission-mode auto is supported.");
|
||||
loc?["checks.permissionModeAuto.undetermined"] ?? "Could not determine whether --permission-mode auto is supported.");
|
||||
}
|
||||
|
||||
if (AutoIsListed(output))
|
||||
{
|
||||
return CheckResult.Ok(Id, Severity, "checks.permissionModeAuto.title",
|
||||
"--permission-mode auto is recognized by this CLI.");
|
||||
loc?["checks.permissionModeAuto.ok"] ?? "--permission-mode auto is recognized by this CLI.");
|
||||
}
|
||||
|
||||
return CheckResult.Fail(Id, Severity, "checks.permissionModeAuto.title",
|
||||
"This claude CLI version does not list 'auto' as a --permission-mode choice.",
|
||||
loc?["checks.permissionModeAuto.notListed"] ?? "This claude CLI version does not list 'auto' as a --permission-mode choice.",
|
||||
"checks.permissionModeAuto.hint");
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using ClaudeDo.Installer.Checks.Interfaces;
|
||||
using ClaudeDo.Installer.Core;
|
||||
using ClaudeDo.Installer.Localization;
|
||||
|
||||
namespace ClaudeDo.Installer.Checks;
|
||||
|
||||
@@ -19,6 +20,7 @@ public sealed class PortCheck : IEnvironmentCheck
|
||||
|
||||
public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
|
||||
{
|
||||
var loc = TrExtension.Localizer;
|
||||
var ports = new (string Label, int Port)[]
|
||||
{
|
||||
("SignalR", ctx.SignalRPort),
|
||||
@@ -39,7 +41,9 @@ public sealed class PortCheck : IEnvironmentCheck
|
||||
continue;
|
||||
}
|
||||
|
||||
blocked.Add(owner is null ? $"{label} port {port} is in use." : $"{label} port {port} is in use by '{owner}'.");
|
||||
blocked.Add(owner is null
|
||||
? loc?.Get("checks.ports.inUse", label, port) ?? $"{label} port {port} is in use."
|
||||
: loc?.Get("checks.ports.inUseBy", label, port, owner) ?? $"{label} port {port} is in use by '{owner}'.");
|
||||
}
|
||||
|
||||
if (blocked.Count > 0)
|
||||
@@ -49,10 +53,12 @@ public sealed class PortCheck : IEnvironmentCheck
|
||||
|
||||
if (ownWorker.Count > 0)
|
||||
{
|
||||
return CheckResult.Ok(Id, Severity, "checks.ports.title", $"In use by the running ClaudeDo Worker: {string.Join(", ", ownWorker)}.");
|
||||
var ownWorkerList = string.Join(", ", ownWorker);
|
||||
return CheckResult.Ok(Id, Severity, "checks.ports.title",
|
||||
loc?.Get("checks.ports.inUseByOwnWorker", ownWorkerList) ?? $"In use by the running ClaudeDo Worker: {ownWorkerList}.");
|
||||
}
|
||||
|
||||
return CheckResult.Ok(Id, Severity, "checks.ports.title", "Ports are available.");
|
||||
return CheckResult.Ok(Id, Severity, "checks.ports.title", loc?["checks.ports.available"] ?? "Ports are available.");
|
||||
}
|
||||
|
||||
private static bool IsFree(int port)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.IO;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Installer.Core;
|
||||
using ClaudeDo.Installer.Localization;
|
||||
|
||||
namespace ClaudeDo.Installer.Checks;
|
||||
|
||||
@@ -14,17 +15,19 @@ public sealed class WriteAccessCheck : IEnvironmentCheck
|
||||
|
||||
public Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
|
||||
{
|
||||
var loc = TrExtension.Localizer;
|
||||
foreach (var target in new[] { ctx.InstallDirectory, Paths.AppDataRoot() })
|
||||
{
|
||||
var error = TryWrite(target);
|
||||
if (error is not null)
|
||||
{
|
||||
return Task.FromResult(CheckResult.Fail(Id, Severity, "checks.writeAccess.title",
|
||||
$"Cannot write to '{target}': {error}", "checks.writeAccess.hint"));
|
||||
loc?.Get("checks.writeAccess.cannotWrite", target, error) ?? $"Cannot write to '{target}': {error}", "checks.writeAccess.hint"));
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(CheckResult.Ok(Id, Severity, "checks.writeAccess.title", "Install directory and data directory are writable."));
|
||||
return Task.FromResult(CheckResult.Ok(Id, Severity, "checks.writeAccess.title",
|
||||
loc?["checks.writeAccess.writable"] ?? "Install directory and data directory are writable."));
|
||||
}
|
||||
|
||||
private static string? TryWrite(string path)
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"categoryGeneral": "Allgemein",
|
||||
"categoryExecution": "Ausführung",
|
||||
"categoryPrime": "Prime Claude",
|
||||
"categoryPrimeHint": "Geplante Claude-Läufe: Ping, My Day füllen oder ein eigener Prompt, zu festen Zeiten.",
|
||||
"categoryWorktrees": "Worktrees",
|
||||
"categoryFiles": "Dateien & Prompts",
|
||||
"categorySkills": "Session Skills",
|
||||
@@ -27,6 +28,7 @@
|
||||
"model": "Modell",
|
||||
"maxTurns": "Max. Durchläufe",
|
||||
"effort": "Effort",
|
||||
"effortHint": "Denkaufwand, den das Modell anwendet. Höher kostet mehr und denkt tiefer.",
|
||||
"modelPresets": "Vorgaben pro Modell",
|
||||
"modelPresetsHint": "Effort und Durchlauf-Budget, mit denen eine Sitzung unter diesem Modell läuft. Overrides auf Listen- und Aufgabenebene gewinnen weiterhin.",
|
||||
"maxTurnsCeiling": "Max. Durchläufe – Obergrenze",
|
||||
@@ -36,15 +38,23 @@
|
||||
"maxParallelExecutions": "Max. parallele Ausführungen",
|
||||
"maxParallelExecutionsHint": "Wie viele Aufgaben aus der Warteschlange der Worker gleichzeitig ausführt.",
|
||||
"usageGateHeading": "Usage-Limit-Stopp",
|
||||
"usageGateHint": "Ab dieser Auslastung holt die Queue keine neuen Tasks mehr; laufende Tasks laufen zu Ende. 0 schaltet den Stopp ab.",
|
||||
"usageGateHint": "Ab dieser Auslastung holt die Warteschlange keine neuen Aufgaben mehr; laufende Aufgaben laufen zu Ende. 0 schaltet den Stopp ab.",
|
||||
"usageGateFiveHourPct": "5-Stunden-Fenster (%)",
|
||||
"usageGateSevenDayPct": "7-Tage-Fenster (%)",
|
||||
"autoContinueOnUsageLimit": "Nach Limit-Reset fortsetzen",
|
||||
"autoContinueOnUsageLimitHint": "Ist ein Run am Nutzungslimit abgebrochen, wird er nach Ablauf des 5-Stunden-Fensters automatisch fortgesetzt. Weckt außerdem die pausierte Queue genau zum Reset-Zeitpunkt statt erst beim nächsten Backstop-Check.",
|
||||
"autoContinueOnUsageLimitHint": "Ist ein Run am Nutzungslimit abgebrochen, wird er nach Ablauf des 5-Stunden-Fensters automatisch fortgesetzt. Weckt außerdem die pausierte Warteschlange genau zum Reset-Zeitpunkt statt erst beim nächsten Backstop-Check.",
|
||||
"listHandlerMaxHandoffs": "Handoff-Budget des List-Handlers",
|
||||
"listHandlerMaxHandoffsHint": "Wie viele Wait/Merge-Handoffs ein „Let Claude handle it\"-Run verketten darf. 4 entspricht zwei Merge-Runden. Für Läufe, die stundenlang durchlaufen sollen, höher setzen; der Wert begrenzt eine entlaufene Kette und darf daher nicht 0 sein.",
|
||||
"powerUseHeading": "PowerUse",
|
||||
"powerUse": "PowerUse aktivieren",
|
||||
"powerUseHint": "Das restliche Fenster ausnutzen: ein List-Handler-Run fragt nichts, löst Konflikte selbst, sammelt neue Arbeit und handoffet weiter, solange sein Budget reicht. Wirkt nur auf Listen, die PowerUse in ihren Listen-Einstellungen erlaubt haben.",
|
||||
"powerUseWindow": "Nur zwischen",
|
||||
"powerUseWindowPlaceholder": "HH:mm",
|
||||
"powerUseHandoffPct": "Übergabe bei (% von 5h)",
|
||||
"powerUseWindowHint": "Beide Zeiten leer = rund um die Uhr. Das Fenster wird bei jedem Start einer Handler-Phase geprüft — eine Kette, die über das Ende hinausläuft, macht ohne PowerUse weiter.",
|
||||
"powerUseWindowInvalid": "PowerUse-Zeiten müssen im Format HH:mm sein oder leer bleiben.",
|
||||
"throttleStagesHeading": "Throttle-Stufen",
|
||||
"throttleStagesHint": "Die Parallelität wird vor dem harten Gate begrenzt: soft auf 2 Slots, hard auf 1. Eingestellt wird das durch Ziehen der Marker an den Gauges im Usage-Monitor. Der Monitor schließt diese Einstellungen — vorher speichern, wenn du etwas geändert hast.",
|
||||
"throttleStagesHint": "Die Parallelität wird vor dem harten Limit-Stopp begrenzt: soft auf 2 Slots, hard auf 1. Eingestellt wird das durch Ziehen der Marker an den Gauges im Usage-Monitor. Der Monitor schließt diese Einstellungen — vorher speichern, wenn du etwas geändert hast.",
|
||||
"throttleStagesValues": "5h: soft {0}% / hard {1}% · 7d: soft {2}% / hard {3}%",
|
||||
"openUsageMonitor": "Usage-Monitor öffnen...",
|
||||
"reportExcludedPaths": "Bericht: ausgeschlossene Pfade (einer pro Zeile)",
|
||||
@@ -226,12 +236,13 @@
|
||||
"reasonIsPlanningParent": "Teil eines Plans",
|
||||
"reasonIsChildTask": "Teilaufgaben haben keine eigene Planungssitzung",
|
||||
"reasonPlanAlreadyFinalized": "Plan ist bereits finalisiert",
|
||||
"reasonNoWorktree": "Kein Worktree für diesen Task",
|
||||
"reasonNoWorktree": "Kein Worktree für diese Aufgabe",
|
||||
"cancel": "Abbrechen",
|
||||
"removeFromQueueTip": "Aus Warteschlange entfernen",
|
||||
"toggleSubtasksTip": "Unteraufgaben ein-/ausklappen",
|
||||
"agentSuggestedTip": "Vom Agenten vorgeschlagen",
|
||||
"interactiveChipTip": "Interaktive Sitzung offen — klicken, um im Mission Control dorthin zu springen",
|
||||
"waitingOnTip": "Wartet auf {0}",
|
||||
"scheduleTitle": "Aufgabe planen",
|
||||
"scheduleWhen": "WANN",
|
||||
"scheduleConfirm": "Planen",
|
||||
@@ -253,21 +264,24 @@
|
||||
"contextOpenFindings": "Findings-Ordner öffnen",
|
||||
"contextImportTickets": "Aus Ticketsystem aktualisieren",
|
||||
"contextSettings": "Einstellungen…",
|
||||
"contextFolderSettings": "Ordner-Einstellungen…",
|
||||
"contextRemoveFromFolder": "Aus Ordner herausnehmen",
|
||||
"newList": "Neue Liste",
|
||||
"addReposTip": "Repos als Listen hinzufügen",
|
||||
"noRepoBannerText": "Noch kein Repo verknüpft. ClaudeDo führt Tasks in einem Git-Repo aus.",
|
||||
"noRepoBannerText": "Noch kein Repo verknüpft. ClaudeDo führt Aufgaben in einem Git-Repo aus.",
|
||||
"noRepoBannerButton": "Repo importieren",
|
||||
"importTicketsResult": "{0} Tickets geprüft, {1} neue Tasks."
|
||||
},
|
||||
"details": {
|
||||
"deleteTaskTip": "Aufgabe löschen",
|
||||
"killSessionTip": "Laufende Sitzung beenden",
|
||||
"killSessionTip": "Laufende Sitzung hart beenden",
|
||||
"sendToQueue": "In Warteschlange einreihen",
|
||||
"sendToQueueTip": "Diese Aufgabe in die Warteschlange einreihen, damit Claude sie beim nächsten Lauf übernimmt",
|
||||
"copyTaskIdTip": "Aufgaben-ID kopieren",
|
||||
"starTip": "Favorit",
|
||||
"agentSettingsTip": "Agent-Einstellungen",
|
||||
"agentSettingsHeading": "Agent-Einstellungen (Überschreibungen)",
|
||||
"waitingOnPrefix": "Wartet auf",
|
||||
"copyFormattedTip": "Titel, Beschreibung und offene Schritte kopieren",
|
||||
"toggleEditPreviewTip": "Bearbeiten/Vorschau umschalten",
|
||||
"planDay": "Tag planen",
|
||||
@@ -293,7 +307,37 @@
|
||||
"steps": "Schritte",
|
||||
"files": "Dateien"
|
||||
},
|
||||
"emptyState": "Wähle eine Aufgabe aus, um Details zu sehen"
|
||||
"emptyState": "Wähle eine Aufgabe aus, um Details zu sehen",
|
||||
"roadblockLabel": "ROADBLOCK",
|
||||
"stepsCard": {
|
||||
"preview": "Vorschau",
|
||||
"edit": "Bearbeiten",
|
||||
"addStepPlaceholder": "Schritt hinzufügen…"
|
||||
},
|
||||
"workConsole": {
|
||||
"continue": "Fortsetzen",
|
||||
"resetAndRetry": "Zurücksetzen & erneut versuchen",
|
||||
"tabOutput": "Ausgabe",
|
||||
"tabGit": "Git",
|
||||
"tabSession": "Sitzung",
|
||||
"outcomeLabel": "ERGEBNIS",
|
||||
"reviewLabel": "REVIEW",
|
||||
"mergeLabel": "MERGE",
|
||||
"targetBranch": "Zielbranch",
|
||||
"openDiff": "Diff öffnen",
|
||||
"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",
|
||||
"cancel": "Abbrechen",
|
||||
"resetDiscardBranch": "Zurücksetzen (Branch verwerfen)…",
|
||||
"submitForReviewHint": "Von Hand daran gearbeitet? Den Worktree zum Review einreichen, um ihn zu mergen.",
|
||||
"submitForReview": "Zum Review einreichen",
|
||||
"outcomesLabel": "ERGEBNISSE"
|
||||
}
|
||||
},
|
||||
"agent": {
|
||||
"openWorktreeTip": "Worktree im Datei-Explorer öffnen — benötigt einen aktiven Worktree",
|
||||
@@ -332,13 +376,26 @@
|
||||
"failed": "Fehlgeschlagen",
|
||||
"roadblock": "Blockade gemeldet",
|
||||
"openSession": "Sitzung öffnen",
|
||||
"homeFolder": "Home (Schnellsitzung)",
|
||||
"browseForFolder": "Ordner wählen…",
|
||||
"recentSessions": "Letzte Sitzungen",
|
||||
"recentSessionsEmpty": "Keine Sitzungen in den letzten 14 Tagen",
|
||||
"resumeSession": "Fortsetzen",
|
||||
"showBrief": "Brief",
|
||||
"handoffSession": "Übergabe",
|
||||
"briefOpenFailed": "Brief konnte nicht geöffnet werden: {0}",
|
||||
"focusMode": "Fokus",
|
||||
"overviewMode": "Übersicht",
|
||||
"closeSession": "Sitzung beenden",
|
||||
"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}",
|
||||
"conptyStarting": "Sitzung wird gestartet…",
|
||||
"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}",
|
||||
@@ -354,6 +411,10 @@
|
||||
"enqueueAlreadyOpen": "Kann nicht in die Warteschlange gestellt werden — dieser Task hat eine offene interaktive Sitzung.",
|
||||
"enqueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}",
|
||||
"queueRefreshFailed": "Warteschlange konnte nicht aktualisiert werden: {0}",
|
||||
"abandonRun": "Lauf abbrechen",
|
||||
"abandonRunTip": "Die noch offenen Tasks dieses Laufs abbrechen und die Sitzung schließen",
|
||||
"abandonRunConfirm": "{0} offene Aufgabe(n) abbrechen und diese Sitzung schließen? Aufgaben im Review oder bereits erledigt bleiben unangetastet.",
|
||||
"abandonRunFailed": "Offene Tasks konnten nicht abgebrochen werden: {0}",
|
||||
"question": {
|
||||
"title": "Claude fragt nach",
|
||||
"placeholder": "Antwort eingeben…",
|
||||
@@ -387,13 +448,22 @@
|
||||
"body": "ClaudeDo kann den Hintergrund-Worker nicht erreichen. Normalerweise wird er bei der Anmeldung automatisch gestartet. Du kannst ihn jetzt starten oder neu installieren, falls das Problem bestehen bleibt.",
|
||||
"dismiss": "Ausblenden",
|
||||
"rerunInstaller": "Installer erneut ausführen",
|
||||
"startWorker": "Worker starten"
|
||||
"startWorker": "Worker starten",
|
||||
"workerNotFound": "Worker-Programm nicht gefunden. Installation prüfen.",
|
||||
"installerNotFound": "Installer nicht gefunden. Installation prüfen.",
|
||||
"startFailed": "Start fehlgeschlagen: {0}"
|
||||
},
|
||||
"listSettings": {
|
||||
"title": "LISTENEINSTELLUNGEN",
|
||||
"folderTitle": "ORDNER",
|
||||
"deleteList": "Liste löschen",
|
||||
"deleteFolder": "Ordner löschen",
|
||||
"createAsFolder": "Stattdessen einen Ordner anlegen",
|
||||
"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...",
|
||||
@@ -403,10 +473,12 @@
|
||||
"findingsTracked": "Findings-Ordner .claudedo einchecken",
|
||||
"findingsTrackedHint": "Aus: der Ordner bleibt über .git/info/exclude aus git heraus. An: Findings reisen mit dem Repo.",
|
||||
"powerUseAllowed": "PowerUse für diese Liste erlauben",
|
||||
"powerUseAllowedHint": "Erlaubt einem List-Handler-Run auf dieser Liste, unbeaufsichtigt zu laufen, sobald PowerUse im Usage-Monitor eingeschaltet ist: er fragt nichts, löst Konflikte selbst und sammelt neue Arbeit. Beide Schalter sind nötig — ein global aktives PowerUse ändert an einer Liste ohne Opt-in also nichts.",
|
||||
"powerUseAllowedHint": "Erlaubt einem List-Handler-Run auf dieser Liste, unbeaufsichtigt zu laufen, sobald PowerUse in Einstellungen → Ausführung eingeschaltet ist: er fragt nichts, löst Konflikte selbst und sammelt neue Arbeit. Beide Schalter sind nötig — ein global aktives PowerUse ändert an einer Liste ohne Opt-in also nichts.",
|
||||
"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",
|
||||
@@ -423,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",
|
||||
@@ -479,6 +552,8 @@
|
||||
"windowTitle": "Claude machen lassen",
|
||||
"scopeList": "Liste: {0}",
|
||||
"scopeReview": "Alle Listen — Tasks im Review",
|
||||
"modelLabel": "Modell",
|
||||
"modelTip": "Modell für die Triage-Session (Dedupe & Verfeinern). Spätere Merge-Phasen laufen immer auf Opus.",
|
||||
"confirm": "Claude machen lassen",
|
||||
"cancel": "Abbrechen",
|
||||
"selectAll": "Alle auswählen",
|
||||
@@ -506,27 +581,27 @@
|
||||
"regenerate": "Neu erstellen",
|
||||
"emptyStateHint": "Noch kein Bericht für diesen Zeitraum. Klicke auf „Erstellen“."
|
||||
},
|
||||
"cheatSheets": {
|
||||
"title": "CHEAT SHEETS",
|
||||
"windowTitle": "Cheat Sheets",
|
||||
"pin": "Anpinnen",
|
||||
"pinTooltip": "Fenster über den anderen halten",
|
||||
"missing": "Cheat Sheet {0} nicht gefunden."
|
||||
},
|
||||
"usageMonitor": {
|
||||
"title": "USAGE MONITOR",
|
||||
"windowTitle": "Usage Monitor",
|
||||
"noGauges": "Noch keine Nutzungslimits gemeldet.",
|
||||
"powerUse": "PowerUse",
|
||||
"powerUseHint": "Das restliche Fenster ausnutzen: ein List-Handler-Run fragt nichts, löst Konflikte selbst, sammelt neue Arbeit und handoffet weiter, solange sein Budget reicht. Wirkt nur auf Listen, die PowerUse in ihren Listen-Einstellungen erlaubt haben.",
|
||||
"powerUseWindow": "Nur zwischen",
|
||||
"powerUseWindowPlaceholder": "HH:mm",
|
||||
"powerUseHandoffPct": "Übergabe bei (% von 5h)",
|
||||
"powerUseWindowHint": "Beide Zeiten leer = rund um die Uhr. Das Fenster wird bei jedem Start einer Handler-Phase geprüft — eine Kette, die über das Ende hinausläuft, macht ohne PowerUse weiter.",
|
||||
"refresh": "Jetzt aktualisieren",
|
||||
"refreshHint": "Abruf alle 5 Min., während ein Task läuft, sonst alle 15 Min.",
|
||||
"refreshHint": "Jetzt aktualisieren — Abruf alle 5 Min., während ein Task läuft, sonst alle 15 Min.",
|
||||
"staleFormat": "Werte veraltet (Stand {0})",
|
||||
"staleGateHint": "Das Gate greift in diesem Zustand nicht.",
|
||||
"staleGateHint": "Der Limit-Stopp greift in diesem Zustand nicht.",
|
||||
"gateBlockedFormat": "Warteschlange pausiert — {0}",
|
||||
"throttleFormat": "Warteschlange gedrosselt: {0}/{1} Slots ({2})",
|
||||
"resetIn": "Reset in {0} ({1})",
|
||||
"dragHint": "Marker ziehen oder Wert unten eintragen.",
|
||||
"legendSoft": "Soft · 2 Slots",
|
||||
"legendHard": "Hard · 1 Slot",
|
||||
"legendGate": "Gate · Pause",
|
||||
"legendGate": "Limit-Stopp · Pause",
|
||||
"gaugeSession": "Session (5 Std.)",
|
||||
"gaugeWeeklyAll": "Woche (alle Modelle)",
|
||||
"gaugeWeeklyScopedFormat": "Woche ({0})",
|
||||
@@ -546,7 +621,7 @@
|
||||
"columnOtherOut": "ÜBRIGES OUT",
|
||||
"columnOtherCache": "ÜBRIGES CACHE",
|
||||
"columnShare": "ANTEIL",
|
||||
"columnTask": "TASK",
|
||||
"columnTask": "AUFGABE",
|
||||
"columnList": "LISTE",
|
||||
"columnModelShort": "MODELL",
|
||||
"columnRuns": "RUNS",
|
||||
@@ -650,35 +725,61 @@
|
||||
"checks": {
|
||||
"git": {
|
||||
"title": "Git",
|
||||
"hint": "Installiere Git und stelle sicher, dass es im PATH liegt."
|
||||
"hint": "Installiere Git von https://git-scm.com/downloads und starte den Installer neu.",
|
||||
"notFound": "git wurde nicht im PATH gefunden.",
|
||||
"exitCode": "'{0}' wurde mit Exitcode {1} beendet.",
|
||||
"resolvedFormat": "{0} — {1}"
|
||||
},
|
||||
"gitIdentity": {
|
||||
"title": "Git-Identität",
|
||||
"hint": "Lege deine Git-Identität fest: git config --global user.name \"...\" und user.email \"...\"."
|
||||
"hint": "Führe aus: git config --global user.name \"Dein Name\" und git config --global user.email \"du@example.com\"",
|
||||
"notFound": "git wurde nicht gefunden — Identität konnte nicht geprüft werden.",
|
||||
"missing": "Fehlende Git-Identität: {0}."
|
||||
},
|
||||
"ports": {
|
||||
"title": "Ports",
|
||||
"hint": "Gib den Port frei oder ändere ihn nach der Installation auf der Worker-Seite."
|
||||
"hint": "Ändere die SignalR-/MCP-Ports auf der Dienste-Seite, oder beende den blockierenden Prozess.",
|
||||
"inUse": "{0}-Port {1} ist belegt.",
|
||||
"inUseBy": "{0}-Port {1} ist belegt von '{2}'.",
|
||||
"inUseByOwnWorker": "Belegt vom laufenden ClaudeDo Worker: {0}.",
|
||||
"available": "Ports sind verfügbar."
|
||||
},
|
||||
"writeAccess": {
|
||||
"title": "Schreibzugriff",
|
||||
"hint": "Wähle ein anderes Installationsverzeichnis oder starte den Installer als Administrator."
|
||||
"hint": "Wähle ein anderes Installationsverzeichnis oder starte den Installer mit ausreichenden Rechten (z. B. als Administrator).",
|
||||
"cannotWrite": "Kein Schreibzugriff auf '{0}': {1}",
|
||||
"writable": "Installations- und Datenverzeichnis sind beschreibbar."
|
||||
},
|
||||
"claudeCli": {
|
||||
"title": "Claude CLI",
|
||||
"hint": "Installiere die Claude CLI und stelle sicher, dass sie im PATH liegt."
|
||||
"hint": "Installiere die Claude CLI (npm install -g @anthropic-ai/claude-code) und starte danach dein Terminal/deine Anmeldesitzung neu, damit der aktualisierte PATH greift.",
|
||||
"notFound": "'{0}' wurde nicht im PATH gefunden. Durchsucht: {1}.",
|
||||
"exitCode": "'{0}' wurde mit Exitcode {1} beendet.",
|
||||
"resolvedFormat": "{0} — {1}"
|
||||
},
|
||||
"claudeVersion": {
|
||||
"title": "Claude-CLI-Version",
|
||||
"hint": "Aktualisiere die Claude CLI auf eine neuere Version."
|
||||
"hint": "Aktualisiere die CLI: npm update -g @anthropic-ai/claude-code",
|
||||
"notFound": "Die claude-CLI wurde nicht gefunden — Version konnte nicht geprüft werden.",
|
||||
"parseFailed": "Konnte keine Version aus '{0}' auslesen.",
|
||||
"tooOld": "claude {0} ist älter als die erforderliche Version {1}.",
|
||||
"ok": "claude {0} erfüllt die Mindestanforderung ({1})."
|
||||
},
|
||||
"claudeAuth": {
|
||||
"title": "Claude-Anmeldung",
|
||||
"hint": "Führe 'claude auth login' aus, um dich anzumelden."
|
||||
"title": "Claude-CLI-Login",
|
||||
"hint": "Starte 'claude' einmal und schließe den Login-Vorgang ab.",
|
||||
"notFound": "Die claude-CLI wurde nicht gefunden — Login konnte nicht geprüft werden.",
|
||||
"undetermined": "Login-Status konnte nicht ermittelt werden.",
|
||||
"notLoggedIn": "Nicht angemeldet.",
|
||||
"loggedIn": "Angemeldet."
|
||||
},
|
||||
"permissionModeAuto": {
|
||||
"title": "Berechtigungsmodus",
|
||||
"hint": "Aktualisiere die Claude CLI auf eine Version, die --permission-mode auto unterstützt."
|
||||
"hint": "Aktualisiere die CLI: npm update -g @anthropic-ai/claude-code. ClaudeDo funktioniert auch im Modus acceptEdits/default, dann mit mehr Rückfragen.",
|
||||
"notFound": "Die claude-CLI wurde nicht gefunden — Unterstützung für permission-mode konnte nicht geprüft werden.",
|
||||
"undetermined": "Konnte nicht ermitteln, ob --permission-mode auto unterstützt wird.",
|
||||
"ok": "--permission-mode auto wird von dieser CLI erkannt.",
|
||||
"notListed": "Diese claude-CLI-Version listet 'auto' nicht als --permission-mode-Option."
|
||||
}
|
||||
},
|
||||
"planning": {
|
||||
@@ -706,6 +807,9 @@
|
||||
"abort": "Merge abbrechen"
|
||||
},
|
||||
"controls": {
|
||||
"markdown": {
|
||||
"copy": "Befehl kopieren"
|
||||
},
|
||||
"datePicker": {
|
||||
"today": "Heute",
|
||||
"tomorrow": "Morgen",
|
||||
@@ -715,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",
|
||||
@@ -722,9 +834,18 @@
|
||||
"repositories": "Repositories",
|
||||
"checkForUpdates": "Nach Updates suchen",
|
||||
"restartWorker": "Worker neu starten",
|
||||
"updateClaudeCli": "Claude CLI aktualisieren",
|
||||
"shutdown": "Beenden",
|
||||
"worktrees": "Worktrees…",
|
||||
"weeklyReport": "Wochenbericht…",
|
||||
"usageMonitor": "Usage Monitor…",
|
||||
"cheatSheets": "Cheat Sheets",
|
||||
"cheatSheetGit": "Git…",
|
||||
"cheatSheetCommits": "Conventional Commits…",
|
||||
"cheatSheetClaude": "Claude CLI…",
|
||||
"cheatSheetClaudeConfig": "Claude-Konfiguration…",
|
||||
"cheatSheetDotnet": "dotnet CLI…",
|
||||
"cheatSheetPowershell": "PowerShell…",
|
||||
"about": "Über…",
|
||||
"addRepos": "Repos als Listen hinzufügen…"
|
||||
},
|
||||
@@ -740,10 +861,10 @@
|
||||
},
|
||||
"vm": {
|
||||
"connection": { "online": "Online", "connecting": "Verbinden…", "offline": "Offline" },
|
||||
"shell": { "restartingWorker": "Worker wird neu gestartet…", "unexpectedError": "Unerwarteter Fehler: {0}" },
|
||||
"shell": { "unexpectedError": "Unerwarteter Fehler: {0}", "shutdownConfirm": "Dadurch wird der ClaudeDo-Worker beendet (laufende Task-Runs und interaktive Sitzungen brechen ab) und die App geschlossen. Fortfahren?" },
|
||||
"mainWindow": { "closeConfirm": { "message": "{0} Mission-Control-Sitzung(en) laufen noch. Beim Beenden werden sie abgebrochen. Trotzdem schließen?" } },
|
||||
"agentStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "review": "Prüfung", "children": "Wartet auf Teilaufgaben", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen" },
|
||||
"taskStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "waitingForReview": "Wartet auf Prüfung", "waitingForChildren": "Wartet auf Teilaufgaben", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen", "parked": "Geparkt", "interactive": "Interaktiv" },
|
||||
"taskStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "waitingForReview": "Wartet auf Prüfung", "waitingForChildren": "Wartet auf Teilaufgaben", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen", "parked": "Geparkt", "interactive": "Interaktiv", "waiting": "Wartet" },
|
||||
"failureReason": { "maxTurns": "Turn-Limit erreicht", "timeout": "Zeitüberschreitung", "usageLimit": "Nutzungslimit erreicht", "error": "Fehler", "cancelled": "Abgebrochen", "unknown": "Grund unbekannt" },
|
||||
"failureReasonTooltip": { "maxTurns": "Turn-Limit erreicht ({0}/{1} Turns) — der Worktree ist meist brauchbar; Task fortsetzen statt zurücksetzen." },
|
||||
"planningBadge": { "active": "PLANUNG", "finalized": "GEPLANT" },
|
||||
@@ -752,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." },
|
||||
@@ -761,7 +882,6 @@
|
||||
"loadFailed": "Nutzungsdaten konnten nicht geladen werden: {0}",
|
||||
"refreshFailed": "Nutzung konnte nicht aktualisiert werden: {0}",
|
||||
"thresholdSaveFailed": "Grenze konnte nicht gespeichert werden: {0}",
|
||||
"powerUseSaveFailed": "PowerUse-Schalter konnte nicht gespeichert werden: {0}",
|
||||
"installStarted": "TokenTracker wird installiert — der Fortschritt läuft in der Log-Leiste.",
|
||||
"installFailed": "TokenTracker konnte nicht installiert werden: {0}",
|
||||
"analyticsRefreshFailed": "Die Auswertung konnte nicht aktualisiert werden: {0}",
|
||||
@@ -772,12 +892,12 @@
|
||||
"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 —" },
|
||||
"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}" },
|
||||
"repoImport": { "loadFailed": "Gespeicherte Ordner konnten nicht geladen werden: {0}", "saveFailed": "Ordner konnten nicht gespeichert werden: {0}" }
|
||||
"repoImport": { "loadFailed": "Gespeicherte Ordner konnten nicht geladen werden: {0}", "saveFailed": "Ordner konnten nicht gespeichert werden: {0}", "createButton": "{0} Liste(n) erstellen" }
|
||||
},
|
||||
"ops": {
|
||||
"stalledHint": "Läuft weiter — Worker antwortet noch",
|
||||
@@ -845,39 +965,5 @@
|
||||
"lastError": "Letzter Fehler: {0}",
|
||||
"throttled": "Gedrosselt: {0}/{1} Slots ({2})"
|
||||
}
|
||||
},
|
||||
"checks": {
|
||||
"git": {
|
||||
"title": "Git",
|
||||
"hint": "Installiere Git von https://git-scm.com/downloads und starte den Installer neu."
|
||||
},
|
||||
"gitIdentity": {
|
||||
"title": "Git-Identität",
|
||||
"hint": "Führe aus: git config --global user.name \"Dein Name\" und git config --global user.email \"du@example.com\""
|
||||
},
|
||||
"ports": {
|
||||
"title": "Ports",
|
||||
"hint": "Ändere die SignalR-/MCP-Ports auf der Dienste-Seite, oder beende den blockierenden Prozess."
|
||||
},
|
||||
"writeAccess": {
|
||||
"title": "Schreibzugriff",
|
||||
"hint": "Wähle ein anderes Installationsverzeichnis oder starte den Installer mit ausreichenden Rechten (z. B. als Administrator)."
|
||||
},
|
||||
"claudeCli": {
|
||||
"title": "Claude CLI",
|
||||
"hint": "Installiere die Claude CLI (npm install -g @anthropic-ai/claude-code) und starte danach dein Terminal/deine Anmeldesitzung neu, damit der aktualisierte PATH greift."
|
||||
},
|
||||
"claudeVersion": {
|
||||
"title": "Claude-CLI-Version",
|
||||
"hint": "Aktualisiere die CLI: npm update -g @anthropic-ai/claude-code"
|
||||
},
|
||||
"claudeAuth": {
|
||||
"title": "Claude-CLI-Login",
|
||||
"hint": "Starte 'claude' einmal und schließe den Login-Vorgang ab."
|
||||
},
|
||||
"permissionModeAuto": {
|
||||
"title": "Unterstützung für --permission-mode auto",
|
||||
"hint": "Aktualisiere die CLI: npm update -g @anthropic-ai/claude-code. ClaudeDo funktioniert auch im Modus acceptEdits/default, dann mit mehr Rückfragen."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
"categoryGeneral": "General",
|
||||
"categoryExecution": "Execution",
|
||||
"categoryPrime": "Prime Claude",
|
||||
"categoryPrimeHint": "Scheduled Claude runs: Ping, Fill My Day, or a custom prompt, at fixed times.",
|
||||
"categoryWorktrees": "Worktrees",
|
||||
"categoryFiles": "Files & Prompts",
|
||||
"categorySkills": "Session Skills",
|
||||
@@ -27,6 +28,7 @@
|
||||
"model": "Model",
|
||||
"maxTurns": "Max turns",
|
||||
"effort": "Effort",
|
||||
"effortHint": "Reasoning effort the model applies. Higher costs more and reasons deeper.",
|
||||
"modelPresets": "Per-model defaults",
|
||||
"modelPresetsHint": "Reasoning effort and turn budget used when a session runs under that model. List- and task-level max-turn overrides still win.",
|
||||
"maxTurnsCeiling": "Max turns ceiling",
|
||||
@@ -43,6 +45,14 @@
|
||||
"autoContinueOnUsageLimitHint": "When a run stops because it hit the usage limit, automatically continue it once the 5-hour window resets. Also wakes the paused queue exactly at reset time instead of waiting for the next backstop check.",
|
||||
"listHandlerMaxHandoffs": "List-handler handoff budget",
|
||||
"listHandlerMaxHandoffsHint": "How many wait/merge handoffs one \"Let Claude handle it\" run may chain. 4 means two merge rounds. Raise it for runs meant to keep going for hours; it bounds a runaway chain, so it cannot be 0.",
|
||||
"powerUseHeading": "PowerUse",
|
||||
"powerUse": "Enable PowerUse",
|
||||
"powerUseHint": "Spend the rest of the window: a list-handler run asks nothing, resolves conflicts itself, harvests new work, and keeps handing off for as long as its budget allows. Only affects lists that allowed PowerUse in their list settings.",
|
||||
"powerUseWindow": "Only between",
|
||||
"powerUseWindowPlaceholder": "HH:mm",
|
||||
"powerUseHandoffPct": "Hand off at (% of 5h)",
|
||||
"powerUseWindowHint": "Leave both times empty for round the clock. A window is checked whenever a handler phase starts, so a chain that runs past the end time continues without PowerUse.",
|
||||
"powerUseWindowInvalid": "PowerUse window times must be HH:mm, or left blank.",
|
||||
"throttleStagesHeading": "Throttle stages",
|
||||
"throttleStagesHint": "Parallelism is capped before the hard gate: soft caps at 2 slots, hard at 1. Edited by dragging the markers on the usage-monitor gauges. Opening the monitor closes these settings — save first if you changed anything.",
|
||||
"throttleStagesValues": "5h: soft {0}% / hard {1}% · 7d: soft {2}% / hard {3}%",
|
||||
@@ -232,6 +242,7 @@
|
||||
"toggleSubtasksTip": "Expand / collapse subtasks",
|
||||
"agentSuggestedTip": "Suggested by the agent",
|
||||
"interactiveChipTip": "Interactive session open — click to jump to it in Mission Control",
|
||||
"waitingOnTip": "Waiting on {0}",
|
||||
"scheduleTitle": "Schedule task",
|
||||
"scheduleWhen": "WHEN",
|
||||
"scheduleConfirm": "Schedule",
|
||||
@@ -253,6 +264,8 @@
|
||||
"contextOpenFindings": "Open findings folder",
|
||||
"contextImportTickets": "Update from ticket system",
|
||||
"contextSettings": "Settings…",
|
||||
"contextFolderSettings": "Folder settings…",
|
||||
"contextRemoveFromFolder": "Move out of folder",
|
||||
"newList": "New list",
|
||||
"addReposTip": "Add repos as lists",
|
||||
"noRepoBannerText": "No repo linked yet. ClaudeDo runs tasks in a git repo.",
|
||||
@@ -268,6 +281,7 @@
|
||||
"starTip": "Star",
|
||||
"agentSettingsTip": "Agent settings",
|
||||
"agentSettingsHeading": "Agent settings (overrides)",
|
||||
"waitingOnPrefix": "Waiting on",
|
||||
"copyFormattedTip": "Copy title, description and open steps",
|
||||
"toggleEditPreviewTip": "Toggle edit/preview",
|
||||
"planDay": "Plan day",
|
||||
@@ -293,7 +307,37 @@
|
||||
"steps": "Steps",
|
||||
"files": "Files"
|
||||
},
|
||||
"emptyState": "Select a task to see its details"
|
||||
"emptyState": "Select a task to see its details",
|
||||
"roadblockLabel": "ROADBLOCK",
|
||||
"stepsCard": {
|
||||
"preview": "Preview",
|
||||
"edit": "Edit",
|
||||
"addStepPlaceholder": "Add step…"
|
||||
},
|
||||
"workConsole": {
|
||||
"continue": "Continue",
|
||||
"resetAndRetry": "Reset & Retry",
|
||||
"tabOutput": "Output",
|
||||
"tabGit": "Git",
|
||||
"tabSession": "Session",
|
||||
"outcomeLabel": "OUTCOME",
|
||||
"reviewLabel": "REVIEW",
|
||||
"mergeLabel": "MERGE",
|
||||
"targetBranch": "Target branch",
|
||||
"openDiff": "Open Diff",
|
||||
"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",
|
||||
"cancel": "Cancel",
|
||||
"resetDiscardBranch": "Reset (discard branch)…",
|
||||
"submitForReviewHint": "Worked on this by hand? Submit the worktree for review to merge it.",
|
||||
"submitForReview": "Submit for review",
|
||||
"outcomesLabel": "OUTCOMES"
|
||||
}
|
||||
},
|
||||
"agent": {
|
||||
"openWorktreeTip": "Open worktree in file explorer — requires an active worktree",
|
||||
@@ -332,13 +376,26 @@
|
||||
"failed": "Failed",
|
||||
"roadblock": "Roadblock reported",
|
||||
"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}",
|
||||
"conptyStarting": "Starting session…",
|
||||
"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}",
|
||||
@@ -354,6 +411,10 @@
|
||||
"enqueueAlreadyOpen": "Can't queue — this task has an open interactive session.",
|
||||
"enqueueFailed": "Queue failed: {0}",
|
||||
"queueRefreshFailed": "Couldn't refresh the queue: {0}",
|
||||
"abandonRun": "Abandon run",
|
||||
"abandonRunTip": "Cancel this run's still-open tasks and close the session",
|
||||
"abandonRunConfirm": "Cancel {0} open task(s) and close this session? Tasks already in review or done are not affected.",
|
||||
"abandonRunFailed": "Couldn't cancel the open tasks: {0}",
|
||||
"question": {
|
||||
"title": "Claude is asking",
|
||||
"placeholder": "Type your answer…",
|
||||
@@ -387,13 +448,22 @@
|
||||
"body": "ClaudeDo can't reach the background worker. It is normally started automatically at logon. You can start it now, or reinstall if the problem persists.",
|
||||
"dismiss": "Dismiss",
|
||||
"rerunInstaller": "Rerun Installer",
|
||||
"startWorker": "Start Worker"
|
||||
"startWorker": "Start Worker",
|
||||
"workerNotFound": "Worker executable not found. Check the installation.",
|
||||
"installerNotFound": "Installer not found. Check the installation.",
|
||||
"startFailed": "Start failed: {0}"
|
||||
},
|
||||
"listSettings": {
|
||||
"title": "LIST SETTINGS",
|
||||
"folderTitle": "FOLDER",
|
||||
"deleteList": "Delete list",
|
||||
"deleteFolder": "Delete folder",
|
||||
"createAsFolder": "Create a folder instead",
|
||||
"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...",
|
||||
@@ -403,10 +473,12 @@
|
||||
"findingsTracked": "Commit the .claudedo findings folder",
|
||||
"findingsTrackedHint": "Off: the folder stays out of git via .git/info/exclude. On: findings travel with the repo.",
|
||||
"powerUseAllowed": "Allow PowerUse for this list",
|
||||
"powerUseAllowedHint": "Lets a list-handler run on this list go unattended when PowerUse is switched on in the usage monitor: it asks nothing, resolves conflicts itself, and harvests new work. Both switches are needed, so leaving PowerUse on globally never changes a list that hasn't opted in.",
|
||||
"powerUseAllowedHint": "Lets a list-handler run on this list go unattended when PowerUse is switched on in Settings → Execution: it asks nothing, resolves conflicts itself, and harvests new work. Both switches are needed, so leaving PowerUse on globally never changes a list that hasn't opted in.",
|
||||
"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",
|
||||
@@ -423,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",
|
||||
@@ -479,6 +552,8 @@
|
||||
"windowTitle": "Let Claude handle it",
|
||||
"scopeList": "List: {0}",
|
||||
"scopeReview": "All lists — tasks waiting for review",
|
||||
"modelLabel": "Model",
|
||||
"modelTip": "Model for the triage session (dedupe & refine). Later merge phases always run on opus.",
|
||||
"confirm": "Let Claude handle it",
|
||||
"cancel": "Cancel",
|
||||
"selectAll": "Select all",
|
||||
@@ -506,18 +581,18 @@
|
||||
"regenerate": "Regenerate",
|
||||
"emptyStateHint": "No report for this range yet. Click “Generate”."
|
||||
},
|
||||
"cheatSheets": {
|
||||
"title": "CHEAT SHEETS",
|
||||
"windowTitle": "Cheat Sheets",
|
||||
"pin": "Pin",
|
||||
"pinTooltip": "Keep this window on top of the others",
|
||||
"missing": "Cheat sheet {0} not found."
|
||||
},
|
||||
"usageMonitor": {
|
||||
"title": "USAGE MONITOR",
|
||||
"windowTitle": "Usage Monitor",
|
||||
"noGauges": "No usage limits reported yet.",
|
||||
"powerUse": "PowerUse",
|
||||
"powerUseHint": "Spend the rest of the window: a list-handler run asks nothing, resolves conflicts itself, harvests new work, and keeps handing off for as long as its budget allows. Only affects lists that allowed PowerUse in their list settings.",
|
||||
"powerUseWindow": "Only between",
|
||||
"powerUseWindowPlaceholder": "HH:mm",
|
||||
"powerUseHandoffPct": "Hand off at (% of 5h)",
|
||||
"powerUseWindowHint": "Leave both times empty for round the clock. A window is checked whenever a handler phase starts, so a chain that runs past the end time continues without PowerUse.",
|
||||
"refresh": "Refresh now",
|
||||
"refreshHint": "Polled every 5 min while a task runs, otherwise every 15 min.",
|
||||
"refreshHint": "Refresh now — polled every 5 min while a task runs, otherwise every 15 min.",
|
||||
"staleFormat": "Values stale (as of {0})",
|
||||
"staleGateHint": "The gate does not apply while values are stale.",
|
||||
"gateBlockedFormat": "Queue paused — {0}",
|
||||
@@ -650,35 +725,61 @@
|
||||
"checks": {
|
||||
"git": {
|
||||
"title": "Git",
|
||||
"hint": "Install Git and make sure it is on PATH."
|
||||
"hint": "Install Git from https://git-scm.com/downloads, then restart the installer.",
|
||||
"notFound": "git was not found on PATH.",
|
||||
"exitCode": "'{0}' exited with code {1}.",
|
||||
"resolvedFormat": "{0} — {1}"
|
||||
},
|
||||
"gitIdentity": {
|
||||
"title": "Git Identity",
|
||||
"hint": "Set your git identity: git config --global user.name \"...\" and user.email \"...\"."
|
||||
"hint": "Run: git config --global user.name \"Your Name\" and git config --global user.email \"you@example.com\"",
|
||||
"notFound": "git was not found — identity could not be checked.",
|
||||
"missing": "Missing git identity: {0}."
|
||||
},
|
||||
"ports": {
|
||||
"title": "Ports",
|
||||
"hint": "Free the port, or change it on the Worker page after install."
|
||||
"hint": "Change the SignalR/MCP ports on the Service page, or stop the process using them.",
|
||||
"inUse": "{0} port {1} is in use.",
|
||||
"inUseBy": "{0} port {1} is in use by '{2}'.",
|
||||
"inUseByOwnWorker": "In use by the running ClaudeDo Worker: {0}.",
|
||||
"available": "Ports are available."
|
||||
},
|
||||
"writeAccess": {
|
||||
"title": "Write Access",
|
||||
"hint": "Choose a different install directory, or run the installer as administrator."
|
||||
"hint": "Choose a different install directory, or run the installer with sufficient permissions (e.g. as administrator).",
|
||||
"cannotWrite": "Cannot write to '{0}': {1}",
|
||||
"writable": "Install directory and data directory are writable."
|
||||
},
|
||||
"claudeCli": {
|
||||
"title": "Claude CLI",
|
||||
"hint": "Install the Claude CLI and make sure it is on PATH."
|
||||
"hint": "Install the Claude CLI (npm install -g @anthropic-ai/claude-code), then restart your terminal/login session so the updated PATH takes effect.",
|
||||
"notFound": "'{0}' was not found on PATH. Searched: {1}.",
|
||||
"exitCode": "'{0}' exited with code {1}.",
|
||||
"resolvedFormat": "{0} — {1}"
|
||||
},
|
||||
"claudeVersion": {
|
||||
"title": "Claude CLI Version",
|
||||
"hint": "Update the Claude CLI to a newer version."
|
||||
"hint": "Update the CLI: npm update -g @anthropic-ai/claude-code",
|
||||
"notFound": "The claude CLI was not found — version could not be checked.",
|
||||
"parseFailed": "Could not parse a version from '{0}'.",
|
||||
"tooOld": "claude {0} is older than the required {1}.",
|
||||
"ok": "claude {0} meets the minimum ({1})."
|
||||
},
|
||||
"claudeAuth": {
|
||||
"title": "Claude Login",
|
||||
"hint": "Run 'claude auth login' to sign in."
|
||||
"title": "Claude CLI Login",
|
||||
"hint": "Run 'claude' once and complete the login flow.",
|
||||
"notFound": "The claude CLI was not found — login could not be checked.",
|
||||
"undetermined": "Login status could not be determined.",
|
||||
"notLoggedIn": "Not logged in.",
|
||||
"loggedIn": "Logged in."
|
||||
},
|
||||
"permissionModeAuto": {
|
||||
"title": "Permission Mode",
|
||||
"hint": "Update the Claude CLI to a version that supports --permission-mode auto."
|
||||
"hint": "Update the CLI: npm update -g @anthropic-ai/claude-code. ClaudeDo still works in acceptEdits/default mode, with more prompts.",
|
||||
"notFound": "The claude CLI was not found — permission-mode support could not be checked.",
|
||||
"undetermined": "Could not determine whether --permission-mode auto is supported.",
|
||||
"ok": "--permission-mode auto is recognized by this CLI.",
|
||||
"notListed": "This claude CLI version does not list 'auto' as a --permission-mode choice."
|
||||
}
|
||||
},
|
||||
"planning": {
|
||||
@@ -706,6 +807,9 @@
|
||||
"abort": "Abort merge"
|
||||
},
|
||||
"controls": {
|
||||
"markdown": {
|
||||
"copy": "Copy command"
|
||||
},
|
||||
"datePicker": {
|
||||
"today": "Today",
|
||||
"tomorrow": "Tomorrow",
|
||||
@@ -715,6 +819,14 @@
|
||||
"done": "Done"
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"delete": "Delete",
|
||||
"ok": "OK",
|
||||
"confirmTitle": "Confirm",
|
||||
"errorTitle": "Error"
|
||||
},
|
||||
"shell": {
|
||||
"menu": {
|
||||
"help": "Help",
|
||||
@@ -722,9 +834,18 @@
|
||||
"repositories": "Repositories",
|
||||
"checkForUpdates": "Check for updates",
|
||||
"restartWorker": "Restart worker",
|
||||
"updateClaudeCli": "Update Claude CLI",
|
||||
"shutdown": "Shutdown",
|
||||
"worktrees": "Worktrees…",
|
||||
"weeklyReport": "Weekly Report…",
|
||||
"usageMonitor": "Usage Monitor…",
|
||||
"cheatSheets": "Cheat Sheets",
|
||||
"cheatSheetGit": "Git…",
|
||||
"cheatSheetCommits": "Conventional Commits…",
|
||||
"cheatSheetClaude": "Claude CLI…",
|
||||
"cheatSheetClaudeConfig": "Claude configuration…",
|
||||
"cheatSheetDotnet": "dotnet CLI…",
|
||||
"cheatSheetPowershell": "PowerShell…",
|
||||
"about": "About…",
|
||||
"addRepos": "Add repos as lists…"
|
||||
},
|
||||
@@ -740,10 +861,10 @@
|
||||
},
|
||||
"vm": {
|
||||
"connection": { "online": "Online", "connecting": "Connecting…", "offline": "Offline" },
|
||||
"shell": { "restartingWorker": "Restarting worker…", "unexpectedError": "Unexpected error: {0}" },
|
||||
"shell": { "unexpectedError": "Unexpected error: {0}", "shutdownConfirm": "This stops the ClaudeDo worker (any running task or interactive session is interrupted) and closes the app. Continue?" },
|
||||
"mainWindow": { "closeConfirm": { "message": "{0} Mission Control session(s) are still running. Closing the app will end them. Close anyway?" } },
|
||||
"agentStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "review": "Review", "children": "Waiting for Subtasks", "done": "Done", "failed": "Failed", "cancelled": "Cancelled" },
|
||||
"taskStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "waitingForReview": "Waiting for Review", "waitingForChildren": "Waiting for Subtasks", "done": "Done", "failed": "Failed", "cancelled": "Cancelled", "parked": "Parked", "interactive": "Interactive" },
|
||||
"taskStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "waitingForReview": "Waiting for Review", "waitingForChildren": "Waiting for Subtasks", "done": "Done", "failed": "Failed", "cancelled": "Cancelled", "parked": "Parked", "interactive": "Interactive", "waiting": "Waiting" },
|
||||
"failureReason": { "maxTurns": "Turn limit reached", "timeout": "Timed out", "usageLimit": "Usage limit reached", "error": "Error", "cancelled": "Cancelled", "unknown": "Unknown reason" },
|
||||
"failureReasonTooltip": { "maxTurns": "Turn limit reached ({0}/{1} turns) — the worktree is usually fine; continue the task instead of resetting it." },
|
||||
"planningBadge": { "active": "PLANNING", "finalized": "PLANNED" },
|
||||
@@ -752,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." },
|
||||
@@ -761,7 +882,6 @@
|
||||
"loadFailed": "Couldn't load usage data: {0}",
|
||||
"refreshFailed": "Couldn't refresh usage: {0}",
|
||||
"thresholdSaveFailed": "Couldn't save the threshold: {0}",
|
||||
"powerUseSaveFailed": "Couldn't save the PowerUse switch: {0}",
|
||||
"installStarted": "Installing TokenTracker — watch the log strip for progress.",
|
||||
"installFailed": "Couldn't install TokenTracker: {0}",
|
||||
"analyticsRefreshFailed": "Couldn't refresh the analytics: {0}",
|
||||
@@ -772,12 +892,12 @@
|
||||
"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 —" },
|
||||
"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}" },
|
||||
"repoImport": { "loadFailed": "Couldn't load remembered folders: {0}", "saveFailed": "Couldn't save folders: {0}" }
|
||||
"repoImport": { "loadFailed": "Couldn't load remembered folders: {0}", "saveFailed": "Couldn't save folders: {0}", "createButton": "Create {0} list(s)" }
|
||||
},
|
||||
"ops": {
|
||||
"stalledHint": "Still running — worker hasn't responded in a while",
|
||||
@@ -845,39 +965,5 @@
|
||||
"lastError": "Last error: {0}",
|
||||
"throttled": "Throttled: {0}/{1} slots ({2})"
|
||||
}
|
||||
},
|
||||
"checks": {
|
||||
"git": {
|
||||
"title": "Git",
|
||||
"hint": "Install Git from https://git-scm.com/downloads, then restart the installer."
|
||||
},
|
||||
"gitIdentity": {
|
||||
"title": "Git identity",
|
||||
"hint": "Run: git config --global user.name \"Your Name\" and git config --global user.email \"you@example.com\""
|
||||
},
|
||||
"ports": {
|
||||
"title": "Ports",
|
||||
"hint": "Change the SignalR/MCP ports on the Service page, or stop the process using them."
|
||||
},
|
||||
"writeAccess": {
|
||||
"title": "Write access",
|
||||
"hint": "Choose a different install directory, or run the installer with sufficient permissions (e.g. as administrator)."
|
||||
},
|
||||
"claudeCli": {
|
||||
"title": "Claude CLI",
|
||||
"hint": "Install the Claude CLI (npm install -g @anthropic-ai/claude-code), then restart your terminal/login session so the updated PATH takes effect."
|
||||
},
|
||||
"claudeVersion": {
|
||||
"title": "Claude CLI version",
|
||||
"hint": "Update the CLI: npm update -g @anthropic-ai/claude-code"
|
||||
},
|
||||
"claudeAuth": {
|
||||
"title": "Claude CLI login",
|
||||
"hint": "Run 'claude' once and complete the login flow."
|
||||
},
|
||||
"permissionModeAuto": {
|
||||
"title": "--permission-mode auto support",
|
||||
"hint": "Update the CLI: npm update -g @anthropic-ai/claude-code. ClaudeDo still works in acceptEdits/default mode, with more prompts."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 —
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Claude CLI
|
||||
|
||||
## Session starten
|
||||
- `claude` — interaktive Session im aktuellen Verzeichnis
|
||||
- `claude "<prompt>"` — Session mit erstem Prompt starten
|
||||
- `claude -c` — letzte Session in diesem Verzeichnis fortsetzen
|
||||
- `claude -r` — Session aus einer Auswahlliste fortsetzen
|
||||
- `claude -r <session-id>` — bestimmte Session fortsetzen
|
||||
- `claude --fork-session -r <id>` — Session unter neuer ID fortsetzen
|
||||
- `claude -n "<name>"` — Anzeigename für die Session setzen
|
||||
|
||||
## Modell und Aufwand
|
||||
- `claude --model sonnet` — Alias oder voller Name (`fable`, `opus`, `sonnet`, `claude-opus-5`)
|
||||
- `claude --effort high` — Denkaufwand: `low`, `medium`, `high`, `xhigh`, `max`
|
||||
- `claude --fallback-model sonnet` — Ausweichmodell bei Überlast (nur mit `-p`)
|
||||
|
||||
## Nicht interaktiv
|
||||
- `claude -p "<prompt>"` — antworten und beenden
|
||||
- `claude -p --output-format json` — ein JSON-Ergebnis
|
||||
- `claude -p --output-format stream-json` — Streaming-JSON für Skripte
|
||||
- `claude -p --include-partial-messages` — Teil-Chunks mitstreamen
|
||||
- `claude -p --max-budget-usd 5` — Kostendeckel für den Lauf
|
||||
- `claude -p --json-schema '<schema>'` — strukturierte Ausgabe validieren
|
||||
|
||||
## Berechtigungen und Tools
|
||||
- `claude --permission-mode <mode>` — `acceptEdits`, `auto`, `plan`, `manual`, `dontAsk`, `bypassPermissions`
|
||||
- `claude --allowed-tools "Bash(git *)" Edit` — nur diese Tools erlauben
|
||||
- `claude --disallowed-tools "Bash(rm *)"` — diese Tools sperren
|
||||
- `claude --tools "Bash,Edit,Read"` — Built-in-Toolset festlegen
|
||||
- `claude --add-dir <pfad>` — weiteres Arbeitsverzeichnis freigeben
|
||||
- `claude --restricted` — ohne befehlsausführende Tools und ohne lokale Settings
|
||||
|
||||
## Kontext und Konfiguration
|
||||
- `claude --append-system-prompt "<text>"` — Systemprompt ergänzen
|
||||
- `claude --system-prompt "<text>"` — Systemprompt ersetzen
|
||||
- `claude --settings <datei|json>` — zusätzliche Settings laden
|
||||
- `claude --agents '<json>'` — eigene Subagents für diese Session
|
||||
- `claude --mcp-config <datei>` — MCP-Server aus JSON laden
|
||||
- `claude --strict-mcp-config` — nur diese MCP-Server, alle anderen ignorieren
|
||||
- `claude --safe-mode` — alle Anpassungen aus, zum Fehlersuchen
|
||||
- `claude --bare` — Minimalmodus ohne Hooks, Plugins, CLAUDE.md
|
||||
|
||||
## Hintergrund-Sessions
|
||||
- `claude --bg "<prompt>"` — im Hintergrund starten, gibt die Kurz-ID aus
|
||||
- `claude agents` — laufende Hintergrund-Sessions auflisten
|
||||
- `claude attach <id>` — Hintergrund-Session in dieses Terminal holen
|
||||
- `claude logs <id>` — letzte Ausgabe einer Hintergrund-Session
|
||||
- `claude stop <id>` — Session stoppen, Verlauf bleibt
|
||||
- `claude rm <id>` — Session löschen
|
||||
|
||||
## Worktree
|
||||
- `claude -w` — neuen Git-Worktree für diese Session anlegen
|
||||
- `claude -w <name>` — Worktree mit eigenem Namen
|
||||
|
||||
## Verwaltung
|
||||
- `claude doctor` — Installation prüfen
|
||||
- `claude update` — auf neue Version aktualisieren
|
||||
- `claude mcp` — MCP-Server konfigurieren
|
||||
- `claude plugin` — Plugins verwalten
|
||||
- `claude auth` — Authentifizierung verwalten
|
||||
- `claude setup-token` — Langzeit-Token einrichten
|
||||
- `claude -d api,hooks` — Debugmodus mit Kategoriefilter
|
||||
- `claude -v` — Version ausgeben
|
||||
@@ -0,0 +1,48 @@
|
||||
# Claude Konfiguration
|
||||
|
||||
## Dateien und Orte
|
||||
- `~/.claude/settings.json` — globale Settings für alle Projekte
|
||||
- `.claude/settings.json` — Projekt-Settings, eingecheckt fürs Team
|
||||
- `.claude/settings.local.json` — persönliche Projekt-Settings, nicht eingecheckt
|
||||
- `Rangfolge: managed > local > projekt > global` — spezifischer gewinnt
|
||||
- `/config` — Settings interaktiv in der Session ansehen und ändern
|
||||
- `claude doctor` — Konfiguration auf Fehler prüfen
|
||||
|
||||
## CLAUDE.md
|
||||
- `~/.claude/CLAUDE.md` — globale Anweisungen für alle Projekte
|
||||
- `CLAUDE.md` — im Projekt-Root, wird bei Start automatisch geladen
|
||||
- `<unterordner>/CLAUDE.md` — wird geladen, sobald dort gearbeitet wird
|
||||
- `@pfad/zur/datei.md` — Import einer weiteren Datei in eine CLAUDE.md
|
||||
- `# <notiz>` — in der Session: Notiz in die CLAUDE.md übernehmen
|
||||
- `/init` — CLAUDE.md für ein Projekt generieren lassen
|
||||
|
||||
## Berechtigungen
|
||||
- `"permissions": { "allow": [...], "deny": [...], "ask": [...] }` — Struktur in settings.json
|
||||
- `Bash(git *)` — Bash-Befehle per Muster erlauben oder sperren
|
||||
- `Edit(src/**)` — Dateizugriff per Glob einschränken
|
||||
- `WebFetch(domain:example.com)` — Webzugriff auf eine Domain begrenzen
|
||||
- `mcp__<server>__<tool>` — Regel für ein einzelnes MCP-Tool
|
||||
- `/permissions` — Regeln in der Session ansehen und ergänzen
|
||||
|
||||
## MCP-Server
|
||||
- `claude mcp add <name> -- <befehl>` — lokalen stdio-Server anlegen
|
||||
- `claude mcp add --transport http <name> <url>` — HTTP-Server anlegen
|
||||
- `claude mcp add --scope project <name> ...` — in `.mcp.json` fürs Team
|
||||
- `claude mcp list` — konfigurierte Server anzeigen
|
||||
- `claude mcp remove <name>` — Server entfernen
|
||||
- `/mcp` — Serverstatus in der Session, OAuth-Login
|
||||
|
||||
## Hooks
|
||||
- `"hooks": { "PreToolUse": [...] }` — Shell-Befehle an Lifecycle-Events hängen
|
||||
- `PreToolUse / PostToolUse` — vor und nach jedem Tool-Aufruf
|
||||
- `UserPromptSubmit` — beim Absenden eines Prompts
|
||||
- `SessionStart / Stop` — bei Sessionbeginn und -ende
|
||||
- `Exit-Code 2` — blockiert die Aktion, stderr geht an Claude
|
||||
- `/hooks` — Hooks interaktiv verwalten
|
||||
|
||||
## Erweiterungen
|
||||
- `.claude/commands/<name>.md` — eigener Slash-Command `/name`
|
||||
- `.claude/skills/<name>/SKILL.md` — Skill, wird bei passender Aufgabe geladen
|
||||
- `.claude/agents/<name>.md` — eigener Subagent-Typ
|
||||
- `~/.claude/...` — dieselben Ordner global statt pro Projekt
|
||||
- `claude plugin` — Plugins installieren und verwalten
|
||||
@@ -0,0 +1,34 @@
|
||||
# Conventional Commits
|
||||
|
||||
## Format
|
||||
- `<type>(<scope>): <beschreibung>` — Grundform, Scope optional
|
||||
- `feat(auth): add login endpoint` — Beispiel mit Scope
|
||||
- `fix: prevent crash on empty input` — Beispiel ohne Scope
|
||||
- `imperativ, klein, kein Punkt am Ende` — Stil der Beschreibung
|
||||
|
||||
## Types
|
||||
- `feat` — neues Feature
|
||||
- `fix` — Bugfix
|
||||
- `refactor` — Umbau ohne Verhaltensänderung
|
||||
- `perf` — Performance-Verbesserung
|
||||
- `docs` — nur Dokumentation
|
||||
- `test` — Tests hinzufügen oder korrigieren
|
||||
- `style` — Formatierung, kein Code-Effekt
|
||||
- `build` — Build-System oder Dependencies
|
||||
- `ci` — CI-Konfiguration
|
||||
- `chore` — Sonstiges ohne src- oder Test-Änderung
|
||||
|
||||
## Breaking Changes
|
||||
- `feat(api)!: remove legacy endpoint` — `!` markiert einen Breaking Change
|
||||
- `BREAKING CHANGE: <erklärung>` — alternativ als Footer im Commit-Body
|
||||
|
||||
## Body und Footer
|
||||
- `Leerzeile nach der Betreffzeile` — dann optionaler Body mit dem Warum
|
||||
- `Refs: #123` — Footer für Ticket-Referenzen
|
||||
- `Reverts: <hash>` — Footer für Reverts
|
||||
|
||||
## Warum das Ganze
|
||||
- `feat` — löst ein Minor-Release aus (SemVer)
|
||||
- `fix` — löst ein Patch-Release aus (SemVer)
|
||||
- `!` oder `BREAKING CHANGE` — löst ein Major-Release aus
|
||||
- `git log --oneline --grep "^feat"` — Verlauf wird filterbar
|
||||
@@ -0,0 +1,49 @@
|
||||
# dotnet CLI
|
||||
|
||||
## Projekt anlegen
|
||||
- `dotnet new list` — verfügbare Vorlagen anzeigen
|
||||
- `dotnet new console -n <name>` — Projekt aus Vorlage anlegen
|
||||
- `dotnet new sln` — Solution anlegen
|
||||
- `dotnet sln add <projekt>` — Projekt in die Solution aufnehmen
|
||||
|
||||
## Bauen
|
||||
- `dotnet restore` — NuGet-Pakete auflösen
|
||||
- `dotnet build <projekt> -c Release` — Release bauen
|
||||
- `dotnet build --no-restore` — ohne Restore, wenn Pakete aktuell sind
|
||||
- `dotnet build -v minimal` — knappere Ausgabe
|
||||
- `dotnet clean -c Release` — Build-Ausgabe löschen
|
||||
|
||||
## Laufen lassen
|
||||
- `dotnet run --project <projekt> -c Release` — starten
|
||||
- `dotnet run --project <projekt> -- <args>` — Argumente an die App
|
||||
- `dotnet watch --project <projekt> run` — bei Dateiänderung neu starten
|
||||
|
||||
## Testen
|
||||
- `dotnet test <projekt> -c Release --no-build` — Tests ohne Neubau
|
||||
- `dotnet test --filter "FullyQualifiedName~<name>"` — nur passende Tests
|
||||
- `dotnet test --filter "FullyQualifiedName!~<name>"` — Tests ausschließen
|
||||
- `dotnet test --logger "console;verbosity=detailed"` — Details pro Test
|
||||
- `dotnet test --blame-hang-timeout 60s` — hängende Tests aufspüren
|
||||
|
||||
## Pakete
|
||||
- `dotnet add <projekt> package <name>` — Paket hinzufügen
|
||||
- `dotnet add <projekt> package <name> --version 1.2.3` — feste Version
|
||||
- `dotnet remove <projekt> package <name>` — Paket entfernen
|
||||
- `dotnet list <projekt> package --outdated` — veraltete Pakete
|
||||
- `dotnet nuget locals all --clear` — NuGet-Caches leeren
|
||||
|
||||
## EF Core Migrationen
|
||||
- `dotnet ef migrations add <name> -p <daten-projekt>` — Migration anlegen
|
||||
- `dotnet ef migrations list -p <daten-projekt>` — Migrationen anzeigen
|
||||
- `dotnet ef migrations remove -p <daten-projekt>` — letzte Migration zurücknehmen
|
||||
- `dotnet ef database update -p <daten-projekt>` — Schema anwenden
|
||||
|
||||
## Veröffentlichen
|
||||
- `dotnet publish <projekt> -c Release -r win-x64 --self-contained` — eigenständiges Build
|
||||
- `dotnet publish <projekt> -c Release -p:PublishSingleFile=true` — eine Exe
|
||||
|
||||
## Diagnose
|
||||
- `dotnet --info` — SDKs, Runtimes, RID
|
||||
- `dotnet --list-sdks` — installierte SDKs
|
||||
- `dotnet format <projekt>` — Formatierung anwenden
|
||||
- `dotnet build -p:TreatWarningsAsErrors=true` — Warnungen als Fehler
|
||||
@@ -0,0 +1,80 @@
|
||||
# Git
|
||||
|
||||
## Start
|
||||
- `git clone <url>` — Repository klonen
|
||||
- `git init` — neues Repository anlegen
|
||||
- `git remote add origin <url>` — Remote verbinden
|
||||
|
||||
## Stand ansehen
|
||||
- `git status -sb` — Kurzstatus mit Branch-Zeile
|
||||
- `git diff` — nicht gestagete Änderungen
|
||||
- `git diff --staged` — gestagete Änderungen
|
||||
- `git diff main...HEAD` — alles, was dieser Branch gegenüber main bringt
|
||||
- `git diff --stat` — nur Datei- und Zeilenzähler
|
||||
- `git log --oneline --graph --decorate -20` — kompakter Verlauf
|
||||
- `git log -p -- <pfad>` — Verlauf einer Datei mit Diffs
|
||||
- `git show <commit>` — ein Commit im Detail
|
||||
|
||||
## Branch
|
||||
- `git switch <branch>` — Branch wechseln
|
||||
- `git switch -c <name>` — Branch anlegen und wechseln
|
||||
- `git switch -` — zurück zum vorherigen Branch
|
||||
- `git branch -vv` — Branches mit Upstream und letztem Commit
|
||||
- `git branch -d <name>` — gemergten Branch löschen (`-D` erzwingt)
|
||||
|
||||
## Committen
|
||||
- `git add <pfad>` — Änderungen stagen
|
||||
- `git add -p` — Änderungen hunk-weise stagen
|
||||
- `git commit -m "<text>"` — gestagete Änderungen committen
|
||||
- `git commit -- <pfad>` — nur diese Pfade committen
|
||||
- `git commit --amend --no-edit` — letzten Commit um die Staging-Area erweitern
|
||||
- `git commit --fixup <commit>` — Fixup für späteres Autosquash
|
||||
|
||||
## Remote
|
||||
- `git fetch --all --prune` — alles holen, gelöschte Remote-Branches aufräumen
|
||||
- `git pull --rebase` — holen und eigene Commits obendrauf setzen
|
||||
- `git push -u origin <name>` — pushen und Upstream setzen
|
||||
- `git push --force-with-lease` — sicheres Force-Push, nie plain `--force`
|
||||
|
||||
## Rebase und Merge
|
||||
- `git rebase main` — Branch auf aktuellen main setzen
|
||||
- `git rebase -i --autosquash main` — interaktiv aufräumen
|
||||
- `git rebase --continue` — nach gelöstem Konflikt weiter
|
||||
- `git rebase --abort` — Rebase abbrechen, Ausgangszustand zurück
|
||||
- `git merge --no-ff <branch>` — Merge-Commit erzwingen
|
||||
- `git merge --abort` — laufenden Merge abbrechen
|
||||
- `git cherry-pick <commit>` — einzelnen Commit übernehmen
|
||||
|
||||
## Konflikte
|
||||
- `git diff --name-only --diff-filter=U` — konfliktbehaftete Dateien
|
||||
- `git checkout --ours <pfad>` — eigene Seite übernehmen
|
||||
- `git checkout --theirs <pfad>` — fremde Seite übernehmen
|
||||
- `git mergetool` — konfiguriertes Merge-Tool starten
|
||||
|
||||
## Rückgängig
|
||||
- `git restore <pfad>` — Datei auf HEAD zurücksetzen, Änderungen weg
|
||||
- `git restore --staged <pfad>` — unstagen, Änderungen behalten
|
||||
- `git reset --soft HEAD~1` — letzten Commit auflösen, Änderungen gestaged behalten
|
||||
- `git reset --hard <commit>` — alles verwerfen, nicht rückholbar
|
||||
- `git revert <commit>` — Commit durch Gegen-Commit aufheben
|
||||
- `git reflog` — Referenzverlauf, um verlorene Commits wiederzufinden
|
||||
|
||||
## Stash
|
||||
- `git stash push -m "<text>"` — Änderungen wegpacken
|
||||
- `git stash push -u` — inklusive unversionierter Dateien
|
||||
- `git stash list` — Stashes anzeigen
|
||||
- `git stash pop` — letzten Stash zurückholen und entfernen
|
||||
- `git stash apply <stash>` — anwenden, aber behalten
|
||||
|
||||
## Worktrees
|
||||
- `git worktree list` — alle Worktrees
|
||||
- `git worktree add <pfad> <branch>` — Worktree für bestehenden Branch
|
||||
- `git worktree add -b <name> <pfad>` — Worktree mit neuem Branch
|
||||
- `git worktree remove <pfad>` — Worktree entfernen
|
||||
- `git worktree prune` — verwaiste Einträge aufräumen
|
||||
|
||||
## Suchen
|
||||
- `git grep -n "<text>"` — im Arbeitsbaum suchen
|
||||
- `git log -S"<text>"` — Commits, die den Text einfügen oder entfernen
|
||||
- `git blame -L 40,60 <pfad>` — Zeilenbereich zuordnen
|
||||
- `git bisect start` — binäre Suche nach dem Fehler-Commit
|
||||
@@ -0,0 +1,52 @@
|
||||
# PowerShell
|
||||
|
||||
## Navigieren und Dateien
|
||||
- `Get-ChildItem` — Verzeichnis auflisten (`ls`, `-Force` zeigt versteckte)
|
||||
- `Set-Location <pfad>` — Verzeichnis wechseln (`cd`)
|
||||
- `New-Item -ItemType Directory <pfad>` — Ordner anlegen (`mkdir`)
|
||||
- `Copy-Item <quelle> <ziel> -Recurse` — kopieren
|
||||
- `Move-Item <quelle> <ziel>` — verschieben oder umbenennen
|
||||
- `Remove-Item <pfad> -Recurse -Force` — löschen, endgültig
|
||||
- `Test-Path <pfad>` — existiert die Datei oder der Ordner?
|
||||
|
||||
## Ansehen und Suchen
|
||||
- `Get-Content <datei> -Tail 50` — letzte Zeilen (`-Wait` folgt wie tail -f)
|
||||
- `Select-String "<text>" <dateien> -List` — in Dateien suchen (grep)
|
||||
- `Get-ChildItem -Recurse -Filter *.log` — Dateien rekursiv finden
|
||||
- `<befehl> | Select-Object -First 10` — Ausgabe begrenzen (head)
|
||||
- `<befehl> | Where-Object Name -like "*test*"` — Ausgabe filtern
|
||||
- `<befehl> | Measure-Object` — zählen (wc)
|
||||
- `<befehl> | Out-GridView` — Ausgabe in sortierbarem Fenster
|
||||
|
||||
## Prozesse und Dienste
|
||||
- `Get-Process | Sort-Object CPU -Descending | Select-Object -First 10` — Top-Prozesse
|
||||
- `Stop-Process -Name <name>` — Prozess beenden (`-Id <pid>` per PID)
|
||||
- `Get-Service <name>` — Dienststatus
|
||||
- `Restart-Service <name>` — Dienst neu starten (Admin nötig)
|
||||
|
||||
## Netzwerk
|
||||
- `Test-NetConnection <host> -Port 443` — Erreichbarkeit und Port testen
|
||||
- `Get-NetTCPConnection -LocalPort <port>` — wer belegt den Port (OwningProcess = PID)
|
||||
- `Invoke-RestMethod <url>` — HTTP-Aufruf, JSON kommt als Objekt zurück
|
||||
- `Resolve-DnsName <host>` — DNS-Auflösung prüfen
|
||||
|
||||
## Umgebung
|
||||
- `$env:PATH -split ";"` — PATH lesbar ausgeben
|
||||
- `$env:NAME = "wert"` — Variable für diese Session setzen
|
||||
- `[Environment]::SetEnvironmentVariable("NAME", "wert", "User")` — dauerhaft setzen
|
||||
- `Get-Command <name>` — wo liegt ein Befehl (which)
|
||||
- `Get-History` — bisherige Befehle dieser Session
|
||||
|
||||
## Windows-Spezifisches
|
||||
- `explorer .` — aktuellen Ordner im Explorer öffnen
|
||||
- `Start-Process <datei>` — mit Standardprogramm öffnen
|
||||
- `Start-Process pwsh -Verb RunAs` — Terminal als Administrator
|
||||
- `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned` — lokale Skripte erlauben
|
||||
- `winget search <name>` — Software im Paketmanager suchen
|
||||
- `winget install <id>` — installieren
|
||||
- `winget upgrade --all` — alles aktualisieren
|
||||
|
||||
## Hilfe
|
||||
- `Get-Help <befehl> -Examples` — Beispiele zu einem Cmdlet
|
||||
- `Get-Member` — Eigenschaften eines Pipeline-Objekts anzeigen
|
||||
- `Get-Alias <kurzform>` — welches Cmdlet steckt hinter einem Alias
|
||||
@@ -41,6 +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 `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.
|
||||
@@ -55,16 +56,28 @@ Design/ — Tokens.axaml (design tokens; merged before styles)
|
||||
| VM | Notes |
|
||||
|---|---|
|
||||
| `SettingsModalViewModel` | Four tabs: General, Worktrees, Files (prompt paths), Prime Claude. General hosts the per-model preset table (`ModelPresetRowViewModel`: effort + max turns per alias) which **replaced** the single global "Max turns" field. The Prime Claude tab gives each schedule row a three-way action radio group (Ping / Fill My Day / Custom) — `RadioButton.GroupName` is bound to a **per-row** `RadioGroup` string, since a shared group name would make every schedule share one selection. `DailyPrepMaxTasks` is disabled unless some row is `FillMyDay`. |
|
||||
| `ListSettingsModalViewModel` | Name, working dir, commit type, "manual list" flag, `VerifyCommand`, delete. Hosts the shared `AgentConfigEditorViewModel` as `Agent` (scope=List) — ⚠️ save delegates to `Agent.SaveAsync(verifyCommand)` because both land in the same `list_config` row via one `UpdateListConfig` call and would otherwise clobber each other. |
|
||||
| `ListSettingsModalViewModel` | Name, working dir, commit type, "manual list" flag, `VerifyCommand`, delete. Hosts the shared `AgentConfigEditorViewModel` as `Agent` (scope=List) — ⚠️ save delegates to `Agent.SaveAsync(verifyCommand)` because both land in the same `list_config` row via one `UpdateListConfig` call and would otherwise clobber each other. **Doubles as the folder editor:** `IsFolder` collapses the body to name + icon, and it's reached either via the create-only "create a folder instead" toggle (`CanToggleFolder`, set by `BeginLoad`) or via `BeginLoadFolder` on an existing folder. There is no list↔folder conversion. Creating a folder writes a `list_folders` row and **deletes the list row the create flow pre-created**, reporting it as `Deleted` so the caller does a full reload. |
|
||||
| `WeeklyReportModalViewModel` | Range pickers default "since last standup weekday → today", cached per range. |
|
||||
| `MergeHelperSelectionModalViewModel` | "Let Claude handle it" picker → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md). |
|
||||
| `UsageMonitorModalViewModel` | Opened from the usage pill (shown **before** the data loads via `BeginLoad`); gauges are **dynamic** per `UsageSnapshotDto.Limits` row, and the 5h/7d ones carry three draggable stage markers (soft/hard/gate) via `UsageGaugeBar` + the pure `UsageThresholdDrag`, plus a colour-matched legend with a `NumericUpDown` per stage → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md). The **analytics** part (models/tasks/cost) comes from the external TokenTracker export, not from us: without the CLI the modal shows a hint card with an install button (gated on Node ≥ 20) instead of the totals header. Gauges, gate and throttle are unaffected by it. |
|
||||
| `UsageMonitorModalViewModel` | Opened from the usage pill (shown **before** the data loads via `BeginLoad`); gauges are **dynamic** per `UsageSnapshotDto.Limits` row, and the 5h/7d ones carry three draggable stage markers (soft/hard/gate) via `UsageGaugeBar` + the pure `UsageThresholdDrag`, plus a colour-matched legend with a `NumericUpDown` per stage → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md). The **analytics** part (models/tasks/cost) comes from the external TokenTracker export, not from us: without the CLI the modal shows a hint card with an install button (gated on Node ≥ 20) instead of the totals header. Gauges, gate and throttle are unaffected by it. The modal is **display + threshold drag only** — the PowerUse switch/window/handoff % it used to host moved to Settings → Execution (`GeneralSettingsTabViewModel`), and "Refresh now" is an icon in the titlebar via `ModalShell.HeaderActions` rather than a body row. |
|
||||
|
||||
Self-explanatory: `RepoImportModalViewModel` (bulk-create lists from git repos; already-wired
|
||||
repos disabled), `MergeModalViewModel`, `WorktreesOverviewModalViewModel`,
|
||||
`UnfinishedPlanningModalViewModel`, `LogVisualizerViewModel` (last 30 min, all levels + a
|
||||
warn/error filter), `WorkerConnectionModalViewModel`, `AboutModalViewModel`.
|
||||
|
||||
`CheatSheetsViewModel` backs the modeless `CheatSheetsWindow` (Help → Cheat Sheets): content is
|
||||
markdown under `Assets/CheatSheets/*.md` as **EmbeddedResource** (not `AvaloniaResource` —
|
||||
`AssetLoader` throws without an initialized Avalonia app, which would make the VM unconstructible
|
||||
in tests), rendered through `MarkdownView`, with a `Topmost` pin so it can sit docked beside the
|
||||
shell. A new sheet = a new `.md` file + its key in `CheatSheetsViewModel.Keys` (+ a menu entry if
|
||||
it should be directly reachable). `MarkdownView` has no table/fenced-code support, so sheets are
|
||||
bullet lists with inline code — rendered via its opt-in `CommandRows` property, which turns a
|
||||
`` `command` — description `` bullet into a mono chip + copy button + description and wraps each
|
||||
`##` section in a collapsible `Expander` (blocks are added to the open section's panel, not to the
|
||||
host). The flag is opt-in because the other three `MarkdownView` call sites (prompt-file viewer,
|
||||
weekly report, task-description preview) render prose where copy buttons would be noise.
|
||||
|
||||
## Diff & Conflicts
|
||||
|
||||
`UnifiedDiffParser` (static) + `DiffModels.cs` shared types + `DiffViewerViewModel` (one unified
|
||||
@@ -79,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
|
||||
@@ -106,6 +119,9 @@ There is no null→bool converter — bind
|
||||
Modals use `TaskCompletionSource` results behind the reusable `ModalShell` control — the dialog
|
||||
sets the result on save/cancel, the caller awaits the TCS.
|
||||
|
||||
`ModalShell.HeaderActions` is the titlebar slot left of maximise/close — for a window-level action
|
||||
that should stay visible without costing a body row (currently only the usage monitor's refresh).
|
||||
|
||||
`ModalShell` also owns the window chrome for every modal: the titlebar drag goes through
|
||||
`Window.BeginMoveDrag` (so Windows snap works — drag to the top edge to maximise; the OS only
|
||||
snaps `CanResize="True"` windows, which is the opt-in), and it insets itself by the window's
|
||||
|
||||
@@ -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>
|
||||
@@ -36,6 +38,9 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- EmbeddedResource, not AvaloniaResource: reading these needs no initialized Avalonia
|
||||
app, so the VM stays constructible in tests. -->
|
||||
<EmbeddedResource Include="Assets/CheatSheets/*.md" />
|
||||
<AvaloniaResource Include="Assets/Fonts/*.ttf" />
|
||||
<AvaloniaResource Include="Assets/Fonts/OFL-InterTight.txt" />
|
||||
<AvaloniaResource Include="Assets/Fonts/OFL-JetBrainsMono.txt" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -297,6 +300,18 @@
|
||||
<Setter Property="Foreground" Value="{StaticResource StatusQueuedBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- waiting → muted amber: a Queued task blocked on an unmet predecessor (a task_dependencies
|
||||
edge, or the planning chain's BlockedByTaskId) — distinct from queued (unblocked, next up
|
||||
to run) and from children (a parent waiting on its own subtasks). Mutually exclusive with
|
||||
.queued on the same chip (IsQueued/IsWaiting), so no declaration-order fight between them. -->
|
||||
<Style Selector="Border.chip.waiting">
|
||||
<Setter Property="Background" Value="#332F1A" />
|
||||
<Setter Property="BorderBrush" Value="#4D451A" />
|
||||
</Style>
|
||||
<Style Selector="Border.chip.waiting > TextBlock">
|
||||
<Setter Property="Foreground" Value="#C9A227" />
|
||||
</Style>
|
||||
|
||||
<!-- done → green (#6FA86B) -->
|
||||
<Style Selector="Border.chip.done">
|
||||
<Setter Property="Background" Value="{StaticResource DoneTintBrush}" />
|
||||
@@ -842,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" />
|
||||
@@ -851,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,12 +28,16 @@ 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);
|
||||
|
||||
/// <summary>Show (or re-show + focus) the modeless Mission Control window. Lazily created; hides on close.</summary>
|
||||
void ShowMissionControl(MissionControlViewModel vm);
|
||||
|
||||
/// <summary>Show (or re-show + focus) the modeless cheat-sheet window for the shell's shared VM.</summary>
|
||||
void ShowCheatSheets(CheatSheetsViewModel vm);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
namespace ClaudeDo.Ui.Services;
|
||||
|
||||
// Two named types because DI resolves them by type, not by key.
|
||||
public sealed class InstallerLocator() : InstallArtifactLocator("uninstaller", "ClaudeDo.Installer.exe");
|
||||
// Two named types because DI resolves them by type, not by key. Not sealed: tests subclass
|
||||
// these to fake Find() without touching the real filesystem/registry.
|
||||
public class InstallerLocator() : InstallArtifactLocator("uninstaller", "ClaudeDo.Installer.exe");
|
||||
|
||||
public sealed class WorkerLocator() : InstallArtifactLocator("worker", "ClaudeDo.Worker.exe");
|
||||
public class WorkerLocator() : InstallArtifactLocator("worker", "ClaudeDo.Worker.exe");
|
||||
|
||||
/// <summary>
|
||||
/// Locates an executable inside a ClaudeDo install: walk up from the running
|
||||
@@ -14,7 +15,7 @@ public abstract class InstallArtifactLocator(string subdir, string exeName)
|
||||
{
|
||||
private const string InstallJson = "install.json";
|
||||
|
||||
public string? Find()
|
||||
public virtual string? Find()
|
||||
=> FindByWalkingUp(AppContext.BaseDirectory)
|
||||
?? (OperatingSystem.IsWindows() ? FindByRegistry() : null);
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -52,13 +59,6 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
/// total.</summary>
|
||||
event Action<string, string, int, int>? OperationProgressEvent;
|
||||
|
||||
/// <summary>(taskId, phase, elapsedSeconds) — phase of an in-flight single-task merge
|
||||
/// ("merging" | "verifying"). Fires while the MergeTask call itself is still pending, so the
|
||||
/// waiting UI can show what it's blocked on; the verify phase re-fires every 30 s. A thin
|
||||
/// forwarder over <see cref="OperationProgressEvent"/> kept for existing consumers (elapsed
|
||||
/// seconds riding in the generic "current" slot).</summary>
|
||||
event Action<string, string, int>? MergeProgressEvent;
|
||||
|
||||
event Action<string, string>? PlanningMergeStartedEvent;
|
||||
event Action<string, string>? PlanningSubtaskMergedEvent;
|
||||
/// <summary>(planningTaskId, subtaskId, conflictedFiles, externallyDriven). externallyDriven
|
||||
@@ -117,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);
|
||||
@@ -138,13 +140,18 @@ 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
|
||||
/// can call handoff_list_handler on its own handler task.</summary>
|
||||
Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, CancellationToken ct = default);
|
||||
/// can call handoff_list_handler on its own handler task. model overrides the triage
|
||||
/// session's model (null = default).</summary>
|
||||
Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, string? model = null, CancellationToken ct = default);
|
||||
/// <summary>Creates the ClaudeDo task that owns a list-handler run (one per run, Idle/IsManual,
|
||||
/// never queued) so the ConPTY tile can be task-based instead of ad-hoc. Returns the new task id.</summary>
|
||||
Task<string> CreateMergeHelperTaskAsync(
|
||||
@@ -158,6 +165,10 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
/// running. The exit code only exists in this process; the worker decides whether it was a
|
||||
/// list-handler session killed by the usage limit and reopens the same phase after the 5h reset.</summary>
|
||||
Task ReportInteractiveSessionExitedAsync(string taskId, int exitCode, CancellationToken ct = default);
|
||||
/// <summary>The handler round's still-open (Queued/Running) tasks, for the Mission Control
|
||||
/// "abandon run" action. Empty when the round has no entry or nothing open -- the caller then
|
||||
/// closes the pane without a confirmation dialog.</summary>
|
||||
Task<IReadOnlyList<HandlerOpenTaskDto>> GetHandlerOpenTasksAsync(string handlerTaskId, CancellationToken ct = default);
|
||||
/// <summary>Starts a planning session and returns the launch spec for an embedded ConPTY
|
||||
/// planning terminal (replaces StartPlanningSessionAsync's external wt window).</summary>
|
||||
Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default);
|
||||
|
||||
@@ -68,6 +68,14 @@ public sealed class PtyTerminalSession : IDisposable
|
||||
await WaitForLaunchGateAsync(s_launchGate, s_launchGateTimeout, ct);
|
||||
try
|
||||
{
|
||||
// If ClaudeDo.App itself was started from inside a claude session (e.g. a dev/test
|
||||
// launch), it inherits these markers and every embedded PTY session would otherwise
|
||||
// inherit them too — the child claude then thinks it's a spawned subagent and
|
||||
// disables transcript saving / degrades its own startup banner. Every ConPTY tile
|
||||
// must look like a fresh top-level session regardless of how ClaudeDo.App was launched.
|
||||
Environment.SetEnvironmentVariable("CLAUDE_CODE_CHILD_SESSION", null);
|
||||
Environment.SetEnvironmentVariable("CLAUDECODE", null);
|
||||
|
||||
foreach (var (key, value) in descriptor.Env)
|
||||
Environment.SetEnvironmentVariable(key, value);
|
||||
|
||||
@@ -129,6 +137,46 @@ public sealed class PtyTerminalSession : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inserts <paramref name="text"/> at the cursor without submitting it — the drop-a-file-path
|
||||
/// case. Same write seam as <see cref="SendLineAsync"/>, just without the Enter.
|
||||
/// </summary>
|
||||
public async Task<bool> SendTextAsync(string text)
|
||||
{
|
||||
if (_control is not { } control || !IsRunning || string.IsNullOrEmpty(text)) return false;
|
||||
try
|
||||
{
|
||||
await SendRawAsync(control, text);
|
||||
return true;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pastes the clipboard into the session. <see cref="TerminalControl"/> wires paste to the
|
||||
/// right-click menu only, so Ctrl+V otherwise reaches the child as a raw control byte and does
|
||||
/// nothing. Goes through the emulator's own <see cref="TerminalView.PasteAsync"/> rather than
|
||||
/// writing the clipboard text raw, because that is what honours bracketed-paste mode — without
|
||||
/// it a multi-line paste is read line by line and submits on the first newline.
|
||||
/// </summary>
|
||||
public async Task<bool> PasteAsync()
|
||||
{
|
||||
if (_control is not { } control || !IsRunning) return false;
|
||||
try
|
||||
{
|
||||
if (s_terminalViewField?.GetValue(control) is not TerminalView view) return false;
|
||||
await view.PasteAsync();
|
||||
return true;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Long enough that the two writes can't be read as one chunk by the child's stdin reader.
|
||||
private static readonly TimeSpan SubmitDelay = TimeSpan.FromMilliseconds(150);
|
||||
|
||||
|
||||
@@ -69,5 +69,5 @@ public static class RepoScanner
|
||||
|
||||
// A real clone has .git as a directory. Worktrees and submodules have it as a file
|
||||
// ("gitdir: ...") — those are not standalone repos and would flood the list.
|
||||
private static bool IsRepo(string dir) => Directory.Exists(Path.Combine(dir, ".git"));
|
||||
public static bool IsRepo(string dir) => Directory.Exists(Path.Combine(dir, ".git"));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -67,7 +69,6 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
public event Action<UsageSnapshotDto>? UsageUpdatedEvent;
|
||||
|
||||
public event Action<string, string, int, int>? OperationProgressEvent;
|
||||
public event Action<string, string, int>? MergeProgressEvent;
|
||||
|
||||
public event Action<string, string>? PlanningMergeStartedEvent;
|
||||
public event Action<string, string>? PlanningSubtaskMergedEvent;
|
||||
@@ -161,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));
|
||||
@@ -171,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));
|
||||
@@ -189,11 +200,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
|
||||
_hub.On<string, string, int, int>("OperationProgress", (opKey, phase, current, total) =>
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
OperationProgressEvent?.Invoke(opKey, phase, current, total);
|
||||
MergeProgressEvent?.Invoke(opKey, phase, current);
|
||||
});
|
||||
Dispatcher.UIThread.Post(() => OperationProgressEvent?.Invoke(opKey, phase, current, total));
|
||||
});
|
||||
|
||||
_hub.On<string, string>("PlanningMergeStarted", (planningTaskId, targetBranch) =>
|
||||
@@ -303,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); }
|
||||
}
|
||||
@@ -317,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); }
|
||||
}
|
||||
|
||||
@@ -331,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); }
|
||||
}
|
||||
|
||||
@@ -358,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)
|
||||
@@ -558,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)
|
||||
@@ -624,11 +647,17 @@ 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> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, CancellationToken ct = default)
|
||||
=> await InvokeTimedAsync<LaunchSpec>("GetMergeHelperLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetMergeHelperLaunchSpec", taskIds, listId, handlerTaskId, 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));
|
||||
|
||||
public async Task<string> CreateMergeHelperTaskAsync(
|
||||
IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default)
|
||||
@@ -641,6 +670,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
public Task ReportInteractiveSessionExitedAsync(string taskId, int exitCode, CancellationToken ct = default)
|
||||
=> InvokeTimedAsync("ReportInteractiveSessionExited", () => _hub.InvokeAsync("ReportInteractiveSessionExited", taskId, exitCode, ct));
|
||||
|
||||
public async Task<IReadOnlyList<HandlerOpenTaskDto>> GetHandlerOpenTasksAsync(string handlerTaskId, CancellationToken ct = default)
|
||||
=> await InvokeTimedAsync<List<HandlerOpenTaskDto>>("GetHandlerOpenTasks", () => _hub.InvokeAsync<List<HandlerOpenTaskDto>>("GetHandlerOpenTasks", handlerTaskId, ct));
|
||||
|
||||
public async Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
||||
=> await InvokeTimedAsync<LaunchSpec>("GetPlanningStartLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetPlanningStartLaunchSpec", taskId, ct));
|
||||
|
||||
|
||||
@@ -236,6 +236,10 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
|
||||
return names.Count == 0 ? null : names;
|
||||
}
|
||||
|
||||
// The list_config row fetched by the most recent LoadForListAsync — exposed so a caller that
|
||||
// needs the same row (ListSettingsModalViewModel) can reuse it instead of fetching it again.
|
||||
public ListConfigDto? LastLoadedListConfig { get; private set; }
|
||||
|
||||
public async System.Threading.Tasks.Task LoadForListAsync(string listId, CancellationToken ct = default)
|
||||
{
|
||||
_suppressSave = true;
|
||||
@@ -246,6 +250,7 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
|
||||
await LoadGlobalDefaultsAsync();
|
||||
|
||||
var cfg = await _worker.GetListConfigAsync(listId);
|
||||
LastLoadedListConfig = cfg;
|
||||
ApplyConfig(cfg?.Model, cfg?.MaxTurns, cfg?.SystemPrompt, cfg?.AgentPath, cfg?.PermissionMode);
|
||||
await ReloadSessionSkillsAsync(cfg?.SessionSkills);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using Iciclecreek.Terminal;
|
||||
|
||||
@@ -36,14 +37,29 @@ public sealed partial class InteractiveTerminalViewModel : ViewModelBase, IDispo
|
||||
/// can show a spinner instead of an empty black pane.</summary>
|
||||
public bool IsStarting => !IsRunning && !HasExited && StartError is null;
|
||||
|
||||
/// <summary>True once the process has died without a <see cref="StartError"/> already covering it —
|
||||
/// i.e. every death that isn't a launch-time failure: a crash after running a while, a clean exit,
|
||||
/// or a quiet exit inside the startup grace window. Drives the host's "session ended" banner so a
|
||||
/// dead pane never looks like it's still running/thinking.</summary>
|
||||
public bool HasExitedAfterStartup => HasExited && StartError is null;
|
||||
|
||||
partial void OnIsRunningChanged(bool value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsStarting));
|
||||
if (value) _startedAtUtc = _utcNow();
|
||||
}
|
||||
|
||||
partial void OnHasExitedChanged(bool value) => OnPropertyChanged(nameof(IsStarting));
|
||||
partial void OnStartErrorChanged(string? value) => OnPropertyChanged(nameof(IsStarting));
|
||||
partial void OnHasExitedChanged(bool value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsStarting));
|
||||
OnPropertyChanged(nameof(HasExitedAfterStartup));
|
||||
}
|
||||
|
||||
partial void OnStartErrorChanged(string? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsStarting));
|
||||
OnPropertyChanged(nameof(HasExitedAfterStartup));
|
||||
}
|
||||
|
||||
public InteractiveTerminalViewModel() : this(() => DateTime.UtcNow)
|
||||
{
|
||||
@@ -96,6 +112,15 @@ public sealed partial class InteractiveTerminalViewModel : ViewModelBase, IDispo
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Raised when a user-initiated paste or drop couldn't reach the session (write seam
|
||||
/// gone, no running process) — mirrors the island ViewModels' ErrorReported, forwarded by
|
||||
/// <see cref="ClaudeDo.Ui.ViewModels.MissionControl.ConPtyPaneViewModel"/> into the footer error strip.</summary>
|
||||
public event Action<string>? ErrorReported;
|
||||
|
||||
/// <summary>Called by the view after a failed <see cref="PasteAsync"/>/<see cref="SendTextAsync"/>
|
||||
/// so the failure surfaces through <see cref="ErrorReported"/> instead of vanishing silently.</summary>
|
||||
public void ReportSendFailed() => ErrorReported?.Invoke(Loc.T("missionControl.terminalSendFailed"));
|
||||
|
||||
/// <summary>Raised when the process died non-zero *after* clearing <see cref="StartupGraceWindow"/> —
|
||||
/// i.e. a session that ran, then broke. That used to be a silent dead tile with no signal at all;
|
||||
/// the worker turns it into a usage-limit auto-restart for list-handler sessions
|
||||
@@ -136,6 +161,15 @@ public sealed partial class InteractiveTerminalViewModel : ViewModelBase, IDispo
|
||||
/// type into (or the write seam is gone).</summary>
|
||||
public Task<bool> SendLineAsync(string text) => _session.SendLineAsync(text);
|
||||
|
||||
/// <summary>Inserts text at the cursor without submitting — see
|
||||
/// <see cref="PtyTerminalSession.SendTextAsync"/> (dropped file paths).</summary>
|
||||
public Task<bool> SendTextAsync(string text) => _session.SendTextAsync(text);
|
||||
|
||||
/// <summary>Pastes the clipboard into the session (Ctrl+V) — see
|
||||
/// <see cref="PtyTerminalSession.PasteAsync"/>. False when there is no running process to
|
||||
/// paste into (or the write seam is gone).</summary>
|
||||
public Task<bool> PasteAsync() => _session.PasteAsync();
|
||||
|
||||
public void Kill() => _session.Kill();
|
||||
|
||||
public void Dispose()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1262,43 +1263,51 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
using var op = ApproveOp.Begin(Loc.T("ops.merge.merging"));
|
||||
// Subscribed only for the duration of the call: the worker broadcasts to every
|
||||
// client, and a VM left on this event would keep reacting to merges for other tasks.
|
||||
void OnMergeProgress(string progressTaskId, string phase, int elapsedSeconds)
|
||||
{
|
||||
if (progressTaskId != taskId || phase != MergePhaseVerifying) return;
|
||||
ApproveOp.Report(Loc.T("ops.merge.verifying", FormatElapsed(elapsedSeconds)));
|
||||
}
|
||||
// Separate event: MergeProgressEvent forwards elapsed-seconds-only (verifying), but the
|
||||
// rebase phase needs the branch count too, which only rides the untrimmed OperationProgress.
|
||||
void OnOperationProgress(string opKey, string phase, int current, int total)
|
||||
{
|
||||
if (opKey != taskId || phase != MergePhaseRebasing) return;
|
||||
ApproveOp.Report(FormatRebasing(current, total));
|
||||
if (opKey != taskId) return;
|
||||
if (phase == MergePhaseVerifying)
|
||||
ApproveOp.Report(Loc.T("ops.merge.verifying", FormatElapsed(current)));
|
||||
else if (phase == MergePhaseRebasing)
|
||||
ApproveOp.Report(FormatRebasing(current, total));
|
||||
}
|
||||
_worker.MergeProgressEvent += OnMergeProgress;
|
||||
_worker.OperationProgressEvent += OnOperationProgress;
|
||||
try
|
||||
{
|
||||
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
|
||||
{
|
||||
_worker.MergeProgressEvent -= OnMergeProgress;
|
||||
_worker.OperationProgressEvent -= OnOperationProgress;
|
||||
}
|
||||
}
|
||||
@@ -1340,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1371,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); }
|
||||
@@ -1509,12 +1518,17 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
// Shared by TaskRowViewModel.UpdateFromEntity — this is the one parser for
|
||||
// `git diff --stat`'s summary line ("N insertions(+), M deletions(-)"). Anchored on the
|
||||
// literal "(+)"/"(-)" markers rather than the English words: git for Windows localizes
|
||||
// "insertion"/"deletion" to the OS language (e.g. German "Einfügung(en)"/"Löschung(en)"),
|
||||
// but always keeps the untranslated (+)/(-) suffix, so this reads correctly in any locale.
|
||||
internal static (int Additions, int Deletions) ParseDiffStat(string? stat)
|
||||
{
|
||||
if (string.IsNullOrEmpty(stat)) return (0, 0);
|
||||
int add = 0, del = 0;
|
||||
var m1 = System.Text.RegularExpressions.Regex.Match(stat, @"(\d+)\s+insertion");
|
||||
var m2 = System.Text.RegularExpressions.Regex.Match(stat, @"(\d+)\s+deletion");
|
||||
var m1 = System.Text.RegularExpressions.Regex.Match(stat, @"(\d+)[^,\n]*\(\+\)");
|
||||
var m2 = System.Text.RegularExpressions.Regex.Match(stat, @"(\d+)[^,\n]*\(-\)");
|
||||
if (m1.Success) int.TryParse(m1.Groups[1].Value, out add);
|
||||
if (m2.Success) int.TryParse(m2.Groups[1].Value, out del);
|
||||
return (add, del);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
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. 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";
|
||||
/// <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
|
||||
// whole folder block (header + children), not just the header.
|
||||
[ObservableProperty] private bool _dropHintAbove;
|
||||
[ObservableProperty] private bool _dropHintBelow;
|
||||
|
||||
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,5 +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;
|
||||
}
|
||||
|
||||
@@ -17,8 +17,9 @@ namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||
|
||||
public enum ListKind { Smart, Virtual, User }
|
||||
|
||||
/// <summary>Confirmed handler run: the scope list and the ordered selected task ids.</summary>
|
||||
public sealed record MergeHelperRequest(string ListId, IReadOnlyList<string> TaskIds);
|
||||
/// <summary>Confirmed handler run: the scope list, the ordered selected task ids, and the model
|
||||
/// picked for the triage session (null = default; review-mode runs skip triage entirely).</summary>
|
||||
public sealed record MergeHelperRequest(string ListId, IReadOnlyList<string> TaskIds, string? TriageModel = null);
|
||||
|
||||
public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
@@ -26,6 +27,12 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
/// island show the notes editor instead of rows.</summary>
|
||||
public const string NotesListId = "smart:notes";
|
||||
|
||||
/// <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;
|
||||
@@ -58,7 +65,8 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
if (row is null || Dialogs is null || _services is null) return;
|
||||
var rawId = row.Id.StartsWith("user:", StringComparison.Ordinal) ? row.Id["user:".Length..] : row.Id;
|
||||
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
|
||||
await vm.LoadAsync(rawId, row.Name, row.WorkingDir, row.DefaultCommitType, row.IsManual, row.FindingsTracked, row.IconKey);
|
||||
vm.ErrorReported += msg => ErrorReported?.Invoke(msg);
|
||||
vm.BeginLoad(rawId, row.Name, row.WorkingDir, row.DefaultCommitType, row.IsManual, row.FindingsTracked, row.IconKey);
|
||||
await Dialogs.ShowListSettingsAsync(vm);
|
||||
if (vm.Deleted) await LoadAsync();
|
||||
else await RefreshRowAsync(row.Id);
|
||||
@@ -110,7 +118,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
await vm.LoadAsync();
|
||||
var ids = await Dialogs.ShowMergeHelperSelectionAsync(vm);
|
||||
if (ids is { Count: > 0 })
|
||||
LetClaudeHandleRequested?.Invoke(new MergeHelperRequest(rawId, ids));
|
||||
LetClaudeHandleRequested?.Invoke(new MergeHelperRequest(rawId, ids, vm.SelectedModel));
|
||||
}
|
||||
|
||||
/// <summary>Raised once per list after the review-scoped selection dialog is confirmed;
|
||||
@@ -201,7 +209,32 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
|
||||
public ObservableCollection<ListNavItemViewModel> Items { get; } = new();
|
||||
public ObservableCollection<ListNavItemViewModel> SmartLists { get; } = new();
|
||||
/// <summary>Every user list, flat, regardless of folder — the set the count refresh, the
|
||||
/// row refresh and the no-repo banner work over.</summary>
|
||||
public ObservableCollection<ListNavItemViewModel> UserLists { get; } = new();
|
||||
/// <summary>The sidebar's top level, in render order: folder rows
|
||||
/// (<see cref="ListFolderNavItemViewModel"/>) and folder-less lists
|
||||
/// (<see cref="ListNavItemViewModel"/>) interleaved, so a list can sit above a folder.
|
||||
/// This is what the view binds to; <see cref="UserLists"/> stays the flat superset.</summary>
|
||||
public ObservableCollection<object> TopLevel { get; } = new();
|
||||
|
||||
/// <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();
|
||||
|
||||
[ObservableProperty] private string _searchText = "";
|
||||
[ObservableProperty] private ListNavItemViewModel? _selectedList;
|
||||
@@ -278,6 +311,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
Items.Clear();
|
||||
SmartLists.Clear();
|
||||
UserLists.Clear();
|
||||
TopLevel.Clear();
|
||||
|
||||
var smart = new[]
|
||||
{
|
||||
@@ -298,6 +332,36 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
var ticketsConfigured = await IsTicketsConfiguredAsync();
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
var lists = new ListRepository(ctx);
|
||||
|
||||
// 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
|
||||
{
|
||||
Id = f.Id,
|
||||
Name = f.Name,
|
||||
IconKey = f.Icon ?? ListFolderIcon,
|
||||
Color = f.Color,
|
||||
IsCollapsed = f.IsCollapsed,
|
||||
ParentFolderId = f.ParentFolderId,
|
||||
};
|
||||
foldersById[f.Id] = 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" });
|
||||
foreach (var l in await lists.GetAllAsync(ct))
|
||||
{
|
||||
@@ -308,16 +372,31 @@ 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,
|
||||
FindingsTracked = l.FindingsTracked,
|
||||
HasTicketProject = ticketsConfigured && cfg?.TicketProjectId > 0,
|
||||
FolderId = l.FolderId,
|
||||
};
|
||||
Items.Add(item);
|
||||
UserLists.Add(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.
|
||||
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); }
|
||||
@@ -364,6 +443,8 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
var filter = _filters.Resolve(item.Id);
|
||||
item.Count = filter is null ? 0 : all.Count(filter.ShouldCount);
|
||||
}
|
||||
|
||||
RecomputeFolderCounts();
|
||||
ok = true;
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
@@ -396,19 +477,22 @@ 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,
|
||||
};
|
||||
Items.Add(item);
|
||||
UserLists.Add(item);
|
||||
TopLevel.Add(item);
|
||||
SelectedList = item;
|
||||
RecomputeHasNoLinkedRepo();
|
||||
|
||||
if (Dialogs is not null && _services is not null)
|
||||
{
|
||||
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
|
||||
await vm.LoadAsync(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType, entity.IsManual, entity.FindingsTracked, entity.Icon);
|
||||
vm.ErrorReported += msg => ErrorReported?.Invoke(msg);
|
||||
// canToggleFolder: only the create flow offers "make this a folder instead".
|
||||
vm.BeginLoad(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType, entity.IsManual, entity.FindingsTracked, entity.Icon, canToggleFolder: true);
|
||||
await Dialogs.ShowListSettingsAsync(vm);
|
||||
if (!vm.Saved && !vm.Deleted)
|
||||
{
|
||||
@@ -430,42 +514,285 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
r.DropHintAbove = false;
|
||||
r.DropHintBelow = false;
|
||||
}
|
||||
}
|
||||
|
||||
public void SetDropHint(ListNavItemViewModel target, bool placeBelow)
|
||||
{
|
||||
foreach (var r in UserLists)
|
||||
foreach (var f in Folders)
|
||||
{
|
||||
var isTarget = ReferenceEquals(r, target);
|
||||
r.DropHintAbove = isTarget && !placeBelow;
|
||||
r.DropHintBelow = isTarget && placeBelow;
|
||||
f.IsDropTarget = false;
|
||||
f.DropHintAbove = false;
|
||||
f.DropHintBelow = false;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ReorderAsync(ListNavItemViewModel source, ListNavItemViewModel target, bool placeBelow)
|
||||
/// <summary>Marks the insert position for a drop next to <paramref name="target"/> — either a
|
||||
/// list row or a folder header (folders take the same two hint lines, drawn around the whole
|
||||
/// folder block).</summary>
|
||||
public void SetDropHint(object target, bool placeBelow)
|
||||
{
|
||||
ClearDropHints();
|
||||
switch (target)
|
||||
{
|
||||
case ListNavItemViewModel row:
|
||||
row.DropHintAbove = !placeBelow;
|
||||
row.DropHintBelow = placeBelow;
|
||||
break;
|
||||
case ListFolderNavItemViewModel folder:
|
||||
folder.DropHintAbove = !placeBelow;
|
||||
folder.DropHintBelow = placeBelow;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public void SetFolderDropTarget(ListFolderNavItemViewModel? target)
|
||||
{
|
||||
ClearDropHints();
|
||||
if (target is not null) target.IsDropTarget = true;
|
||||
}
|
||||
|
||||
/// <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)?.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) =>
|
||||
DropAsync(source, target, placeBelow);
|
||||
|
||||
/// <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 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 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)
|
||||
{
|
||||
if (source.Kind != ListKind.User || target.Kind != ListKind.User) return;
|
||||
if (ReferenceEquals(source, target)) return;
|
||||
if (target is ListNavItemViewModel { Kind: not ListKind.User }) return;
|
||||
|
||||
MoveWithinCollection(UserLists, source, target, placeBelow);
|
||||
if (source is ListFolderNavItemViewModel folder)
|
||||
{
|
||||
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 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)
|
||||
{
|
||||
ContainerOf(row).Remove(row);
|
||||
row.FolderId = targetFolderId;
|
||||
ContainerOf(row).Add(row);
|
||||
}
|
||||
|
||||
MoveWithinCollection(ContainerOf(row), row, target, placeBelow);
|
||||
await PersistOrderAsync(folderChanged ? row : null);
|
||||
}
|
||||
|
||||
/// <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 is ListFolderNavItemViewModel sourceFolder)
|
||||
{
|
||||
if (ReferenceEquals(sourceFolder, folder)) return;
|
||||
if (IsSelfOrDescendant(sourceFolder, folder?.Id)) return;
|
||||
if (sourceFolder.ParentFolderId == folder?.Id) return;
|
||||
|
||||
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,
|
||||
/// 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(object? movedItem)
|
||||
{
|
||||
var ordered = new List<(string Id, bool IsFolder)>();
|
||||
void Walk(IEnumerable<object> items)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
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 orderedIds = UserLists.Select(i => i.Id["user:".Length..]).ToList();
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var ok = false;
|
||||
try
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
var lists = new ListRepository(ctx);
|
||||
await lists.ReorderAsync(orderedIds);
|
||||
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;
|
||||
}
|
||||
finally { OperationTiming.Shared.Record("db", "ListsIsland.ReorderAsync", sw.Elapsed, ok); }
|
||||
|
||||
RecomputeFolderCounts();
|
||||
}
|
||||
|
||||
/// <summary>Context-menu counterpart to dragging a list out of a folder.</summary>
|
||||
[RelayCommand]
|
||||
private async Task RemoveFromFolderAsync(ListNavItemViewModel? row)
|
||||
{
|
||||
if (row is null) return;
|
||||
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 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();
|
||||
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.
|
||||
ErrorReported?.Invoke(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task OpenFolderSettingsAsync(ListFolderNavItemViewModel? folder)
|
||||
{
|
||||
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, 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.
|
||||
if (vm.Saved || vm.Deleted) await LoadAsync();
|
||||
}
|
||||
|
||||
private void RecomputeFolderCounts()
|
||||
{
|
||||
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) =>
|
||||
rowId.StartsWith("user:", StringComparison.Ordinal) ? rowId["user:".Length..] : rowId;
|
||||
|
||||
private static void MoveWithinCollection(
|
||||
ObservableCollection<ListNavItemViewModel> coll,
|
||||
ListNavItemViewModel source,
|
||||
ListNavItemViewModel target,
|
||||
System.Collections.IList coll,
|
||||
object source,
|
||||
object target,
|
||||
bool placeBelow)
|
||||
{
|
||||
var srcIdx = coll.IndexOf(source);
|
||||
@@ -478,7 +805,8 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
if (finalIdx >= coll.Count) finalIdx = coll.Count - 1;
|
||||
if (finalIdx == srcIdx) return;
|
||||
|
||||
coll.Move(srcIdx, finalIdx);
|
||||
coll.RemoveAt(srcIdx);
|
||||
coll.Insert(finalIdx, source);
|
||||
}
|
||||
|
||||
partial void OnSelectedListChanged(ListNavItemViewModel? value)
|
||||
@@ -516,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();
|
||||
}
|
||||
@@ -562,12 +890,13 @@ 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,
|
||||
FindingsTracked = entity.FindingsTracked,
|
||||
HasTicketProject = hasTicketProject,
|
||||
FolderId = entity.FolderId,
|
||||
};
|
||||
|
||||
var seedNames = new HashSet<string>(new[] { "My Day", "Important", "Planned" });
|
||||
@@ -577,6 +906,22 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
|
||||
UserLists.Insert(insertAt, item);
|
||||
Items.Insert(SmartLists.Count + insertAt, item);
|
||||
|
||||
// Inside a folder, the same DB-order position; at the top level just appended — the mixed
|
||||
// top level has no index that a per-folder DB order maps onto, and AddAsync numbers a new
|
||||
// list past everything anyway.
|
||||
if (Folders.FirstOrDefault(f => f.Id == item.FolderId) is { } folder)
|
||||
{
|
||||
var containerIdx = ordered
|
||||
.Where(l => l.FolderId == item.FolderId)
|
||||
.Select(l => l.Id)
|
||||
.ToList()
|
||||
.IndexOf(entity.Id);
|
||||
folder.Children.Insert(
|
||||
containerIdx < 0 || containerIdx > folder.Children.Count ? folder.Children.Count : containerIdx,
|
||||
item);
|
||||
}
|
||||
else TopLevel.Add(item);
|
||||
RecomputeHasNoLinkedRepo();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -112,10 +112,31 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
[NotifyPropertyChangedFor(nameof(IsQueued))]
|
||||
[NotifyPropertyChangedFor(nameof(IsWaiting))]
|
||||
[NotifyPropertyChangedFor(nameof(StatusChipClass))]
|
||||
[NotifyPropertyChangedFor(nameof(StatusLabel))]
|
||||
[NotifyPropertyChangedFor(nameof(StatusChipTooltip))]
|
||||
[NotifyPropertyChangedFor(nameof(CanRemoveFromQueue))]
|
||||
private string? _blockedByTaskId;
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(DependsOnTaskId))]
|
||||
private IReadOnlyList<string> _dependsOnTaskIds = [];
|
||||
// Extrinsic — assigned by TasksIslandViewModel.ApplyDependencyWaitState, which alone has the
|
||||
// cross-row graph (plus any cross-list predecessor lookup) needed to tell an unmet
|
||||
// task_dependencies edge from a satisfied one. True only for the task_dependencies case;
|
||||
// IsWaiting also ORs in the older BlockedByTaskId planning-chain link.
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsQueued))]
|
||||
[NotifyPropertyChangedFor(nameof(IsWaiting))]
|
||||
[NotifyPropertyChangedFor(nameof(StatusChipClass))]
|
||||
[NotifyPropertyChangedFor(nameof(StatusLabel))]
|
||||
[NotifyPropertyChangedFor(nameof(StatusChipTooltip))]
|
||||
[NotifyPropertyChangedFor(nameof(CanRemoveFromQueue))]
|
||||
private bool _isBlockedByDependency;
|
||||
// The predecessor(s) this row is waiting on, rendered ("#42" or "#42, #57"), or null when not
|
||||
// waiting. Prefers BlockedByTaskId's single predecessor (the planning chain "parent wins" the
|
||||
// same way it does for the chain rail), else lists every unmet task_dependencies edge.
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(StatusChipTooltip))]
|
||||
private string? _dependencyWaitLabel;
|
||||
|
||||
// The single predecessor, or null when the task has none or several. The chain rail can only
|
||||
// draw a line, so a task waiting on n predecessors gets a ChainAfterLabel instead — see
|
||||
@@ -274,9 +295,9 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
// task). A live ConPTY session is that same shape but not parked — it reads "Interactive".
|
||||
public bool IsParked => Status == TaskStatus.Idle && WorktreeState == ClaudeDo.Data.Models.WorktreeState.Active
|
||||
&& !HasInteractiveSession;
|
||||
public bool IsQueued => Status == TaskStatus.Queued && string.IsNullOrEmpty(BlockedByTaskId);
|
||||
public bool IsWaiting => Status == TaskStatus.Queued && !string.IsNullOrEmpty(BlockedByTaskId);
|
||||
public bool CanRemoveFromQueue => IsQueued || HasQueuedSubtasks;
|
||||
public bool IsQueued => Status == TaskStatus.Queued && string.IsNullOrEmpty(BlockedByTaskId) && !IsBlockedByDependency;
|
||||
public bool IsWaiting => Status == TaskStatus.Queued && (!string.IsNullOrEmpty(BlockedByTaskId) || IsBlockedByDependency);
|
||||
public bool CanRemoveFromQueue => IsQueued || IsWaiting || HasQueuedSubtasks;
|
||||
// "Send to queue" is the single queue entry. On a finalized planning parent it queues the
|
||||
// plan (children) via CanQueuePlan; an Active (not-yet-finalized) planning parent is hidden —
|
||||
// it must be finalized first. The worker never sees a UI-hosted ConPTY session (it never
|
||||
@@ -411,9 +432,12 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
: FailureReasonLabel;
|
||||
|
||||
// Drives the status chip tooltip: an open interactive session takes priority (it's tappable),
|
||||
// otherwise a Failed task's reason, otherwise no tooltip.
|
||||
// then the predecessor(s) a waiting task is blocked on, then a Failed task's reason,
|
||||
// otherwise no tooltip.
|
||||
public string? StatusChipTooltip
|
||||
=> HasInteractiveSession ? Loc.T("tasks.interactiveChipTip") : FailureReasonTooltip;
|
||||
=> HasInteractiveSession ? Loc.T("tasks.interactiveChipTip")
|
||||
: IsWaiting && DependencyWaitLabel is not null ? Loc.T("tasks.waitingOnTip", DependencyWaitLabel)
|
||||
: FailureReasonTooltip;
|
||||
|
||||
public string DiffAdditionsText => $"+{DiffAdditions}";
|
||||
public string DiffDeletionsText => $"−{DiffDeletions}";
|
||||
@@ -421,7 +445,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
|
||||
public string StatusLabel
|
||||
=> HasInteractiveSession ? Loc.T("vm.taskStatus.interactive")
|
||||
: IsParked ? Loc.T("vm.taskStatus.parked") : Status switch
|
||||
: IsParked ? Loc.T("vm.taskStatus.parked")
|
||||
: IsWaiting ? Loc.T("vm.taskStatus.waiting") : Status switch
|
||||
{
|
||||
TaskStatus.Idle => Loc.T("vm.taskStatus.idle"),
|
||||
TaskStatus.Queued => Loc.T("vm.taskStatus.queued"),
|
||||
@@ -443,7 +468,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
|
||||
public string StatusChipClass => HasInteractiveSession
|
||||
? "interactive"
|
||||
: (Status, IsBlocked: !string.IsNullOrEmpty(BlockedByTaskId)) switch
|
||||
: (Status, IsBlocked: !string.IsNullOrEmpty(BlockedByTaskId) || IsBlockedByDependency) switch
|
||||
{
|
||||
(TaskStatus.Running, _) => "running",
|
||||
(TaskStatus.WaitingForReview, _) => "review",
|
||||
@@ -475,7 +500,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
// Any entity-backed refresh reflects a settled state, so the transient creation-phase
|
||||
// banner (set from the OperationProgress broadcast) has nothing left to announce.
|
||||
CreationPhase = null;
|
||||
var (add, del) = ParseDiffStat(t.Worktree?.DiffStat);
|
||||
var (add, del) = DetailsIslandViewModel.ParseDiffStat(t.Worktree?.DiffStat);
|
||||
Number = t.Number;
|
||||
Title = t.Title;
|
||||
ListName = t.List?.Name ?? "";
|
||||
@@ -504,18 +529,4 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
FailureTurnsUsed = t.FailureTurnsUsed;
|
||||
FailureMaxTurns = t.FailureMaxTurns;
|
||||
}
|
||||
|
||||
// Best-effort parse of diff stat strings like "+12 -3" or "12 additions, 3 deletions".
|
||||
private static (int add, int del) ParseDiffStat(string? s)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(s)) return (0, 0);
|
||||
int add = 0, del = 0;
|
||||
var parts = s.Split(new[] { ' ', ',', '\t' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
foreach (var p in parts)
|
||||
{
|
||||
if (p.Length > 1 && p[0] == '+' && int.TryParse(p.AsSpan(1), out var a)) add = a;
|
||||
else if (p.Length > 1 && (p[0] == '-' || p[0] == '\u2212') && int.TryParse(p.AsSpan(1), out var d)) del = d;
|
||||
}
|
||||
return (add, del);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,12 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
// Two events (TaskUpdated + WorktreeUpdated) drive the same delta refresh, so two reads for
|
||||
// one task can be in flight at once. Only the newest may write to the row.
|
||||
private readonly Dictionary<string, long> _deltaSeq = new();
|
||||
// Number/Title/Status for a predecessor id that isn't loaded into Items (a different list, My
|
||||
// Day, or the completed group) -- populated by RefreshExternalDependencyCacheAsync so
|
||||
// ApplyChainGrouping/ApplyDependencyWaitState can still render "after #N" and the blocked
|
||||
// chip for a cross-list predecessor instead of silently dropping it. Refreshed, never evicted:
|
||||
// stale entries just get overwritten on the next load/delta that references the same id.
|
||||
private readonly Dictionary<string, (int Number, string Title, TaskStatus Status)> _externalDependencyInfo = new();
|
||||
private long _deltaCounter;
|
||||
// Phase 3 reconcile tick — see the block at the end of this class.
|
||||
private readonly System.Timers.Timer _reconcileTimer = new(4_000);
|
||||
@@ -363,6 +369,12 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
r.ParentTaskId == parent.Id && (r.IsQueued || r.IsWaiting));
|
||||
}
|
||||
|
||||
// entity is not null here whenever `existing` is still reachable (see the branching
|
||||
// above: an early return covers every entity-null-but-existing case except delete, which
|
||||
// needs no lookup refresh).
|
||||
if (entity is not null && existing is not null)
|
||||
await RefreshExternalDependencyCacheAsync(db, DependencyLookupIds([existing]));
|
||||
|
||||
Regroup();
|
||||
UpdateSubtitle();
|
||||
}
|
||||
@@ -548,6 +560,8 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
r.ParentFinalized = !string.IsNullOrEmpty(r.ParentTaskId)
|
||||
&& finalizedParents.Contains(r.ParentTaskId!);
|
||||
|
||||
await RefreshExternalDependencyCacheAsync(db, DependencyLookupIds(Items), ct);
|
||||
|
||||
Regroup();
|
||||
UpdateSubtitle();
|
||||
ok = true;
|
||||
@@ -556,6 +570,32 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
finally { OperationTiming.Shared.Record("db", "TasksIsland.LoadForListAsync", sw.Elapsed, ok); }
|
||||
}
|
||||
|
||||
// Every predecessor id a row in `items` could need labelled/checked: its task_dependencies
|
||||
// edges plus the planning chain's BlockedByTaskId (both resolved the same way in
|
||||
// ApplyDependencyWaitState/BuildAfterLabel).
|
||||
private static IEnumerable<string> DependencyLookupIds(IEnumerable<TaskRowViewModel> items) =>
|
||||
items.SelectMany(r => string.IsNullOrEmpty(r.BlockedByTaskId)
|
||||
? r.DependsOnTaskIds
|
||||
: r.DependsOnTaskIds.Append(r.BlockedByTaskId));
|
||||
|
||||
// Resolves predecessor ids that aren't loaded into Items (a different list, My Day, or the
|
||||
// completed group) so cross-list dependency display doesn't silently drop them. Cheap: reuses
|
||||
// the caller's already-open context, one query for however many ids are actually missing.
|
||||
private async Task RefreshExternalDependencyCacheAsync(
|
||||
ClaudeDoDbContext db, IEnumerable<string> ids, CancellationToken ct = default)
|
||||
{
|
||||
var known = Items.Select(r => r.Id).ToHashSet();
|
||||
var missing = ids.Where(id => !string.IsNullOrEmpty(id) && !known.Contains(id)).Distinct().ToList();
|
||||
if (missing.Count == 0) return;
|
||||
|
||||
var rows = await db.Tasks
|
||||
.Where(t => missing.Contains(t.Id))
|
||||
.Select(t => new { t.Id, t.Number, t.Title, t.Status })
|
||||
.ToListAsync(ct);
|
||||
foreach (var r in rows)
|
||||
_externalDependencyInfo[r.Id] = (r.Number, r.Title, r.Status);
|
||||
}
|
||||
|
||||
internal void Regroup()
|
||||
{
|
||||
// Collapse parents that have children by default, so subtasks stay tucked away until
|
||||
@@ -691,13 +731,70 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
// with a label rather than an orphaned rail. Whether the head is resolvable at all
|
||||
// still uses the whole-Items graph (rowsById), independent of section.
|
||||
var rowsById = Items.ToDictionary(r => r.Id);
|
||||
overdue = ApplyChainGrouping(overdue, rowsById);
|
||||
open = ApplyChainGrouping(open, rowsById);
|
||||
completed = ApplyChainGrouping(completed, rowsById);
|
||||
|
||||
// Blocked-by-dependency is independent of section (a blocked row can be Overdue or Open
|
||||
// alike) and independent of chain membership, so it's computed once over all of Items
|
||||
// rather than per section.
|
||||
foreach (var r in Items)
|
||||
ApplyDependencyWaitState(r, rowsById, _externalDependencyInfo);
|
||||
|
||||
overdue = ApplyChainGrouping(overdue, rowsById, _externalDependencyInfo);
|
||||
open = ApplyChainGrouping(open, rowsById, _externalDependencyInfo);
|
||||
completed = ApplyChainGrouping(completed, rowsById, _externalDependencyInfo);
|
||||
|
||||
return (overdue, open, completed);
|
||||
}
|
||||
|
||||
// Assigns IsBlockedByDependency/DependencyWaitLabel for one row. BlockedByTaskId (the planning
|
||||
// chain's single link) wins the label the same way a planning child's parent indent wins over
|
||||
// chain-rail membership -- "parent wins" is the existing precedent (ApplyChainGrouping below).
|
||||
private static void ApplyDependencyWaitState(
|
||||
TaskRowViewModel row, Dictionary<string, TaskRowViewModel> rowsById,
|
||||
IReadOnlyDictionary<string, (int Number, string Title, TaskStatus Status)> external)
|
||||
{
|
||||
if (row.Status != TaskStatus.Queued)
|
||||
{
|
||||
row.IsBlockedByDependency = false;
|
||||
row.DependencyWaitLabel = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var unmet = row.DependsOnTaskIds.Where(id => IsUnmet(id, rowsById, external)).ToList();
|
||||
row.IsBlockedByDependency = unmet.Count > 0;
|
||||
|
||||
var waitIds = !string.IsNullOrEmpty(row.BlockedByTaskId) ? [row.BlockedByTaskId] : unmet;
|
||||
row.DependencyWaitLabel = waitIds.Count == 0 ? null
|
||||
: string.Join(", ", waitIds.Select(id => ResolveLabel(id, rowsById, external)).Where(s => s is not null));
|
||||
}
|
||||
|
||||
// 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. 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 && p.WorktreeState != WorktreeState.Merged;
|
||||
if (external.TryGetValue(id, out var e)) return e.Status != TaskStatus.Done;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Resolves a predecessor id to its display label, checking the in-view rows first and falling
|
||||
// back to the cross-list cache (RefreshExternalDependencyCacheAsync) -- null only when the
|
||||
// predecessor is genuinely unresolvable anywhere (e.g. a deleted task).
|
||||
private static string? ResolveLabel(
|
||||
string id, Dictionary<string, TaskRowViewModel> rowsById,
|
||||
IReadOnlyDictionary<string, (int Number, string Title, TaskStatus Status)> external)
|
||||
{
|
||||
if (rowsById.TryGetValue(id, out var p)) return p.Number > 0 ? $"#{p.Number}" : p.Title;
|
||||
if (external.TryGetValue(id, out var e)) return e.Number > 0 ? $"#{e.Number}" : e.Title;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Cycles are rejected by TaskStateService.SetDependsOnAsync, so this should always
|
||||
// terminate quickly — capped anyway against a corrupt/legacy row forming a loop.
|
||||
private const int MaxChainWalkDepth = 64;
|
||||
@@ -724,14 +821,15 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
|
||||
// Content only — "after " + localization is Slice 2's concern (the view), which is why this
|
||||
// holds just the identifying fragment rather than an assembled sentence. Names every
|
||||
// predecessor it can resolve: a task can wait on n of them, and hiding all but the first
|
||||
// would misreport what the row is actually waiting for.
|
||||
private static string? BuildAfterLabel(TaskRowViewModel row, Dictionary<string, TaskRowViewModel> rowsById)
|
||||
// predecessor it can resolve (in-view first, then the cross-list external cache): a task can
|
||||
// wait on n of them, and hiding all but the first would misreport what the row is waiting for.
|
||||
private static string? BuildAfterLabel(
|
||||
TaskRowViewModel row, Dictionary<string, TaskRowViewModel> rowsById,
|
||||
IReadOnlyDictionary<string, (int Number, string Title, TaskStatus Status)> external)
|
||||
{
|
||||
var parts = row.DependsOnTaskIds
|
||||
.Select(id => rowsById.TryGetValue(id, out var p) ? p : null)
|
||||
.Select(id => ResolveLabel(id, rowsById, external))
|
||||
.Where(p => p is not null)
|
||||
.Select(p => p!.Number > 0 ? $"#{p.Number}" : p.Title)
|
||||
.ToList();
|
||||
return parts.Count == 0 ? null : string.Join(", ", parts);
|
||||
}
|
||||
@@ -741,7 +839,8 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
// (ascending ChainStep), regardless of their SortOrder-derived position — mirroring how
|
||||
// planning children already sit right after their parent regardless of Items order.
|
||||
private static List<TaskRowViewModel> ApplyChainGrouping(
|
||||
List<TaskRowViewModel> section, Dictionary<string, TaskRowViewModel> rowsById)
|
||||
List<TaskRowViewModel> section, Dictionary<string, TaskRowViewModel> rowsById,
|
||||
IReadOnlyDictionary<string, (int Number, string Title, TaskStatus Status)> external)
|
||||
{
|
||||
var sectionIds = section.Select(r => r.Id).ToHashSet();
|
||||
var headIdOf = new Dictionary<TaskRowViewModel, string>();
|
||||
@@ -763,7 +862,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
r.ShowAsChainMember = false;
|
||||
r.ChainStep = null;
|
||||
r.ChainAfterLabel = BuildAfterLabel(r, rowsById);
|
||||
r.ChainAfterLabel = BuildAfterLabel(r, rowsById, external);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -773,7 +872,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
r.ShowAsChainMember = false;
|
||||
r.ChainStep = null;
|
||||
r.ChainAfterLabel = BuildAfterLabel(r, rowsById);
|
||||
r.ChainAfterLabel = BuildAfterLabel(r, rowsById, external);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -790,7 +889,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
r.ShowAsChainMember = false;
|
||||
r.ChainStep = null;
|
||||
r.ChainAfterLabel = BuildAfterLabel(r, rowsById);
|
||||
r.ChainAfterLabel = BuildAfterLabel(r, rowsById, external);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1074,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;
|
||||
@@ -1110,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;
|
||||
}
|
||||
|
||||
@@ -1138,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;
|
||||
@@ -1240,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;
|
||||
}
|
||||
|
||||
@@ -1304,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;
|
||||
@@ -1554,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;
|
||||
@@ -1564,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))]
|
||||
@@ -1606,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;
|
||||
@@ -1739,7 +1833,12 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
// reflects any of that after a Regroup — without this the tick would heal the row's data
|
||||
// while leaving a completed task sitting in the Open section under a stale count. Gated
|
||||
// on a real change so an idle tick stays free.
|
||||
if (groupingChanged) Regroup();
|
||||
if (groupingChanged)
|
||||
{
|
||||
await using var db2 = await _dbFactory.CreateDbContextAsync(ct);
|
||||
await RefreshExternalDependencyCacheAsync(db2, DependencyLookupIds(Items), ct);
|
||||
Regroup();
|
||||
}
|
||||
}
|
||||
|
||||
// The slice of a row that Regroup reads: which section it lands in, where it sits inside it,
|
||||
|
||||
@@ -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;
|
||||
@@ -329,6 +335,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
UsagePill.OpenMonitorRequested += () => _ = OpenUsageMonitor();
|
||||
MissionControl.OpenInApp = id => _ = RevealTaskAsync(id);
|
||||
MissionControl.OpenSettingsRequested = () => Lists.OpenSettingsCommand.Execute(null);
|
||||
MissionControl.OpenRequested = OpenMissionControl;
|
||||
MissionControl.ErrorReported += FlashFooterError;
|
||||
// Keep the task rows' "Interactive" chip in step with Mission Control's open ConPTY panes.
|
||||
MissionControl.ConPtySessions.CollectionChanged += (_, _) => SyncInteractiveSessionChips();
|
||||
@@ -369,7 +376,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
Lists.LetClaudeHandleRequested += req =>
|
||||
{
|
||||
OpenMissionControl();
|
||||
_ = MissionControl.OpenMergeHelperConPtySessionAsync(req.ListId, req.TaskIds);
|
||||
_ = MissionControl.OpenMergeHelperConPtySessionAsync(req.ListId, req.TaskIds, req.TriageModel);
|
||||
};
|
||||
Lists.MergeReviewsRequested += req =>
|
||||
{
|
||||
@@ -514,6 +521,19 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
if (Dialogs is not null) await Dialogs.ShowAboutAsync(vm);
|
||||
}
|
||||
|
||||
// One shared VM so the sheets are parsed once and the window keeps its selection/pin
|
||||
// across reopens; the window itself is single-instance in the dialog service.
|
||||
private CheatSheetsViewModel? _cheatSheets;
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenCheatSheet(string? key)
|
||||
{
|
||||
if (Dialogs is null) return;
|
||||
_cheatSheets ??= new CheatSheetsViewModel();
|
||||
if (!string.IsNullOrEmpty(key)) _cheatSheets.Select(key);
|
||||
Dialogs.ShowCheatSheets(_cheatSheets);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task OpenLogVisualizer()
|
||||
{
|
||||
@@ -617,30 +637,66 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
await _updateCheck.CheckNowAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
[ObservableProperty] private string? _restartWorkerStatus;
|
||||
// How long to wait after launching the new worker process before deciding it survived
|
||||
// (e.g. a port conflict kills it almost immediately — see #292 for the worker-side fix).
|
||||
private const int RestartSurvivalCheckMs = 2000;
|
||||
|
||||
// Test seam: overrides the real kill-and-relaunch body, so tests can assert the
|
||||
// success/failure footer routing without spawning a real worker process.
|
||||
internal Func<Task>? RestartWorkerOperation { get; set; }
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RestartWorkerAsync()
|
||||
{
|
||||
RestartWorkerStatus = Loc.T("vm.shell.restartingWorker");
|
||||
try
|
||||
{
|
||||
await Task.Run(RestartWorkerService);
|
||||
await FlashRestartStatusAsync("Worker restarted.");
|
||||
await (RestartWorkerOperation ?? (() => Task.Run(RestartWorkerService)))();
|
||||
FlashFooterSuccess("Worker restarted.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await FlashRestartStatusAsync($"Restart failed: {ex.Message}");
|
||||
FlashFooterError($"Restart failed: {ex.Message} — check the worker log.");
|
||||
}
|
||||
}
|
||||
|
||||
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.");
|
||||
|
||||
// Only kill the worker belonging to THIS installation — not any other
|
||||
// ClaudeDo.Worker on the machine (e.g. a second install).
|
||||
KillMatchingWorkerProcesses(exe);
|
||||
|
||||
var started = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(exe) { UseShellExecute = true });
|
||||
if (started is not null && started.WaitForExit(RestartSurvivalCheckMs))
|
||||
throw new InvalidOperationException($"worker process exited immediately (code {started.ExitCode})");
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
var exeFull = System.IO.Path.GetFullPath(exe);
|
||||
foreach (var p in System.Diagnostics.Process.GetProcessesByName("ClaudeDo.Worker"))
|
||||
{
|
||||
@@ -656,16 +712,124 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
catch { /* may have exited or be inaccessible */ }
|
||||
finally { p.Dispose(); }
|
||||
}
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(exe) { UseShellExecute = true });
|
||||
}
|
||||
|
||||
private async Task FlashRestartStatusAsync(string text)
|
||||
// Test seam: overrides the real kill-worker + close-window body.
|
||||
internal Action? ShutdownOperation { get; set; }
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ShutdownAsync()
|
||||
{
|
||||
RestartWorkerStatus = text;
|
||||
await Task.Delay(3000);
|
||||
if (RestartWorkerStatus == text) RestartWorkerStatus = null;
|
||||
if (Dialogs is null) return;
|
||||
var confirmed = await Dialogs.ConfirmAsync(Loc.T("vm.shell.shutdownConfirm"));
|
||||
if (!confirmed) return;
|
||||
(ShutdownOperation ?? ShutdownWorkerAndCloseApp)();
|
||||
}
|
||||
|
||||
private void ShutdownWorkerAndCloseApp()
|
||||
{
|
||||
var exe = _workerLocator.Find();
|
||||
if (exe is not null) KillMatchingWorkerProcesses(exe);
|
||||
|
||||
// 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;
|
||||
|
||||
private bool CanUpdateClaudeCli() => !_updatingClaudeCli;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanUpdateClaudeCli))]
|
||||
private async Task UpdateClaudeCliAsync()
|
||||
{
|
||||
_updatingClaudeCli = true;
|
||||
UpdateClaudeCliCommand.NotifyCanExecuteChanged();
|
||||
try
|
||||
{
|
||||
var dto = Worker is null ? null : await Worker.GetClaudeBinAsync();
|
||||
if (dto is null)
|
||||
{
|
||||
FlashFooterError("Update failed: worker offline.");
|
||||
return;
|
||||
}
|
||||
|
||||
var (success, message) = await RunClaudeUpdateAsync(dto);
|
||||
if (success) FlashFooterSuccess(message);
|
||||
else FlashFooterError(message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_updatingClaudeCli = false;
|
||||
UpdateClaudeCliCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<(bool Success, string Message)> RunClaudeUpdateAsync(ClaudeBinDto dto)
|
||||
{
|
||||
var resolved = ClaudeDo.Data.Environment.ExecutableResolver.Resolve(dto.Value);
|
||||
if (resolved is null) return (false, $"Update failed: '{dto.Value}' not found.");
|
||||
|
||||
var psi = resolved.IsShim
|
||||
? BuildShimUpdatePsi(resolved.Path)
|
||||
: new System.Diagnostics.ProcessStartInfo { FileName = resolved.Path, ArgumentList = { "update" } };
|
||||
psi.UseShellExecute = false;
|
||||
psi.RedirectStandardOutput = true;
|
||||
psi.RedirectStandardError = true;
|
||||
psi.CreateNoWindow = true;
|
||||
|
||||
using var process = new System.Diagnostics.Process { StartInfo = psi };
|
||||
var stdout = new System.Text.StringBuilder();
|
||||
var stderr = new System.Text.StringBuilder();
|
||||
process.OutputDataReceived += (_, e) => { if (e.Data is not null) stdout.AppendLine(e.Data); };
|
||||
process.ErrorDataReceived += (_, e) => { if (e.Data is not null) stderr.AppendLine(e.Data); };
|
||||
|
||||
try { process.Start(); }
|
||||
catch (Exception ex) { return (false, $"Update failed: {ex.Message}"); }
|
||||
|
||||
process.BeginOutputReadLine();
|
||||
process.BeginErrorReadLine();
|
||||
|
||||
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
|
||||
var timedOut = false;
|
||||
// On timeout: kill the tree, which unblocks WaitForExitAsync below and lets the
|
||||
// async output readers drain/complete naturally (mirrors VerifyCommandRunner).
|
||||
await using var ctr = cts.Token.Register(() =>
|
||||
{
|
||||
timedOut = true;
|
||||
try { process.Kill(entireProcessTree: true); } catch { /* already exited */ }
|
||||
});
|
||||
|
||||
await process.WaitForExitAsync(CancellationToken.None);
|
||||
|
||||
return timedOut
|
||||
? (false, "Update failed: timed out after 5 minutes.")
|
||||
: SummarizeClaudeUpdateOutcome(process.ExitCode, stdout.ToString(), stderr.ToString());
|
||||
}
|
||||
|
||||
private static System.Diagnostics.ProcessStartInfo BuildShimUpdatePsi(string shimPath)
|
||||
{
|
||||
var shim = ClaudeDo.Data.Environment.ExecutableResolver.BuildShimStartInfo(shimPath, new[] { "update" });
|
||||
return new System.Diagnostics.ProcessStartInfo { FileName = shim.FileName, Arguments = shim.Arguments };
|
||||
}
|
||||
|
||||
// Pure so a test can drive it with fake process output instead of spawning a real `claude`.
|
||||
internal static (bool Success, string Message) SummarizeClaudeUpdateOutcome(int exitCode, string stdout, string stderr)
|
||||
{
|
||||
if (exitCode == 0)
|
||||
return (true, LastNonEmptyLine(stdout) ?? "Claude CLI is already up to date.");
|
||||
|
||||
var detail = LastNonEmptyLine(stderr) ?? LastNonEmptyLine(stdout) ?? "";
|
||||
return (false, $"Update failed (exit {exitCode}): {detail}");
|
||||
}
|
||||
|
||||
private static string? LastNonEmptyLine(string text) =>
|
||||
text.Split('\n').Select(l => l.Trim()).LastOrDefault(l => l.Length > 0);
|
||||
|
||||
[RelayCommand]
|
||||
private void DismissBanner()
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@ using System;
|
||||
using System.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using ClaudeDo.Ui.Services;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.MissionControl;
|
||||
@@ -18,6 +19,32 @@ 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
|
||||
/// pane is added to the visible collection, so no change notification is needed.</summary>
|
||||
public bool IsListHandler { get; set; }
|
||||
|
||||
[ObservableProperty] private string _displayTitle;
|
||||
|
||||
[ObservableProperty] private InteractiveTerminalViewModel _terminal = new();
|
||||
@@ -26,6 +53,10 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
|
||||
/// flight, so a rapid double-click can't race two submissions for the same task.</summary>
|
||||
[ObservableProperty] private bool _isSubmitPending;
|
||||
|
||||
/// <summary>Set by the host while an abandon-run round trip (query open tasks, confirm,
|
||||
/// cancel each, close pane) is in flight, so a rapid double-click can't race two runs.</summary>
|
||||
[ObservableProperty] private bool _isAbandonPending;
|
||||
|
||||
/// <summary>Raised when the terminal failed to start — the host surfaces this via the footer error strip.</summary>
|
||||
public event Action<string>? ErrorReported;
|
||||
|
||||
@@ -36,6 +67,12 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
|
||||
/// commits the worktree and moves the task to WaitingForReview.</summary>
|
||||
public event Action<string>? SubmitForReviewRequested;
|
||||
|
||||
/// <summary>Raised when the user abandons a list-handler run (<see cref="IsListHandler"/>
|
||||
/// only): the host looks up this round's still-open tasks, confirms with the user (naming the
|
||||
/// count), cancels them, then closes this pane -- one action instead of closing the tile and
|
||||
/// separately cancelling the queue.</summary>
|
||||
public event Action<string>? AbandonRunRequested;
|
||||
|
||||
/// <summary>Raised with <c>(taskId, exitCode)</c> when a task-based session that had been running
|
||||
/// dies non-zero. The host reports it to the worker, which decides whether this was a list
|
||||
/// handler killed by the usage limit and worth reopening. Ad-hoc panes never raise it — there is
|
||||
@@ -43,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>
|
||||
@@ -53,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);
|
||||
@@ -72,14 +110,18 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
terminal.PropertyChanged += OnTerminalPropertyChanged;
|
||||
terminal.ExitedAfterStartup += OnTerminalExitedAfterStartup;
|
||||
terminal.ErrorReported += OnTerminalErrorReported;
|
||||
}
|
||||
|
||||
private void Unsubscribe(InteractiveTerminalViewModel terminal)
|
||||
{
|
||||
terminal.PropertyChanged -= OnTerminalPropertyChanged;
|
||||
terminal.ExitedAfterStartup -= OnTerminalExitedAfterStartup;
|
||||
terminal.ErrorReported -= OnTerminalErrorReported;
|
||||
}
|
||||
|
||||
private void OnTerminalErrorReported(string message) => ErrorReported?.Invoke(message);
|
||||
|
||||
private void OnTerminalExitedAfterStartup(int exitCode)
|
||||
{
|
||||
if (TaskId is { } id) SessionDiedAfterStartup?.Invoke(id, exitCode);
|
||||
@@ -91,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)
|
||||
{
|
||||
@@ -103,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)
|
||||
@@ -121,8 +163,18 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
|
||||
SubmitForReviewCommand.NotifyCanExecuteChanged();
|
||||
RetryCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
if (e.PropertyName is nameof(InteractiveTerminalViewModel.HasExitedAfterStartup)
|
||||
or nameof(InteractiveTerminalViewModel.ExitCode))
|
||||
{
|
||||
OnPropertyChanged(nameof(ExitedBannerText));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>"Session ended (code N)" text for <see cref="InteractiveTerminalViewModel.HasExitedAfterStartup"/> —
|
||||
/// computed here (rather than formatted in XAML) since <c>loc:Tr</c> has no binding-arg support.</summary>
|
||||
public string ExitedBannerText => Loc.T("missionControl.conptyExited", Terminal.ExitCode ?? 0);
|
||||
|
||||
/// <summary>Types a message into this pane's live session and submits it (the worker's
|
||||
/// pre-usage-limit warning). False when the session isn't running.</summary>
|
||||
public System.Threading.Tasks.Task<bool> SendLineAsync(string text) => Terminal.SendLineAsync(text);
|
||||
@@ -141,9 +193,20 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
|
||||
|
||||
partial void OnIsSubmitPendingChanged(bool value) => SubmitForReviewCommand.NotifyCanExecuteChanged();
|
||||
|
||||
// A launch failure permanently occupies the TaskId dedupe slot unless the user can retry —
|
||||
// re-opening the same task would otherwise just re-focus a dead tile.
|
||||
private bool CanRetry() => Terminal.HasExited && Terminal.StartError is not null;
|
||||
private bool CanAbandonRun() => IsListHandler && !IsAbandonPending;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanAbandonRun))]
|
||||
private void AbandonRun()
|
||||
{
|
||||
if (TaskId is { } id) AbandonRunRequested?.Invoke(id);
|
||||
}
|
||||
|
||||
partial void OnIsAbandonPendingChanged(bool value) => AbandonRunCommand.NotifyCanExecuteChanged();
|
||||
|
||||
// A dead session permanently occupies the TaskId dedupe slot unless the user can retry — whether
|
||||
// it died at launch (StartError) or after (HasExitedAfterStartup), the same descriptor factory
|
||||
// can always relaunch it, so any HasExited state is retryable.
|
||||
private bool CanRetry() => Terminal.HasExited;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanRetry))]
|
||||
private void Retry()
|
||||
|
||||
@@ -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;
|
||||
@@ -58,6 +65,11 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
// View-layer seam: open the app Settings modal from the Mission Control window.
|
||||
public Action? OpenSettingsRequested { get; set; }
|
||||
|
||||
// Set by the shell: bring Mission Control on screen. A ConPTY pane only launches its process once
|
||||
// its view is attached, so a run started from outside the UI (start_list_handler MCP tool) or a
|
||||
// handoff arriving while Mission Control is closed would otherwise sit there without a process.
|
||||
public Action? OpenRequested { get; set; }
|
||||
|
||||
public bool HasPanes => Panes.Count > 0;
|
||||
|
||||
// Read-only activity strip. Beyond the queue itself (Queued/Running) it also carries the rows
|
||||
@@ -70,17 +82,29 @@ 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; }
|
||||
|
||||
// View-layer seam: confirms the "abandon run" action, naming the open-task count. Wired by the
|
||||
// view the same way TasksIslandViewModel/DetailsIslandViewModel are.
|
||||
public Func<string, System.Threading.Tasks.Task<bool>>? ConfirmAsync { get; set; }
|
||||
|
||||
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;
|
||||
@@ -99,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) => { _ = OpenMergeHelperConPtySessionAsync(listId, taskIds); };
|
||||
_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();
|
||||
@@ -170,7 +197,14 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
internal async System.Threading.Tasks.Task RefreshSessionTargetsAsync()
|
||||
{
|
||||
SessionTargets.Clear();
|
||||
// Browse goes in first so the menu stays usable even if the list query below fails.
|
||||
// Home + browse go in first so the menu stays usable even if the list query below fails.
|
||||
// Home is the target for quick general questions that belong to no project.
|
||||
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
SessionTargets.Add(new SessionTargetViewModel
|
||||
{
|
||||
Label = Loc.T("missionControl.homeFolder"),
|
||||
StartCommand = new RelayCommand(() => _ = OpenAdHocConPtySessionAsync(home)),
|
||||
});
|
||||
SessionTargets.Add(new SessionTargetViewModel
|
||||
{
|
||||
Label = Loc.T("missionControl.browseForFolder"),
|
||||
@@ -198,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
|
||||
@@ -228,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)
|
||||
@@ -306,19 +444,39 @@ 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
|
||||
// (no worktree of its own) — see TaskEntity.HandlerBaseCommit/HandlerHeadCommit.
|
||||
public System.Threading.Tasks.Task OpenMergeHelperConPtySessionAsync(string listId, IReadOnlyList<string> taskIds)
|
||||
public System.Threading.Tasks.Task OpenMergeHelperConPtySessionAsync(string listId, IReadOnlyList<string> taskIds, string? triageModel = null)
|
||||
=> OpenListHandlerConPtySessionAsync(listId, taskIds,
|
||||
"missionControl.mergeHelperTitle", "missionControl.mergeHelperTaskTitle",
|
||||
taskId => _worker.GetMergeHelperLaunchSpecAsync(taskIds, listId, taskId));
|
||||
taskId => _worker.GetMergeHelperLaunchSpecAsync(taskIds, listId, taskId, triageModel));
|
||||
|
||||
// Merge-only list-handler session over a list's WaitingForReview tasks (the review list's
|
||||
// "Let Claude merge the reviews"). Same host-task shape as the full run, but the session
|
||||
@@ -370,7 +528,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
}
|
||||
|
||||
AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
|
||||
() => DescribeAsync(() => launchSpec(taskId))));
|
||||
() => DescribeAsync(() => launchSpec(taskId))) { IsListHandler = true });
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -390,7 +548,13 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase)
|
||||
{
|
||||
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);
|
||||
|
||||
@@ -410,7 +574,8 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
catch { /* best-effort title lookup */ }
|
||||
|
||||
AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
|
||||
() => DescribeAsync(() => _worker.GetMergeHelperHandoffLaunchSpecAsync(taskId, survivingTaskIds, nextPhase))));
|
||||
() => DescribeAsync(() => _worker.GetMergeHelperHandoffLaunchSpecAsync(taskId, survivingTaskIds, nextPhase)))
|
||||
{ IsListHandler = true }, slot);
|
||||
}
|
||||
|
||||
// The 5h usage window is about to run out under this handler session: type the worker's warning
|
||||
@@ -426,15 +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 += CloseConPtySession;
|
||||
pane.CloseRequested += OnPaneCloseRequested;
|
||||
pane.SubmitForReviewRequested += OnPaneSubmitForReview;
|
||||
pane.SessionDiedAfterStartup += OnPaneSessionDied;
|
||||
ConPtySessions.Add(pane);
|
||||
pane.AbandonRunRequested += OnPaneAbandonRun;
|
||||
ConPtySessions.Insert(Math.Min(index ?? ConPtySessions.Count, ConPtySessions.Count), pane);
|
||||
pane.Start();
|
||||
}
|
||||
|
||||
@@ -489,13 +666,65 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
// Handler-run abort ("Not-Aus"): cancels every still-open (Queued/Running) task this handler
|
||||
// round queued or started, then closes the pane -- one action instead of "close tile" + "cancel
|
||||
// the tasks separately", which used to leave orphaned rows nobody merges. No open tasks (or no
|
||||
// round entry at all -- 7-day retention, or a run predating this feature) closes the pane
|
||||
// straight away, no confirmation needed. Guarded by IsAbandonPending like OnPaneSubmitForReview.
|
||||
private async void OnPaneAbandonRun(string taskId)
|
||||
{
|
||||
if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is not { } pane || pane.IsAbandonPending)
|
||||
return;
|
||||
|
||||
pane.IsAbandonPending = true;
|
||||
try
|
||||
{
|
||||
var open = await _worker.GetHandlerOpenTasksAsync(taskId);
|
||||
if (open.Count > 0)
|
||||
{
|
||||
if (ConfirmAsync is null) return;
|
||||
if (!await ConfirmAsync(Loc.T("missionControl.abandonRunConfirm", open.Count))) return;
|
||||
|
||||
foreach (var task in open)
|
||||
await _worker.CancelTaskAsync(task.Id);
|
||||
}
|
||||
|
||||
CloseConPtySession(pane);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorReported?.Invoke(Loc.T("missionControl.abandonRunFailed", ex.Message));
|
||||
}
|
||||
finally
|
||||
{
|
||||
pane.IsAbandonPending = false;
|
||||
}
|
||||
}
|
||||
|
||||
// The tile's own X: a reflex click must not silently kill a live session the way it would a
|
||||
// regular window. Confirm only when the terminal is actually running -- a dead one (StartError,
|
||||
// or HasExited i.e. the #290 exited banner) is pure cleanup and closes straight away, same as it
|
||||
// always did. Every other close path (handoff replacing a pane, submit-for-review, abandon-run)
|
||||
// still calls CloseConPtySession directly and is unaffected.
|
||||
private async void OnPaneCloseRequested(ConPtyPaneViewModel pane)
|
||||
{
|
||||
if (pane.Terminal.IsRunning)
|
||||
{
|
||||
if (ConfirmAsync is null) return;
|
||||
if (!await ConfirmAsync(Loc.T("missionControl.closeSessionConfirm"))) return;
|
||||
}
|
||||
|
||||
CloseConPtySession(pane);
|
||||
}
|
||||
|
||||
private void CloseConPtySession(ConPtyPaneViewModel pane)
|
||||
{
|
||||
if (!ConPtySessions.Contains(pane)) return;
|
||||
pane.ErrorReported -= OnConPtyPaneError;
|
||||
pane.CloseRequested -= CloseConPtySession;
|
||||
pane.CloseRequested -= OnPaneCloseRequested;
|
||||
pane.SubmitForReviewRequested -= OnPaneSubmitForReview;
|
||||
pane.SessionDiedAfterStartup -= OnPaneSessionDied;
|
||||
pane.AbandonRunRequested -= OnPaneAbandonRun;
|
||||
ConPtySessions.Remove(pane);
|
||||
pane.Dispose();
|
||||
}
|
||||
@@ -519,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;
|
||||
@@ -539,16 +763,19 @@ 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())
|
||||
{
|
||||
c.ErrorReported -= OnConPtyPaneError;
|
||||
c.CloseRequested -= CloseConPtySession;
|
||||
c.CloseRequested -= OnPaneCloseRequested;
|
||||
c.SubmitForReviewRequested -= OnPaneSubmitForReview;
|
||||
c.SessionDiedAfterStartup -= OnPaneSessionDied;
|
||||
c.AbandonRunRequested -= OnPaneAbandonRun;
|
||||
c.Dispose();
|
||||
}
|
||||
ConPtySessions.Clear();
|
||||
@@ -575,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
|
||||
{
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using ClaudeDo.Data;
|
||||
@@ -10,8 +12,14 @@ namespace ClaudeDo.Ui.ViewModels.Modals;
|
||||
|
||||
public sealed partial class AboutModalViewModel : ViewModelBase
|
||||
{
|
||||
// MinVer sets AssemblyVersion to {Major}.0.0.0 for binding compat — Assembly.GetName().Version
|
||||
// shows "2.0.0" no matter the release. FileVersion carries the real Major.Minor.Patch.
|
||||
public string AppVersion { get; } =
|
||||
Assembly.GetExecutingAssembly().GetName().Version?.ToString(3) ?? "0.0.0";
|
||||
Version.TryParse(
|
||||
FileVersionInfo.GetVersionInfo(Assembly.GetExecutingAssembly().Location).FileVersion,
|
||||
out var version)
|
||||
? version.ToString(3)
|
||||
: "0.0.0";
|
||||
public string DataFolderPath { get; } = Paths.AppDataRoot();
|
||||
public string LogsFolderPath { get; } = Path.Combine(Paths.AppDataRoot(), "logs");
|
||||
public string WorkerConfigPath { get; } = Path.Combine(Paths.AppDataRoot(), "worker.config.json");
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Modals;
|
||||
|
||||
/// <summary>One cheat sheet: title from the file's leading <c># </c> heading, body is the rest.</summary>
|
||||
public sealed record CheatSheet(string Key, string Title, string Body);
|
||||
|
||||
/// <summary>
|
||||
/// Backs the non-modal cheat-sheet window (Help → Cheat Sheets). Content lives as markdown
|
||||
/// under <c>Assets/CheatSheets/*.md</c> (embedded resources) and is rendered by the existing
|
||||
/// <c>MarkdownView</c>,
|
||||
/// so a new sheet is a new file — but the key list here stays explicit to keep the order
|
||||
/// deterministic and a missing resource visible instead of silently absent.
|
||||
/// </summary>
|
||||
public partial class CheatSheetsViewModel : ViewModelBase
|
||||
{
|
||||
private static readonly string[] Keys =
|
||||
["git", "conventional-commits", "claude-cli", "claude-config", "dotnet-cli", "powershell"];
|
||||
|
||||
public ObservableCollection<CheatSheet> Sheets { get; } = [];
|
||||
|
||||
[ObservableProperty]
|
||||
private CheatSheet? _selectedSheet;
|
||||
|
||||
/// <summary>Keeps the window above the shell so it can sit docked at a screen edge.</summary>
|
||||
[ObservableProperty]
|
||||
private bool _isPinned;
|
||||
|
||||
public Action? CloseAction { get; set; }
|
||||
|
||||
public CheatSheetsViewModel()
|
||||
{
|
||||
foreach (var key in Keys) Sheets.Add(Load(key));
|
||||
SelectedSheet = Sheets.FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <summary>Preselects a sheet by key; unknown keys keep the current selection.</summary>
|
||||
public void Select(string key)
|
||||
{
|
||||
var sheet = Sheets.FirstOrDefault(s => s.Key == key);
|
||||
if (sheet is not null) SelectedSheet = sheet;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Close() => CloseAction?.Invoke();
|
||||
|
||||
private static CheatSheet Load(string key)
|
||||
{
|
||||
var assembly = typeof(CheatSheetsViewModel).Assembly;
|
||||
// Match by suffix rather than composing the logical name from RootNamespace — the
|
||||
// prefix is a build detail, and a rename shouldn't silently blank the sheet.
|
||||
var name = assembly.GetManifestResourceNames()
|
||||
.FirstOrDefault(n => n.EndsWith($".CheatSheets.{key}.md", StringComparison.Ordinal));
|
||||
var stream = name is null ? null : assembly.GetManifestResourceStream(name);
|
||||
if (stream is null)
|
||||
return new CheatSheet(key, key, Loc.T("modals.cheatSheets.missing", $"{key}.md"));
|
||||
|
||||
using var reader = new StreamReader(stream);
|
||||
var text = reader.ReadToEnd().Replace("\r\n", "\n");
|
||||
|
||||
// The window shows the title in its own selector, so strip the leading heading
|
||||
// rather than rendering it a second time inside the body.
|
||||
var lines = new List<string>(text.Split('\n'));
|
||||
var title = key;
|
||||
var first = lines.FindIndex(l => !string.IsNullOrWhiteSpace(l));
|
||||
if (first >= 0 && lines[first].StartsWith("# ", StringComparison.Ordinal))
|
||||
{
|
||||
title = lines[first][2..].Trim();
|
||||
lines.RemoveRange(0, first + 1);
|
||||
}
|
||||
return new CheatSheet(key, title, string.Join('\n', lines).Trim());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -34,8 +36,16 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
public Func<string, Task<bool>>? ConfirmAsync { get; set; }
|
||||
public Func<string, Task>? ShowErrorAsync { get; set; }
|
||||
|
||||
// Mirrors UsageMonitorModalViewModel.ErrorReported — the caller wires this into FlashFooterError.
|
||||
public event Action<string>? ErrorReported;
|
||||
|
||||
[ObservableProperty] private string _name = "";
|
||||
[ObservableProperty] private string _workingDir = "";
|
||||
// Recomputed whenever WorkingDir changes (typing or Browse) — RepoScanner.IsRepo is the same
|
||||
// check RepoImport uses, so a folder either scans in there or warns here, never neither.
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(SaveCommand))]
|
||||
private string _workingDirWarning = "";
|
||||
[ObservableProperty] private string _defaultCommitType = CommitTypeRegistry.DefaultType;
|
||||
// A manual list holds reminders: tasks created here start out manual (TaskEntity.IsManual).
|
||||
[ObservableProperty] private bool _isManual;
|
||||
@@ -46,19 +56,74 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
// lands; a non-zero exit keeps the task out of Done instead of silently reporting merged.
|
||||
[ObservableProperty] private string _verifyCommand = "";
|
||||
// Per-list opt-in for PowerUse. A handler run on this list only goes unattended (no questions,
|
||||
// harvests new work, longer handoff chain) when this AND the global switch in the usage monitor
|
||||
// are both on. Off by default — the global switch alone must never change a list's behaviour.
|
||||
// harvests new work, longer handoff chain) when this AND the global switch in Settings →
|
||||
// Execution are both on. Off by default — the global switch alone must never change a list's behaviour.
|
||||
[ObservableProperty] private bool _powerUseAllowed;
|
||||
// Dropdown hidden entirely when no ticket-system base URL is configured (Settings → Files tab).
|
||||
// Icon key into the UI icon library (IconKeyConverter -> "Icon.<key>" StreamGeometry).
|
||||
[ObservableProperty] private string _icon = DefaultIcon;
|
||||
[ObservableProperty] private bool _ticketsAvailable;
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ShowTicketProject))]
|
||||
private bool _ticketsAvailable;
|
||||
|
||||
/// <summary>The dropdown needs a configured ticket system *and* a list — folders link to nothing.</summary>
|
||||
public bool ShowTicketProject => TicketsAvailable && !IsFolder;
|
||||
|
||||
// ---- Folder mode ----
|
||||
// A folder is a sidebar grouping, not a list: it carries only a name and an icon, so every
|
||||
// other field/section collapses. Reached two ways — the "create a folder instead" toggle in
|
||||
// the create flow (FolderId still null), and editing an existing folder (FolderId set).
|
||||
// There is no list <-> folder conversion: the toggle is offered on create only.
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(HeaderTitle))]
|
||||
[NotifyPropertyChangedFor(nameof(DeleteLabel))]
|
||||
[NotifyPropertyChangedFor(nameof(ShowTicketProject))]
|
||||
private bool _isFolder;
|
||||
|
||||
/// <summary>True only while creating — an existing list can't become a folder or back.</summary>
|
||||
[ObservableProperty] private bool _canToggleFolder;
|
||||
|
||||
private string? _folderId;
|
||||
|
||||
// Resolved rather than a {loc:Tr} key because the text depends on IsFolder.
|
||||
public string HeaderTitle => Loc.T(IsFolder ? "modals.listSettings.folderTitle" : "modals.listSettings.title");
|
||||
|
||||
public string DeleteLabel => Loc.T(IsFolder ? "modals.listSettings.deleteFolder" : "modals.listSettings.deleteList");
|
||||
[ObservableProperty] private TicketProjectOption? _selectedTicketProject;
|
||||
public ObservableCollection<TicketProjectOption> TicketProjects { get; } = new();
|
||||
|
||||
// Gates Save/Delete while the core fields (name/agent/verify command) are still loading, so a
|
||||
// save can't land before they're populated and clobber the list_config row with blanks. Ticket
|
||||
// projects load separately (see LoadTicketProjectsAsync) and are NOT covered by this — Save's
|
||||
// own tri-state handles that case instead.
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(SaveCommand))]
|
||||
[NotifyCanExecuteChangedFor(nameof(DeleteCommand))]
|
||||
private bool _isLoading;
|
||||
|
||||
// Set when the core load (name/agent/verify command) threw — keeps Save gated even after
|
||||
// IsLoading resets to false in LoadAsync's finally, so a failed load can't clobber the
|
||||
// stored config with blank fields.
|
||||
private bool _loadFailed;
|
||||
|
||||
// True once GetTicketProjectsAsync has completed successfully for the current load — Save
|
||||
// only writes an explicit TicketProjectId when this is true, so a slow/unreachable ticket
|
||||
// system leaves the stored link untouched instead of wiping it to "none".
|
||||
private bool _ticketProjectsLoaded;
|
||||
|
||||
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.
|
||||
@@ -67,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
|
||||
@@ -76,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).
|
||||
@@ -91,6 +215,37 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
Agent = new AgentConfigEditorViewModel(worker, AgentConfigScope.List);
|
||||
}
|
||||
|
||||
// Fires the load without blocking the caller, so the modal window can be shown right away
|
||||
// instead of appearing only after seven serial hub round-trips (one of them a 10s-timeout
|
||||
// ticket-system HTTP call) have finished — same pattern as UsageMonitorModalViewModel.BeginLoad.
|
||||
public void BeginLoad(
|
||||
string listId,
|
||||
string name,
|
||||
string? workingDir,
|
||||
string defaultCommitType,
|
||||
bool isManual = false,
|
||||
bool findingsTracked = false,
|
||||
string? icon = null,
|
||||
bool canToggleFolder = false)
|
||||
{
|
||||
CanToggleFolder = canToggleFolder;
|
||||
_ = LoadAsync(listId, name, workingDir, defaultCommitType, isManual, findingsTracked, icon);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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, string? color = null)
|
||||
{
|
||||
FolderColor = color ?? "";
|
||||
_folderId = folderId;
|
||||
IsFolder = true;
|
||||
CanToggleFolder = false;
|
||||
Name = name;
|
||||
Icon = IsKnownIcon(icon) ? icon! : DefaultFolderIcon;
|
||||
}
|
||||
|
||||
public async Task LoadAsync(
|
||||
string listId,
|
||||
string name,
|
||||
@@ -101,45 +256,107 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
string? icon = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ListId = listId;
|
||||
Name = name;
|
||||
IsManual = isManual;
|
||||
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;
|
||||
WorkingDir = workingDir ?? "";
|
||||
DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? CommitTypeRegistry.DefaultType : defaultCommitType;
|
||||
|
||||
await Agent.LoadForListAsync(listId, ct);
|
||||
var cfg = await _worker.GetListConfigAsync(listId);
|
||||
VerifyCommand = cfg?.VerifyCommand ?? "";
|
||||
PowerUseAllowed = cfg?.PowerUseAllowed ?? false;
|
||||
|
||||
var ticketSettings = TicketSettingsTabViewModel.FeatureEnabled
|
||||
? await _worker.GetTicketSettingsAsync()
|
||||
: null;
|
||||
TicketsAvailable = !string.IsNullOrWhiteSpace(ticketSettings?.ApiBaseUrl);
|
||||
TicketProjects.Clear();
|
||||
if (TicketsAvailable)
|
||||
ListConfigDto? cfg = null;
|
||||
IsLoading = true;
|
||||
_loadFailed = false;
|
||||
try
|
||||
{
|
||||
ListId = listId;
|
||||
Name = name;
|
||||
IsManual = isManual;
|
||||
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 = IsKnownIcon(icon) ? icon! : DefaultIcon;
|
||||
WorkingDir = workingDir ?? "";
|
||||
DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? CommitTypeRegistry.DefaultType : defaultCommitType;
|
||||
|
||||
await Agent.LoadForListAsync(listId, ct);
|
||||
cfg = Agent.LastLoadedListConfig;
|
||||
VerifyCommand = cfg?.VerifyCommand ?? "";
|
||||
PowerUseAllowed = cfg?.PowerUseAllowed ?? false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// BeginLoad fires this without awaiting it — catching here (instead of letting it
|
||||
// propagate) turns what would be an unobserved task exception, with the modal stuck
|
||||
// open and every field blank, into a reported error and a locked Save button.
|
||||
_loadFailed = true;
|
||||
ErrorReported?.Invoke(Loc.T("vm.listSettings.loadFailed", ex.Message));
|
||||
}
|
||||
finally { IsLoading = false; }
|
||||
|
||||
if (_loadFailed) return;
|
||||
|
||||
// Deliberately NOT awaited: an unreachable ticket system (10s HTTP timeout) must not hold
|
||||
// up the rest of the modal. The dropdown just stays empty until this finishes.
|
||||
_ = LoadTicketProjectsAsync(cfg?.TicketProjectId);
|
||||
}
|
||||
|
||||
private async Task LoadTicketProjectsAsync(int? existingTicketProjectId)
|
||||
{
|
||||
_ticketProjectsLoaded = false;
|
||||
TicketProjects.Clear();
|
||||
try
|
||||
{
|
||||
var ticketSettings = TicketSettingsTabViewModel.FeatureEnabled
|
||||
? await _worker.GetTicketSettingsAsync()
|
||||
: null;
|
||||
TicketsAvailable = !string.IsNullOrWhiteSpace(ticketSettings?.ApiBaseUrl);
|
||||
if (!TicketsAvailable)
|
||||
{
|
||||
SelectedTicketProject = null;
|
||||
return;
|
||||
}
|
||||
|
||||
TicketProjects.Add(new TicketProjectOption(0, Loc.T("vm.listSettings.ticketProjectNone")));
|
||||
foreach (var p in await _worker.GetTicketProjectsAsync())
|
||||
TicketProjects.Add(new TicketProjectOption(p.Id, $"{p.DepartmentName} / {p.Title}"));
|
||||
|
||||
SelectedTicketProject = cfg?.TicketProjectId is int id
|
||||
SelectedTicketProject = existingTicketProjectId is int id
|
||||
? TicketProjects.FirstOrDefault(t => t.Id == id) ?? TicketProjects[0]
|
||||
: TicketProjects[0];
|
||||
_ticketProjectsLoaded = true;
|
||||
}
|
||||
else
|
||||
catch (Exception ex)
|
||||
{
|
||||
SelectedTicketProject = null;
|
||||
// Ticket system unreachable/slow — leave the dropdown as-is. Save's tri-state (below)
|
||||
// skips writing TicketProjectId when _ticketProjectsLoaded is false, so the stored
|
||||
// link survives instead of being wiped to "none".
|
||||
ErrorReported?.Invoke(Loc.T("vm.listSettings.ticketProjectsLoadFailed", ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private bool CanSave() => !IsLoading && !_loadFailed && (IsFolder || string.IsNullOrEmpty(WorkingDirWarning));
|
||||
private bool CanDelete() => !IsLoading;
|
||||
|
||||
// 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; }
|
||||
WorkingDirWarning = !Directory.Exists(value)
|
||||
? Loc.T("vm.listSettings.workingDirMissing")
|
||||
: RepoScanner.IsRepo(value) ? "" : Loc.T("vm.listSettings.workingDirNotRepo");
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanSave))]
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
if (IsFolder)
|
||||
{
|
||||
await SaveFolderAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
await _worker.UpdateListAsync(new UpdateListDto(
|
||||
ListId,
|
||||
string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name,
|
||||
@@ -149,10 +366,11 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
FindingsTracked,
|
||||
Icon));
|
||||
|
||||
// Tri-state: dropdown never shown (no base URL) -> null, leave the stored link alone.
|
||||
// Dropdown shown -> always an explicit value, 0 for "— none —" to actively clear it.
|
||||
// Never null here, or an explicit unlink would silently fail to reach the worker.
|
||||
var ticketProjectId = TicketsAvailable ? (SelectedTicketProject?.Id ?? 0) : (int?)null;
|
||||
// Tri-state: dropdown never shown (no base URL), or ticket projects failed/never loaded
|
||||
// (e.g. an unreachable ticket system) -> null, leave the stored link alone. Dropdown shown
|
||||
// and loaded -> always an explicit value, 0 for "— none —" to actively clear it. Never
|
||||
// null in that case, or an explicit unlink would silently fail to reach the worker.
|
||||
var ticketProjectId = TicketsAvailable && _ticketProjectsLoaded ? (SelectedTicketProject?.Id ?? 0) : (int?)null;
|
||||
|
||||
// Always explicit (never null) — this modal owns the checkbox, so unchecking it has to reach
|
||||
// the worker as a real false rather than a "leave it alone".
|
||||
@@ -165,13 +383,52 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
CloseAction?.Invoke();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
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;
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
var lists = new ListRepository(ctx);
|
||||
|
||||
if (_folderId is null)
|
||||
{
|
||||
await lists.AddFolderAsync(new ListFolderEntity
|
||||
{
|
||||
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
|
||||
// doesn't need it. Deleted=true (it genuinely is) makes the caller do a full reload,
|
||||
// which is what picks the new folder up.
|
||||
await lists.DeleteAsync(ListId);
|
||||
Deleted = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
await lists.RenameFolderAsync(_folderId, name, Icon, NullIfEmpty(FolderColor));
|
||||
}
|
||||
|
||||
Saved = true;
|
||||
CloseAction?.Invoke();
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanDelete))]
|
||||
private async Task DeleteAsync()
|
||||
{
|
||||
if (IsFolder)
|
||||
{
|
||||
await DeleteFolderAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
var displayName = string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name;
|
||||
if (ConfirmAsync is not null)
|
||||
{
|
||||
var ok = await ConfirmAsync($"Delete list \"{displayName}\" and all its tasks? This cannot be undone.");
|
||||
var ok = await ConfirmAsync(Loc.T("vm.listSettings.confirmDeleteList", displayName));
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
@@ -188,7 +445,35 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
|| ex.InnerException?.Message.Contains("FOREIGN KEY", StringComparison.OrdinalIgnoreCase) == true))
|
||||
{
|
||||
if (ShowErrorAsync is not null)
|
||||
await ShowErrorAsync("This list has planning sessions with child tasks. Discard those first, then delete the list.");
|
||||
await ShowErrorAsync(Loc.T("vm.listSettings.deleteBlockedPlanning"));
|
||||
return;
|
||||
}
|
||||
|
||||
Deleted = true;
|
||||
CloseAction?.Invoke();
|
||||
}
|
||||
|
||||
private async Task DeleteFolderAsync()
|
||||
{
|
||||
// Create mode with the folder toggle on: nothing has been written yet, so this is a cancel
|
||||
// — and the caller's rollback drops the pre-created list row.
|
||||
if (_folderId is null) { CloseAction?.Invoke(); return; }
|
||||
|
||||
var displayName = string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name;
|
||||
if (ConfirmAsync is not null)
|
||||
{
|
||||
var ok = await ConfirmAsync(Loc.T("vm.listSettings.confirmDeleteFolder", displayName));
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
await new ListRepository(ctx).DeleteFolderAsync(_folderId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorReported?.Invoke(ex.Message);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,15 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
|
||||
|
||||
public ObservableCollection<MergeHelperTaskRowViewModel> Tasks { get; } = new();
|
||||
|
||||
/// <summary>Model choices for the triage session. Only opus/fable are offered on purpose —
|
||||
/// triage is the dedupe/refine leverage point; cheaper models aren't worth a picker slot.</summary>
|
||||
public IReadOnlyList<string> ModelOptions { get; } = new[] { "opus", "fable" };
|
||||
|
||||
[ObservableProperty] private string _selectedModel = Data.Models.ModelRegistry.HandlerTriageAlias;
|
||||
|
||||
/// <summary>Hidden in review mode — those runs start directly at the merge phase, no triage.</summary>
|
||||
public bool ShowModelPicker => !_reviewMode;
|
||||
|
||||
[ObservableProperty] private string _scopeLabel = "";
|
||||
|
||||
public bool HasTasks => Tasks.Count > 0;
|
||||
@@ -100,6 +109,7 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
|
||||
{
|
||||
_reviewMode = true;
|
||||
ScopeLabel = Loc.T("modals.mergeHelper.scopeReview");
|
||||
OnPropertyChanged(nameof(ShowModelPicker));
|
||||
}
|
||||
|
||||
/// <summary>Groups a confirmed review-mode selection by owning list, preserving row order —
|
||||
|
||||
@@ -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,22 +96,29 @@ 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");
|
||||
_worker.MergeProgressEvent += OnMergeProgress;
|
||||
_worker.OperationProgressEvent += OnOperationProgress;
|
||||
try
|
||||
{
|
||||
var result = await _worker.MergeTaskAsync(
|
||||
TaskId, SelectedBranch!, RemoveWorktree, CommitMessage);
|
||||
TaskId, SelectedBranch!, RemoveWorktree, CommitMessage, autostash);
|
||||
|
||||
switch (result.Status)
|
||||
{
|
||||
@@ -131,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
|
||||
@@ -148,34 +173,23 @@ public sealed partial class MergeModalViewModel : ViewModelBase
|
||||
}
|
||||
finally
|
||||
{
|
||||
_worker.MergeProgressEvent -= OnMergeProgress;
|
||||
_worker.OperationProgressEvent -= OnOperationProgress;
|
||||
ProgressMessage = null;
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnMergeProgress(string taskId, string phase, int elapsedSeconds)
|
||||
{
|
||||
if (taskId != TaskId) return;
|
||||
// Rebasing is handled by OnOperationProgress (needs the branch total, which this
|
||||
// elapsed-seconds-only forwarder drops) -- don't overwrite it with the generic fallback.
|
||||
if (phase == MergePhaseRebasing) return;
|
||||
ProgressMessage = phase switch
|
||||
{
|
||||
MergePhaseVerifying => Loc.T("vm.merge.progressVerifying", FormatElapsed(elapsedSeconds)),
|
||||
_ => Loc.T("vm.merge.progressMerging"),
|
||||
};
|
||||
}
|
||||
|
||||
// Separate event: MergeProgressEvent forwards elapsed-seconds-only (verifying), but the
|
||||
// rebase phase needs the branch count too, which only rides the untrimmed OperationProgress.
|
||||
private void OnOperationProgress(string opKey, string phase, int current, int total)
|
||||
{
|
||||
if (opKey != TaskId || phase != MergePhaseRebasing) return;
|
||||
ProgressMessage = total > 0
|
||||
? $"{Loc.T("ops.worker.rebasingAfterMerge")} ({current}/{total})"
|
||||
: Loc.T("ops.worker.rebasingAfterMerge");
|
||||
if (opKey != TaskId) return;
|
||||
ProgressMessage = phase switch
|
||||
{
|
||||
MergePhaseRebasing => total > 0
|
||||
? $"{Loc.T("ops.worker.rebasingAfterMerge")} ({current}/{total})"
|
||||
: Loc.T("ops.worker.rebasingAfterMerge"),
|
||||
MergePhaseVerifying => Loc.T("vm.merge.progressVerifying", FormatElapsed(current)),
|
||||
_ => Loc.T("vm.merge.progressMerging"),
|
||||
};
|
||||
}
|
||||
|
||||
/// Mirrors TaskMergeService.PhaseVerifying — a hub payload token, not a display string.
|
||||
|
||||
@@ -44,7 +44,7 @@ public sealed partial class RepoImportModalViewModel : ViewModelBase
|
||||
|
||||
public int CreateCount => Repos.Count(r => r.IsChecked && !r.AlreadyAdded);
|
||||
public bool CanCreate => CreateCount > 0;
|
||||
public string CreateButtonText => $"Create {CreateCount} list(s)";
|
||||
public string CreateButtonText => Loc.T("vm.repoImport.createButton", CreateCount);
|
||||
public bool HasFolders => _folders.Count > 0;
|
||||
|
||||
public RepoImportModalViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient workerClient)
|
||||
|
||||
@@ -30,6 +30,15 @@ public sealed partial class GeneralSettingsTabViewModel : ViewModelBase
|
||||
// Cap on a list-handler run's wait<->merge handoff chain. 4 = two merge rounds (the long-standing
|
||||
// default); raise it for runs meant to keep going for hours. Bounds a runaway chain, so never 0.
|
||||
[ObservableProperty] private int _listHandlerMaxHandoffs = 4;
|
||||
// PowerUse master switch: a handler run spends the rest of the window unattended. On its own it
|
||||
// changes nothing — the run's LIST must also opt in (list settings → PowerUse).
|
||||
[ObservableProperty] private bool _powerUseEnabled;
|
||||
// Optional local-time window (HH:mm) narrowing when PowerUse applies; both blank = round the
|
||||
// clock. Unparseable input is normalized to null on save, same as the worker does.
|
||||
[ObservableProperty] private string? _powerUseWindowStart;
|
||||
[ObservableProperty] private string? _powerUseWindowEnd;
|
||||
// 5h-usage percentage at which a PowerUse handler session hands off. Clamped 50..99.
|
||||
[ObservableProperty] private int _powerUseHandoffPct = 95;
|
||||
// Newline-separated path prefixes excluded from the weekly report.
|
||||
[ObservableProperty] private string _reportExcludedPaths = @"C:\Private";
|
||||
// 0=Sunday..6=Saturday (System.DayOfWeek); default Wednesday.
|
||||
@@ -110,8 +119,13 @@ public sealed partial class GeneralSettingsTabViewModel : ViewModelBase
|
||||
foreach (var row in ModelPresets)
|
||||
if (row.MaxTurns is < 1 or > 200)
|
||||
return $"Max turns for {row.Model} must be between 1 and 200.";
|
||||
if (!IsValidWindowBound(PowerUseWindowStart) || !IsValidWindowBound(PowerUseWindowEnd))
|
||||
return Loc.T("settings.general.powerUseWindowInvalid");
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool IsValidWindowBound(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) || PowerUseWindow.Parse(value) is not null;
|
||||
}
|
||||
|
||||
/// <summary>One row of the per-model preset table. Single consumer, so it lives here.</summary>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Linq;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Localization;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using ClaudeDo.Ui.Services;
|
||||
@@ -130,14 +131,6 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
||||
// carried through load→save verbatim so saving Settings can never reset a dragged value.
|
||||
private (int FiveSoft, int FiveHard, int SevenSoft, int SevenHard) _throttleStages = (50, 65, 50, 65);
|
||||
|
||||
// Same deal: the PowerUse master switch is toggled on the usage monitor. Carried load→save so
|
||||
// opening and saving Settings can't switch it off behind the user's back.
|
||||
private bool _powerUseEnabled;
|
||||
|
||||
// Ditto for the PowerUse time window and handoff threshold — usage-monitor-owned, invisible
|
||||
// here, but they must survive a Settings save.
|
||||
private (string? Start, string? End, int HandoffPct) _powerUseWindow = (null, null, 95);
|
||||
|
||||
public async Task LoadAsync()
|
||||
{
|
||||
IsBusy = true;
|
||||
@@ -156,11 +149,10 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
||||
General.UsageGateSevenDayPct = dto.UsageGateSevenDayPct;
|
||||
General.AutoContinueOnUsageLimit = dto.AutoContinueOnUsageLimit;
|
||||
General.ListHandlerMaxHandoffs = dto.ListHandlerMaxHandoffs;
|
||||
// Owned by the usage monitor, not editable here — but it MUST be round-tripped,
|
||||
// because SaveAsync rebuilds the whole DTO positionally and would otherwise write
|
||||
// the record default (false) back and silently switch PowerUse off.
|
||||
_powerUseEnabled = dto.PowerUseEnabled;
|
||||
_powerUseWindow = (dto.PowerUseWindowStart, dto.PowerUseWindowEnd, dto.PowerUseHandoffPct);
|
||||
General.PowerUseEnabled = dto.PowerUseEnabled;
|
||||
General.PowerUseWindowStart = dto.PowerUseWindowStart;
|
||||
General.PowerUseWindowEnd = dto.PowerUseWindowEnd;
|
||||
General.PowerUseHandoffPct = dto.PowerUseHandoffPct;
|
||||
_throttleStages = (
|
||||
dto.UsageThrottleFiveHourSoftPct, dto.UsageThrottleFiveHourHardPct,
|
||||
dto.UsageThrottleSevenDaySoftPct, dto.UsageThrottleSevenDayHardPct);
|
||||
@@ -228,11 +220,13 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
||||
_throttleStages.SevenSoft,
|
||||
_throttleStages.SevenHard,
|
||||
General.AutoContinueOnUsageLimit,
|
||||
_powerUseEnabled,
|
||||
General.PowerUseEnabled,
|
||||
General.ListHandlerMaxHandoffs,
|
||||
_powerUseWindow.Start,
|
||||
_powerUseWindow.End,
|
||||
_powerUseWindow.HandoffPct);
|
||||
// The worker stores an unparseable time as null; normalize here too so the box
|
||||
// shows what was actually saved instead of a typo that looks like a live window.
|
||||
PowerUseWindow.Normalize(General.PowerUseWindowStart),
|
||||
PowerUseWindow.Normalize(General.PowerUseWindowEnd),
|
||||
Math.Clamp(General.PowerUseHandoffPct, 50, 99));
|
||||
await _worker.UpdateAppSettingsAsync(dto);
|
||||
await Prime.SaveAsync();
|
||||
await OnlineInbox.SaveAsync();
|
||||
|
||||
@@ -2,7 +2,6 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
@@ -101,77 +100,6 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
|
||||
[ObservableProperty]
|
||||
private IReadOnlyList<UsageGaugeRowViewModel> _gaugeRows = Array.Empty<UsageGaugeRowViewModel>();
|
||||
|
||||
/// <summary>
|
||||
/// PowerUse master switch. It lives here rather than in Settings because turning it on is a
|
||||
/// "spend the rest of the window" decision, made while looking at how much window is left.
|
||||
/// On its own it changes nothing — a list-handler run only goes unattended when its LIST also
|
||||
/// opted in (list settings → PowerUse), so this can stay on without affecting other lists.
|
||||
/// </summary>
|
||||
[ObservableProperty] private bool _powerUseEnabled;
|
||||
|
||||
/// <summary>Optional local-time window (<c>HH:mm</c>) narrowing when PowerUse applies. Both
|
||||
/// blank = round the clock. Committed on focus loss / Enter, not per keystroke.</summary>
|
||||
[ObservableProperty] private string? _powerUseWindowStart;
|
||||
[ObservableProperty] private string? _powerUseWindowEnd;
|
||||
|
||||
/// <summary>5h-usage percentage at which a PowerUse handler session should hand off.</summary>
|
||||
[ObservableProperty] private int _powerUseHandoffPct = 95;
|
||||
|
||||
// Set while LoadAsync writes the stored value in, so seeding the property doesn't immediately
|
||||
// write it straight back to the worker.
|
||||
private bool _loadingPowerUse;
|
||||
|
||||
partial void OnPowerUseEnabledChanged(bool value)
|
||||
{
|
||||
if (_loadingPowerUse) return;
|
||||
_ = SavePowerUse();
|
||||
}
|
||||
|
||||
/// Read-modify-write, same discipline as SaveStagesAsync: never clobber a field this modal
|
||||
/// doesn't own. All four PowerUse fields go in one write, so committing one box can't drop a
|
||||
/// value the user just typed into a sibling box.
|
||||
[RelayCommand]
|
||||
private async Task SavePowerUse()
|
||||
{
|
||||
if (_loadingPowerUse) return;
|
||||
try
|
||||
{
|
||||
var settings = await _worker.GetAppSettingsAsync();
|
||||
if (settings is null)
|
||||
{
|
||||
ErrorReported?.Invoke(Loc.T("vm.usageMonitor.powerUseSaveFailed", Loc.T("vm.settingsModal.workerOffline")));
|
||||
return;
|
||||
}
|
||||
|
||||
// The worker stores an unparseable time as null; normalize here too so the box shows
|
||||
// what was actually saved instead of a typo that looks like a live window.
|
||||
var start = PowerUseWindow.Normalize(PowerUseWindowStart);
|
||||
var end = PowerUseWindow.Normalize(PowerUseWindowEnd);
|
||||
var pct = Math.Clamp(PowerUseHandoffPct, 50, 99);
|
||||
|
||||
await _worker.UpdateAppSettingsAsync(settings with
|
||||
{
|
||||
PowerUseEnabled = PowerUseEnabled,
|
||||
PowerUseWindowStart = start,
|
||||
PowerUseWindowEnd = end,
|
||||
PowerUseHandoffPct = pct,
|
||||
});
|
||||
|
||||
_loadingPowerUse = true;
|
||||
try
|
||||
{
|
||||
PowerUseWindowStart = start;
|
||||
PowerUseWindowEnd = end;
|
||||
PowerUseHandoffPct = pct;
|
||||
}
|
||||
finally { _loadingPowerUse = false; }
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorReported?.Invoke(Loc.T("vm.usageMonitor.powerUseSaveFailed", ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsStale => Snapshot?.IsStale == true;
|
||||
public string? LastError => Snapshot?.LastError;
|
||||
public bool IsGateBlocked => Snapshot?.IsGateBlocked == true;
|
||||
@@ -221,20 +149,6 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
|
||||
_worker.UsageUpdatedEvent -= OnUsageUpdated;
|
||||
_worker.UsageUpdatedEvent += OnUsageUpdated;
|
||||
|
||||
var settings = await _worker.GetAppSettingsAsync();
|
||||
if (settings is not null)
|
||||
{
|
||||
_loadingPowerUse = true;
|
||||
try
|
||||
{
|
||||
PowerUseEnabled = settings.PowerUseEnabled;
|
||||
PowerUseWindowStart = settings.PowerUseWindowStart;
|
||||
PowerUseWindowEnd = settings.PowerUseWindowEnd;
|
||||
PowerUseHandoffPct = settings.PowerUseHandoffPct;
|
||||
}
|
||||
finally { _loadingPowerUse = false; }
|
||||
}
|
||||
|
||||
ApplyPresetRange(SelectedPresetDays);
|
||||
await LoadUsageDataAsync();
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Modals;
|
||||
@@ -11,6 +13,9 @@ public sealed partial class WorkerConnectionModalViewModel : ViewModelBase
|
||||
private readonly WorkerLocator _workerLocator;
|
||||
private readonly InstallerLocator _installerLocator;
|
||||
|
||||
// Test seam: real Process.Start by default, swappable to simulate a blocked/failed launch.
|
||||
internal Action<ProcessStartInfo> ProcessStarter { get; set; } = psi => Process.Start(psi);
|
||||
|
||||
public WorkerConnectionModalViewModel(WorkerLocator workerLocator, InstallerLocator installerLocator)
|
||||
{
|
||||
_workerLocator = workerLocator;
|
||||
@@ -19,15 +24,29 @@ public sealed partial class WorkerConnectionModalViewModel : ViewModelBase
|
||||
|
||||
public Action? CloseAction { get; set; }
|
||||
|
||||
[ObservableProperty] private string? _errorText;
|
||||
|
||||
[RelayCommand] private void Close() => CloseAction?.Invoke();
|
||||
|
||||
[RelayCommand]
|
||||
private void StartWorker()
|
||||
{
|
||||
var exe = _workerLocator.Find();
|
||||
if (exe is null) return;
|
||||
try { Process.Start(new ProcessStartInfo(exe) { UseShellExecute = true }); }
|
||||
catch { /* nothing useful to show */ }
|
||||
if (exe is null)
|
||||
{
|
||||
ErrorText = Loc.T("modals.workerConnection.workerNotFound");
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
ProcessStarter(new ProcessStartInfo(exe) { UseShellExecute = true });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorText = Loc.T("modals.workerConnection.startFailed", ex.Message);
|
||||
return;
|
||||
}
|
||||
ErrorText = null;
|
||||
CloseAction?.Invoke();
|
||||
}
|
||||
|
||||
@@ -35,18 +54,26 @@ public sealed partial class WorkerConnectionModalViewModel : ViewModelBase
|
||||
private void RerunInstaller()
|
||||
{
|
||||
var path = _installerLocator.Find();
|
||||
if (path is null) return;
|
||||
if (path is null)
|
||||
{
|
||||
ErrorText = Loc.T("modals.workerConnection.installerNotFound");
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
// See IslandsShellViewModel.UpdateNow: an inherited CWD inside the install dir
|
||||
// makes the installer block its own app\ rename.
|
||||
Process.Start(new ProcessStartInfo(path)
|
||||
ProcessStarter(new ProcessStartInfo(path)
|
||||
{
|
||||
UseShellExecute = true,
|
||||
WorkingDirectory = Path.GetTempPath(),
|
||||
});
|
||||
Environment.Exit(0);
|
||||
}
|
||||
catch { /* nothing useful to show */ }
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorText = Loc.T("modals.workerConnection.startFailed", ex.Message);
|
||||
return;
|
||||
}
|
||||
Environment.Exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,14 @@
|
||||
using System;
|
||||
using System.Text.RegularExpressions;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.Documents;
|
||||
using Avalonia.Input.Platform;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
|
||||
namespace ClaudeDo.Ui.Views.Controls;
|
||||
|
||||
@@ -13,12 +17,27 @@ public partial class MarkdownView : UserControl
|
||||
public static readonly StyledProperty<string?> MarkdownProperty =
|
||||
AvaloniaProperty.Register<MarkdownView, string?>(nameof(Markdown));
|
||||
|
||||
/// <summary>
|
||||
/// Renders a bullet that starts with inline code as a command row: the command as a
|
||||
/// monospace chip with a copy button, the rest as a description below it, and a rule under
|
||||
/// each <c>##</c> section. Opt-in — the prompt-file viewer and the weekly report render
|
||||
/// prose where nearly every bullet carries inline code, and copy buttons there are noise.
|
||||
/// </summary>
|
||||
public static readonly StyledProperty<bool> CommandRowsProperty =
|
||||
AvaloniaProperty.Register<MarkdownView, bool>(nameof(CommandRows));
|
||||
|
||||
public string? Markdown
|
||||
{
|
||||
get => GetValue(MarkdownProperty);
|
||||
set => SetValue(MarkdownProperty, value);
|
||||
}
|
||||
|
||||
public bool CommandRows
|
||||
{
|
||||
get => GetValue(CommandRowsProperty);
|
||||
set => SetValue(CommandRowsProperty, value);
|
||||
}
|
||||
|
||||
private StackPanel? _host;
|
||||
|
||||
public MarkdownView()
|
||||
@@ -34,6 +53,8 @@ public partial class MarkdownView : UserControl
|
||||
base.OnPropertyChanged(change);
|
||||
if (change.Property == MarkdownProperty)
|
||||
Render(change.GetNewValue<string?>());
|
||||
else if (change.Property == CommandRowsProperty)
|
||||
Render(Markdown);
|
||||
}
|
||||
|
||||
private static readonly Regex NumberedItem = new(@"^\s*(\d+)\.\s+(.*)$", RegexOptions.Compiled);
|
||||
@@ -48,6 +69,9 @@ public partial class MarkdownView : UserControl
|
||||
return;
|
||||
|
||||
var lines = md.Replace("\r\n", "\n").Split('\n');
|
||||
// In CommandRows mode every "## " opens a collapsible section and blocks land inside it;
|
||||
// otherwise this stays the host for the whole document.
|
||||
var target = _host;
|
||||
int i = 0;
|
||||
while (i < lines.Length)
|
||||
{
|
||||
@@ -56,19 +80,22 @@ public partial class MarkdownView : UserControl
|
||||
|
||||
if (line.StartsWith("### "))
|
||||
{
|
||||
_host.Children.Add(Heading(line[4..], 13));
|
||||
target.Children.Add(Heading(line[4..], 13));
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (line.StartsWith("## "))
|
||||
{
|
||||
_host.Children.Add(Heading(line[3..], 15));
|
||||
if (CommandRows)
|
||||
target = OpenSection(line[3..]);
|
||||
else
|
||||
target.Children.Add(Heading(line[3..], 15));
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (line.StartsWith("# "))
|
||||
{
|
||||
_host.Children.Add(Heading(line[2..], 17));
|
||||
target.Children.Add(Heading(line[2..], 17));
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
@@ -77,7 +104,10 @@ public partial class MarkdownView : UserControl
|
||||
{
|
||||
while (i < lines.Length && (lines[i].StartsWith("- ") || lines[i].StartsWith("* ")))
|
||||
{
|
||||
_host.Children.Add(Bullet(lines[i][2..]));
|
||||
var item = lines[i][2..];
|
||||
target.Children.Add(CommandRows && TrySplitCommand(item, out var cmd, out var desc)
|
||||
? CommandRow(cmd, desc)
|
||||
: Bullet(item));
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
@@ -89,7 +119,7 @@ public partial class MarkdownView : UserControl
|
||||
while (i < lines.Length && NumberedItem.IsMatch(lines[i]))
|
||||
{
|
||||
var m = NumberedItem.Match(lines[i]);
|
||||
_host.Children.Add(Numbered(m.Groups[1].Value, m.Groups[2].Value));
|
||||
target.Children.Add(Numbered(m.Groups[1].Value, m.Groups[2].Value));
|
||||
i++;
|
||||
}
|
||||
continue;
|
||||
@@ -104,7 +134,7 @@ public partial class MarkdownView : UserControl
|
||||
paragraph.Append(lines[i].Trim());
|
||||
i++;
|
||||
}
|
||||
_host.Children.Add(Paragraph(paragraph.ToString()));
|
||||
target.Children.Add(Paragraph(paragraph.ToString()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +156,120 @@ public partial class MarkdownView : UserControl
|
||||
return tb;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a collapsible section for a <c>##</c> heading and returns the panel its blocks go
|
||||
/// into. Native <see cref="Expander"/> — it carries the chevron, keyboard toggle and
|
||||
/// animation, and the app already uses it unstyled in the worktrees overview.
|
||||
/// </summary>
|
||||
private StackPanel OpenSection(string title)
|
||||
{
|
||||
var header = new TextBlock
|
||||
{
|
||||
FontSize = 15,
|
||||
FontWeight = FontWeight.SemiBold,
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
};
|
||||
AppendInlines(header.Inlines!, title);
|
||||
|
||||
var body = new StackPanel { Spacing = 6 };
|
||||
_host!.Children.Add(new Expander
|
||||
{
|
||||
Header = header,
|
||||
Content = body,
|
||||
IsExpanded = true,
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||
HorizontalContentAlignment = HorizontalAlignment.Stretch,
|
||||
});
|
||||
return body;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Splits <c>`command` — description</c> into its two parts. False when the bullet doesn't
|
||||
/// open with inline code, so prose bullets keep the plain renderer.
|
||||
/// </summary>
|
||||
internal static bool TrySplitCommand(string text, out string command, out string description)
|
||||
{
|
||||
command = "";
|
||||
description = "";
|
||||
if (text.Length < 3 || text[0] != '`') return false;
|
||||
var close = text.IndexOf('`', 1);
|
||||
if (close <= 1) return false;
|
||||
command = text[1..close];
|
||||
description = text[(close + 1)..].TrimStart(' ', '—', '–', '-', ':').Trim();
|
||||
return true;
|
||||
}
|
||||
|
||||
private Control CommandRow(string command, string description)
|
||||
{
|
||||
var grid = new Grid
|
||||
{
|
||||
ColumnDefinitions = new ColumnDefinitions("*,Auto"),
|
||||
Margin = new Thickness(0, 2, 0, 2),
|
||||
};
|
||||
|
||||
var text = new SelectableTextBlock { Text = command, TextWrapping = TextWrapping.Wrap };
|
||||
// Bound, not resolved: Render can run while detached, where a resource lookup finds
|
||||
// nothing and would freeze on the fallback.
|
||||
text.Bind(TextBlock.FontFamilyProperty, this.GetResourceObservable("MonoFont"));
|
||||
text.Bind(TextBlock.FontSizeProperty, this.GetResourceObservable("FontSizeMono"));
|
||||
|
||||
var chip = new Border
|
||||
{
|
||||
CornerRadius = new CornerRadius(4),
|
||||
Padding = new Thickness(7, 4),
|
||||
HorizontalAlignment = HorizontalAlignment.Left,
|
||||
Child = text,
|
||||
};
|
||||
chip.Bind(Border.BackgroundProperty, this.GetResourceObservable("Surface2Brush"));
|
||||
|
||||
var stack = new StackPanel { Spacing = 3, Children = { chip } };
|
||||
if (description.Length > 0)
|
||||
{
|
||||
var desc = new TextBlock
|
||||
{
|
||||
Classes = { "body" },
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
Margin = new Thickness(2, 0, 0, 0),
|
||||
};
|
||||
AppendInlines(desc.Inlines!, description);
|
||||
stack.Children.Add(desc);
|
||||
}
|
||||
Grid.SetColumn(stack, 0);
|
||||
grid.Children.Add(stack);
|
||||
|
||||
var copy = CopyButton(command);
|
||||
Grid.SetColumn(copy, 1);
|
||||
grid.Children.Add(copy);
|
||||
return grid;
|
||||
}
|
||||
|
||||
private Button CopyButton(string command)
|
||||
{
|
||||
var copyIcon = new PathIcon { Width = 11, Height = 11 };
|
||||
copyIcon.Bind(PathIcon.DataProperty, this.GetResourceObservable("Icon.Copy"));
|
||||
var checkIcon = new PathIcon { Width = 11, Height = 11 };
|
||||
checkIcon.Bind(PathIcon.DataProperty, this.GetResourceObservable("Icon.Check"));
|
||||
|
||||
var button = new Button
|
||||
{
|
||||
Classes = { "icon-btn" },
|
||||
Content = copyIcon,
|
||||
VerticalAlignment = VerticalAlignment.Top,
|
||||
Margin = new Thickness(6, 0, 0, 0),
|
||||
};
|
||||
ToolTip.SetTip(button, Loc.T("controls.markdown.copy"));
|
||||
|
||||
button.Click += async (_, _) =>
|
||||
{
|
||||
if (TopLevel.GetTopLevel(this)?.Clipboard is not { } clipboard) return;
|
||||
await clipboard.SetTextAsync(command);
|
||||
// The clipboard gives no feedback of its own; a brief check mark is the confirmation.
|
||||
button.Content = checkIcon;
|
||||
DispatcherTimer.RunOnce(() => button.Content = copyIcon, TimeSpan.FromSeconds(1.5));
|
||||
};
|
||||
return button;
|
||||
}
|
||||
|
||||
private static Control Paragraph(string text)
|
||||
{
|
||||
var tb = new SelectableTextBlock { TextWrapping = TextWrapping.Wrap };
|
||||
|
||||
@@ -14,19 +14,21 @@
|
||||
Background="{DynamicResource DeepBrush}"
|
||||
BorderBrush="{DynamicResource LineBrush}"
|
||||
BorderThickness="0,0,0,1">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto" Margin="14,0">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto,Auto" Margin="14,0">
|
||||
<TextBlock Text="{TemplateBinding Title}"
|
||||
FontFamily="{DynamicResource MonoFont}"
|
||||
FontSize="{DynamicResource FontSizeMono}"
|
||||
LetterSpacing="1.4"
|
||||
Foreground="{DynamicResource TextBrush}"
|
||||
VerticalAlignment="Center"/>
|
||||
<Button Name="PART_MaxButton" Grid.Column="1" Classes="icon-btn"
|
||||
<ContentPresenter Grid.Column="1" Content="{TemplateBinding HeaderActions}"
|
||||
VerticalAlignment="Center" Margin="0,0,8,0"/>
|
||||
<Button Name="PART_MaxButton" Grid.Column="2" Classes="icon-btn"
|
||||
IsVisible="{TemplateBinding ShowMaximizeButton}"
|
||||
VerticalAlignment="Center">
|
||||
<PathIcon Name="PART_MaxIcon" Data="{StaticResource Icon.WinMax}" Width="10" Height="10"/>
|
||||
</Button>
|
||||
<Button Grid.Column="2" Classes="icon-btn" Content="✕"
|
||||
<Button Grid.Column="3" Classes="icon-btn" Content="✕"
|
||||
FontSize="{DynamicResource FontSizeBody}"
|
||||
Command="{TemplateBinding CloseCommand}"
|
||||
VerticalAlignment="Center"/>
|
||||
|
||||
@@ -17,6 +17,11 @@ public class ModalShell : ContentControl
|
||||
public static readonly StyledProperty<object?> FooterProperty =
|
||||
AvaloniaProperty.Register<ModalShell, object?>(nameof(Footer));
|
||||
|
||||
// Window-level actions rendered in the titlebar, left of maximise/close. Keeps a single
|
||||
// always-visible action (e.g. the usage monitor's refresh) out of the body's vertical flow.
|
||||
public static readonly StyledProperty<object?> HeaderActionsProperty =
|
||||
AvaloniaProperty.Register<ModalShell, object?>(nameof(HeaderActions));
|
||||
|
||||
public static readonly StyledProperty<ICommand?> CloseCommandProperty =
|
||||
AvaloniaProperty.Register<ModalShell, ICommand?>(nameof(CloseCommand));
|
||||
|
||||
@@ -27,6 +32,7 @@ public class ModalShell : ContentControl
|
||||
|
||||
public string? Title { get => GetValue(TitleProperty); set => SetValue(TitleProperty, value); }
|
||||
public object? Footer { get => GetValue(FooterProperty); set => SetValue(FooterProperty, value); }
|
||||
public object? HeaderActions { get => GetValue(HeaderActionsProperty); set => SetValue(HeaderActionsProperty, value); }
|
||||
public ICommand? CloseCommand { get => GetValue(CloseCommandProperty); set => SetValue(CloseCommandProperty, value); }
|
||||
public bool ShowMaximizeButton { get => GetValue(ShowMaximizeButtonProperty); set => SetValue(ShowMaximizeButtonProperty, value); }
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
xmlns:term="using:Iciclecreek.Terminal"
|
||||
x:Class="ClaudeDo.Ui.Views.InteractiveTerminalView"
|
||||
x:DataType="vm:InteractiveTerminalViewModel"
|
||||
x:Name="Root">
|
||||
x:Name="Root"
|
||||
DragDrop.AllowDrop="True">
|
||||
<!--
|
||||
Process="" suppresses TerminalView.OnLoaded's built-in auto-launch (it otherwise spawns its
|
||||
own cmd.exe/bash the moment this control loads). PtyTerminalSession sets Process/Args/
|
||||
|
||||
@@ -1,6 +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;
|
||||
@@ -12,6 +18,12 @@ public partial class InteractiveTerminalView : UserControl
|
||||
InitializeComponent();
|
||||
TerminalHost.Loaded += OnTerminalHostLoaded;
|
||||
DataContextChanged += OnDataContextChanged;
|
||||
|
||||
// Tunnel: TerminalControl handles KeyDown itself and forwards Ctrl+V to the child as a raw
|
||||
// control byte, where nothing acts on it — paste has to be intercepted before it gets there.
|
||||
AddHandler(KeyDownEvent, OnPreviewKeyDown, RoutingStrategies.Tunnel);
|
||||
AddHandler(DragDrop.DragOverEvent, OnDragOver);
|
||||
AddHandler(DragDrop.DropEvent, OnDrop);
|
||||
}
|
||||
|
||||
private void OnDataContextChanged(object? sender, EventArgs e)
|
||||
@@ -25,4 +37,97 @@ public partial class InteractiveTerminalView : UserControl
|
||||
if (DataContext is InteractiveTerminalViewModel vm)
|
||||
vm.AttachControl(TerminalHost);
|
||||
}
|
||||
|
||||
private void OnPreviewKeyDown(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key != Key.V || !e.KeyModifiers.HasFlag(KeyModifiers.Control) || e.KeyModifiers.HasFlag(KeyModifiers.Alt))
|
||||
return;
|
||||
if (DataContext is not InteractiveTerminalViewModel vm) return;
|
||||
|
||||
e.Handled = true;
|
||||
_ = PasteAndReportAsync(this, 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)
|
||||
{
|
||||
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;
|
||||
|
||||
private void OnDragOver(object? sender, DragEventArgs e)
|
||||
{
|
||||
e.DragEffects = IsFilesDrop(e) ? DragDropEffects.Copy : DragDropEffects.None;
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
// Dropping files types their paths into the session's composer (no Enter) — the common case is
|
||||
// "look at this file", not "run something".
|
||||
private void OnDrop(object? sender, DragEventArgs e)
|
||||
{
|
||||
if (DataContext is not InteractiveTerminalViewModel vm || !IsFilesDrop(e)) return;
|
||||
e.Handled = true;
|
||||
|
||||
var text = FormatDroppedPaths(e.DataTransfer!.TryGetFiles()?.Select(i => i.TryGetLocalPath()));
|
||||
if (text.Length > 0) _ = SendTextAndReportAsync(vm, text);
|
||||
}
|
||||
|
||||
private static async System.Threading.Tasks.Task SendTextAndReportAsync(InteractiveTerminalViewModel vm, string text)
|
||||
{
|
||||
if (!await vm.SendTextAsync(text))
|
||||
vm.ReportSendFailed();
|
||||
}
|
||||
|
||||
/// <summary>Joins dropped file paths into one insertable chunk: quoted when they contain a
|
||||
/// space, space-separated, with a trailing space so the user can keep typing.</summary>
|
||||
internal static string FormatDroppedPaths(IEnumerable<string?>? paths)
|
||||
{
|
||||
var usable = (paths ?? Enumerable.Empty<string?>())
|
||||
.Where(p => !string.IsNullOrWhiteSpace(p))
|
||||
.Select(p => p!.Contains(' ') ? $"\"{p}\"" : p)
|
||||
.ToList();
|
||||
return usable.Count == 0 ? string.Empty : string.Join(" ", usable) + " ";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -88,8 +88,8 @@
|
||||
ToolTip.Tip="{loc:Tr details.toggleEditPreviewTip}"
|
||||
Command="{Binding ToggleEditDescriptionCommand}">
|
||||
<Panel>
|
||||
<TextBlock Text="Preview" IsVisible="{Binding IsEditingDescription}"/>
|
||||
<TextBlock Text="Edit" IsVisible="{Binding !IsEditingDescription}"/>
|
||||
<TextBlock Text="{loc:Tr details.stepsCard.preview}" IsVisible="{Binding IsEditingDescription}"/>
|
||||
<TextBlock Text="{loc:Tr details.stepsCard.edit}" IsVisible="{Binding !IsEditingDescription}"/>
|
||||
</Panel>
|
||||
</Button>
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
<!-- Steps -->
|
||||
<StackPanel IsVisible="{Binding IsStepsSection}" Spacing="6">
|
||||
<TextBox Text="{Binding NewSubtaskTitle, Mode=TwoWay}"
|
||||
PlaceholderText="Add step…"
|
||||
PlaceholderText="{loc:Tr details.stepsCard.addStepPlaceholder}"
|
||||
Padding="8"
|
||||
Background="{DynamicResource Surface3Brush}"
|
||||
BorderBrush="{DynamicResource LineBrush}"
|
||||
|
||||
@@ -41,6 +41,14 @@
|
||||
</Viewbox>
|
||||
<TextBlock Classes="meta" Text="{Binding Task.Branch}"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Dependency-wait line: this task is Queued but blocked on an unmet predecessor (a
|
||||
task_dependencies edge, or the planning chain's BlockedByTaskId). -->
|
||||
<StackPanel Orientation="Horizontal" Spacing="4" Margin="0,4,0,0"
|
||||
IsVisible="{Binding Task.IsWaiting, FallbackValue=False}">
|
||||
<TextBlock Classes="meta" Text="{loc:Tr details.waitingOnPrefix}"/>
|
||||
<TextBlock Classes="meta" Text="{Binding Task.DependencyWaitLabel}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Column 1: trash button (not running) -->
|
||||
|
||||
@@ -172,12 +172,12 @@
|
||||
TextWrapping="Wrap" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="btn accent" Content="Continue"
|
||||
<Button Classes="btn accent" Content="{loc:Tr details.workConsole.continue}"
|
||||
Command="{Binding ContinueCommand}"
|
||||
IsVisible="{Binding ShowContinue}"
|
||||
ToolTip.Tip="{loc:Tr session.continueNoSessionTip}"
|
||||
ToolTip.ShowOnDisabled="True" />
|
||||
<Button Classes="btn" Content="Reset & Retry"
|
||||
<Button Classes="btn" Content="{loc:Tr details.workConsole.resetAndRetry}"
|
||||
Command="{Binding ResetAndRetryCommand}"
|
||||
IsVisible="{Binding ShowResetAndRetry}"
|
||||
ToolTip.Tip="{loc:Tr session.resetAndRetryInteractiveTip}"
|
||||
@@ -194,7 +194,7 @@
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button Classes="tab-btn"
|
||||
Classes.active="{Binding IsOutputTab}"
|
||||
Content="Output"
|
||||
Content="{loc:Tr details.workConsole.tabOutput}"
|
||||
Command="{Binding SelectTabCommand}"
|
||||
CommandParameter="output" />
|
||||
<Button Classes="tab-btn"
|
||||
@@ -202,7 +202,7 @@
|
||||
Command="{Binding SelectTabCommand}"
|
||||
CommandParameter="git">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Text="Git" VerticalAlignment="Center" />
|
||||
<TextBlock Text="{loc:Tr details.workConsole.tabGit}" VerticalAlignment="Center" />
|
||||
<!-- Review-pending dot: where to act when a task awaits review -->
|
||||
<Ellipse Width="6" Height="6" VerticalAlignment="Center"
|
||||
Fill="{DynamicResource AccentBrush}"
|
||||
@@ -211,7 +211,7 @@
|
||||
</Button>
|
||||
<Button Classes="tab-btn"
|
||||
Classes.active="{Binding IsSessionTab}"
|
||||
Content="Session"
|
||||
Content="{loc:Tr details.workConsole.tabSession}"
|
||||
IsVisible="{Binding HasChildOutcomes}"
|
||||
Command="{Binding SelectTabCommand}"
|
||||
CommandParameter="session" />
|
||||
@@ -233,7 +233,7 @@
|
||||
BorderBrush="{DynamicResource LineBrush}"
|
||||
BorderThickness="1" CornerRadius="8">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="section-label" Text="OUTCOME" />
|
||||
<TextBlock Classes="section-label" Text="{loc:Tr details.workConsole.outcomeLabel}" />
|
||||
<ScrollViewer MaxHeight="160" VerticalScrollBarVisibility="Auto">
|
||||
<SelectableTextBlock Text="{Binding SessionOutcome}"
|
||||
TextWrapping="Wrap"
|
||||
@@ -309,9 +309,9 @@
|
||||
<!-- Merge controls — shown whenever there's a worktree / unit to merge.
|
||||
Header reads REVIEW while a decision is pending, otherwise MERGE. -->
|
||||
<StackPanel Spacing="14" IsVisible="{Binding Merge.ShowMergeSection}">
|
||||
<TextBlock Classes="section-label" Text="REVIEW"
|
||||
<TextBlock Classes="section-label" Text="{loc:Tr details.workConsole.reviewLabel}"
|
||||
IsVisible="{Binding IsWaitingForReview}" />
|
||||
<TextBlock Classes="section-label" Text="MERGE"
|
||||
<TextBlock Classes="section-label" Text="{loc:Tr details.workConsole.mergeLabel}"
|
||||
IsVisible="{Binding !IsWaitingForReview}" />
|
||||
|
||||
<!-- Change summary (review only) -->
|
||||
@@ -323,10 +323,14 @@
|
||||
|
||||
<!-- Target branch + pre-flight mergeability -->
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Classes="field-label" Text="Target branch" />
|
||||
<TextBlock Classes="field-label" Text="{loc:Tr details.workConsole.targetBranch}" />
|
||||
<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">
|
||||
@@ -344,7 +348,7 @@
|
||||
|
||||
<!-- Inspect: diff / worktree / combined diff -->
|
||||
<WrapPanel Orientation="Horizontal">
|
||||
<Button Classes="btn" Content="Open Diff" Margin="0,0,8,8"
|
||||
<Button Classes="btn" Content="{loc:Tr details.workConsole.openDiff}" Margin="0,0,8,8"
|
||||
ToolTip.Tip="{loc:Tr agent.openDiffTip}"
|
||||
ToolTip.ShowOnDisabled="True"
|
||||
Command="{Binding Merge.OpenDiffCommand}" />
|
||||
@@ -353,11 +357,11 @@
|
||||
ToolTip.ShowOnDisabled="True"
|
||||
Command="{Binding Merge.OpenWorktreeCommand}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="5">
|
||||
<TextBlock Text="Worktree" />
|
||||
<TextBlock Text="{loc:Tr details.workConsole.worktree}" />
|
||||
<PathIcon Data="{StaticResource Icon.ArrowOut}" Width="11" Height="11" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Classes="btn" Content="Review Combined Diff" Margin="0,0,8,8"
|
||||
<Button Classes="btn" Content="{loc:Tr details.workConsole.reviewCombinedDiff}" Margin="0,0,8,8"
|
||||
ToolTip.Tip="{loc:Tr agent.reviewCombinedDiffTip}"
|
||||
ToolTip.ShowOnDisabled="True"
|
||||
Command="{Binding Merge.ReviewCombinedDiffCommand}" />
|
||||
@@ -377,7 +381,7 @@
|
||||
Foreground="{DynamicResource AmberBrush}" VerticalAlignment="Center" />
|
||||
<TextBlock Classes="meta" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource AmberBrush}"
|
||||
Text="Open the diff to enable merge" />
|
||||
Text="{loc:Tr details.workConsole.openDiffHint}" />
|
||||
</StackPanel>
|
||||
<TextBlock Classes="meta" Margin="17,0,0,0"
|
||||
Text="{loc:Tr session.approveMergesUnitHint}" />
|
||||
@@ -396,23 +400,23 @@
|
||||
|
||||
<WrapPanel Orientation="Horizontal">
|
||||
<StackPanel Orientation="Horizontal" Spacing="{StaticResource SpaceSm}" Margin="0,0,8,8">
|
||||
<Button Classes="btn accent" Content="Approve & Merge"
|
||||
<Button Classes="btn accent" Content="{loc:Tr details.workConsole.approveMerge}"
|
||||
Command="{Binding ApproveReviewCommand}" />
|
||||
<controls:OperationIndicator Status="{Binding ApproveOp}" />
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="{StaticResource SpaceSm}" Margin="0,0,8,8">
|
||||
<Button Classes="btn" Content="Park"
|
||||
ToolTip.Tip="Set aside — back to Idle, keeps the worktree"
|
||||
<Button Classes="btn" Content="{loc:Tr details.workConsole.park}"
|
||||
ToolTip.Tip="{loc:Tr details.workConsole.parkTip}"
|
||||
Command="{Binding ParkReviewCommand}" />
|
||||
<controls:OperationIndicator Status="{Binding ParkOp}" />
|
||||
</StackPanel>
|
||||
<Button Classes="btn" Content="Cancel" Margin="0,0,8,8"
|
||||
<Button Classes="btn" Content="{loc:Tr details.workConsole.cancel}" Margin="0,0,8,8"
|
||||
ToolTip.Tip="{loc:Tr session.mergeDrainHint}"
|
||||
ToolTip.ShowOnDisabled="True"
|
||||
Command="{Binding CancelReviewCommand}" />
|
||||
</WrapPanel>
|
||||
|
||||
<Button Classes="prompt-action" Content="Reset (discard branch)…"
|
||||
<Button Classes="prompt-action" Content="{loc:Tr details.workConsole.resetDiscardBranch}"
|
||||
ToolTip.Tip="{loc:Tr session.reviewResetTip}"
|
||||
Command="{Binding ResetReviewCommand}" />
|
||||
</StackPanel>
|
||||
@@ -434,9 +438,9 @@
|
||||
<Border Height="1" Background="{DynamicResource LineBrush}" />
|
||||
<TextBlock Classes="meta" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextMuteBrush}"
|
||||
Text="Worked on this by hand? Submit the worktree for review to merge it." />
|
||||
Text="{loc:Tr details.workConsole.submitForReviewHint}" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="{StaticResource SpaceSm}">
|
||||
<Button Classes="btn accent" Content="Submit for review" HorizontalAlignment="Left"
|
||||
<Button Classes="btn accent" Content="{loc:Tr details.workConsole.submitForReview}" HorizontalAlignment="Left"
|
||||
Command="{Binding SubmitForReviewCommand}" />
|
||||
<controls:OperationIndicator Status="{Binding SubmitOp}" />
|
||||
</StackPanel>
|
||||
@@ -466,7 +470,7 @@
|
||||
|
||||
<!-- Child outcomes -->
|
||||
<StackPanel Spacing="6" IsVisible="{Binding HasChildOutcomes}">
|
||||
<TextBlock Classes="section-label" Text="OUTCOMES" />
|
||||
<TextBlock Classes="section-label" Text="{loc:Tr details.workConsole.outcomesLabel}" />
|
||||
<ItemsControl ItemsSource="{Binding ChildOutcomes}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ChildOutcomeRowViewModel">
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
<PathIcon Data="{StaticResource Icon.Warning}"
|
||||
Foreground="{DynamicResource StatusReviewBrush}"
|
||||
Width="14" Height="14" VerticalAlignment="Center"/>
|
||||
<TextBlock Classes="section-label" Text="ROADBLOCK"
|
||||
<TextBlock Classes="section-label" Text="{loc:Tr details.roadblockLabel}"
|
||||
Foreground="{DynamicResource StatusReviewBrush}"
|
||||
VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user