Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f27fc3049 | ||
|
|
23ed2b4543 | ||
|
|
f37b59d58f | ||
|
|
43b03e4837 | ||
|
|
214e53b714 | ||
|
|
8cf3a3a9d0 | ||
|
|
36ce8fb622 | ||
|
|
feb0afa33a | ||
|
|
bba7117d54 | ||
|
|
ff7197c410 | ||
|
|
59d990c267 | ||
|
|
a75acbb2c0 | ||
|
|
b35755e1f0 | ||
|
|
4b1f5c65c3 | ||
|
|
2e29e03a8e | ||
|
|
8c5db95c01 | ||
|
|
89ed885295 | ||
|
|
6c400dfb1a | ||
|
|
1b58e628e6 | ||
|
|
34970ca600 | ||
|
|
0314319872 | ||
|
|
f7625baa24 | ||
|
|
9e4a14040e | ||
|
|
d9f42d7d48 | ||
|
|
542501cbe2 | ||
|
|
309bdcc903 | ||
|
|
6f7dcff719 | ||
|
|
d9d4f931ae | ||
|
|
01dc44174a | ||
|
|
ae778d5c30 | ||
|
|
3fab4016f7 | ||
|
|
393e59460d | ||
|
|
b7e7aebce9 | ||
|
|
d8f78056c1 | ||
|
|
9a9b86fdcd | ||
|
|
0b2802efbb | ||
|
|
0c0bb7f24c | ||
|
|
9c015f34cc | ||
|
|
c8af06d0bf | ||
|
|
5bf08873bd | ||
|
|
c956ab102e | ||
|
|
d9355383a4 | ||
|
|
f6e0e824ef | ||
|
|
de68515128 | ||
|
|
03f5eae35b | ||
|
|
72141b133a | ||
|
|
426d8eb137 | ||
|
|
ef50973d01 | ||
|
|
ce3f233710 | ||
|
|
3e11090fe3 | ||
|
|
7c0fe92a3e | ||
|
|
89312e0b22 | ||
|
|
476a94960e | ||
|
|
20b5ff8f3e | ||
|
|
2d1570ed15 | ||
|
|
203112a051 | ||
|
|
aef41f0594 | ||
|
|
cbab4da54c | ||
|
|
34c71bbe02 | ||
|
|
a76788a5b9 | ||
|
|
d0c931e128 | ||
|
|
1200a599cc | ||
|
|
46e9303da6 | ||
|
|
0fea339b6d | ||
|
|
5dae5ff679 | ||
|
|
74e06d6219 | ||
|
|
e79f1bf22c | ||
|
|
eaa697b08e | ||
|
|
a4596eea5b | ||
|
|
57257c3ead | ||
|
|
f97914878b | ||
|
|
a736cf3098 | ||
|
|
06fb73c4dd | ||
|
|
ef5dad2700 | ||
|
|
e9c1ff2e70 | ||
|
|
61645acf17 | ||
|
|
c834f17c78 | ||
|
|
27dca48c43 | ||
|
|
4917533cb4 | ||
|
|
93fd6740a1 | ||
|
|
674db27f6e | ||
|
|
0e6a48e268 | ||
|
|
4976ded7ae | ||
|
|
ec25ed3c75 | ||
|
|
2dbec543df | ||
|
|
fed0b6ac51 | ||
|
|
bc959f25b3 | ||
|
|
7f7f237656 | ||
|
|
78f4d182e8 | ||
|
|
f85b9cddfd | ||
|
|
61105ff9d3 | ||
|
|
488c28a9d5 | ||
|
|
acf3987a8a | ||
|
|
c33e57b9c7 | ||
|
|
76f260a9a5 | ||
|
|
3d74e3dac5 | ||
|
|
bc0aa28afb | ||
|
|
2b2f61d7fc | ||
|
|
0cfc28adb9 | ||
|
|
b776fd28d8 | ||
|
|
f66cbca65d | ||
|
|
335bfe957b | ||
|
|
2b7b58be37 | ||
|
|
acd188bca9 | ||
|
|
a353897345 | ||
|
|
823368484e | ||
|
|
44d94caabc | ||
|
|
1f373a5a67 | ||
|
|
9da522b0c9 | ||
|
|
724ad90f88 | ||
|
|
dc73f138d2 | ||
|
|
07e9f8d60e | ||
|
|
cd5b27c063 | ||
|
|
fb86a3c1bf | ||
|
|
9bb0e67a97 | ||
|
|
8ac272df50 | ||
|
|
ae845bf06b | ||
|
|
69c7803bdf | ||
|
|
3fe1feadf9 | ||
|
|
8911408bca | ||
|
|
814b083a4e | ||
|
|
4a9cff5543 | ||
|
|
0e4de9e4c0 | ||
|
|
68445a9f2a | ||
|
|
a6926f53b5 | ||
|
|
d316f3c6a5 | ||
|
|
faa91e0661 | ||
|
|
db3c7e69e5 | ||
|
|
2b19c8c49d | ||
|
|
cf3db9197c | ||
|
|
3dc735b2af | ||
|
|
0e9373c083 | ||
|
|
97af85c816 | ||
|
|
16adcad84f | ||
|
|
2b40ea2d2b | ||
|
|
7724f5eb70 | ||
|
|
cb775958a7 | ||
|
|
e35b054a2e | ||
|
|
9fc2e26edb | ||
|
|
915f2c8352 | ||
|
|
57c30c4a18 | ||
|
|
47583ab1fe | ||
|
|
a0b234fe10 | ||
|
|
c34680b42d | ||
|
|
5bb49495e4 | ||
|
|
0ac49653c9 | ||
|
|
899648adc0 | ||
|
|
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 | ||
|
|
5be972564a | ||
|
|
ecb1cdbb06 |
+192
@@ -1,5 +1,197 @@
|
||||
# Changelog
|
||||
|
||||
## v2.19.0 — 2026-10-02
|
||||
|
||||
### Features
|
||||
|
||||
- resolve conflicts straight from the merge modal (214e53b7)
|
||||
- ssh workspaces settings tab (89ed8852)
|
||||
- ssh workspaces as quick-session targets in mission control (6c400dfb)
|
||||
- ssh launch spec with reverse-tunnelled claudedo MCP (1b58e628)
|
||||
- remote MCP listener gated by per-session tokens (34970ca6)
|
||||
- store ssh workspaces on app_settings (03143198)
|
||||
- folder/file-type icons and change-kind colours in the file tree (ae778d5c)
|
||||
- offer Mark as Idle for cancelled tasks (3fab4016)
|
||||
- attach clipboard image on Ctrl+V in the task description (393e5946)
|
||||
- rename sessions inline in the Recent sessions flyout (5bf08873)
|
||||
- merge helper knows company-repo approve ends in a draft PR (0e6a48e2)
|
||||
- external services tab with Azure DevOps PAT, draft PR button and link in company repos (4976ded7)
|
||||
- show running Worker operations in a Running now section (2dbec543)
|
||||
- Azure DevOps PAT settings and connection test (fed0b6ac)
|
||||
- approve in a company repo verifies in the worktree, pushes and opens a draft PR (bc959f25)
|
||||
- company unit roots start from a fetched origin base, local base fast-forwarded best-effort (7f7f2376)
|
||||
- Azure DevOps client and PAT store, ported from pr-create.ps1 (78f4d182)
|
||||
- fetch/push/ff primitives with auth env, no task trailer in company repos (76f260a9)
|
||||
- worktrees.pull_request_url for the company-repo draft PR (bc0aa28a)
|
||||
- show task number in Mission Control activity strip (f66cbca6)
|
||||
- combined diff of a unit is base..head, no integration branch checkout (44d94caa)
|
||||
- approve a unit parent like a single task, orchestrator only for legacy units (1f373a5a)
|
||||
- fast-forward finished children into the parent's unit branch (9da522b0)
|
||||
- add ff-only merge and allow a merged worktree without merge commit (724ad90f)
|
||||
- colour agent output by kind and stop cutting tool calls at 120 chars (9bb0e67a)
|
||||
- base branch field in list settings, real branch names in dialogs (ae845bf0)
|
||||
- use the list base branch as worktree base and default merge target (69c7803b)
|
||||
- per-list base branch, preserved by every config writer (3fe1fead)
|
||||
- name task branches <type>/<number>-<slug> (814b083a)
|
||||
- branch type and number rules for task branches (4a9cff55)
|
||||
- selectable list/folder colors with accent-aware swatches and custom picker (a6926f53)
|
||||
- detect company repos by origin remote pattern (faa91e06)
|
||||
- stream claude update progress into the footer strip (2b19c8c4)
|
||||
- swap tiles by dragging their header (97af85c8)
|
||||
- tickets + plan day button, fallback list and prime action (e35b054a)
|
||||
- add RunTicketImportNow and ticket fallback list endpoints (9fc2e26e)
|
||||
- ImportTickets runs the ticket import before filling MyDay (915f2c83)
|
||||
- add get_ticket_import_candidates and import_ticket tools (57c30c4a)
|
||||
- sweep all project boards for assigned tickets and tasks (47583ab1)
|
||||
- add ImportTickets prime kind, ticket fallback list and import prompt (a0b234fe)
|
||||
|
||||
### Fixes
|
||||
|
||||
- pass the stash consent to the conflict resolver so it opens on a dirty target (feb0afa3)
|
||||
- auto-scroll the lists sidebar while dragging near its top/bottom edge (bba7117d)
|
||||
- close the pseudo console when a conpty tile is disposed (a75acbb2)
|
||||
- remove remote mcp config via trap when the tile kills ssh (b35755e1)
|
||||
- put ~/.local/bin on PATH for remote claude in ssh sessions (4b1f5c65)
|
||||
- ssh settings tab survives an offline worker without wiping workspaces (8c5db95c)
|
||||
- stop pinning --effort on hand-driven interactive sessions (d9f42d7d)
|
||||
- drop outbound HttpClient request logs from the worker log (542501cb)
|
||||
- wrap table headers, ellipsis-trim long cells with tooltip (b7e7aebc)
|
||||
- offer clean reset for parked tasks in the Git tab (9a9b86fd)
|
||||
- detect external merges, clean up empty dirs, prune in owning repo (0c0bb7f2)
|
||||
- Log Visualizer shows every footer-log message (c956ab10)
|
||||
- align island header separators via shared header height (34c71bbe)
|
||||
- show post-merge verify as Verifying instead of In review (46e9303d)
|
||||
- open diff viewer and merge window as independent top-level windows (0fea339b)
|
||||
- reset cursor to arrow over pane header buttons (74e06d62)
|
||||
- preview_merge_set no longer reports a symbol the same diff re-declares (06fb73c4)
|
||||
- batch_get_tasks accept "number" and "id" in fields filter (ef5dad27)
|
||||
- Mission Control no longer lists finished operations as running (e9c1ff2e)
|
||||
- drop SignalR hub invocation events so PATs stay out of the worker log (61645acf)
|
||||
- strip credentials from origin URL in the not-ready message (c834f17c)
|
||||
- kept worktree in a company repo opens a PR, unexpected ADO/git errors never fail a task start (4917533c)
|
||||
- keep Git Credential Manager from prompting during company fetch/push (674db27f)
|
||||
- open the merge modal non-modal so a running merge no longer blocks the main window (3d74e3da)
|
||||
- hide Submit for review on list-handler panes in Mission Control (0cfc28ad)
|
||||
- prevent dragging completed tasks in the task list (b776fd28)
|
||||
- add right padding to add-task input so the caret clears the last glyph (335bfe95)
|
||||
- block approve of a planning parent with an unfinished child, surface git stderr on ff failure (acd188bc)
|
||||
- return to the list base branch after integration cleanup, not a guessed task branch (cd5b27c0)
|
||||
- deliver AltGr characters to ConPTY tiles as text (cf3db919)
|
||||
- register ticket import tools in the external MCP container (2b40ea2d)
|
||||
|
||||
### Performance
|
||||
|
||||
- read untracked-worktree sweep from .git instead of spawning git per list (59d990c2)
|
||||
|
||||
### Refactoring
|
||||
|
||||
- drop ForceSetStatusAsync, route every status write through a guarded transition (ff7197c4)
|
||||
- delete dead Icon.* geometries and icon-stroke styles, add MDI name guard test (de685151)
|
||||
- Material icons for list, folder and smart-list icon keys with legacy mapping (7c0fe92a)
|
||||
- drop harness duplicates and maintainer notes from bundled prompts (a76788a5)
|
||||
- detect untracked worktrees by path instead of branch prefix (8911408b)
|
||||
|
||||
### Documentation
|
||||
|
||||
- document ssh workspace sessions (2e29e03a)
|
||||
- spec and plan for ssh workspace sessions (f7625baa)
|
||||
- document company mode (fetch, push, draft PR) and its open smoke tests (93fd6740)
|
||||
- plan for unit branch part 3 (company mode, draft PR) (2b2f61d7)
|
||||
- describe unit-branch routing in the review_task tool description (a3538973)
|
||||
- document unit branch, child absorb and legacy approve routing (82336848)
|
||||
- plan for unit branch part 2 (children fast-forward) (dc73f138)
|
||||
- document new branch naming and per-list base branch (8ac272df)
|
||||
- unit branch part 1 - naming and per-list base branch (db3c7e69)
|
||||
- per-list base branch and fetch before unit branch (3dc735b2)
|
||||
- unit branch and draft PR design (0e9373c0)
|
||||
- note the external MCP DI registration trap (16adcad8)
|
||||
- document cross-project ticket import and open checks (7724f5eb)
|
||||
- note the ImportTickets prime radio and the ticket fallback list (cb775958)
|
||||
- implementation plan for cross-project ticket import (c34680b4)
|
||||
- switch import source to project-board sweep after live probe (5bb49495)
|
||||
- design for cross-project ticket import with day planning (0ac49653)
|
||||
- update for v2.18.0 (899648ad)
|
||||
|
||||
## v2.18.0 — 2026-09-30
|
||||
|
||||
### Features
|
||||
|
||||
- column picker for the overview grid (a7c3d185)
|
||||
- folders slide open and closed, rows below follow (2be8ba5d)
|
||||
- animate folder expand in the sidebar (928e3905)
|
||||
- assign a colour to a sidebar folder (bb92dc27)
|
||||
- write weekly report as short bullets with haiku (8a31a67c)
|
||||
- searchable Material Design icon library for lists and folders (f2ff865d)
|
||||
- coloured dots for icon-less lists, guide line under chevron (9991f1fc)
|
||||
- make sidebar folder hierarchy readable (75b78cb2)
|
||||
- mission-control pane title shows usage limit reset time (5be97256)
|
||||
|
||||
### Fixes
|
||||
|
||||
- Recent-Sessions Resume/Brief-Buttons fuehren ihren Command wieder aus (60358100)
|
||||
- convert ticket HTML to markdown on import (27d2b1d8)
|
||||
- stop row buttons flashing disabled while a row command runs (2558e02b)
|
||||
- show diff window before parsing so ParseOp spinner is visible (ecb1cdbb)
|
||||
|
||||
### Documentation
|
||||
|
||||
- add visual verification session notes 2026-09-16 (05deda83)
|
||||
- update for v2.17.0 (f88d2a89)
|
||||
|
||||
## 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
|
||||
|
||||
@@ -100,7 +100,7 @@ So, per task:
|
||||
|
||||
### Gotchas
|
||||
- **Subagents:** use the `sonnet` model; stage files explicitly by path — never `git add -A` (parallel sessions often leave unrelated WIP in the tree).
|
||||
- **Icons:** `PathIcon` *fills* its geometry — line-art icons must be filled geometry or a `Path Classes="icon-stroke"`, else they render invisible. Colour comes from the `ICON PAINTING` block in `IslandStyles.axaml` (class + state); setting `Foreground`/`Stroke` inline kills the hover state.
|
||||
- **Icons:** Material Design Icons via `mdi:Name` + the `IconKey` converter (a misspelled name silently renders nothing — `MdiIconNamesTests` guards it). Colour comes from the `ICON PAINTING` block in `IslandStyles.axaml` (class + state); setting `Foreground`/`Stroke` inline kills the hover state.
|
||||
- **Localization:** `locales/en.json` and `locales/de.json` keys must stay in parity (Localization.Tests enforces it).
|
||||
- **Test fakes:** changing `IWorkerClient` / `WorkerHub` / ViewModel constructors breaks hand-rolled fakes in both test projects — update them.
|
||||
|
||||
|
||||
@@ -11,14 +11,16 @@ process hosts (real `claude` TUI in a Mission Control tile).
|
||||
Autonomous queue tasks are **not** covered here — they stay on the stream-json path via
|
||||
`TaskRunner`. See [worker-task-pipeline.md](worker-task-pipeline.md).
|
||||
|
||||
## The four session kinds
|
||||
## The six session kinds
|
||||
|
||||
| Kind | Hub spec method | Notes |
|
||||
|---|---|---|
|
||||
| Task session | `GetInteractiveLaunchSpec` | Effort from the task/list model preset |
|
||||
| Ad-hoc | `GetAdHocLaunchSpec` | Effort from the global default |
|
||||
| Planning | (planning start/resume) | Effort from `PlanningAlias`; uses `--permission-mode default`, **not** `plan` |
|
||||
| Task session | `GetInteractiveLaunchSpec` | No `--effort` — CLI default |
|
||||
| Ad-hoc | `GetAdHocLaunchSpec` | No `--effort` — CLI default |
|
||||
| Planning | (planning start/resume) | No `--effort`; uses `--permission-mode default`, **not** `plan` |
|
||||
| List handler | `GetMergeHelperLaunchSpec` / `GetMergeHelperHandoffLaunchSpec` | Model + effort fixed per role (`ModelRegistry.HandlerTriageAlias`/`HandlerWaitAlias`/`HandlerMergeAlias`), **not** from list config; `--permission-mode` via `PermissionModeResolver` (unattended) |
|
||||
| SSH workspace | `GetSshLaunchSpec` | Exe is `ssh.exe`, not claude: `-R <random port>:127.0.0.1:<remote_mcp_port>` + one remote shell command that base64-decodes a `claudedo` MCP config (per-session token) to `~/.claudedo/mcp-<paneId>.json` and runs claude. **Not** recorded in `mission_control_sessions`, no `claudedo_pane`/handoff hint. Workspaces: `app_settings.ssh_workspaces`, Settings → SSH-Workspaces |
|
||||
| Custom tile | none — built in the UI | `MissionControlViewModel.OpenCustomTileSession`: any console program from `ui.config.json` → `CustomTiles` (Settings → Custom tiles). Exe resolved via `ExecutableResolver` (shims through `cmd /c`), args split by `CustomTile.SplitArgs` (the library re-quotes each token). Empty folder → a submenu of home/list folders/browse. Not recorded, no env, no MCP |
|
||||
|
||||
## ⚠️ Gotcha: never pass task free-text as a CLI argument
|
||||
|
||||
@@ -39,9 +41,10 @@ description skips the file **and** the positional arg entirely — it still gets
|
||||
|
||||
## Argument ordering
|
||||
|
||||
Every spec passes `--effort <level>` from the relevant model's preset. It **leads** the args —
|
||||
except for a fresh task session with a brief, where `--add-dir <sessionDir>` must come first so
|
||||
`--effort` (a single-value flag) can sit directly before the positional kickoff.
|
||||
Only the list-handler specs pass `--effort` (from the role model's preset). Hand-driven sessions
|
||||
(task, ad-hoc, planning, handoff, resume) deliberately pass none, so the CLI default / the user's
|
||||
`/effort` setting applies. A positional kickoff must follow a single-value flag (`--session-id`,
|
||||
`--append-system-prompt-file`), never a variadic one (`--add-dir`, `--allowedTools`).
|
||||
|
||||
`--model` is deliberately **NOT** forced on an interactive session — the user can still switch
|
||||
models in the TUI.
|
||||
@@ -87,7 +90,7 @@ own transcript:
|
||||
`TaskRepository.SetInteractiveSessionIdAsync` — **before** the `LaunchSpec` is returned, i.e.
|
||||
before the ConPTY host ever spawns `claude`. `BuildFreshTaskArgsAsync` then passes it as
|
||||
`--session-id <guid>`, placed as the single-value flag directly before the positional kickoff
|
||||
(or, with no brief, right after `--effort`).
|
||||
(or, with no brief, as the only arg).
|
||||
3. **Fresh worktree wins.** `isFreshWorktree` forces `run` to `null` *and* is checked before
|
||||
reading `task.InteractiveSessionId`, so a recreated worktree never resumes a stale id from
|
||||
either source — it always takes the fresh path, which overwrites the stale
|
||||
@@ -97,6 +100,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
|
||||
@@ -285,6 +330,8 @@ bails out immediately instead of racing past the collection-based dedupe.
|
||||
(scoped to `MainWindow` via `AddClassHandler<MainWindow>`) never runs there — **Escape always
|
||||
reaches the PTY**. See the note in `src/ClaudeDo.Ui/CLAUDE.md`.
|
||||
|
||||
**AltGr bypass:** Windows reports AltGr as Ctrl+Alt, and Iciclecreek's `TerminalView.OnKeyDown` turns any Ctrl/Alt combo into a control sequence (NUL/0x1C/ESC) and marks it handled, so `@ \ { } € ~ |` never arrive as text. `InteractiveTerminalView.OnPreviewKeyDown` (tunnel) detects it via `IsAltGrText` and sends `KeySymbol` through `SendTextAsync` first.
|
||||
|
||||
`TaskRowViewModel.HasInteractiveSession` shows an accent "Interactive" chip instead of "Parked";
|
||||
tapping it jumps to that Mission Control pane. `TasksIslandViewModel.SyncInteractiveSessions`
|
||||
mirrors Mission Control's open panes onto the rows.
|
||||
@@ -311,7 +358,7 @@ Autonomous and interactive sessions do **not** share a system prompt. Per start
|
||||
|---|---|---|
|
||||
| Autonomous run/continue/retry | `TaskRunner.ResolveConfigAsync` → `ClaudeArgsBuilder.Build` | `--append-system-prompt <text>`, recomputed and re-sent on **every** invocation including a `--resume` continue (`PromptKind.System` + improvement/list/task overrides) |
|
||||
| Interactive task session, fresh | `InteractiveLaunchSpecService.BuildForTaskAsync` → `BuildFreshTaskArgsAsync` | none — no `--append-system-prompt(-file)` at all |
|
||||
| Interactive task session, resume | `InteractiveLaunchSpecService.BuildForTaskAsync` → `WindowsTerminalLauncher.BuildResumeArgs` | none — only `--resume <id>` (+ `--effort`) |
|
||||
| Interactive task session, resume | `InteractiveLaunchSpecService.BuildForTaskAsync` → `WindowsTerminalLauncher.BuildResumeArgs` | none — only `--resume <id>` |
|
||||
| Ad-hoc directory session | `InteractiveLaunchSpecService.BuildForDirectoryAsync` | none |
|
||||
| Planning session start | `InteractiveLaunchSpecService.BuildPlanningStart` → `WindowsTerminalLauncher.BuildPlanningStartArgs` | `--append-system-prompt-file <path>` (`PromptKind.Planning`) |
|
||||
| Planning session resume | `InteractiveLaunchSpecService.BuildPlanningResume` → `WindowsTerminalLauncher.BuildPlanningResumeArgs` | none — only `--permission-mode default --allowedTools <planning allowlist> --resume <id>` |
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# External MCP tool surface
|
||||
|
||||
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
|
||||
> Last verified against commit `38af549` (2026-08-11).
|
||||
> Drift check: `git log --oneline 38af549..HEAD -- src/ClaudeDo.Worker/External`
|
||||
> Last verified against commit `cb775958` (2026-10-01).
|
||||
> Drift check: `git log --oneline cb775958..HEAD -- src/ClaudeDo.Worker/External`
|
||||
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
|
||||
|
||||
Covers `src/ClaudeDo.Worker/External/` — the always-on MCP tools ClaudeDo exposes to general
|
||||
@@ -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** —
|
||||
@@ -99,6 +99,7 @@ Daily prep: `GetDailyPrepCandidates`, `SetMyDay`.
|
||||
| `QueueStateMcpTools` | `GetQueueState` |
|
||||
| `AttachmentMcpTools` | `AddTaskAttachment`, `ListTaskAttachments`, `RemoveTaskAttachment` |
|
||||
| `ListHandlerMcpTools` | `StartListHandler` |
|
||||
| `TicketMcpTools` | `GetTicketImportCandidates` (open assigned ticket-system items not yet imported + all lists + fallback list; `available=false` + `reason` when no ticket system), `ImportTicket` (one Idle task per item; no `list` → fallback list; `created=false` always carries `skippedReason`) |
|
||||
|
||||
## Per-tool behaviour worth knowing
|
||||
|
||||
@@ -108,13 +109,13 @@ Description/Result) in `tasksFull` instead (`tasks` null). Filtering by `created
|
||||
happens before the lean/full projection either way.
|
||||
|
||||
**`UpdateTaskStatus`** accepts `Idle` / `Queued` / `Cancelled` / `Done` only.
|
||||
- `Cancelled` goes through `TaskStateService.CancelAsync(..., allowFromIdle: true)` — the
|
||||
**only** caller that opts into cancelling from `Idle`. `PlanningChainCoordinator` relies on
|
||||
`Idle` staying a no-op there by default, because a child parked back to `Idle` mid-chain is
|
||||
a manual opt-out signal.
|
||||
- `Done` goes through `TaskStateService.ForceSetStatusAsync` (the same unconditional write the
|
||||
UI's "set status freely" affordance uses) but is **refused** for a task with an active
|
||||
worktree, since that would skip `review_task`'s merge.
|
||||
- `Cancelled` goes through `TaskStateService.CancelAsync(..., retire: true)` — like the UI's
|
||||
"Mark as cancelled", it opts into cancelling from `Idle`/`Failed`. `PlanningChainCoordinator`
|
||||
relies on `Idle` staying a no-op there by default, because a child parked back to `Idle`
|
||||
mid-chain is a manual opt-out signal.
|
||||
- `Done` goes through `TaskStateService.MarkDoneAsync` (the done toggle, so a chain successor
|
||||
unblocks and the parent advances) but is **refused** for a task with an active worktree,
|
||||
since that would skip `review_task`'s merge.
|
||||
|
||||
**`AddTask`** — always creates the task; also returns `possibleDuplicates` (up to 3, id/title/status
|
||||
only, no descriptions) — open (non-terminal) tasks in the *same list* whose normalized title
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Review, merge & conflict resolution
|
||||
|
||||
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
|
||||
> Last verified against `168ab1cd` (2026-08-26), which added the batch verify
|
||||
> (`skipVerify` + `verify_merges`).
|
||||
> Last verified against `0e6a48e2` (2026-10-01): parent's worktree is the unit branch (children
|
||||
> fast-forwarded into it); in a company repo approve ends in a pushed branch + draft PR.
|
||||
> Planning mode renders per file and `DiffLinesView` is retired.
|
||||
> Drift check: `git log --oneline 20bce9b..HEAD -- src/ClaudeDo.Worker/Lifecycle src/ClaudeDo.Worker/State src/ClaudeDo.Worker/Planning src/ClaudeDo.Ui/ViewModels/Conflicts src/ClaudeDo.Worker/External`
|
||||
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
|
||||
@@ -15,12 +15,32 @@ Covers the review→merge path: `TaskStateService` review transitions, `TaskMerg
|
||||
`ApproveReview` (hub) and `review_task` approve (MCP) are the **single** review+merge action.
|
||||
There is no separate "Merge all" entry.
|
||||
|
||||
- **Task with children** → drives `PlanningMergeOrchestrator`: merges the parent worktree if
|
||||
`Active`, then each `Done` child in order, then sets the parent `Done`. A mid-merge conflict
|
||||
pauses for `ContinuePlanningMerge` / `AbortPlanningMerge`.
|
||||
- **Childless task** → `TaskMergeService.ApproveAndMergeAsync`. A conflict keeps the task in
|
||||
`WaitingForReview`.
|
||||
- **Legacy unit** (`UnitBranchService.HasUnabsorbedChildrenAsync`: a `Done` child whose worktree
|
||||
is still `Active`, i.e. planned before the unit branch existed) → drives
|
||||
`PlanningMergeOrchestrator`: merges the parent worktree if `Active` (planning parents too, for a
|
||||
mixed unit), then each `Done` child in order, then sets the parent `Done`. A mid-merge conflict
|
||||
pauses for `ContinuePlanningMerge` / `AbortPlanningMerge`. Children in `WaitingForReview` are
|
||||
silently skipped there.
|
||||
- **Everything else** — a childless task, or a parent whose children were absorbed into its unit
|
||||
branch → `TaskMergeService.ApproveAndMergeAsync` on the task's own worktree. A conflict keeps
|
||||
the task in `WaitingForReview`. A planning parent with any child not `Done` is `blocked` there
|
||||
(`subtask X is not Done (status …)`, same as the orchestrator) — no half plan on the target.
|
||||
- **Child of a unit** (parent has an `Active` worktree) → `ApproveAndMergeAsync` absorbs it into
|
||||
the unit branch (`UnitBranchService.AbsorbChildAsync`, ff-only) instead of merging to the target.
|
||||
- **No active worktree** (sandbox run) → straight to `Done`.
|
||||
- **Company repo** (`PullRequestPublisher.IsCompanyRepoAsync`, origin matches
|
||||
`company_remote_pattern`, checked live) with an `Active` or `Kept` worktree → `ApproveCompanyAsync`
|
||||
instead of a merge, after the planning-parent gate and the child absorb: missing PAT / non-ADO
|
||||
remote is `blocked` before anything runs, then the verify command in the **worktree**
|
||||
(`skipVerify` ignored — no local merge to defer it to; red = `verify_failed`, nothing pushed),
|
||||
then `PullRequestPublisher.PublishAsync`: `git push -u origin <branch>` (PAT via `GIT_CONFIG_*`
|
||||
env, `AzureDevOpsClient.GitAuthEnv`), find-or-create the draft PR, store
|
||||
`worktrees.pull_request_url`, `WorktreeUpdated`. Result `pr_created` / `pr_updated` + URL
|
||||
(`MergeResult.PullRequestUrl`; MCP puts it in `mergeMessage`). Task stays `WaitingForReview`,
|
||||
worktree `Active`, ticket `InBearbeitung`; `Done` is the user's manual toggle after the ADO
|
||||
merge. A PR failure after a successful push is `blocked` with `gepusht, PR fehlgeschlagen: …`.
|
||||
⚠️ Only approve branches — `merge_task`/the merge modal and the legacy-unit orchestrator still
|
||||
merge locally in a company repo.
|
||||
|
||||
Review transitions all live in `TaskStateService`: `SubmitForReviewAsync`,
|
||||
`SubmitForChildrenAsync`, `ApproveReviewAsync`, `RejectToQueueAsync`, `RejectToIdleAsync`,
|
||||
@@ -184,9 +204,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 =
|
||||
@@ -203,7 +223,8 @@ succeeds, written **only** by `WorktreeRepository.SetMergedAsync` (which atomica
|
||||
|
||||
It is the only thing that makes `revert_merge` possible without heuristically searching
|
||||
`git log` — see [external-mcp.md](external-mcp.md) → `RevertMerge`. Null for any worktree
|
||||
merged before the field existed.
|
||||
merged before the field existed, and for a child absorbed into its unit branch (a fast-forward
|
||||
has no merge commit) — such a child is reverted with its whole unit, never alone.
|
||||
|
||||
## Review gate in the UI
|
||||
|
||||
@@ -231,6 +252,11 @@ Single-task mode starts the conflict merge, then parses each conflicted file int
|
||||
conflict `MergeFileSegment`s via the worker's `GetMergeConflictDocuments`. Types live in
|
||||
`ConflictModels`: `MergeFile` / `MergeFileSegment` / `MergeConflictBlock`.
|
||||
|
||||
⚠️ **The resolver restarts the merge itself** (`StartConflictMerge`, `leaveConflictsInTree`), so the caller's
|
||||
`autostash` consent must travel with it (`MergeCoordinator.ResolveConflictAsync(..., autostash)` →
|
||||
`OpenAsync(target, autostash)` → hub). Without it a dirty target answers `dirty_target`, the
|
||||
resolver stays closed after the stash-retry approve already reported `conflict`.
|
||||
|
||||
Exposed per active file: `ActiveOursText` / `ActiveResultText` / `ActiveTheirsText`
|
||||
(reconstructed from `MergeFile.OursText/ResultText/TheirsText`; Result seeds unresolved
|
||||
conflicts with Ours), plus `ActiveFile` / `SelectFileCommand` (multi-file switcher),
|
||||
|
||||
@@ -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. |
|
||||
|
||||
+39
-1
@@ -13,6 +13,7 @@ Checkliste lebt in `docs/verification-handoff.md`.
|
||||
|
||||
## UX / Nits (offen)
|
||||
|
||||
- **Eigene Tiles — Sichtprüfung offen** (gebaut 2026-10-05, `f37b59d5`). Nur Unit-Tests, nicht in der laufenden App geklickt. Prüfen: Einstellungen → „Eigene Tiles" anlegen/speichern; im +-Flyout öffnet ein Eintrag mit Ordner direkt ein Tile; ein Eintrag ohne Ordner zeigt ein **Untermenü** (Home, Listen-Ordner, „Ordner wählen…") — wackligster Teil ist der selbstreferenzierende `SessionTargetItemTheme` (`DynamicResource`) in `MissionControlView.axaml`; ungültiges Programm zeigt die Fehlermeldung im Tile.
|
||||
- **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>").
|
||||
- **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.
|
||||
@@ -32,7 +33,7 @@ Der Sichtprüfungs-Block dieses Batches (Ctrl+K/`#`, Titel-Edit, ConPTY-/Refine-
|
||||
Interactive-Chip, Diff-Viewer-Abstände, Manual-Tasks, Vorgaben-pro-Modell-Tabelle) ist von Mika
|
||||
verifiziert und deshalb hier entfernt. Es bleiben die Entscheidungen, die daran hängen:
|
||||
|
||||
- Interaktive ConPTY-Sessions bekommen `--effort`, aber **kein** `--model` — die Session läuft weiter unter dem Modell aus Mikas Claude-Config, der Effort kommt aus dem Preset des Modells, das ClaudeDo für die Task auflösen würde. Falls ClaudeDo auch interaktiv das Modell erzwingen soll, ist das ein Folge-Task.
|
||||
- Interaktive (handgesteuerte) ConPTY-Sessions bekommen weder `--model` noch `--effort` — beides kommt aus Mikas Claude-Config bzw. dem CLI-Default. Nur die List-Handler-Sessions pinnen Modell + Effort pro Rolle.
|
||||
- `AppSettings.DefaultMaxTurns` ist jetzt tatsächlich verdrahtet (`ModelPresets.For(..., global.DefaultMaxTurns)` in `TaskRunner.ResolveConfigAsync`): reiner Fallback für ein Modell, das auch nach `ModelRegistry.TryNormalizeAlias` auf keine Preset-Zeile trifft — vorher war das Feld tot (hartkodierte 30). Hat weiterhin keinen eigenen Editor mehr (nur die Preset-Tabelle pro Modell); Spalte könnte später entfallen, falls das nie zutrifft.
|
||||
|
||||
## Beobachtung (offen — Entscheidung Mika)
|
||||
@@ -74,6 +75,35 @@ Bleibt manuell zu prüfen (UI-Pfad, nie sichtbar gelaufen):
|
||||
- [ ] Base-URL leeren → Menüeintrag verschwindet, keine Netzwerkaufrufe mehr.
|
||||
- [ ] Sichtprüfung des Settings-Tabs + des Ticket-Projekt-Dropdowns (nie gerendert gesehen).
|
||||
|
||||
## Offene Verifikation (2026-10-01, Unit-Branch Teil 3: Firmen-Modus / Draft-PR)
|
||||
|
||||
Build + gefilterte Tests grün (Worker: AzureDevOps/CompanyFetchBase/CompanyApprove/CommitMessageBuilder,
|
||||
Ui: TicketSettingsTab/MergeSectionCompanyRepo, Data: PromptFiles, Localization). **Nie gegen echtes ADO,
|
||||
nicht visuell geprüft:**
|
||||
|
||||
- [ ] Sichtprüfung: Settings-Reiter heißt „Externe Dienste", Abschnitt Azure DevOps (PAT, Test, Löschen);
|
||||
Detail-Pane im Firmen-Repo: kein Ziel-Dropdown, Button „Draft-PR erstellen" → nach dem Approve
|
||||
„PR aktualisieren" + Button „Pull Request öffnen"; Approve bleibt hinter „Diff öffnen" gegated.
|
||||
- [ ] PAT eintragen, „Verbindung testen" — meldet Repo + Default-Branch der ersten Firmen-Liste.
|
||||
- [ ] Ticket-Task in einem echten Firmen-Repo bis zum Draft-PR: Branch `<typ>/<ticket>-<slug>` startet
|
||||
von frisch gefetchtem `origin/<basis>`, Commit ohne `ClaudeDo-Task:`-Trailer, PR-Titel
|
||||
`…, Resolves #<id>`, Beschreibung mit Ticket-Link (Projekt-Id-Lookup über `GET /api/ticket/{id}`
|
||||
ist nur gegen den Stub getestet) und `Geprüft:`-Zeile, Task bleibt WaitingForReview.
|
||||
- [ ] Zweiter Approve nach Reject mit Feedback → `pr_updated`, derselbe PR bekommt die neuen Commits.
|
||||
- [ ] Planning-Unit mit 2 Children im Firmen-Repo → ein PR für die ganze Unit.
|
||||
- [ ] API-Version-Fallback (Server meldet „supports is X") ist nur gegen den Stub getestet.
|
||||
|
||||
## Offene Verifikation (2026-10-01, Ticket-Import über alle Projekte)
|
||||
|
||||
Build + Unit-Tests grün, **nicht visuell/live verifiziert**:
|
||||
|
||||
- [ ] Sichtprüfung: Button „Tickets + Tag planen" im Prep-Panel (nur sichtbar mit Base-URL + Token),
|
||||
Fallback-Listen-Dropdown in Settings → Ticketsystem, Radio „Tickets + Tag planen" im Prime-Tab.
|
||||
- [ ] E2E: Button einmal gegen das echte Ticketsystem klicken — Items landen in sinnvollen Listen, die
|
||||
Fallback-Liste fängt den Rest, danach ist MyDay gefüllt; ein zweiter Klick importiert nichts Neues.
|
||||
- [ ] `PATCH /api/Ticket/{id}/status` auf einem **Task** (nicht Ticket) ist ungetestet — nachdem der erste
|
||||
importierte Task den Status wechselt, im Worker-Log auf eine `TicketStatusSync`-Warnung prüfen.
|
||||
|
||||
## Offene Verifikation (2026-07-27)
|
||||
|
||||
- **Empty-Repo-Hardening (2026-07-30)** — Worker/App neu starten (laufende Instanz hat den
|
||||
@@ -273,6 +303,14 @@ Weitere Punkte, gebaut + unit-getestet auf `main`, aber **nicht visuell verifizi
|
||||
`ClaudeProcess`/`ClaudeCliPreflight` den Shim über `cmd.exe /c` tatsächlich startet, nicht
|
||||
nur, dass der Check ihn findet).
|
||||
|
||||
## Offene Verifikation (2026-10-02, SSH-Workspaces)
|
||||
|
||||
- [ ] Sichtprüfung Settings → SSH-Workspaces: Reiter wählt richtig, Zeilen-Layout, Host-Vorschläge aus `~/.ssh/config`, Papierkorb-Hover, Fehlermeldung bei ungültiger Zeile.
|
||||
- [ ] VPS-Eintrag im Quick-Session-Menü startet eine Kachel; ssh-Prompts (Host-Key, Passphrase) erscheinen in der Kachel.
|
||||
- [ ] In der Remote-Session zeigt `/mcp` `claudedo` verbunden, `list_task_lists` funktioniert.
|
||||
- [ ] Kachel schließen baut den Tunnel ab (Port auf der VPS frei), zwei parallele Kacheln auf denselben Host gehen.
|
||||
- [ ] Worker nach Update neu starten — erst dann lauscht der Remote-Listener auf 47823.
|
||||
|
||||
---
|
||||
|
||||
## Bewusst verworfen (nicht erneut vorschlagen)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,928 @@
|
||||
# Unit-Branch Teil 1: Branch-Naming + Basis-Branch pro Liste — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Task-Branches heißen `<typ>/<nummer>-<slug>` statt `claudedo/{nr}-{slug}`, Firmen-Repos werden über ein konfigurierbares Remote-Muster erkannt, und jede Liste kann einen Basis-Branch (z.B. `dev`) festlegen, der Basis neuer Worktrees und Default-Merge-Ziel ist.
|
||||
|
||||
**Architecture:** Erkennung = statischer Helper `CompanyRepo` über `git remote get-url origin` + `WorkerConfig.CompanyRemotePattern`. Naming bleibt in `WorktreeManager` (`NameFor` + `BranchTypeFor`). `list_config.base_branch` ist eine neue nullable Spalte, tri-state durch alle Writer gereicht; `WorktreeManager.ResolveBaseCommitAsync` und `TaskMergeService.ResolveTargetAsync` lesen sie.
|
||||
|
||||
**Tech Stack:** .NET 8, EF Core (SQLite), xUnit mit echtem git, Avalonia 12.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-10-01-unit-branch-draft-pr-design.md` (Abschnitt 1 + Basis-Branch). Teil 2 (Unit-Branch/Children) und Teil 3 (Firmen-Modus: Fetch, Push, Draft-PR) folgen als eigene Pläne.
|
||||
|
||||
**Abweichung von der Spec (bewusst):** Der Self-Heal in `WorktreeManager.CreateAsync` bekommt **keinen** zusätzlichen DB-Ownership-Guard. Er feuert nur, wenn auch der Id-suffixte Name schon existiert — und der enthält die Task-Id, gehört also per Konstruktion diesem Task. Ein fremder Branch mit dem Klarnamen wird schon vom Kollisionscheck auf das Suffix umgelenkt. Nötig ist nur, dass der Kollisionscheck den **vollen neuen** Branchnamen prüft (Task 3).
|
||||
|
||||
**Konventionen für jede Task:**
|
||||
- Build: `dotnet build ClaudeDo.slnx -c Release` (nie Debug — ein laufender Worker lockt es).
|
||||
- Tests: `dotnet test <projekt> -c Release --no-build --filter "<filter>"` — nie die volle Suite.
|
||||
- Commit: `git add <pfade>` explizit, dann `git commit -m "..." -- <pfade>` (geteilter Checkout, nie `git add -A`). Kein Co-Authored-Trailer.
|
||||
|
||||
---
|
||||
|
||||
## Dateiübersicht
|
||||
|
||||
| Datei | Änderung |
|
||||
|---|---|
|
||||
| `src/ClaudeDo.Worker/Config/WorkerConfig.cs` | + `CompanyRemotePattern` (`company_remote_pattern`, Default `/_git/`) |
|
||||
| `src/ClaudeDo.Data/Git/GitService.cs` | + `GetRemoteUrlAsync` |
|
||||
| `src/ClaudeDo.Worker/Git/CompanyRepo.cs` | neu: `Matches`, `IsCompanyAsync` |
|
||||
| `src/ClaudeDo.Worker/Runner/CommitMessageBuilder.cs` | `ToSlug` löst Umlaute auf |
|
||||
| `src/ClaudeDo.Worker/Runner/WorktreeManager.cs` | `NameFor(task, ticketId, isCompany)`, `BranchTypeFor`, neuer Branchname, Ticket-Auflösung, Basis-Branch |
|
||||
| `src/ClaudeDo.Worker/Lifecycle/UntrackedWorktreeRecovery.cs` | Pfad- statt Präfix-Filter |
|
||||
| `src/ClaudeDo.Data/Models/ListConfigEntity.cs`, `Configuration/ListConfigEntityConfiguration.cs`, `Repositories/ListRepository.cs`, Migration | + `BaseBranch` |
|
||||
| `src/ClaudeDo.Data/Wire.cs` | `UpdateListConfigDto` / `ListConfigDto` + `BaseBranch` |
|
||||
| `src/ClaudeDo.Worker/Hub/WorkerHub.cs` | `UpdateListConfig` / `GetListConfig` |
|
||||
| `src/ClaudeDo.Worker/External/ConfigMcpTools.cs` | erhält `BaseBranch` **und** `PowerUseAllowed` (Bestandsbug), `get_list_config` liefert `baseBranch` |
|
||||
| `src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs` | `ResolveTargetAsync`, leeres Ziel zentral in `MergeAsync` aufgelöst |
|
||||
| `src/ClaudeDo.Ui/ViewModels/Agent/AgentConfigEditorViewModel.cs`, `ViewModels/Modals/ListSettingsModalViewModel.cs`, `Views/Modals/ListSettingsModalView.axaml`, `locales/*.json` | Basis-Branch-Feld |
|
||||
| `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` | echte Branchnamen in Dialogen, kein `← main` |
|
||||
| `src/ClaudeDo.Worker/CLAUDE.md`, `src/ClaudeDo.Data/CLAUDE.md` | Doku |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Firmen-Repo-Erkennung
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Worker/Config/WorkerConfig.cs` (nach `TicketApiBaseUrl`, ~Zeile 71)
|
||||
- Modify: `src/ClaudeDo.Data/Git/GitService.cs` (neben `ListLocalBranchesAsync`, ~Zeile 404)
|
||||
- Create: `src/ClaudeDo.Worker/Git/CompanyRepo.cs`
|
||||
- Test: `tests/ClaudeDo.Worker.Tests/Git/CompanyRepoTests.cs`
|
||||
|
||||
- [ ] **Step 1: Failing test schreiben**
|
||||
|
||||
```csharp
|
||||
using ClaudeDo.Data.Git;
|
||||
using ClaudeDo.Worker.Git;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.Git;
|
||||
|
||||
public sealed class CompanyRepoTests : IDisposable
|
||||
{
|
||||
private readonly GitRepoFixture _repo = new();
|
||||
public void Dispose() => _repo.Dispose();
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://srv.local/Coll/Proj/_git/Repo", "/_git/", true)]
|
||||
[InlineData("https://mika@srv.local/tfs/Coll/Proj/_git/Mein%20Repo.git", "/_git/", true)]
|
||||
[InlineData("ssh://git@git.kuns.dev:22222/kuns/ClaudeDo.git", "/_git/", false)]
|
||||
[InlineData(null, "/_git/", false)]
|
||||
[InlineData("https://srv.local/Coll/Proj/_git/Repo", "", false)]
|
||||
[InlineData("https://GITHUB.com/acme/repo.git", "github.com/acme", true)]
|
||||
public void Matches(string? url, string pattern, bool expected)
|
||||
=> Assert.Equal(expected, CompanyRepo.Matches(url, pattern));
|
||||
|
||||
[Fact]
|
||||
public async Task IsCompanyAsync_reads_origin()
|
||||
{
|
||||
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||
var git = new GitService();
|
||||
Assert.False(await CompanyRepo.IsCompanyAsync(git, _repo.RepoDir, "/_git/", CancellationToken.None));
|
||||
|
||||
GitRepoFixture.RunGit(_repo.RepoDir, "remote", "add", "origin", "https://srv.local/Coll/Proj/_git/Repo");
|
||||
|
||||
Assert.True(await CompanyRepo.IsCompanyAsync(git, _repo.RepoDir, "/_git/", CancellationToken.None));
|
||||
Assert.Equal("https://srv.local/Coll/Proj/_git/Repo", await git.GetRemoteUrlAsync(_repo.RepoDir));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Test laufen lassen — muss am Compiler scheitern**
|
||||
|
||||
Run: `dotnet build ClaudeDo.slnx -c Release`
|
||||
Expected: FAIL, `CompanyRepo` / `GetRemoteUrlAsync` existieren nicht.
|
||||
|
||||
- [ ] **Step 3: Implementieren**
|
||||
|
||||
`WorkerConfig.cs`, direkt nach der `TicketApiBaseUrl`-Property:
|
||||
|
||||
```csharp
|
||||
/// <summary>Teilstring der <c>origin</c>-URL, an dem ein Firmen-Repo erkannt wird. Nur per Hand editierbar.</summary>
|
||||
[JsonPropertyName("company_remote_pattern")]
|
||||
public string CompanyRemotePattern { get; set; } = "/_git/";
|
||||
```
|
||||
|
||||
`GitService.cs`, neben `ListLocalBranchesAsync`:
|
||||
|
||||
```csharp
|
||||
/// <summary>URL des Remotes <paramref name="remote"/>, oder null wenn es keins gibt.</summary>
|
||||
public async Task<string?> GetRemoteUrlAsync(string repoDir, string remote = "origin", CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["remote", "get-url", remote], ct);
|
||||
return exitCode == 0 && !string.IsNullOrWhiteSpace(stdout) ? stdout.Trim() : null;
|
||||
}
|
||||
```
|
||||
|
||||
`src/ClaudeDo.Worker/Git/CompanyRepo.cs`:
|
||||
|
||||
```csharp
|
||||
using ClaudeDo.Data.Git;
|
||||
|
||||
namespace ClaudeDo.Worker.Git;
|
||||
|
||||
/// <summary>Erkennt Firmen-Repos am <c>origin</c>-Remote; das Muster kommt aus <c>WorkerConfig.CompanyRemotePattern</c>.</summary>
|
||||
public static class CompanyRepo
|
||||
{
|
||||
/// <summary>True wenn <paramref name="remoteUrl"/> das (nicht-leere) Muster enthält, ohne Groß-/Kleinschreibung.</summary>
|
||||
public static bool Matches(string? remoteUrl, string? pattern) =>
|
||||
!string.IsNullOrWhiteSpace(remoteUrl) && !string.IsNullOrWhiteSpace(pattern)
|
||||
&& remoteUrl.Contains(pattern, StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>Liest <c>origin</c> live (kein Cache — ein Remote kann sich jederzeit ändern).</summary>
|
||||
public static async Task<bool> IsCompanyAsync(GitService git, string workingDir, string? pattern, CancellationToken ct) =>
|
||||
Matches(await git.GetRemoteUrlAsync(workingDir, "origin", ct), pattern);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build + Test**
|
||||
|
||||
Run: `dotnet build ClaudeDo.slnx -c Release` → 0 Fehler.
|
||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --no-build --filter "FullyQualifiedName~CompanyRepoTests"`
|
||||
Expected: PASS (7 Tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/Config/WorkerConfig.cs src/ClaudeDo.Data/Git/GitService.cs src/ClaudeDo.Worker/Git/CompanyRepo.cs tests/ClaudeDo.Worker.Tests/Git/CompanyRepoTests.cs
|
||||
git commit -m "feat(worker): detect company repos by origin remote pattern" -- src/ClaudeDo.Worker/Config/WorkerConfig.cs src/ClaudeDo.Data/Git/GitService.cs src/ClaudeDo.Worker/Git/CompanyRepo.cs tests/ClaudeDo.Worker.Tests/Git/CompanyRepoTests.cs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Naming-Bausteine (Typ, Nummer, Umlaute)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Worker/Runner/CommitMessageBuilder.cs:49-60` (`ToSlug`)
|
||||
- Modify: `src/ClaudeDo.Worker/Runner/WorktreeManager.cs:41-47` (`NameFor`) + neues `BranchTypeFor`
|
||||
- Test: `tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs:82-89` (Theory ersetzen), `tests/ClaudeDo.Worker.Tests/Runner/CommitMessageBuilderTests.cs`
|
||||
|
||||
- [ ] **Step 1: Tests anpassen/ergänzen**
|
||||
|
||||
In `WorktreeManagerTests.cs` die Theory `NameFor_NumberPlusCappedTitleSlug` (Zeilen 82-89) ersetzen durch:
|
||||
|
||||
```csharp
|
||||
[Theory]
|
||||
[InlineData("Fix: Löschen des Tasks!", null, false, "7-fix-loeschen-des-tasks")]
|
||||
[InlineData("äöü", null, false, "7-aeoeue")]
|
||||
[InlineData("a very long title that goes well beyond the forty char cap", null, false, "7-a-very-long-title-that-goes-well-beyond")]
|
||||
[InlineData("test task", 412, false, "412-test-task")]
|
||||
[InlineData("test task", 412, true, "412-test-task")]
|
||||
[InlineData("test task", null, true, "test-task")]
|
||||
[InlineData("!!!", null, true, "7")]
|
||||
public void NameFor_NumberRules(string title, int? ticketId, bool isCompany, string expected)
|
||||
{
|
||||
var task = new TaskEntity { Id = "x", ListId = "y", Title = title, Number = 7, CreatedAt = DateTime.UtcNow };
|
||||
Assert.Equal(expected, WorktreeManager.NameFor(task, ticketId, isCompany));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("feat", "feature")]
|
||||
[InlineData("fix", "fix")]
|
||||
[InlineData("refactor", "refactor")]
|
||||
[InlineData("docs", "docs")]
|
||||
[InlineData("test", "test")]
|
||||
[InlineData("chore", "chore")]
|
||||
[InlineData("ci", "chore")]
|
||||
[InlineData("perf", "chore")]
|
||||
[InlineData(" FEAT ", "feature")]
|
||||
[InlineData("", "chore")]
|
||||
public void BranchTypeFor_maps_commit_types(string commitType, string expected)
|
||||
=> Assert.Equal(expected, WorktreeManager.BranchTypeFor(commitType));
|
||||
```
|
||||
|
||||
In `CommitMessageBuilderTests.cs` einen Test ergänzen:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public void ToSlug_transliterates_umlauts()
|
||||
{
|
||||
Assert.Equal("aerger-ueber-groesse-strasse", CommitMessageBuilder.ToSlug("Ärger über Größe Straße"));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build — muss scheitern**
|
||||
|
||||
Run: `dotnet build ClaudeDo.slnx -c Release`
|
||||
Expected: FAIL, `NameFor` hat keine 3-Parameter-Überladung, `BranchTypeFor` fehlt.
|
||||
|
||||
- [ ] **Step 3: Implementieren**
|
||||
|
||||
`CommitMessageBuilder.ToSlug` — erste Zeile ersetzen:
|
||||
|
||||
```csharp
|
||||
// Umlaute auflösen statt strippen: "Löschen" -> "loeschen", nicht "lschen".
|
||||
var lower = name.ToLowerInvariant()
|
||||
.Replace("ä", "ae").Replace("ö", "oe").Replace("ü", "ue").Replace("ß", "ss");
|
||||
```
|
||||
|
||||
`WorktreeManager.cs` — `NameFor` (Zeilen 41-47) ersetzen durch:
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Branch leaf and folder name: <c>{nummer}-{slug}</c>. Nummer = Ticket-Id, sonst die Task-Nummer —
|
||||
/// außer im Firmen-Repo ohne Ticket, dort sähe sie wie eine Ticket-Id aus und entfällt.
|
||||
/// </summary>
|
||||
public static string NameFor(TaskEntity task, int? ticketId, bool isCompanyRepo)
|
||||
{
|
||||
var titleSlug = CommitMessageBuilder.ToSlug(task.Title);
|
||||
if (titleSlug.Length > 40) titleSlug = titleSlug[..40].TrimEnd('-');
|
||||
var number = ticketId?.ToString() ?? (isCompanyRepo ? null : task.Number.ToString());
|
||||
if (titleSlug.Length == 0) return number ?? task.Number.ToString();
|
||||
return number is null ? titleSlug : $"{number}-{titleSlug}";
|
||||
}
|
||||
|
||||
/// <summary>Branch-Präfix nach work-conventions: <c>feat</c> → <c>feature</c>, Typen ohne eigenes Präfix → <c>chore</c>.</summary>
|
||||
public static string BranchTypeFor(string? commitType)
|
||||
{
|
||||
var t = (commitType ?? "").Trim().ToLowerInvariant();
|
||||
return t switch
|
||||
{
|
||||
"feat" => "feature",
|
||||
"fix" or "refactor" or "docs" or "test" or "chore" => t,
|
||||
_ => "chore",
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
In `CreateAsync` vorerst nur den Aufruf kompilierbar machen (Zeile 62): `var name = NameFor(task, null, false);` — die echte Verdrahtung macht Task 3.
|
||||
|
||||
Die übrigen `NameFor(task)`-Aufrufe in `WorktreeManagerTests.cs` (Zeilen 131, 137, 160, 314, 320) auf `WorktreeManager.NameFor(task, null, false)` umstellen — sonst kompiliert das Testprojekt nicht. Die `claudedo/`-Literale dort bleiben in diesem Task stehen (Task 3 ändert sie).
|
||||
|
||||
- [ ] **Step 4: Build + Test**
|
||||
|
||||
Run: `dotnet build ClaudeDo.slnx -c Release` → 0 Fehler.
|
||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --no-build --filter "FullyQualifiedName~WorktreeManagerTests|FullyQualifiedName~CommitMessageBuilderTests"`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/Runner/CommitMessageBuilder.cs src/ClaudeDo.Worker/Runner/WorktreeManager.cs tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs tests/ClaudeDo.Worker.Tests/Runner/CommitMessageBuilderTests.cs
|
||||
git commit -m "feat(worker): branch type and number rules for task branches" -- src/ClaudeDo.Worker/Runner/CommitMessageBuilder.cs src/ClaudeDo.Worker/Runner/WorktreeManager.cs tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs tests/ClaudeDo.Worker.Tests/Runner/CommitMessageBuilderTests.cs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: `CreateAsync` nutzt das neue Schema
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Worker/Runner/WorktreeManager.cs:49-90` (`CreateAsync`) + neue private `ResolveTicketIdAsync`
|
||||
- Test: `tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs`
|
||||
|
||||
- [ ] **Step 1: Tests anpassen + neue Tests**
|
||||
|
||||
Bestehende Literale umstellen (der Test-Task hat `CommitType = "chore"`, also Präfix `chore/`):
|
||||
- Zeile 69: `Assert.StartsWith($"chore/{task.Number}-test-task", ctx.BranchName);`
|
||||
- Zeile 131: `var foreign = $"chore/{WorktreeManager.NameFor(task, null, false)}";`
|
||||
- Zeile 138: `Assert.Equal($"chore/{suffixed}", ctx.BranchName);`
|
||||
- Zeile 160: `var branchName = $"chore/{WorktreeManager.NameFor(task, null, false)}";`
|
||||
- Zeile 314: `var branchName = $"chore/{WorktreeManager.NameFor(task, null, false)}";`
|
||||
|
||||
Neue Tests in derselben Klasse (vor `MakeEntities`):
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task CreateAsync_CompanyRepoWithTicket_UsesTicketIdAndFeaturePrefix()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var repo = CreateRepo();
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "remote", "add", "origin", "https://srv.local/Coll/Proj/_git/Repo");
|
||||
var (task, list) = MakeEntities(repo.RepoDir);
|
||||
task.CommitType = "feat";
|
||||
task.TicketRef = "bandel:412";
|
||||
var (mgr, _) = await CreateManagerAsync(task, list);
|
||||
|
||||
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
|
||||
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
|
||||
|
||||
Assert.StartsWith("feature/412-test-task", ctx.BranchName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_CompanyRepoWithoutTicket_OmitsNumber()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var repo = CreateRepo();
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "remote", "add", "origin", "https://srv.local/Coll/Proj/_git/Repo");
|
||||
var (task, list) = MakeEntities(repo.RepoDir);
|
||||
var (mgr, _) = await CreateManagerAsync(task, list);
|
||||
|
||||
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
|
||||
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
|
||||
|
||||
Assert.StartsWith("chore/test-task", ctx.BranchName);
|
||||
Assert.DoesNotContain($"/{task.Number}-", ctx.BranchName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateAsync_ChildWithoutTicket_UsesParentTicket()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var repo = CreateRepo();
|
||||
var (child, list) = MakeEntities(repo.RepoDir);
|
||||
var parent = new TaskEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(), ListId = list.Id, Title = "parent", CommitType = "feat",
|
||||
TicketRef = "bandel:77", CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
child.ParentTaskId = parent.Id;
|
||||
|
||||
var db = new DbFixture();
|
||||
_dbFixtures.Add(db);
|
||||
using (var seedCtx = db.CreateContext())
|
||||
{
|
||||
await new ListRepository(seedCtx).AddAsync(list);
|
||||
await new TaskRepository(seedCtx).AddAsync(parent);
|
||||
await new TaskRepository(seedCtx).AddAsync(child);
|
||||
}
|
||||
var mgr = new WorktreeManager(new GitService(), db.CreateFactory(),
|
||||
new WorkerConfig { WorktreeRootStrategy = "sibling" }, NullLogger<WorktreeManager>.Instance);
|
||||
|
||||
var ctx = await mgr.CreateAsync(child, list, CancellationToken.None);
|
||||
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
|
||||
|
||||
Assert.StartsWith("chore/77-test-task", ctx.BranchName);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Test laufen lassen — muss scheitern**
|
||||
|
||||
Run: `dotnet build ClaudeDo.slnx -c Release && dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --no-build --filter "FullyQualifiedName~WorktreeManagerTests"`
|
||||
Expected: FAIL — Branches heißen noch `claudedo/…`.
|
||||
|
||||
- [ ] **Step 3: Implementieren**
|
||||
|
||||
In `CreateAsync` Zeile 62 (`var name = NameFor(task, null, false);`) ersetzen durch:
|
||||
|
||||
```csharp
|
||||
var isCompany = await CompanyRepo.IsCompanyAsync(_git, workingDir, _cfg.CompanyRemotePattern, ct);
|
||||
var name = NameFor(task, await ResolveTicketIdAsync(task, ct), isCompany);
|
||||
var branchType = BranchTypeFor(task.CommitType);
|
||||
```
|
||||
|
||||
Kollisionscheck + Branchname (Zeilen 80-89) ersetzen durch:
|
||||
|
||||
```csharp
|
||||
// Numbers restart after a DB reset, and without the claudedo/ prefix a taken name may also be
|
||||
// the user's own branch — the self-heal below would force-delete it, the id suffix keeps it untouched.
|
||||
if (Directory.Exists(Path.Combine(parentDir, name))
|
||||
|| (await _git.ListLocalBranchesAsync(workingDir, ct)).Contains($"{branchType}/{name}"))
|
||||
{
|
||||
var id = task.Id.Replace("-", "");
|
||||
name = $"{name}-{id[..Math.Min(8, id.Length)]}";
|
||||
}
|
||||
|
||||
var branchName = $"{branchType}/{name}";
|
||||
```
|
||||
|
||||
Neue private Methode (vor `ResolveBaseCommitAsync`):
|
||||
|
||||
```csharp
|
||||
/// <summary>Ticket-Id des Tasks, sonst die seines Parents (Children erben das Ticket ihrer Unit).</summary>
|
||||
private async Task<int?> ResolveTicketIdAsync(TaskEntity task, CancellationToken ct)
|
||||
{
|
||||
var own = TicketStatusMap.ParseBandelTicketId(task.TicketRef);
|
||||
if (own is not null || task.ParentTaskId is null) return own;
|
||||
using var ctx = _dbFactory.CreateDbContext();
|
||||
var parentRef = await ctx.Tasks.AsNoTracking()
|
||||
.Where(t => t.Id == task.ParentTaskId)
|
||||
.Select(t => t.TicketRef)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
return TicketStatusMap.ParseBandelTicketId(parentRef);
|
||||
}
|
||||
```
|
||||
|
||||
Usings ergänzen: `using ClaudeDo.Worker.Tickets;` (für `TicketStatusMap`). `ClaudeDo.Worker.Git` ist schon importiert.
|
||||
|
||||
- [ ] **Step 4: Build + Test**
|
||||
|
||||
Run: `dotnet build ClaudeDo.slnx -c Release && dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --no-build --filter "FullyQualifiedName~WorktreeManagerTests|FullyQualifiedName~InteractiveLaunchSpecServiceTests|FullyQualifiedName~TaskRunnerTests"`
|
||||
Expected: PASS. Schlägt ein Test fehl, der einen `claudedo/{nummer}-…`-Namen **erwartet** (nicht nur als Fixture seedet), auf `chore/…` umstellen und im Bericht nennen.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/Runner/WorktreeManager.cs tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs
|
||||
git commit -m "feat(worker): name task branches <type>/<number>-<slug>" -- src/ClaudeDo.Worker/Runner/WorktreeManager.cs tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: UntrackedWorktreeRecovery ohne Präfix
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Worker/Lifecycle/UntrackedWorktreeRecovery.cs`
|
||||
- Test: `tests/ClaudeDo.Worker.Tests/Lifecycle/UntrackedWorktreeRecoveryTests.cs` (bestehende Datei; per `Glob tests/**/UntrackedWorktreeRecoveryTests.cs` finden und dort ergänzen)
|
||||
|
||||
- [ ] **Step 1: Failing test**
|
||||
|
||||
```csharp
|
||||
[Theory]
|
||||
[InlineData(@"C:\src\.claudedo-worktrees\lager-app\412-fix", @"C:\Users\x\.claudeDo\worktrees", true)]
|
||||
[InlineData(@"C:\src\.claudedo-worktrees\planning\abc123", @"C:\Users\x\.claudeDo\worktrees", false)]
|
||||
[InlineData(@"C:\Users\x\.claudeDo\worktrees\lager-app\412-fix", @"C:\Users\x\.claudeDo\worktrees", true)]
|
||||
[InlineData(@"C:\Users\x\.claudeDo\worktrees\planning\abc123", @"C:\Users\x\.claudeDo\worktrees", false)]
|
||||
[InlineData(@"C:\src\my-own-worktree", @"C:\Users\x\.claudeDo\worktrees", false)]
|
||||
public void IsTaskWorktreePath(string path, string centralRoot, bool expected)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows()) return;
|
||||
Assert.Equal(expected, UntrackedWorktreeRecovery.IsTaskWorktreePath(path, centralRoot));
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build — scheitert** (`IsTaskWorktreePath` fehlt).
|
||||
|
||||
Run: `dotnet build ClaudeDo.slnx -c Release` → FAIL.
|
||||
|
||||
- [ ] **Step 3: Implementieren**
|
||||
|
||||
Konstante `BranchPrefix` löschen. Konstruktor um `WorkerConfig cfg` erweitern (Feld `_cfg`; `using ClaudeDo.Worker.Config;`). Klassen-Summary auf „ClaudeDo-Task-Worktrees (unter `.claudedo-worktrees` oder dem Central-Root, ohne Planning-Sessions)“ ändern.
|
||||
|
||||
In `StartAsync` nach dem Laden der Listen den Central-Root bestimmen:
|
||||
|
||||
```csharp
|
||||
var settings = await new AppSettingsRepository(ctx).GetAsync(cancellationToken);
|
||||
var centralRoot = !string.IsNullOrWhiteSpace(settings.CentralWorktreeRoot)
|
||||
? settings.CentralWorktreeRoot
|
||||
: _cfg.CentralWorktreeRoot;
|
||||
```
|
||||
|
||||
Den Filter `claudedoPaths` ersetzen:
|
||||
|
||||
```csharp
|
||||
var claudedoPaths = registered
|
||||
.Where(w => w.Branch is not null && IsTaskWorktreePath(w.Path, centralRoot))
|
||||
.Select(w => w.Path);
|
||||
```
|
||||
|
||||
Neue statische Methode (neben `FindUntrackedPaths`):
|
||||
|
||||
```csharp
|
||||
/// <summary>True für einen Pfad unter <c>.claudedo-worktrees</c> oder dem Central-Root, außer Planning-Session-Worktrees.</summary>
|
||||
public static bool IsTaskWorktreePath(string path, string centralRoot)
|
||||
{
|
||||
var sep = Path.DirectorySeparatorChar;
|
||||
var full = Normalize(path);
|
||||
string? rest = null;
|
||||
|
||||
var central = Normalize(centralRoot);
|
||||
if (full.StartsWith(central + sep, StringComparison.OrdinalIgnoreCase))
|
||||
rest = full[(central.Length + 1)..];
|
||||
else
|
||||
{
|
||||
var marker = $"{sep}.claudedo-worktrees{sep}";
|
||||
var idx = full.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
|
||||
if (idx >= 0) rest = full[(idx + marker.Length)..];
|
||||
}
|
||||
|
||||
return rest is not null && !rest.StartsWith($"planning{sep}", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Build + Test**
|
||||
|
||||
Run: `dotnet build ClaudeDo.slnx -c Release && dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --no-build --filter "FullyQualifiedName~UntrackedWorktreeRecovery"`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/Lifecycle/UntrackedWorktreeRecovery.cs tests/ClaudeDo.Worker.Tests/Lifecycle/UntrackedWorktreeRecoveryTests.cs
|
||||
git commit -m "refactor(worker): detect untracked worktrees by path instead of branch prefix" -- src/ClaudeDo.Worker/Lifecycle/UntrackedWorktreeRecovery.cs tests/ClaudeDo.Worker.Tests/Lifecycle/UntrackedWorktreeRecoveryTests.cs
|
||||
```
|
||||
|
||||
(Pfad der Testdatei an den tatsächlichen Fundort anpassen.)
|
||||
|
||||
---
|
||||
|
||||
### Task 5: `list_config.base_branch` durch alle Writer
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Data/Models/ListConfigEntity.cs`, `src/ClaudeDo.Data/Configuration/ListConfigEntityConfiguration.cs`, `src/ClaudeDo.Data/Repositories/ListRepository.cs:161-180`
|
||||
- Create: Migration `AddListBaseBranch` (generiert)
|
||||
- Modify: `src/ClaudeDo.Data/Wire.cs:111,115`
|
||||
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs:577-647`
|
||||
- Modify: `src/ClaudeDo.Worker/External/ConfigMcpTools.cs:13,53-59,104-139`
|
||||
- Test: `tests/ClaudeDo.Worker.Tests/Hub/ListConfigBaseBranchTests.cs`
|
||||
|
||||
- [ ] **Step 1: Failing tests**
|
||||
|
||||
`tests/ClaudeDo.Worker.Tests/Hub/ListConfigBaseBranchTests.cs` — Hub-Aufbau 1:1 aus `tests/ClaudeDo.Worker.Tests/Tickets/ListConfigTicketProjectTests.cs` (gleicher `CreateHub`, `SeedListAsync`, `ReadConfigAsync`):
|
||||
|
||||
```csharp
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.External;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
using Xunit;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.Hub;
|
||||
|
||||
/// BaseBranch und PowerUseAllowed sitzen in der SetConfigAsync-kopiert-verbatim-Falle: jeder Writer,
|
||||
/// der sie nicht durchreicht, setzt sie still zurück.
|
||||
public sealed class ListConfigBaseBranchTests : IDisposable
|
||||
{
|
||||
private readonly DbFixture _db = new();
|
||||
public void Dispose() => _db.Dispose();
|
||||
|
||||
private WorkerHub CreateHub()
|
||||
{
|
||||
var factory = _db.CreateFactory();
|
||||
var broadcaster = new HubBroadcaster(new CapturingHubContext());
|
||||
var hub = new WorkerHub(
|
||||
null!, null!, null!, null!, broadcaster, factory,
|
||||
null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!,
|
||||
null!, new ClaudeDo.Worker.Online.OnlineInboxConfig(), new ClaudeDo.Worker.OnlineRefreshTokenStore(new ClaudeDo.Worker.DpapiTokenStore("unused.token")),
|
||||
new ClaudeDo.Worker.Runner.PendingQuestionRegistry(), null!);
|
||||
hub.Clients = new FakeHubCallerClients(new RecordingClientProxy());
|
||||
hub.Context = new FakeHubCallerContext();
|
||||
return hub;
|
||||
}
|
||||
|
||||
private async Task<string> SeedListAsync(string? baseBranch, bool powerUse = false)
|
||||
{
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
await using var ctx = _db.CreateContext();
|
||||
var repo = new ListRepository(ctx);
|
||||
await repo.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
|
||||
await repo.SetConfigAsync(new ListConfigEntity { ListId = listId, BaseBranch = baseBranch, PowerUseAllowed = powerUse });
|
||||
return listId;
|
||||
}
|
||||
|
||||
private async Task<ListConfigEntity?> ReadConfigAsync(string listId)
|
||||
{
|
||||
await using var ctx = _db.CreateContext();
|
||||
return await new ListRepository(ctx).GetConfigAsync(listId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task set_list_config_preserves_BaseBranch_and_PowerUseAllowed()
|
||||
{
|
||||
var listId = await SeedListAsync("dev", powerUse: true);
|
||||
var factory = _db.CreateFactory();
|
||||
await using var ctx = factory.CreateDbContext();
|
||||
var tool = new ConfigMcpTools(new ListRepository(ctx), new TaskRepository(ctx),
|
||||
new HubBroadcaster(new CapturingHubContext()), factory);
|
||||
|
||||
await tool.SetListConfig(listId, model: "opus", cancellationToken: CancellationToken.None);
|
||||
|
||||
var cfg = await ReadConfigAsync(listId);
|
||||
Assert.Equal("dev", cfg!.BaseBranch);
|
||||
Assert.True(cfg.PowerUseAllowed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task get_list_config_returns_BaseBranch()
|
||||
{
|
||||
var listId = await SeedListAsync("dev");
|
||||
var factory = _db.CreateFactory();
|
||||
await using var ctx = factory.CreateDbContext();
|
||||
var tool = new ConfigMcpTools(new ListRepository(ctx), new TaskRepository(ctx),
|
||||
new HubBroadcaster(new CapturingHubContext()), factory);
|
||||
|
||||
var result = await tool.GetListConfig(listId, CancellationToken.None);
|
||||
|
||||
Assert.Equal("dev", result.Config!.BaseBranch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateListConfig_null_keeps_BaseBranch()
|
||||
{
|
||||
var listId = await SeedListAsync("dev");
|
||||
await CreateHub().UpdateListConfig(new UpdateListConfigDto(listId, "opus", null, null));
|
||||
Assert.Equal("dev", (await ReadConfigAsync(listId))!.BaseBranch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateListConfig_empty_clears_BaseBranch()
|
||||
{
|
||||
var listId = await SeedListAsync("dev");
|
||||
await CreateHub().UpdateListConfig(new UpdateListConfigDto(listId, "opus", null, null, BaseBranch: ""));
|
||||
Assert.Null((await ReadConfigAsync(listId))!.BaseBranch);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task UpdateListConfig_sets_BaseBranch_and_GetListConfig_returns_it()
|
||||
{
|
||||
var listId = await SeedListAsync(null);
|
||||
var hub = CreateHub();
|
||||
await hub.UpdateListConfig(new UpdateListConfigDto(listId, null, null, null, BaseBranch: "dev"));
|
||||
Assert.Equal("dev", (await hub.GetListConfig(listId))!.BaseBranch);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build — scheitert** (`BaseBranch` fehlt).
|
||||
|
||||
- [ ] **Step 3: Implementieren**
|
||||
|
||||
`ListConfigEntity.cs`, nach `PowerUseAllowed`:
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Basis neuer Worktrees und Default-Merge-/PR-Ziel (z.B. <c>dev</c>). Null = ausgecheckter Branch
|
||||
/// (privat) bzw. Default-Branch des Remotes (Firma). ⚠️ SetConfigAsync kopiert verbatim.
|
||||
/// </summary>
|
||||
public string? BaseBranch { get; set; }
|
||||
```
|
||||
|
||||
`ListConfigEntityConfiguration.cs`: `builder.Property(c => c.BaseBranch).HasColumnName("base_branch");`
|
||||
|
||||
`ListRepository.SetConfigAsync`, im `else`-Zweig: `existing.BaseBranch = config.BaseBranch;`
|
||||
|
||||
Migration erzeugen:
|
||||
|
||||
```bash
|
||||
dotnet ef migrations add AddListBaseBranch --project src/ClaudeDo.Data/ClaudeDo.Data.csproj --configuration Release
|
||||
```
|
||||
|
||||
Erwartet: neue Migration mit genau einem `AddColumn<string>(name: "base_branch", table: "list_config", nullable: true)`. Steht mehr drin (fremde Snapshot-Drift), abbrechen und melden.
|
||||
|
||||
`Wire.cs` — Kommentar über `UpdateListConfigDto` um eine Zeile ergänzen: `// BaseBranch: null = keep stored, "" = clear, value = set.` Dann beide Records erweitern:
|
||||
|
||||
```csharp
|
||||
public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null, string? VerifyCommand = null, string? PermissionMode = null, int? TicketProjectId = null, bool? PowerUseAllowed = null, string? BaseBranch = null);
|
||||
```
|
||||
|
||||
```csharp
|
||||
public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null, string? VerifyCommand = null, string? PermissionMode = null, int? TicketProjectId = null, bool PowerUseAllowed = false, string? BaseBranch = null);
|
||||
```
|
||||
|
||||
`WorkerHub.UpdateListConfig` — nach der `powerUseAllowed`-Zeile:
|
||||
|
||||
```csharp
|
||||
// Tri-State wie oben: null = behalten, "" = löschen.
|
||||
var baseBranch = dto.BaseBranch is null ? existing?.BaseBranch : dto.BaseBranch.NullIfBlank();
|
||||
```
|
||||
|
||||
Leer-Prüfung um `&& baseBranch is null` ergänzen, im `new ListConfigEntity { … }` `BaseBranch = baseBranch,` ergänzen. `GetListConfig`: `config.BaseBranch` als letztes Argument an `new ListConfigDto(…)` hängen.
|
||||
|
||||
`ConfigMcpTools.cs`:
|
||||
- Zeile 13: `public sealed record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns, string? VerifyCommand, string? BaseBranch = null);`
|
||||
- `GetListConfig`: `new ListConfigDto(cfg.Model, cfg.SystemPrompt, cfg.AgentPath, cfg.MaxTurns, cfg.VerifyCommand, cfg.BaseBranch)`
|
||||
- `hasUnrelatedSettings` erweitern:
|
||||
|
||||
```csharp
|
||||
var hasUnrelatedSettings = existing is not null
|
||||
&& (existing.SessionSkills is not null
|
||||
|| existing.PermissionMode is not null || existing.TicketProjectId is not null
|
||||
|| existing.BaseBranch is not null || existing.PowerUseAllowed);
|
||||
```
|
||||
|
||||
- im `SetConfigAsync(new ListConfigEntity { … })` ergänzen: `BaseBranch = existing?.BaseBranch, PowerUseAllowed = existing?.PowerUseAllowed ?? false,`
|
||||
- `config = allCleared ? null : new ListConfigDto(m, sp, ap, mt, vc, existing?.BaseBranch);`
|
||||
|
||||
- [ ] **Step 4: Build + Test**
|
||||
|
||||
Run: `dotnet build ClaudeDo.slnx -c Release && dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --no-build --filter "FullyQualifiedName~ListConfigBaseBranchTests|FullyQualifiedName~ListConfigTicketProjectTests|FullyQualifiedName~ListConfigHubTests|FullyQualifiedName~ExternalMcpToolSchemaTests"`
|
||||
Run: `dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release --no-build --filter "FullyQualifiedName~Migration"`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Data/Models/ListConfigEntity.cs src/ClaudeDo.Data/Configuration/ListConfigEntityConfiguration.cs src/ClaudeDo.Data/Repositories/ListRepository.cs src/ClaudeDo.Data/Migrations src/ClaudeDo.Data/Wire.cs src/ClaudeDo.Worker/Hub/WorkerHub.cs src/ClaudeDo.Worker/External/ConfigMcpTools.cs tests/ClaudeDo.Worker.Tests/Hub/ListConfigBaseBranchTests.cs
|
||||
git commit -m "feat(data): per-list base branch, preserved by every config writer" -- src/ClaudeDo.Data/Models/ListConfigEntity.cs src/ClaudeDo.Data/Configuration/ListConfigEntityConfiguration.cs src/ClaudeDo.Data/Repositories/ListRepository.cs src/ClaudeDo.Data/Migrations src/ClaudeDo.Data/Wire.cs src/ClaudeDo.Worker/Hub/WorkerHub.cs src/ClaudeDo.Worker/External/ConfigMcpTools.cs tests/ClaudeDo.Worker.Tests/Hub/ListConfigBaseBranchTests.cs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Basis-Branch als Worktree-Basis und Merge-Ziel
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Worker/Runner/WorktreeManager.cs` (`ResolveBaseCommitAsync`, Fallback-Zeile)
|
||||
- Modify: `src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs` (`GetTargetsAsync` ~869, `PreviewAsync` ~910, `ApproveAndMergeAsync` ~1077, `MergeAsync` ~375)
|
||||
- Test: `tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs`, `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs`
|
||||
|
||||
- [ ] **Step 1: Failing tests**
|
||||
|
||||
`WorktreeManagerTests.cs`:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task CreateAsync_BaseBranchSet_BasesOnItEvenWhenAnotherBranchIsCheckedOut()
|
||||
{
|
||||
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||
|
||||
var repo = CreateRepo();
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "checkout", "-b", "dev");
|
||||
File.WriteAllText(Path.Combine(repo.RepoDir, "dev.txt"), "dev");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "dev work");
|
||||
var devHead = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "checkout", "-b", "elsewhere", repo.BaseCommit);
|
||||
|
||||
var (task, list) = MakeEntities(repo.RepoDir);
|
||||
var (mgr, db) = await CreateManagerAsync(task, list);
|
||||
using (var ctx0 = db.CreateContext())
|
||||
await new ListRepository(ctx0).SetConfigAsync(new ListConfigEntity { ListId = list.Id, BaseBranch = "dev" });
|
||||
|
||||
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
|
||||
_worktreeCleanups.Add((repo.RepoDir, ctx.WorktreePath));
|
||||
|
||||
Assert.Equal(devHead, ctx.BaseCommit);
|
||||
}
|
||||
```
|
||||
|
||||
`TaskMergeServiceTests.cs` — am vorhandenen Setup der Klasse orientieren (Repo + `SeedWorktree`-Helper, wie die übrigen Tests der Datei). Ein Test, der zeigt, dass `GetTargetsAsync` `BaseBranch` als Default liefert, auch wenn ein anderer Branch ausgecheckt ist:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task GetTargetsAsync_prefers_list_base_branch()
|
||||
{
|
||||
// Setup wie die anderen Tests dieser Datei: Repo, Liste, Task mit Worktree (SeedWorktree).
|
||||
// Zusätzlich: Branch "dev" anlegen (`git branch dev`), list_config.BaseBranch = "dev" setzen,
|
||||
// Main-Checkout bleibt auf seinem Default-Branch.
|
||||
// Assert: targets.DefaultBranch == "dev".
|
||||
}
|
||||
```
|
||||
|
||||
Der Implementer schreibt diesen Test konkret mit dem Fixture-Setup, das in `TaskMergeServiceTests.cs` schon für `GetTargetsAsync`/Merge-Tests verwendet wird (per `Grep "GetTargetsAsync" tests/ClaudeDo.Worker.Tests` den nächstliegenden bestehenden Test kopieren und um `BaseBranch` ergänzen). Ein zweiter Test: `ApproveAndMergeAsync(taskId, "")` mit `BaseBranch = "dev"` merged auf `dev` (`git log dev --oneline` enthält den Merge-Commit, der vorher ausgecheckte Branch nicht).
|
||||
|
||||
- [ ] **Step 2: Tests laufen lassen — scheitern**
|
||||
|
||||
Run: `dotnet build ClaudeDo.slnx -c Release && dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --no-build --filter "FullyQualifiedName~BaseBranch|FullyQualifiedName~prefers_list_base_branch"`
|
||||
Expected: FAIL.
|
||||
|
||||
- [ ] **Step 3: Implementieren**
|
||||
|
||||
`WorktreeManager.ResolveBaseCommitAsync` — die letzte Zeile `return await _git.RevParseHeadAsync(workingDir, ct);` ersetzen:
|
||||
|
||||
```csharp
|
||||
using var cfgCtx = _dbFactory.CreateDbContext();
|
||||
var baseBranch = await cfgCtx.ListConfigs.AsNoTracking()
|
||||
.Where(c => c.ListId == task.ListId)
|
||||
.Select(c => c.BaseBranch)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
return string.IsNullOrWhiteSpace(baseBranch)
|
||||
? await _git.RevParseHeadAsync(workingDir, ct)
|
||||
: await _git.RevParseAsync(workingDir, baseBranch, ct);
|
||||
```
|
||||
|
||||
(Ein nicht existierender Branch wirft aus `RevParseAsync` mit „git rev-parse 'dev' failed“ — gewollt, der Lauf scheitert mit lesbarer Meldung.)
|
||||
|
||||
`TaskMergeService` — neue öffentliche Methode (bei `GetTargetsAsync`):
|
||||
|
||||
```csharp
|
||||
/// <summary>Leeres Ziel → <c>list_config.base_branch</c>, sonst der ausgecheckte Branch des Main-Checkouts.</summary>
|
||||
public async Task<string> ResolveTargetAsync(ListEntity list, string? targetBranch, CancellationToken ct)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(targetBranch)) return targetBranch;
|
||||
using var ctx = _dbFactory.CreateDbContext();
|
||||
var baseBranch = await ctx.ListConfigs.AsNoTracking()
|
||||
.Where(c => c.ListId == list.Id)
|
||||
.Select(c => c.BaseBranch)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
return string.IsNullOrWhiteSpace(baseBranch)
|
||||
? await _git.GetCurrentBranchAsync(list.WorkingDir!, ct)
|
||||
: baseBranch;
|
||||
}
|
||||
```
|
||||
|
||||
Aufrufer umstellen:
|
||||
- `GetTargetsAsync`: `var current = await _git.GetCurrentBranchAsync(...)` → `var current = await ResolveTargetAsync(list, null, ct);`
|
||||
- `PreviewAsync` (~910) und `ApproveAndMergeAsync` (~1077): die `var target = string.IsNullOrWhiteSpace(targetBranch) ? await _git.GetCurrentBranchAsync(...) : targetBranch;`-Blöcke → `var target = await ResolveTargetAsync(list, targetBranch, ct);`
|
||||
- `MergeAsync`: direkt nachdem `list.WorkingDir` geprüft ist und **vor** dem `currentBranch`-Vergleich (~Zeile 414) `targetBranch = await ResolveTargetAsync(list, targetBranch, ct);` einfügen. Grund: `PlanningMergeOrchestrator.DrainAsync` reicht ein leeres Ziel (MCP `review_task` ohne `targetBranch`) unverändert durch, was heute in `git checkout ""` → Blocked endet. Mit der zentralen Auflösung ist das für alle Aufrufer behoben.
|
||||
|
||||
- [ ] **Step 4: Build + Test**
|
||||
|
||||
Run: `dotnet build ClaudeDo.slnx -c Release && dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --no-build --filter "FullyQualifiedName~WorktreeManagerTests|FullyQualifiedName~TaskMergeServiceTests|FullyQualifiedName~PlanningMergeOrchestratorTests"`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/Runner/WorktreeManager.cs src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs
|
||||
git commit -m "feat(worker): use the list base branch as worktree base and default merge target" -- src/ClaudeDo.Worker/Runner/WorktreeManager.cs src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: UI — Basis-Branch-Feld + echte Branchnamen
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Ui/ViewModels/Agent/AgentConfigEditorViewModel.cs:216-231`
|
||||
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs` (Property ~57, Load ~276, Save ~375)
|
||||
- Modify: `src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml` (nach dem WorkingDir-StackPanel, ~Zeile 125)
|
||||
- Modify: `src/ClaudeDo.Localization/locales/en.json`, `de.json` (Block `modals.listSettings`, nach `workingDirectoryPlaceholder`)
|
||||
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs:670,933,1223,1385`
|
||||
- Test: `tests/ClaudeDo.Ui.Tests` — bestehende ListSettingsModal-Tests per `Grep "ListSettingsModalViewModel" tests/ClaudeDo.Ui.Tests` finden
|
||||
|
||||
- [ ] **Step 1: Failing test**
|
||||
|
||||
In der vorhandenen ListSettingsModal-Testklasse (gleiche Fake-`IWorkerClient`-Mechanik wie die dortigen Save-Tests, die `UpdateListConfigDto` mitschneiden):
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task Save_sends_BaseBranch_explicitly()
|
||||
{
|
||||
// Arrange: VM wie in den Nachbartests laden; BaseBranch = "dev" setzen.
|
||||
// Act: Save ausführen.
|
||||
// Assert: der mitgeschnittene UpdateListConfigDto hat BaseBranch == "dev".
|
||||
// Zweiter Fall: BaseBranch = "" → DTO.BaseBranch == "" (nicht null), damit Leeren wirklich löscht.
|
||||
}
|
||||
```
|
||||
|
||||
Der Implementer schreibt die beiden Fälle konkret gegen den Fake, den die Nachbartests (z.B. der PowerUseAllowed-Save-Test) verwenden.
|
||||
|
||||
- [ ] **Step 2: Build/Test — scheitert** (`BaseBranch` fehlt im VM).
|
||||
|
||||
- [ ] **Step 3: Implementieren**
|
||||
|
||||
`AgentConfigEditorViewModel.SaveAsync`: Signatur um `string? baseBranch = null` erweitern und im List-Zweig durchreichen:
|
||||
|
||||
```csharp
|
||||
public async System.Threading.Tasks.Task SaveAsync(
|
||||
string? verifyCommand = null, int? ticketProjectId = null, bool? powerUseAllowed = null, string? baseBranch = null)
|
||||
```
|
||||
|
||||
```csharp
|
||||
await _worker.UpdateListConfigAsync(new UpdateListConfigDto(TargetId, model, sp, ap, turns, skills, verifyCommand, permission, ticketProjectId, powerUseAllowed, baseBranch));
|
||||
```
|
||||
|
||||
`ListSettingsModalViewModel`:
|
||||
|
||||
```csharp
|
||||
// Basis neuer Worktrees und Default-Merge-Ziel; leer = ausgecheckter Branch.
|
||||
[ObservableProperty] private string _baseBranch = "";
|
||||
```
|
||||
|
||||
Load (nach `VerifyCommand = …`): `BaseBranch = cfg?.BaseBranch ?? "";`
|
||||
Save: `await Agent.SaveAsync(…, PowerUseAllowed, BaseBranch.Trim());` — immer explizit, `""` löscht (das Modal besitzt das Feld).
|
||||
|
||||
View, nach dem WorkingDir-StackPanel:
|
||||
|
||||
```xml
|
||||
<StackPanel Spacing="4" IsVisible="{Binding !IsFolder}">
|
||||
<TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.baseBranch}"/>
|
||||
<TextBox Text="{Binding BaseBranch, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
|
||||
PlaceholderText="{loc:Tr modals.listSettings.baseBranchPlaceholder}"/>
|
||||
<TextBlock Text="{loc:Tr modals.listSettings.baseBranchHint}"
|
||||
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
|
||||
</StackPanel>
|
||||
```
|
||||
|
||||
Locales (beide Dateien, gleiche Keys, direkt nach `workingDirectoryPlaceholder`):
|
||||
|
||||
en.json:
|
||||
```json
|
||||
"baseBranch": "Base branch",
|
||||
"baseBranchPlaceholder": "(checked-out branch)",
|
||||
"baseBranchHint": "New task branches start here and approved work merges back here, e.g. dev. Leave empty to use whatever branch is checked out.",
|
||||
```
|
||||
|
||||
de.json:
|
||||
```json
|
||||
"baseBranch": "Basis-Branch",
|
||||
"baseBranchPlaceholder": "(ausgecheckter Branch)",
|
||||
"baseBranchHint": "Neue Task-Branches starten hier, und freigegebene Arbeit wird hierhin gemergt, z.B. dev. Leer = der gerade ausgecheckte Branch.",
|
||||
```
|
||||
|
||||
Nach dem Editieren beide JSON-Dateien separat parsen (`python -c "import json;json.load(open('src/ClaudeDo.Localization/locales/de.json',encoding='utf-8'))"`), weil ein kaputtes `de.json` vom LocaleStore still geschluckt wird.
|
||||
|
||||
`DetailsIslandViewModel`:
|
||||
- Zeilen 670 und 933: `BranchLine = entity.Worktree is { } w ? $"{w.BranchName} ← main" : null;` → `BranchLine = entity.Worktree?.BranchName;` (das Ziel ist nicht mehr immer `main`).
|
||||
- Zeilen 1223 und 1385: `var branchName = $"claudedo/{Task.Id.Replace("-", "")}";` → `var branchName = BranchLine ?? "the task branch";`
|
||||
|
||||
- [ ] **Step 4: Build + Test**
|
||||
|
||||
Run: `dotnet build ClaudeDo.slnx -c Release && dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --no-build --filter "FullyQualifiedName~ListSettings|FullyQualifiedName~DetailsIsland"`
|
||||
Run: `dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release --no-build`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/ViewModels/Agent/AgentConfigEditorViewModel.cs src/ClaudeDo.Ui/ViewModels/Modals/ListSettingsModalViewModel.cs src/ClaudeDo.Ui/Views/Modals/ListSettingsModalView.axaml src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs <ui-testdatei>
|
||||
git commit -m "feat(ui): base branch field in list settings, real branch names in dialogs" -- <dieselben pfade>
|
||||
```
|
||||
|
||||
**Sichtprüfung offen (Mika):** Feld im Listen-Modal, Branchzeile im Detail-Pane.
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Doku
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Worker/CLAUDE.md` (Key Components → WorktreeManager; Notes → „Task worktree branches follow…“; External → „Task worktree branches and folders are named…“; Config → `company_remote_pattern`)
|
||||
- Modify: `src/ClaudeDo.Data/CLAUDE.md` (list_config: `base_branch`)
|
||||
|
||||
- [ ] **Step 1: Texte ersetzen**
|
||||
|
||||
Überall `claudedo/{number}-{title-slug}` → `<typ>/<nummer>-<slug>` mit der Regel: Typ aus `WorktreeManager.BranchTypeFor` (`feat`→`feature`, sonst `fix/refactor/docs/test/chore`, Rest `chore`); Nummer = Ticket-Id (eigene oder des Parents), sonst Task-Nummer, im Firmen-Repo ohne Ticket keine. Planning bleibt `claudedo/planning/{guid}`.
|
||||
|
||||
Config-Abschnitt ergänzen: `` `company_remote_pattern` (`/_git/`) — Teilstring der origin-URL, an dem `CompanyRepo` ein Firmen-Repo erkennt; nur per Hand editierbar. ``
|
||||
|
||||
Data/CLAUDE.md: `list_config.base_branch` — Basis neuer Worktrees + Default-Merge-Ziel, tri-state in `UpdateListConfigDto`, ⚠️ verbatim-copy-Falle wie `ticket_project_id`.
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/CLAUDE.md src/ClaudeDo.Data/CLAUDE.md
|
||||
git commit -m "docs(worker): document new branch naming and per-list base branch" -- src/ClaudeDo.Worker/CLAUDE.md src/ClaudeDo.Data/CLAUDE.md
|
||||
```
|
||||
@@ -0,0 +1,184 @@
|
||||
# Unit-Branch Teil 2: Children per fast-forward — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Children of a unit start from the unit branch head and are fast-forwarded into it as soon as they finish; a parent is approved like a single task.
|
||||
|
||||
**Architecture:** New `UnitBranchService` (Worker/Runner) owns the git side: it creates the planning parent's worktree on "Queue plan" and absorbs a finished child (`merge --ff-only` in the parent worktree, child worktree removed, row `Merged`, `MergeCommit` null). It never touches task status — `TaskRunner` (`FailAsync` on a ff failure) and `TaskMergeService` (approve of an interactive child) own the transitions, so `TaskStateService`/`PlanningChainCoordinator` stay git-free and the DI graph stays acyclic. Legacy units (a `Done` child with an `Active` worktree) keep going through `PlanningMergeOrchestrator`.
|
||||
|
||||
**Tech Stack:** .NET 8, EF Core SQLite, git CLI, xUnit (real git + real SQLite in Worker.Tests).
|
||||
|
||||
Spec: `docs/superpowers/specs/2026-10-01-unit-branch-draft-pr-design.md`, section 2. Private behaviour only — company mode (push/PR) is part 3.
|
||||
|
||||
## File map
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/ClaudeDo.Data/Git/GitService.cs` | + `MergeFastForwardOnlyAsync` |
|
||||
| `src/ClaudeDo.Data/Repositories/WorktreeRepository.cs` | `SetMergedAsync(…, string? mergeCommit)` |
|
||||
| `src/ClaudeDo.Worker/Runner/WorktreeManager.cs` | `ResolveBaseCommitAsync`: child → `rev-parse <parentWt.BranchName>` for any parent with an Active worktree |
|
||||
| `src/ClaudeDo.Worker/Runner/UnitBranchService.cs` | **new** — `EnsureUnitWorktreeAsync`, `AbsorbChildAsync`, `HasUnabsorbedChildrenAsync` |
|
||||
| `src/ClaudeDo.Worker/Program.cs` | register `UnitBranchService` (+ `Share<>` for the external container is not needed — the MCP side only uses the static helper) |
|
||||
| `src/ClaudeDo.Worker/Planning/PlanningChainCoordinator.cs` | `QueuePlanAsync` calls `EnsureUnitWorktreeAsync` |
|
||||
| `src/ClaudeDo.Worker/Runner/TaskRunner.cs` | `HandleSuccess`: absorb before `CompleteAsync`, ff failure → `MarkFailed` |
|
||||
| `src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs` | `ApproveAndMergeAsync`: child of a unit worktree → absorb instead of merge |
|
||||
| `src/ClaudeDo.Worker/Hub/WorkerHub.cs`, `src/ClaudeDo.Worker/External/ExternalMcpService.cs` | approve routing: orchestrator only for unabsorbed (legacy) children |
|
||||
| `src/ClaudeDo.Worker/Planning/PlanningMergeOrchestrator.cs` | also merges a *planning* parent's own Active worktree (mixed legacy unit) |
|
||||
| `src/ClaudeDo.Worker/Planning/PlanningAggregator.cs` | combined diff = `unitBase..unitBranch` without `checkout -b` when the parent owns the unit; per-child diff of absorbed children from the commit range |
|
||||
| `tests/ClaudeDo.Worker.Tests/Runner/UnitBranchTests.cs` | **new** — all unit-branch tests |
|
||||
| `src/ClaudeDo.Worker/CLAUDE.md`, `docs/explore-notes/review-merge.md` | docs |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Git primitive + nullable MergeCommit
|
||||
|
||||
**Files:** Modify `src/ClaudeDo.Data/Git/GitService.cs` (after `MergeNoFfAsync`), `src/ClaudeDo.Data/Repositories/WorktreeRepository.cs:39`
|
||||
|
||||
- [ ] **Step 1: Add the method**
|
||||
|
||||
```csharp
|
||||
/// <summary><c>git merge --ff-only</c>: moves the checked-out branch to <paramref name="sourceRef"/> or fails without touching it.</summary>
|
||||
public async Task<(int ExitCode, string Stderr)> MergeFastForwardOnlyAsync(
|
||||
string worktreePath, string sourceRef, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, _, stderr) = await RunGitAsync(worktreePath, ["merge", "--ff-only", sourceRef], ct);
|
||||
return (exitCode, stderr);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2:** `SetMergedAsync(string taskId, string? mergeCommit, …)` — an absorbed child has no merge commit.
|
||||
- [ ] **Step 3:** Covered by the Task 3 tests (no standalone test for a one-line git wrapper).
|
||||
|
||||
### Task 2: Child base = unit head
|
||||
|
||||
**Files:** Modify `src/ClaudeDo.Worker/Runner/WorktreeManager.cs` (`ResolveBaseCommitAsync`)
|
||||
|
||||
- [ ] **Step 1: Failing test** (`UnitBranchTests.Child_starts_from_unit_head`): planning parent (`PlanningPhase.Finalized`) with a worktree, commit a file in the parent worktree (`git -C <wt> commit`), then `CreateAsync(child)` → `ctx.BaseCommit == rev-parse <parentBranch>`. Fails today because planning children fall through to `HEAD` of the main checkout.
|
||||
- [ ] **Step 2: Implement**
|
||||
|
||||
```csharp
|
||||
if (task.ParentTaskId is not null)
|
||||
{
|
||||
using var ctx = _dbFactory.CreateDbContext();
|
||||
var parentWt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.ParentTaskId, ct);
|
||||
if (parentWt is { State: WorktreeState.Active })
|
||||
return await _git.RevParseAsync(workingDir, parentWt.BranchName, ct);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3:** run `--filter "FullyQualifiedName~UnitBranchTests|FullyQualifiedName~WorktreeManagerTests"`, commit.
|
||||
|
||||
### Task 3: UnitBranchService
|
||||
|
||||
**Files:** Create `src/ClaudeDo.Worker/Runner/UnitBranchService.cs`, register in `Program.cs` next to `WorktreeManager`.
|
||||
|
||||
```csharp
|
||||
public sealed record AbsorbResult(bool Applicable, string? Error)
|
||||
{
|
||||
public static readonly AbsorbResult NotApplicable = new(false, null);
|
||||
public static readonly AbsorbResult Absorbed = new(true, null);
|
||||
public bool Failed => Error is not null;
|
||||
}
|
||||
|
||||
public sealed class UnitBranchService
|
||||
{
|
||||
// ctor: GitService, WorktreeManager, IDbContextFactory, HubBroadcaster?, ILogger
|
||||
|
||||
/// Planning parent → own worktree = unit branch; no-op if it already has an Active one or the list has no git working dir.
|
||||
public async Task EnsureUnitWorktreeAsync(string parentId, CancellationToken ct);
|
||||
|
||||
/// ff the parent worktree to the child branch; child row Merged (MergeCommit null), child worktree + branch removed.
|
||||
/// NotApplicable when the child or its parent has no Active worktree. Never changes task status.
|
||||
public async Task<AbsorbResult> AbsorbChildAsync(string childId, CancellationToken ct);
|
||||
|
||||
/// Legacy routing: a Done child whose worktree is still Active was never absorbed.
|
||||
public static Task<bool> HasUnabsorbedChildrenAsync(ClaudeDoDbContext ctx, string parentId, CancellationToken ct) =>
|
||||
ctx.Tasks.AnyAsync(t => t.ParentTaskId == parentId && t.Status == TaskStatus.Done
|
||||
&& t.Worktree != null && t.Worktree.State == WorktreeState.Active, ct);
|
||||
}
|
||||
```
|
||||
|
||||
Absorb order: ff → parent `UpdateHeadAsync(head, diffstat base..head)` → child `SetMergedAsync(null)` → best-effort `worktree remove --force` + `branch -D` → `WorktreeUpdated` for both. ff failure returns `Error` with a clear message and a `ponytail:` comment (no merge fallback).
|
||||
|
||||
- [ ] Tests (real git): two serial children absorbed, child 2 sees child 1's file, parent branch head == child 2 head, child rows `Merged` with null `MergeCommit`, child branches gone; a child forked from the old head after the unit moved → `Failed`, unit branch unchanged.
|
||||
- [ ] Commit.
|
||||
|
||||
### Task 4: Queue plan creates the parent worktree
|
||||
|
||||
**Files:** `PlanningChainCoordinator.cs` (optional ctor param `UnitBranchService? unitBranch = null`), `TaskStateServiceBuilder.cs` (optional pass-through).
|
||||
|
||||
```csharp
|
||||
if (_unitBranch is not null) await _unitBranch.EnsureUnitWorktreeAsync(parentTaskId, ct);
|
||||
return await SetupChainAsync(parentTaskId, enqueue: true, ct);
|
||||
```
|
||||
|
||||
- [ ] Test: finalized planning parent + 1 child in a git list → after `QueuePlanAsync` the parent has an Active worktree row whose base = repo HEAD; a second call keeps the same row.
|
||||
- [ ] Commit.
|
||||
|
||||
### Task 5: TaskRunner absorbs before CompleteAsync
|
||||
|
||||
**Files:** `TaskRunner.cs` (optional trailing ctor param `UnitBranchService? unitBranch = null`; in the child branch of `HandleSuccess`):
|
||||
|
||||
```csharp
|
||||
if (_unitBranch is not null)
|
||||
{
|
||||
var absorb = await _unitBranch.AbsorbChildAsync(task.Id, CancellationToken.None);
|
||||
if (absorb.Failed)
|
||||
{
|
||||
await MarkFailed(task.Id, task.Number, task.Title, slot, absorb.Error, result.TurnCount);
|
||||
return;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] Test: TaskRunner with `FakeClaudeProcess` writing a file into its working dir, two queued children of a unit → both Done, unit head has both files; a child whose base was overtaken → `Failed` with the ff message.
|
||||
- [ ] Commit.
|
||||
|
||||
### Task 6: Interactive child approve absorbs
|
||||
|
||||
**Files:** `TaskMergeService.cs` (optional ctor param `UnitBranchService? unitBranch = null`); in `ApproveAndMergeAsync` after the `WaitingForReview` guard:
|
||||
|
||||
```csharp
|
||||
if (task.ParentTaskId is not null && _unitBranch is not null)
|
||||
{
|
||||
var absorb = await _unitBranch.AbsorbChildAsync(taskId, ct);
|
||||
if (absorb.Failed) return Blocked(absorb.Error!);
|
||||
if (absorb.Applicable)
|
||||
{
|
||||
var approved = await _state.ApproveReviewAsync(taskId, ct);
|
||||
return approved.Ok ? new MergeResult(StatusMerged, Array.Empty<string>(), null) : Blocked(approved.Reason ?? "approve failed");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
A blocked approve keeps the child `WaitingForReview` (the reviewer can reject/re-run) — the `Failed` rule applies to autonomous runs.
|
||||
|
||||
- [ ] Test: child in `WaitingForReview` with a commit → approve → child Done, unit branch contains the file, main untouched.
|
||||
- [ ] Commit.
|
||||
|
||||
### Task 7: Approve routing
|
||||
|
||||
**Files:** `WorkerHub.ApproveReview`, `ExternalMcpService.ReviewTask`, `PlanningMergeOrchestrator.StartAsync`.
|
||||
|
||||
- Replace `hasChildren` with `UnitBranchService.HasUnabsorbedChildrenAsync` for the orchestrator branch (MCP still computes `emptyChildren` whenever children exist; absorbed children count as empty when their row has no `HeadCommit`).
|
||||
- Orchestrator: `if (parentHasWorktree) idsToMerge.Add(parentTaskId)` (drop `!isPlanning`) so a mixed legacy unit still lands the unit branch.
|
||||
- [ ] Tests: approve of a new parent (all children absorbed) merges the unit branch onto `main` and the parent goes Done; a legacy parent (Done child with Active worktree, no parent worktree) still goes through the orchestrator and lands the child.
|
||||
- [ ] Commit.
|
||||
|
||||
### Task 8: Combined diff without the integration branch
|
||||
|
||||
**Files:** `PlanningAggregator.cs`.
|
||||
|
||||
- `BuildIntegrationBranchAsync`: parent with Active worktree and no unabsorbed child → `Ok(parentWt.BranchName, git diff <BaseCommit> <BranchName>)`, no checkout in the main checkout. Otherwise legacy path.
|
||||
- `GetAggregatedDiffAsync`: a node whose worktree directory is gone but has `HeadCommit` → `GetCommitRangeDiffAsync(repoDir, BaseCommit, HeadCommit)`; an improvement parent's own node is limited to `BaseCommit..firstChild.BaseCommit` once children were forked from it (else it would repeat the absorbed children).
|
||||
- [ ] Test: unit with an absorbed child → combined diff contains the child's file, main checkout stays on `main`, no `planning/*-integration` branch exists.
|
||||
- [ ] Commit.
|
||||
|
||||
### Task 9: Docs
|
||||
|
||||
`src/ClaudeDo.Worker/CLAUDE.md` (Planning Flow, Unified parent model, WorktreeManager base rule), `docs/explore-notes/review-merge.md` (Approve section, MergeCommit/revert note, Unit-Branch section). Commit.
|
||||
|
||||
## Verification
|
||||
|
||||
`dotnet build ClaudeDo.slnx -c Release`, then
|
||||
`dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --no-build --filter "FullyQualifiedName~UnitBranch|FullyQualifiedName~WorktreeManager|FullyQualifiedName~PlanningMerge|FullyQualifiedName~PlanningAggregator|FullyQualifiedName~StandaloneChildren|FullyQualifiedName~TaskMergeService|FullyQualifiedName~ReviewTask"`.
|
||||
@@ -0,0 +1,112 @@
|
||||
# Unit-Branch Teil 3: Firmen-Modus (Fetch, Push, Draft-PR) — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** In a company repo (origin matches `company_remote_pattern`) a unit root starts from a freshly fetched `origin/<basis>`, and Approve ends in a pushed branch plus an Azure DevOps **draft PR** instead of a local merge.
|
||||
|
||||
**Architecture:** `AzureDevOpsClient` (port of `pr-create.ps1`) is the only HTTP surface to ADO; it also builds the git auth environment (PAT as `http.extraHeader` via `GIT_CONFIG_COUNT/KEY_0/VALUE_0`, never on the command line). `WorktreeManager.ResolveBaseCommitAsync` fetches the basis for unit roots in company repos. `TaskMergeService.ApproveAndMergeAsync` branches on `CompanyRepo.IsCompanyAsync` into `ApproveCompanyAsync`: verify in the unit-root worktree → `PullRequestPublisher.PublishAsync` (push, find-or-create draft PR, store `worktrees.pull_request_url`, `WorktreeUpdated`). The task stays `WaitingForReview`; result status `pr_created` / `pr_updated`. Every failure is a `blocked` result with a precise reason (hub → `HubException`, MCP → tool error, UI → footer strip).
|
||||
|
||||
**Tech Stack:** .NET 8, EF Core SQLite, git CLI, xUnit (real git + SQLite), Avalonia.
|
||||
|
||||
Spec: `docs/superpowers/specs/2026-10-01-unit-branch-draft-pr-design.md`, sections 3–5, decision rows "Basis-Branch" / "Basis aktualisieren", section 7.
|
||||
|
||||
**Live finding (ticket system, 2026-10-01):** `ParentId` of a ticket is its project (`ItemType 0`, `ParentTicketID` null); a task's parent is the ticket. Project id = walk `GET /api/ticket/{id}` up until `parentTicketID` is null (max 3 hops). Ticket web URL: `https://ticket.fb-tuning.local/board/project/<project>/ticket/<id>`.
|
||||
|
||||
## File map
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `src/ClaudeDo.Data/Models/WorktreeEntity.cs`, `Configuration/WorktreeEntityConfiguration.cs`, `Migrations/*AddWorktreePullRequestUrl*` | `PullRequestUrl` → `worktrees.pull_request_url` |
|
||||
| `src/ClaudeDo.Data/Repositories/WorktreeRepository.cs` | `SetPullRequestUrlAsync` |
|
||||
| `src/ClaudeDo.Data/Git/GitService.cs` | env-aware `RunGitAsync`; `FetchBranchAsync`, `PushBranchAsync`, `FastForwardLocalBranchAsync`, `GetRemoteHeadBranchAsync` |
|
||||
| `src/ClaudeDo.Worker/Runner/CommitMessageBuilder.cs`, `WorktreeManager.cs` | trailer off in company repos; fetch basis for unit roots |
|
||||
| `src/ClaudeDo.Worker/AzureDevOps/AzureDevOpsClient.cs` | **new** — remote parsing, auth env, default branch, find-or-create draft PR, api-version fallback |
|
||||
| `src/ClaudeDo.Worker/AzureDevOps/PullRequestPublisher.cs` | **new** — push + PR + URL + ticket link |
|
||||
| `src/ClaudeDo.Worker/DpapiTokenStore.cs`, `Program.cs` | `AzureDevOpsPatStore` (`~/.claudeDo/azdo.pat`), DI |
|
||||
| `src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs` | `ApproveCompanyAsync`, `StatusPrCreated/Updated`, `MergeResult.PullRequestUrl`, `MergeTargets.IsCompanyRepo` |
|
||||
| `src/ClaudeDo.Worker/Hub/WorkerHub.cs`, `External/ExternalMcpService.cs` | ADO settings methods; PR URL in results |
|
||||
| `src/ClaudeDo.Data/Wire.cs` | `AzureDevOpsSettingsDto`, `AzureDevOpsConnectionDto`, `MergeResultDto.PullRequestUrl`, `MergeTargetsDto.IsCompanyRepo` |
|
||||
| `src/ClaudeDo.Ui/...` | IWorkerClient/WorkerClient, settings tab "Externe Dienste", detail-pane PR button/link |
|
||||
| `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs` | new members |
|
||||
| `src/ClaudeDo.Data/PromptFiles.cs` | company-repo paragraph in the merge prompt (merge + merge_final share it) |
|
||||
| `tests/ClaudeDo.Worker.Tests/AzureDevOps/*` | **new** — client, fetch basis, company approve |
|
||||
| docs | Worker/CLAUDE.md, review-merge.md, open.md |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: `worktrees.pull_request_url`
|
||||
|
||||
- [ ] `WorktreeEntity.PullRequestUrl` (`string?`), mapped to `pull_request_url`.
|
||||
- [ ] `dotnet ef migrations add AddWorktreePullRequestUrl` (project `src/ClaudeDo.Data`). Check the generated Designer contains `base_branch` (parent `AddListBaseBranch`) — see trap `ef-migration-parallel-designer-collision`.
|
||||
- [ ] `WorktreeRepository.SetPullRequestUrlAsync(taskId, url, ct)` via `ExecuteUpdateAsync`.
|
||||
- [ ] Covered by Task 5 tests.
|
||||
|
||||
### Task 2: Git primitives + trailer
|
||||
|
||||
- [ ] `RunGitAsync(..., IReadOnlyDictionary<string,string>? env = null)`; network ops always set `GIT_TERMINAL_PROMPT=0`.
|
||||
- [ ] `FetchBranchAsync(repoDir, branch, env, ct)` → `git fetch origin +refs/heads/<b>:refs/remotes/origin/<b>` (explicit refspec: works without a configured fetch refspec); returns `(ExitCode, Stderr)`.
|
||||
- [ ] `FastForwardLocalBranchAsync(repoDir, branch, ct)` → `git fetch . refs/remotes/origin/<b>:refs/heads/<b>` (local, no network; git refuses a non-fast-forward itself).
|
||||
- [ ] `PushBranchAsync(worktreePath, branch, env, ct)` → `git push -u origin <b>`.
|
||||
- [ ] `GetRemoteHeadBranchAsync(repoDir, ct)` → `symbolic-ref --short refs/remotes/origin/HEAD` minus `origin/`, or null.
|
||||
- [ ] `CommitMessageBuilder.Build(..., bool includeTrailer = true)`; `WorktreeManager.CommitIfChangedAsync` passes `!isCompany`. Test in `CommitMessageBuilderTests`: no `ClaudeDo-Task:` line when false.
|
||||
|
||||
### Task 3: `AzureDevOpsClient` + PAT store
|
||||
|
||||
- [ ] `AzureDevOpsPatStore(DpapiTokenStore)` marker, registered with `InAppData("azdo.pat")`.
|
||||
- [ ] `AzureDevOpsClient(IHttpClientFactory, AzureDevOpsPatStore)`:
|
||||
- `static AzdoRepo? ParseRemote(string remote)` — trim, strip `.git`, strip `user[:pw]@`, split at `/_git/`; `Repo` unescaped.
|
||||
- `static IReadOnlyDictionary<string,string> GitAuthEnv(string? pat)` — `GIT_TERMINAL_PROMPT=0` + (pat ? `GIT_CONFIG_COUNT=1`, `GIT_CONFIG_KEY_0=http.extraHeader`, `GIT_CONFIG_VALUE_0=Authorization: Basic <b64(":pat")>`).
|
||||
- `static string? SupportedVersion(string error)` — `supports is (\d+\.\d+)`.
|
||||
- `GetDefaultBranchAsync(repo, ct)` → `GET …/_apis/git/repositories/<repo>` → `defaultBranch` without `refs/heads/`.
|
||||
- `EnsureDraftPullRequestAsync(repo, source, target, title, description, ct)` → `GET …/pullrequests?searchCriteria.sourceRefName=refs/heads/<s>&searchCriteria.status=active`; first hit is reused, else `POST …/pullrequests` with `isDraft: true`. Returns `(Url, Created)`, URL `<webUrl>/pullrequest/<id>`.
|
||||
- api version starts at `6.0`, one retry with the version the error names (remembered).
|
||||
- errors → `AzureDevOpsException` with status code + server message; no PAT → `AzureDevOpsException("Kein Azure-DevOps-PAT hinterlegt …")`.
|
||||
- [ ] Tests (`AzureDevOpsClientTests`, `StubHandler` from Tickets): the two `-SelfTest` remotes + non-ADO remote, auth header `Basic OmdlaGVpbQ==`, version parse (match / foreign error), reuse of an active PR (no POST), create with `isDraft` + refs, version fallback retry.
|
||||
|
||||
### Task 4: Fetch the basis before a unit root
|
||||
|
||||
`WorktreeManager.ResolveBaseCommitAsync`, unit-root branch only, when `CompanyRepo.IsCompanyAsync`:
|
||||
1. basis = `base_branch` → ADO `defaultBranch` (if the remote parses and a PAT is set) → `GetRemoteHeadBranchAsync`. None → today's behaviour.
|
||||
2. `FetchBranchAsync(basis, GitAuthEnv(pat))`. Failure → warning, base = local `basis` (else HEAD). No throw.
|
||||
3. Success → base = `rev-parse origin/<basis>`; then best-effort local update: not checked out → `FastForwardLocalBranchAsync` (only if the local branch exists); checked out + clean (`HasChangesAsync(includeUntracked:false)`) → `MergeFastForwardOnlyAsync(workingDir, origin/<basis>)`; otherwise warning only.
|
||||
- [ ] Tests (`CompanyFetchBaseTests`, bare repo as origin, pattern = bare path): bare one commit ahead → unit root's `BaseCommit` == bare head, local `main` fast-forwarded; dirty checked-out `main` stays untouched (file content + HEAD unchanged) but the root still starts from origin; unreachable origin → worktree created from local `main`; private repo (pattern mismatch) → no fetch (origin ahead is ignored).
|
||||
|
||||
### Task 5: Company approve
|
||||
|
||||
- [ ] `TaskMergeService`: `StatusPrCreated = "pr_created"`, `StatusPrUpdated = "pr_updated"`; `MergeResult(..., string? PullRequestUrl = null)`; `MergeTargets(..., bool IsCompanyRepo = false)` filled in `GetTargetsAsync`.
|
||||
- [ ] `ApproveAndMergeAsync`: after the child-absorb block, if `wt` is Active and the repo is a company repo → `ApproveCompanyAsync` (ignores `skipVerify`):
|
||||
- verify via `RunVerifyGateAsync(verifyCommand, wt.Path, …)` with `PhaseVerifying` broadcast; red → `verify_failed`, nothing pushed.
|
||||
- `PullRequestPublisher.PublishAsync(task, list, wt, verified, ct)` wrapped in `ProgressReporter.RunAsync` ("push + draft PR running").
|
||||
- [ ] `PullRequestPublisher` (`GitService`, `AzureDevOpsClient`, `TicketClientFactory?`, db factory, broadcaster, logger):
|
||||
- no PAT → blocked; remote does not parse → blocked.
|
||||
- push with `GitAuthEnv`; rejected → blocked `push rejected: <stderr>`.
|
||||
- target = `base_branch` else ADO default branch.
|
||||
- title `<type>(<list-slug>): <title>[, Resolves #<ticket>]`; description `Resolves [#id](<url>)` (link only with a project id) + task description + `Geprüft: <cmd> grün` / `Geprüft: nichts automatisch`.
|
||||
- PR error after push → blocked `gepusht, PR fehlgeschlagen: …`.
|
||||
- success → `SetPullRequestUrlAsync`, `WorktreeUpdated`, `pr_created`/`pr_updated` with URL. Task status and worktree state untouched.
|
||||
- [ ] Hub `ApproveReview`: `MergeResultDto(..., r.PullRequestUrl)`. MCP `review_task`: `mergeMessage` = PR URL for `pr_*`; tool description mentions it.
|
||||
- [ ] Tests (`CompanyApproveTests`; origin URL `http://ado.test/coll/proj/_git/Repo` + `url.<bare>.pushInsteadOf` so the push lands in a local bare repo while the remote still parses as ADO; HTTP stubbed): creates draft PR (branch in bare, URL stored, task still WaitingForReview, worktree Active, title/description), reuses existing PR → `pr_updated` without POST, verify red → no push, PR error after push → blocked "gepusht, PR fehlgeschlagen", `skipVerify` ignored.
|
||||
|
||||
### Task 6: Hub + wire + UI client
|
||||
|
||||
- [ ] Wire: `AzureDevOpsSettingsDto(bool TokenSet)`, `AzureDevOpsConnectionDto(bool Ok, string? Repo, string? DefaultBranch, string? Error)`, `MergeResultDto.PullRequestUrl`, `MergeTargetsDto.IsCompanyRepo`.
|
||||
- [ ] Hub: `GetAzureDevOpsSettings`, `SetAzureDevOpsToken`, `ClearAzureDevOpsToken`, `TestAzureDevOpsConnection` (first list whose origin matches the pattern and parses → `GetDefaultBranchAsync`; every error is an answer, not a throw).
|
||||
- [ ] `IWorkerClient`/`WorkerClient` + `StubWorkerClient`.
|
||||
|
||||
### Task 7: UI
|
||||
|
||||
- [ ] Settings: tab header "Externe Dienste"/"External services"; second section Azure DevOps (PAT field, test, clear) in `TicketSettingsTabViewModel` (saved together with the ticket settings).
|
||||
- [ ] Detail pane: `Merge.IsCompanyRepo` (from targets) and `Merge.PullRequestUrl` (from the worktree row); target dropdown hidden in company repos; approve label "Draft-PR erstellen" / "PR aktualisieren"; PR link button (`ShellOpen.Url`); approve errors in company mode go to the footer strip (`ErrorReported`) instead of a modal; still gated behind "open diff".
|
||||
- [ ] Locales en + de in parity, both parsed separately.
|
||||
|
||||
### Task 8: Merge-helper prompt
|
||||
|
||||
- [ ] Company-repo paragraph in `MergeHelperMergeDefault`: approve returns `pr_created`/`pr_updated` + URL, task stays WaitingForReview, no `verify_merges`, summary lists PR links.
|
||||
|
||||
### Task 9: Docs
|
||||
|
||||
- [ ] `src/ClaudeDo.Worker/CLAUDE.md` (tickets/config section, new Azure DevOps section), `docs/explore-notes/review-merge.md`, `docs/open.md` (manual smoke: PAT + test button, ticket task to draft PR, planning unit with 2 children).
|
||||
|
||||
## Verification
|
||||
|
||||
`dotnet build ClaudeDo.slnx -c Release`; tests filtered: `FullyQualifiedName~AzureDevOps|FullyQualifiedName~CompanyFetchBase|FullyQualifiedName~CompanyApprove|FullyQualifiedName~CommitMessageBuilder|FullyQualifiedName~UnitBranch|FullyQualifiedName~WorktreeManager` (Worker), Localization.Tests, Ui.Tests filtered to settings/details.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,132 @@
|
||||
# Ticket-Import über alle Projekte + Tagesplanung — Design
|
||||
|
||||
**Datum:** 2026-10-01 · **Status:** abgenommen
|
||||
|
||||
## Ziel
|
||||
|
||||
Ein Lauf holt **alle** Mika zugewiesenen, offenen Ticket-Items (Tickets, Tasks, Subtasks) aus dem
|
||||
Bandel-Ticketsystem, lässt Claude jedes Item nach **Inhalt** der passenden ClaudeDo-Liste zuordnen
|
||||
und plant danach den Tag (bestehender FillMyDay-Lauf). Auslösbar per Button und als Prime-Schedule.
|
||||
|
||||
## Warum nicht der bestehende Import
|
||||
|
||||
`TicketImportService.ImportAsync` arbeitet pro Liste über die feste Verknüpfung
|
||||
`ListConfigEntity.TicketProjectId` und nur für Board-Items vom Typ Ticket. Live-Stand 2026-10-01:
|
||||
|
||||
- 6 von 10 offenen, zugewiesenen Items sind Tasks/Subtasks (z.B. 595/596/598/668 unter
|
||||
„Lager Technik Plugin“) — der heutige Import sieht sie nie.
|
||||
- Projekt „Lager“ (493) enthält Items für mehrere Repos (Hub Lagertechnik, SorterController) und
|
||||
ist mit zwei Listen verknüpft. Projekt→Liste ist die falsche Zuordnungsachse; Inhalt ist die
|
||||
richtige.
|
||||
- DueDate ist fast überall null, Priorität fast überall „Niedrig“ — was heute dran ist, entscheidet
|
||||
weiterhin FillMyDay, nicht die Ticketdaten.
|
||||
|
||||
## Entscheidungen
|
||||
|
||||
| Frage | Entscheidung |
|
||||
|---|---|
|
||||
| Zuordnung | Claude nach Inhalt (Titel, Parent, Projekt). Projekt-Verknüpfung einer Liste ist nur ein Hinweis. |
|
||||
| Granularität | Flach: jedes zugewiesene Ticket/Task/Subtask wird ein eigener ClaudeDo-Task. Parent steht in der Beschreibung. Keine Hierarchie. |
|
||||
| Ablauf | Ein Lauf, zwei Schritte: Zuordnungs-Lauf, danach FillMyDay — im selben `PrimeRunner`-Gate. |
|
||||
| Kein Treffer | Item geht in die **Fallback-Liste** (App-Setting). Signal für Mika: dafür fehlt eine Liste. Ist keine gesetzt → übersprungen und in der Zusammenfassung genannt. |
|
||||
| Fallback anwenden | Der **Worker**, nicht Claude: `import_ticket` ohne `list` → Fallback-Liste. |
|
||||
|
||||
## Komponenten
|
||||
|
||||
### 1. Datenquelle (`Worker/Tickets/`)
|
||||
|
||||
**Sweep über alle Projekt-Boards** — nur Routen, die `BandelTicketClient` schon hat und die seit
|
||||
2026-09-01 live verifiziert sind: `GET /api/Navigation/sidebar` (alle sichtbaren Projekte) →
|
||||
pro Projekt `GET /api/Board/project/{id}`. Filter, `statusId ∈ {1, 2}`:
|
||||
|
||||
- Ticket (Board-Item): `ticketAssignees[].assigneeName == userName`
|
||||
- Task (`tasks[]` am Board-Item): **`ticketTaskAssignees[].assigneeName`** == userName —
|
||||
unabhängig davon, wem das Parent-Ticket gehört (730/732 hängen an 728 von marcel.sievers).
|
||||
|
||||
Projekt-Titel kommt aus der Sidebar, Parent aus dem umschließenden Board-Item.
|
||||
`TicketBoardItem` bekommt `Tasks` (`id`, `title`, `statusId`, `hasChildren`,
|
||||
`ticketTaskAssignees`).
|
||||
|
||||
**Live-Befund 2026-10-01** (7 Projekte, ~1,2 s): exakt die 10 offenen Items, die das Ticket-MCP
|
||||
`list_assigned_items` liefert — 665, 593 (+595/596/598/668), 1014, 1016, 1017, 730, 732.
|
||||
Verworfen wurde `GET /api/board/global/{userId}`: liefert kein Projekt, braucht eine userId, die
|
||||
`/pat/me` nicht hergibt (`/users/username/{u}` = 401 mit PAT), und ist **nicht** auf den
|
||||
Token-Inhaber beschränkt (`global/22` lieferte 147 fremde Items).
|
||||
|
||||
Subtasks (Ebene 3): live existiert keiner (`hasChildren` überall false). Board-Tasks mit
|
||||
`hasChildren = true` werden übersprungen.
|
||||
<!-- ponytail: keine Rekursion über GET /api/item/{id}/children — nachrüsten, sobald Subtasks real vorkommen -->
|
||||
|
||||
**Offen, manuell zu prüfen:** `PATCH /api/Ticket/{id}/status` auf einem **Task** (nicht Ticket) —
|
||||
nicht live getestet, weil es ein Schreibzugriff aufs Firmensystem ist. Laut Bandel.APIs-Breadcrumb
|
||||
gleiche Tabelle, Statusrecht u.a. für Assignees. Scheitert es, loggt `TicketStatusSync` nur eine
|
||||
Warnung (wirft nie) → beim ersten echten Lauf im Worker-Log prüfen.
|
||||
|
||||
Details pro Item für die Beschreibung: `GET /api/ticket/{id}` (live: liefert `description`,
|
||||
`parentTicketID`).
|
||||
|
||||
### 2. `TicketImportService`
|
||||
|
||||
- `GetCandidatesAsync(ct)` → zugewiesene offene Items, deren `bandel:<id>` noch **in keiner Liste**
|
||||
als `TicketRef` existiert, plus alle User-Listen (Id, Name, WorkingDir, verknüpftes
|
||||
Ticket-Projekt) als Zuordnungskontext.
|
||||
- `ImportItemAsync(itemId, listId?, ct)` → lädt Details via `GET /api/ticket/{id}` (Beschreibung,
|
||||
HTML→Markdown über das bestehende `HtmlToMarkdown`), legt einen `Idle`-Task an:
|
||||
`TicketRef = bandel:<id>`, `CreatedBy = "ticketsystem"`, Beschreibung endet mit
|
||||
`Ticket #<id>` und bei Kind-Items `Teil von #<parentId> <parentTitle>`.
|
||||
`listId == null` → `AppSettings.TicketFallbackListId`; ist die auch null → kein Task, Ergebnis
|
||||
meldet „übersprungen“. Bereits importiert (global) → No-op mit entsprechender Meldung.
|
||||
- **Fix im bestehenden `ImportAsync`:** Dedupe über `TicketRef` **listenübergreifend** statt nur
|
||||
`t.ListId == listId` — sonst legt „Aus Ticketsystem aktualisieren“ ein bereits global
|
||||
zugeordnetes Item ein zweites Mal an.
|
||||
|
||||
### 3. MCP-Tools (External-MCP, neue `External/TicketMcpTools`)
|
||||
|
||||
- `get_ticket_import_candidates()` → `{ available, items[], lists[], fallbackList }`.
|
||||
`available = false` mit Grund, wenn das Ticketsystem nicht eingerichtet ist.
|
||||
- `import_ticket(ticketId, list = null)` → Bestätigungs-Record (`created`, `taskNumber`,
|
||||
`listName`, `usedFallback`, `skippedReason`). `list` akzeptiert Name oder Id.
|
||||
|
||||
Konventionen aus `src/ClaudeDo.Worker/CLAUDE.md` gelten (C#-Default für optionale Parameter,
|
||||
kein nacktes `Task`-Return, Tool-Doku nach `McpToolDocs`).
|
||||
|
||||
### 4. Ablauf (`Worker/Prime/`)
|
||||
|
||||
- `PrimeActionKind.ImportTickets`, DB-Wert `import_tickets` (Converter in
|
||||
`PrimeScheduleEntityConfiguration`).
|
||||
- `TicketImportPrompt` (analog `DailyPrepPrompt`): Prompt über `PromptFiles`
|
||||
(`PromptKind.TicketImport`), Args `--allowedTools` nur die zwei Tools, `--permission-mode
|
||||
acceptEdits`. Prompt-Kern: Kandidaten holen, jedes Item der inhaltlich passenden Liste zuordnen
|
||||
(Projekt-Verknüpfung = Hinweis), bei Unsicherheit `list` weglassen, am Ende Zusammenfassung
|
||||
(importiert pro Liste, Fallback, übersprungen).
|
||||
- `PrimeRunner.RunImportTicketsAsync`: Log einmal leeren, `PrepStarted`, Zuordnungs-Lauf, dann den
|
||||
FillMyDay-Teil (bestehende Logik ohne erneutes Leeren/`PrepStarted`), `PrepFinished`. Schlägt
|
||||
der Import fehl, läuft FillMyDay trotzdem (bestehende Tasks sind weiter planbar); das Outcome
|
||||
meldet den Importfehler.
|
||||
- Ist das Ticketsystem nicht eingerichtet, fällt der Lauf auf reines FillMyDay zurück.
|
||||
|
||||
### 5. Hub + UI
|
||||
|
||||
- `WorkerHub.RunTicketImportNow()` (synthetischer Schedule, Kind fix `ImportTickets`, wie
|
||||
`RunDailyPrepNow`); `IWorkerClient`/`WorkerClient` + Test-Fakes nachziehen.
|
||||
- Prep-Panel (`DetailsIslandView`, neben „Plan day“): Button „Tickets + Tag planen“, nur sichtbar
|
||||
wenn das Ticketsystem eingerichtet ist; gleiche Enable-Logik wie `PlanDayCommand`.
|
||||
- Prime-Tab: neuer Aktionstyp wählbar.
|
||||
- Settings → Ticketsystem: Dropdown „Fallback-Liste“ (User-Listen + „keine“).
|
||||
- `AppSettingsEntity.TicketFallbackListId` (nullable) + EF-Migration. Locale-Keys en + de.
|
||||
|
||||
## Tests
|
||||
|
||||
- `GetCandidatesAsync`: Status-Filter, Assignee-Filter über `ticketAssignees`, globale Dedupe.
|
||||
- `ImportItemAsync`: explizite Liste, Fallback, keine Fallback-Liste → übersprungen, Duplikat.
|
||||
- `ImportAsync`: Item bereits in anderer Liste → nicht erneut angelegt.
|
||||
- `PrimeRunner`: `ImportTickets` → zwei Claude-Läufe in Reihenfolge, ein Gate, Import-Fehler
|
||||
blockiert FillMyDay nicht.
|
||||
- Fake-Client/Fake-Claude, **kein** echtes Claude. Live-Verifikation (Schritt 1) und
|
||||
Sichtprüfung der UI sind manuelle Punkte → `docs/open.md`.
|
||||
|
||||
## Bewusst nicht drin
|
||||
|
||||
- Rückabgleich „Ticket im Ticketsystem erledigt, Task in ClaudeDo noch offen“.
|
||||
- Ticket-Hierarchie als ClaudeDo-Parent/Subtasks.
|
||||
- Aktualisieren bereits importierter Items (würde Notizen überschreiben — gleiche Regel wie heute).
|
||||
@@ -0,0 +1,249 @@
|
||||
# Unit-Branch + Draft-PR — Design
|
||||
|
||||
**Datum:** 2026-10-01 · **Status:** abgenommen (mit Basis-Branch pro Liste + Fetch)
|
||||
**Diagramme:** Obsidian `07 - Plattform & Technik/ClaudeDo/ClaudeDo - Branch und PR Ablauf.md`
|
||||
**Ersetzt:** die offene Entscheidung aus `2026-08-06-planning-chain-fork-base-design.md`
|
||||
|
||||
## Ziel
|
||||
|
||||
Aufgaben starten und später zu fertigen PRs zurückkommen. In Firmen-Repos (Azure DevOps Server)
|
||||
heißen Branches nach dem work-conventions-Schema, und das Review endet in einem **Draft-PR** statt
|
||||
in einem lokalen Merge auf `main`. Nebenbei sehen Planning-Children endlich die Arbeit ihres
|
||||
Vorgängers.
|
||||
|
||||
## Begriffe
|
||||
|
||||
- **Unit:** ein einzelner Task, oder ein Parent (Planning oder Improvement) samt Children.
|
||||
- **Unit-Branch:** der eine Branch, auf dem die ganze Arbeit einer Unit landet. Er gehört dem
|
||||
Worktree des Unit-Roots (Einzeltask bzw. Parent).
|
||||
- **Firmen-Repo:** Die `origin`-URL enthält `company_remote_pattern` (`worker.config.json`,
|
||||
Default `/_git/`, nur per Hand editierbar). Geprüft wird live über `git remote get-url origin`
|
||||
und ohne Cache.
|
||||
|
||||
## Entscheidungen
|
||||
|
||||
| Frage | Entscheidung |
|
||||
|---|---|
|
||||
| Branchname | Einheitlich `<typ>/<nummer>-<slug>`. Die Nummer ist die Ticket-Id aus `TicketRef` (beim Child die des Unit-Roots), sonst privat die Task-Nummer. Im Firmen-Repo ohne Ticket entfällt sie (`<typ>/<slug>`). `claudedo/{nr}-…` fällt weg. Planning-Session-Worktrees bleiben `claudedo/planning/{guid}`. |
|
||||
| Typ | `feat`→`feature`. `fix`, `refactor`, `docs`, `test`, `chore` bleiben, wie sie sind. Alles andere (`ci`, `perf`, `style`, `build`) wird `chore`. |
|
||||
| Slug | `CommitMessageBuilder.ToSlug` plus Umlaut-Auflösung (`ä→ae`, `ö→oe`, `ü→ue`, `ß→ss`), ≤40 Zeichen wie heute. |
|
||||
| Children | Starten vom aktuellen Head des Unit-Branches. Ist ein Child fertig, wird der Unit-Branch sofort per **fast-forward** auf den Head des Childs gezogen. Das gilt privat und in Firmen-Repos. |
|
||||
| Review-Ende privat | Unverändert: Approve merged den Unit-Branch auf `main`, dann läuft Verify auf `main`. |
|
||||
| Review-Ende Firma | Approve führt Verify im Worktree des Unit-Roots aus, dann Push und Draft-PR. Der Task bleibt `WaitingForReview` mit PR-Link. `Done` setzt Mika per Toggle nach dem ADO-Merge. |
|
||||
| PR-Automatik | Kein Auto-PR bei einem Statuswechsel. Automatisiert wird über den Merge-Helper, dessen `approve` dann den PR erzeugt. |
|
||||
| ADO-Zugang | Eigener PAT (Scope Code Read & Write). Er wird per `DpapiTokenStore` in `~/.claudeDo/azdo.pat` gespeichert, ohne Abhängigkeit auf das work-conventions-Plugin. |
|
||||
| dependsOn über Units | Wartet bewusst auf den echten Merge. Abhängige Arbeit gehört in eine Unit mit einem PR. |
|
||||
| Basis-Branch | Pro Liste einstellbar (`list_config.base_branch`, z.B. `dev` für die LagerApp). Leer heißt: privat der gerade ausgecheckte Branch des Main-Checkouts (wie heute), Firma der Default-Branch des ADO-Repos. Gilt als Basis des Unit-Branches, als PR-Ziel und als Vorauswahl des privaten Merge-Ziels. |
|
||||
| Basis aktualisieren | Firmen-Repo: `git fetch origin <basis>`, bevor der Unit-Branch angelegt wird. Der Unit-Branch startet von `origin/<basis>`, der lokale Basis-Branch wird best-effort per fast-forward nachgezogen. |
|
||||
|
||||
## 1. Branch-Naming (`WorktreeManager`)
|
||||
|
||||
- `NameFor(task, ticketId, isCompany)` liefert den Leaf `<nummer>-<slug>` bzw. `<slug>`, und
|
||||
`BranchFor` liefert `<typ>/<leaf>`. Der Ordnername des Worktrees bleibt der Leaf.
|
||||
- **Kollisionsprüfung** (heute `Contains($"claudedo/{name}")`, Zeile 83) prüft den neuen vollen
|
||||
Branchnamen. Bei einem Treffer kommt das Id-Suffix dran, wie heute.
|
||||
- **Self-Heal** (`git branch -D` nach „already exists“) läuft nur noch, wenn der Branch in einer
|
||||
`worktrees`-Zeile steht, deren Task der aktuelle Task ist. Sonst gibt es keinen Delete, sondern
|
||||
das Id-Suffix, und der Branch des Nutzers bleibt unangetastet. Ohne das Präfix `claudedo/` ist
|
||||
das Pflicht, sonst löscht ClaudeDo einen fremden `feature/…`-Branch.
|
||||
- **`UntrackedWorktreeRecovery`** filtert statt nach dem Präfix nach „Worktree-Pfad liegt unter
|
||||
einer ClaudeDo-Worktree-Root (`.claudedo-worktrees` bzw. Central-Root), aber nicht unter
|
||||
`planning/`“.
|
||||
- `PlanningAggregator.CleanupIntegrationBranchAsync:137` (Präfix-Check beim Checkout-Fallback)
|
||||
wird mit Abschnitt 3 ohnehin hinfällig.
|
||||
- Bestehende Worktrees behalten ihren Namen. Der Branchname steht in der DB und wird nie neu
|
||||
abgeleitet, eine Migration ist also nicht nötig.
|
||||
- **Bugfix nebenbei:** `DetailsIslandViewModel.cs:1223/1385` baut für Dialogtexte
|
||||
`claudedo/{Task.Id}`, das ist schon heute falsch. Künftig steht dort `worktree.BranchName`. Das
|
||||
literale `"← main"` (`:670/:933`) zeigt das echte Ziel an.
|
||||
|
||||
## 2. Unit-Branch und Children
|
||||
|
||||
**Wer besitzt den Unit-Branch?**
|
||||
- **Einzeltask:** sein eigener Worktree. Am Ablauf ändert sich nichts.
|
||||
- **Improvement-Parent:** sein eigener Worktree, den er schon heute hat. Seine Children starten
|
||||
schon heute von `parent.HeadCommit`.
|
||||
- **Planning-Parent:** hat heute **keinen** Worktree. Neu: `QueuePlanAsync` legt per
|
||||
`WorktreeManager.CreateAsync` einen Worktree für den Parent an, auf dem Unit-Branch und mit
|
||||
`main` HEAD als Basis. Damit hat jeder Parent eine `worktrees`-Zeile, die den Unit-Branch und
|
||||
später die PR-URL trägt.
|
||||
|
||||
**Basis eines Childs** (`ResolveBaseCommitAsync`): Das ist immer der aktuelle Head des
|
||||
Unit-Branches (`rev-parse <unitBranch>`). Der Sonderfall „nur bei `PlanningPhase.None`“ fällt weg.
|
||||
|
||||
**Basis eines Unit-Roots** (Einzeltask, Parent):
|
||||
- **Privat:** `list_config.base_branch`, sonst der ausgecheckte Branch des Main-Checkouts. Heute
|
||||
ist es `RevParseHeadAsync`. Ist `base_branch` gesetzt, wird es `rev-parse <base_branch>`, auch
|
||||
wenn gerade ein anderer Branch ausgecheckt ist.
|
||||
- **Firma:**
|
||||
- Zuerst `git fetch origin <basis>`. `<basis>` ist `base_branch`, sonst der Default-Branch des
|
||||
Repos per ADO-API, ersatzweise `git symbolic-ref refs/remotes/origin/HEAD`.
|
||||
- Der Unit-Branch startet von `origin/<basis>`.
|
||||
- Dann wird der lokale `<basis>` best-effort nachgezogen:
|
||||
- nicht ausgecheckt: `git fetch origin <basis>:<basis>` (lehnt einen nicht-fast-forward
|
||||
selbst ab)
|
||||
- ausgecheckt und sauber: `git -C <workingDir> merge --ff-only origin/<basis>`
|
||||
- schmutzig oder divergiert: Warnung ins Log, kein Eingriff in den Checkout
|
||||
- Schlägt der Fetch fehl (offline, keine Credentials), wird gewarnt und es geht mit dem
|
||||
lokalen `<basis>` weiter. Der Task scheitert daran nicht.
|
||||
- Der Fetch nutzt dieselbe PAT-Übergabe wie der Push (Abschnitt 3).
|
||||
- Der Fetch läuft einmal pro Unit-Root, nicht pro Child. Children starten vom Unit-Branch.
|
||||
|
||||
**Spalte `list_config.base_branch`** (nullable string, Migration):
|
||||
- Sie wird im Listeneinstellungs-Modal gepflegt, als Freitext mit Vorschlägen aus
|
||||
`ListLocalBranchesAsync`, und in `get_list_config` mitgeliefert.
|
||||
- ⚠️ `ListRepository.SetConfigAsync` kopiert verbatim (siehe `TicketProjectId`). Jeder Schreiber
|
||||
muss den Wert durchreichen, sonst setzt ihn z.B. `set_list_config` still zurück:
|
||||
`WorkerHub.UpdateListConfig`, `ConfigMcpTools`, alle Stellen, die eine frische
|
||||
`ListConfigEntity` bauen.
|
||||
- `UpdateListConfigDto` braucht ihn tri-state wie `TicketProjectId`, damit ein Aufrufer, der das
|
||||
Feld nicht kennt, es nicht löscht.
|
||||
- **Merge-Ziel privat:** `TaskMergeService.GetTargetsAsync` setzt `DefaultBranch` auf
|
||||
`base_branch`, falls gesetzt. Das leere Ziel (`ApproveAndMergeAsync:1077`, `PreviewAsync:910`)
|
||||
löst ebenfalls auf `base_branch` auf, sonst wie heute.
|
||||
|
||||
**Absorbieren eines Childs:** Neu ist `UnitBranchService.AbsorbChildAsync(childId)`, das in
|
||||
`git -C <parentWorktree> merge --ff-only <childBranch>` mündet.
|
||||
- Die Kette ist seriell (`BlockedByTaskId`), und das Child startet vom Head des Unit-Branches.
|
||||
Deshalb ist es **immer** ein fast-forward, Konflikte sind ausgeschlossen. Lässt es sich nicht
|
||||
fast-forwarden, z.B. weil jemand ein Child manuell parallel per „Run now“ gestartet hat, wird
|
||||
das Child `Failed` mit einer klaren Meldung, ohne Merge-Versuch.
|
||||
`ponytail: kein Merge-Fallback; einbauen, falls parallele Children ein echter Fall werden.`
|
||||
- Danach wird der Worktree des Childs entfernt, der Branch gelöscht und die Zeile auf `Merged`
|
||||
gesetzt (`MergeCommit` = null, denn es gibt keinen Merge-Commit). `revert_merge` greift auf
|
||||
Child-Ebene deshalb nicht, revertiert wird die ganze Unit.
|
||||
- Aufgerufen wird es aus `TaskRunner.HandleSuccess` direkt vor `_state.CompleteAsync`. Ein
|
||||
ff-Fehler wird so zu `FailAsync` statt zu einem Unblock des Nachfolgers.
|
||||
- Interaktive Children landen in `WaitingForReview`, und ihr Approve geht durch
|
||||
`TaskMergeService.ApproveAndMergeAsync`. Dort gilt neu: Hat der Task einen Parent mit eigenem
|
||||
Worktree, wird `AbsorbChildAsync` aufgerufen statt auf das Ziel gemergt.
|
||||
- Kein Git-Code in `TaskStateService` oder `PlanningChainCoordinator`, die bleiben git-frei.
|
||||
|
||||
**Was dabei wegfällt:** Den Unit-Merge Child für Child am Ende gibt es für neue Units nicht mehr.
|
||||
Ein Parent wird beim Approve wie ein Einzeltask behandelt, über seinen eigenen Worktree in
|
||||
`TaskMergeService.ApproveAndMergeAsync`. Auch der Integration-Branch
|
||||
(`PlanningAggregator`, `planning/{slug}-integration`, dafür `checkout -b` im Main-Checkout) ist
|
||||
für neue Units unnötig, denn der kombinierte Diff ist `unitBase..unitHead`.
|
||||
|
||||
**Routing beim Approve** (`WorkerHub.ApproveReview`, `ExternalMcpService.ReviewTask`): Heute
|
||||
geht jeder Task mit Children an den Orchestrator. Neu gilt: Gibt es ein `Done`-Child mit einem
|
||||
`Active`-Worktree, also eins, das nicht absorbiert wurde, läuft der Legacy-Orchestrator. Sonst
|
||||
geht es über `ApproveAndMergeAsync` auf den Worktree des Parents.
|
||||
|
||||
**Altbestand:** Parents ohne eigenen Worktree (vor dem Rollout geplant) laufen weiter über
|
||||
`PlanningMergeOrchestrator` und den bestehenden Aggregator. Diesen Legacy-Pfad entfernen wir in
|
||||
einem Folge-Task, sobald keine solchen Units mehr offen sind.
|
||||
|
||||
## 3. Approve im Firmen-Repo
|
||||
|
||||
`TaskMergeService.ApproveAndMergeAsync` verzweigt nach `IsCompanyRepo(list)`:
|
||||
|
||||
1. **Verify** im Worktree des Unit-Roots (`IVerifyCommandRunner.RunAsync(worktreePath, …)`), nicht
|
||||
im Main-Checkout. Ohne Verify-Command entfällt der Schritt. Rot heißt: Ergebnis
|
||||
`verify_failed`, der Task bleibt `WaitingForReview`, kein Push. `skipVerify` wird im
|
||||
Firmen-Modus ignoriert, weil kein Merge landet, auf den man das Verify verschieben könnte.
|
||||
2. **Push** mit `git -C <worktree> push -u origin <unitBranch>`. Der PAT geht als
|
||||
`http.extraHeader` über `GIT_CONFIG_COUNT`/`GIT_CONFIG_KEY_0`/`GIT_CONFIG_VALUE_0` in der
|
||||
Prozess-Umgebung mit. Er steht also nicht in der Kommandozeile, und es gibt keinen
|
||||
Credential-Manager-Prompt im Hintergrundprozess.
|
||||
3. **PR**, neuer `AzureDevOpsClient`, portiert aus `pr-create.ps1`:
|
||||
- Der Remote wird an `/_git/` zerlegt: API-Basis = alles davor, Repo = Segment danach.
|
||||
Eingebettete Zugangsdaten fliegen raus.
|
||||
- `GET …/pullrequests?searchCriteria.sourceRefName=refs/heads/<branch>&searchCriteria.status=active`.
|
||||
Gibt es schon einen PR, wird er wiederverwendet (idempotent, auch wenn der Skill in der
|
||||
Session schon einen angelegt hat).
|
||||
- Sonst `POST …/pullrequests` mit `isDraft: true` und `targetRefName = <basis>`. Das ist
|
||||
`list_config.base_branch`, sonst `defaultBranch` aus `GET …/repositories/<repo>`.
|
||||
- Die API-Version fällt auf die zurück, die der Server in seiner Fehlermeldung nennt, wie im
|
||||
Skript.
|
||||
4. Die **PR-URL** wird gespeichert, in der neuen nullable Spalte `worktrees.pull_request_url`
|
||||
(Migration). Danach folgen `WorktreeUpdated` und der Rückgabestatus `pr_created` bzw.
|
||||
`pr_updated`.
|
||||
5. Der Task bleibt `WaitingForReview`, der Worktree bleibt `Active`, und das Ticket bleibt auf
|
||||
`InBearbeitung`.
|
||||
|
||||
**PR-Inhalt:**
|
||||
- **Titel:** `<commitType>(<list-slug>): <titel>, Resolves #<ticket>`. Ohne Ticket entfällt
|
||||
`Resolves`.
|
||||
- **Beschreibung:**
|
||||
- `Resolves [#412](<ticket-url>)`. Die Projekt-Id kommt über `BandelTicketClient.GetTicketAsync`
|
||||
→ `ParentTicketID` (bei Tasks eine Ebene höher). Ohne Id gibt es keinen Link.
|
||||
- Leerzeile, dann die Task-Beschreibung.
|
||||
- Leerzeile, dann `Geprüft: <verify command> grün` bzw. `Geprüft: nichts automatisch`.
|
||||
|
||||
**Commits im Firmen-Repo:** `CommitMessageBuilder` lässt den Trailer `ClaudeDo-Task:` weg. Er
|
||||
wird nirgends gelesen, und die Konvention verbietet Signatur-Trailer.
|
||||
|
||||
**Rerun nach Änderungswunsch:** Ein normaler Reject mit Feedback, der auf demselben Worktree
|
||||
weiterläuft. Der nächste Approve pusht nach, und der vorhandene PR aktualisiert sich.
|
||||
|
||||
**Fehler:** Fehlt der PAT, wird der Push abgelehnt oder scheitert ein REST-Aufruf, dann landen
|
||||
Statusmeldung und Footer-Strip (`FlashFooterError`) beim Nutzer, und der Task bleibt
|
||||
`WaitingForReview`. Scheitert der PR nach einem erfolgreichen Push, sagt die Meldung das genau
|
||||
so: „gepusht, PR fehlgeschlagen: …“.
|
||||
|
||||
**Laufzeit:** Verify plus Push können länger als 5 s dauern. Der MCP-Pfad meldet deshalb Progress
|
||||
über `ProgressReporter`, sonst bricht der MCP-Client nach 300 s Leerlauf ab.
|
||||
|
||||
## 4. Merge-Helper
|
||||
|
||||
Die Prompts in `PromptFiles` (merge und merge_final) bekommen einen Firmen-Repo-Absatz:
|
||||
- `review_task approve` liefert `pr_created`/`pr_updated` mit URL.
|
||||
- `verify_merges` entfällt, weil das Verify bereits im Approve läuft.
|
||||
- Die Abschluss-Zusammenfassung listet die PR-Links auf.
|
||||
- Die Regel „erst mergen, dann neu starten“ bleibt harmlos, da `main` sich nicht bewegt.
|
||||
|
||||
## 5. UI
|
||||
|
||||
- Der Settings-Reiter „Ticketsystem“ heißt künftig **„Externe Dienste“**. Er bekommt einen
|
||||
zweiten Abschnitt **Azure DevOps** mit PAT-Feld, Test-Button und Löschen-Button, gespiegelt vom
|
||||
Ticket-PAT.
|
||||
- Neue Hub-Methoden: `GetAzureDevOpsSettings`, `SetAzureDevOpsToken`, `ClearAzureDevOpsToken`,
|
||||
`TestAzureDevOpsConnection`. Neuer DI-Marker `AzureDevOpsPatStore`.
|
||||
- Der Test braucht ein Firmen-Repo als Kontext: Er nimmt die erste Liste, deren `origin` passt,
|
||||
und ruft `GET …/repositories/<repo>` auf.
|
||||
- Im Detail-Pane heißt der Approve-Button im Firmen-Repo **„Draft-PR erstellen“**. Er ist weiter
|
||||
hinter „Diff öffnen“ gegated, und das Ziel-Dropdown ist ausgeblendet. Ist eine PR-URL gesetzt,
|
||||
erscheint ein Link und der Button heißt „PR aktualisieren“.
|
||||
- Locale-Keys kommen in `en.json` und `de.json` gleichzeitig hinzu.
|
||||
|
||||
## 6. Bewusst draußen
|
||||
|
||||
- PR-Status-Polling bzw. automatisches `Done` nach dem ADO-Merge.
|
||||
- Andere PR-Provider als Azure DevOps. `company_remote_pattern` schaltet nur das Schema um, die
|
||||
PR-API ist ADO-spezifisch.
|
||||
- Aufräumen von Worktree und lokalem Branch, wenn ein Firmen-Task auf `Done` gesetzt wird. Das
|
||||
geht über die bestehende Worktree-Übersicht.
|
||||
- `git fetch` in privaten Repos. Die Basis bleibt dort lokal.
|
||||
- Eine UI für `company_remote_pattern`.
|
||||
|
||||
## 7. Tests
|
||||
|
||||
Keine Tests mit der echten Claude-CLI und keine gegen das echte ADO.
|
||||
|
||||
- **Naming:** Typ-Mapping, Nummernregel (Ticket, privat, Firma ohne Ticket), Umlaute,
|
||||
Kollision mit einem fremden Branch (der Branch überlebt, das Suffix greift), Self-Heal löscht
|
||||
nur DB-eigene Branches. Die betroffenen Asserts in `WorktreeManagerTests` (`:69`, `:131/138`,
|
||||
`:160`, `:314`) passe ich an.
|
||||
- **Unit-Branch** (echtes git, Worker.Tests):
|
||||
- Ein Child startet vom Head des Unit-Branches.
|
||||
- Zwei serielle Children werden per fast-forward absorbiert, und Child 2 sieht die Dateien von
|
||||
Child 1.
|
||||
- Ein nicht fast-forwardbares Child wird `Failed`.
|
||||
- Ein Planning-Parent bekommt seinen Worktree bei `QueuePlanAsync`.
|
||||
- **Approve Firma:**
|
||||
- Ein lokales Bare-Repo dient als `origin`, und das Muster wird auf dessen Pfad gesetzt.
|
||||
- `AzureDevOpsClient` wird gefakt: PR anlegen, vorhandenen PR wiederverwenden, Verify rot
|
||||
heißt kein Push, PR-Fehler nach dem Push.
|
||||
- **Remote-Parsing:** die Fälle aus dem `-SelfTest` des Skripts.
|
||||
- **Basis:**
|
||||
- `base_branch` gesetzt: Der Unit-Root startet davon, auch wenn ein anderer Branch ausgecheckt
|
||||
ist.
|
||||
- Firma: Nach dem Fetch startet der Unit-Root von `origin/<basis>`. Das testet ein Bare-Repo,
|
||||
das einen Commit voraus ist.
|
||||
- Der lokale Basis-Branch wird nachgezogen, ein schmutziger Checkout bleibt unangetastet.
|
||||
- Ein Fetch-Fehler bricht den Task nicht.
|
||||
- `SetConfigAsync`, `set_list_config` und `UpdateListConfig` erhalten `base_branch`.
|
||||
- **Manueller Smoke-Test (Mika):** PAT eintragen, Test-Button. Ein Ticket-Task in einem echten
|
||||
Firmen-Repo bis zum Draft-PR durchspielen, danach eine Planning-Unit mit 2 Children.
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
# SSH workspaces — remote interactive sessions
|
||||
|
||||
**Date:** 2026-10-02 · **Status:** approved (inline, Mika)
|
||||
|
||||
## Goal
|
||||
|
||||
Start an interactive `claude` session on a server (e.g. the VPS) as a Mission Control tile,
|
||||
with the `claudedo` MCP reachable from there. One extra entry per workspace in the
|
||||
quick-session flyout ("VPS"), click → tile → real claude TUI over ssh.
|
||||
|
||||
## Scope
|
||||
|
||||
In:
|
||||
- SSH workspaces configured in Settings: **Name, Host alias, Remote path**.
|
||||
- Host alias is picked from `%USERPROFILE%\.ssh\config` (`Host` lines, wildcards skipped);
|
||||
user/key/port stay in the ssh config — ClaudeDo stores none of it.
|
||||
- Quick-session flyout lists each workspace after the local targets.
|
||||
- The tile runs `ssh.exe` with a reverse tunnel to the worker's external MCP for the
|
||||
lifetime of the ssh process; the remote claude gets a `claudedo` MCP entry pointing at it.
|
||||
- Per-session token on a dedicated remote-only MCP listener.
|
||||
|
||||
Out (v1):
|
||||
- Autonomous tasks on a remote host — the whole worktree/merge/verify pipeline is local-FS.
|
||||
- `handoff_session` and "Recent sessions"/resume for SSH tiles — both build local paths
|
||||
(brief.md, cwd). SSH sessions are **not recorded** in `mission_control_sessions` and get
|
||||
**no** `claudedo_pane` entry / handoff hint.
|
||||
- Path translation in MCP tool results (worktree/attachment paths stay Windows paths).
|
||||
- Password/key management UI — ssh prompts inside the tile like in a terminal.
|
||||
|
||||
## Design
|
||||
|
||||
### Storage
|
||||
`AppSettingsEntity.SshWorkspaces` (`ssh_workspaces`) — JSON array of
|
||||
`{ id, name, host, remotePath }`. Same pattern as `RepoImportFolders`: dedicated repository
|
||||
`Get/SetSshWorkspacesAsync` + hub `GetSshWorkspaces`/`SetSshWorkspaces`, **not** part of
|
||||
`AppSettingsDto` (`AppSettingsRepository.UpdateAsync` copies named fields only, so the column is
|
||||
never clobbered by a general settings save). One EF migration adds the column.
|
||||
|
||||
Validation at the boundary (`SshWorkspace.Validate`, used by Settings before save and by the hub on
|
||||
set): name non-empty; host matches `^[A-Za-z0-9._@][A-Za-z0-9._@-]*$` — **a leading `-` would be
|
||||
parsed by ssh as an option (`-oProxyCommand=…` = local code execution)**; remote path starts with
|
||||
`/` or `~`, contains no `'` and no line break (it is single-quoted on the remote shell).
|
||||
|
||||
### Launch spec (Worker)
|
||||
New hub method `GetSshLaunchSpec(string workspaceId, string paneId)` →
|
||||
`InteractiveLaunchSpecService.BuildForSshAsync`:
|
||||
|
||||
- `Exe` = `ssh` resolved on PATH (Windows OpenSSH); clear error if missing.
|
||||
- `Cwd` = local user profile (ssh needs a local cwd; nothing remote reads it).
|
||||
- Remote port: random per session in `20000–60999`.
|
||||
- Token: random 32 bytes (hex), registered in a singleton `RemoteSessionTokenRegistry`.
|
||||
- Args (each a separate token, **no embedded double quotes** — ConPTY flattens Args into one
|
||||
Windows command line, see `docs/explore-notes/conpty-sessions.md`):
|
||||
```
|
||||
-t -o ExitOnForwardFailure=yes
|
||||
-R <remotePort>:127.0.0.1:<RemoteMcpPort>
|
||||
<hostAlias>
|
||||
umask 077 && mkdir -p ~/.claudedo && echo <b64> | base64 -d > ~/.claudedo/mcp-<paneId>.json
|
||||
&& cd '<remotePath>' && MCP_TOOL_TIMEOUT=<ms> claude --mcp-config ~/.claudedo/mcp-<paneId>.json;
|
||||
rm -f ~/.claudedo/mcp-<paneId>.json
|
||||
```
|
||||
`<b64>` = base64 of `{"mcpServers":{"claudedo":{"type":"http","url":"http://127.0.0.1:<remotePort>/mcp","headers":{"X-ClaudeDo-Remote-Token":"<token>"}}}}`.
|
||||
Base64 has no shell- or argv-sensitive characters; the file (mode 600 via umask) keeps the
|
||||
JSON out of claude's own argv.
|
||||
- The server name is `claudedo` (not `claudedo_pane`) so tool names match local sessions.
|
||||
- Remote prerequisites: POSIX shell with `base64`, `claude` on PATH and logged in.
|
||||
|
||||
### Remote-only MCP listener (security)
|
||||
The external MCP is loopback-only and keyless by default (`ExternalMcpAuthMiddleware`).
|
||||
Tunnel traffic arrives as loopback, so exposing the existing port would hand every process
|
||||
on the server that can reach the forwarded port full control of the local ClaudeDo.
|
||||
|
||||
- New `WorkerConfig.RemoteMcpPort` (default `47823`, `0` = feature off). The external MCP app
|
||||
additionally binds `127.0.0.1:<RemoteMcpPort>`.
|
||||
- `ExternalMcpAuthMiddleware`: a request whose `Connection.LocalPort == RemoteMcpPort`
|
||||
must carry a valid `X-ClaudeDo-Remote-Token` (registry lookup) → else 401. The existing
|
||||
key/loopback rule applies unchanged to the normal port.
|
||||
- Tunnels always target `RemoteMcpPort`, never `ExternalMcpPort`.
|
||||
|
||||
Known ceilings (accepted):
|
||||
- The remote command line (incl. base64 → token) is visible to other users on the server via
|
||||
`ps`/`/proc`. Protects against processes/containers that reach the port without reading
|
||||
cmdlines; fine for a single-user VPS. Upgrade path: deliver the token via stdin before the TUI.
|
||||
- Tokens live in memory until worker restart (no revoke on tile close). A leaked token only
|
||||
works while some tunnel to that port is open.
|
||||
|
||||
### UI
|
||||
- **Settings:** new "SSH" category (Erweitert group, last tab) — list of workspaces with add/remove,
|
||||
name textbox, host combobox (from ssh config, editable for aliases not in the file),
|
||||
remote path textbox. Saves via `UpdateAppSettingsAsync`.
|
||||
- **Quick-session flyout:** `RefreshSessionTargetsAsync` appends one `SessionTargetViewModel`
|
||||
per workspace (label = name, icon/suffix marks it as remote) →
|
||||
`OpenSshConPtySessionAsync(workspace)` → new `ConPtyPaneViewModel` with title = name, spec
|
||||
via `GetSshLaunchSpecAsync`.
|
||||
- Localization keys in en + de.
|
||||
|
||||
## Testing
|
||||
- `RemoteSessionTokenRegistry` + middleware: remote port without/with wrong/with valid token;
|
||||
normal port unaffected.
|
||||
- `BuildForSshAsync`: arg list shape, no `"` in any arg, base64 decodes to the expected JSON,
|
||||
path validation rejects `'` / relative paths, unknown workspace id throws.
|
||||
- ssh-config host parsing (wildcards, multiple aliases per `Host` line, comments).
|
||||
- Manual: real VPS — tile opens, `/mcp` shows `claudedo` connected, `list_task_lists` works,
|
||||
closing the tile tears the tunnel down; second tile to the same host works in parallel.
|
||||
@@ -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 |
|
||||
@@ -32,6 +32,7 @@
|
||||
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
<StyleInclude Source="avares://Avalonia.Controls.ColorPicker/Themes/Fluent/Fluent.xaml" />
|
||||
<StyleInclude Source="avares://AvaloniaEdit/Themes/Fluent/AvaloniaEdit.xaml" />
|
||||
<StyleInclude Source="avares://ClaudeDo.Ui/Design/IslandStyles.axaml" />
|
||||
<!-- Global defaults: every Window inherits Inter Tight + body size.
|
||||
|
||||
@@ -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,10 +12,11 @@ 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`), Icon, FolderId
|
||||
- **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt, IsManual (reminder list — tasks created here default to `IsManual`), Icon, Color, FolderId
|
||||
- `Color` (`color`, nullable) — dot colour: a swatch key (`Swatch3`), a literal `#RRGGBB`, or null = the hash-derived identity colour. Same value format as `ListFolderEntity.Color`; resolved UI-side by `ColorBrushConverter`.
|
||||
- `FolderId` (`folder_id`, nullable FK → `list_folders.id`, `ON DELETE SET NULL`) — the sidebar folder the list sits in; null = top level. Written only by the UI (`ListsIslandViewModel` drag/context menu), never by MCP.
|
||||
- **ListFolderEntity** — Id, Name, Icon, SortOrder, IsCollapsed, CreatedAt → `list_folders`. A pure sidebar grouping: no tasks, no config, no working dir, **one level** (folders never nest). Deliberately its own table rather than a flag on `lists`, so every caller that enumerates lists (MCP `list_task_lists`, "move to list", the queue picker, repo import) needs no folder filtering. Folder CRUD lives on `ListRepository`.
|
||||
- **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.
|
||||
- **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, BaseBranch (all nullable), PowerUseAllowed. `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. `BaseBranch` (`base_branch`) = base of new worktrees + default merge target; tri-state in `UpdateListConfigDto` (null = keep, "" = clear). ⚠️ Same verbatim-copy trap as `ticket_project_id`: `SetConfigAsync` overwrites every column, so every writer must carry it forward.
|
||||
- ⚠️ `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`)
|
||||
- **TaskRunEntity** — per-run record: session_id, turns, result, structured output, exit code, log path, nullable `Model` (what the run actually executed with), and `TokensIn`/`TokensOut`/`CacheReadTokens`/`CacheWriteTokens`. ⚠️ Token fields come from the **session transcript**, not the stream-json event, as a per-run delta → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md).
|
||||
@@ -24,6 +25,7 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
|
||||
- **WeekReportEntity** — Id, StartDate/EndDate (DateOnly), Markdown, GeneratedAt → `week_reports`, unique index on (start_date, end_date)
|
||||
- **TaskAttachmentEntity** — Id, TaskId (FK, ON DELETE CASCADE), FileName, ByteSize, CreatedAt → `task_attachments`
|
||||
- **TaskDependencyEntity** — one declared "run after" edge: TaskId + DependsOnTaskId, composite PK, table `task_dependencies`. Both FKs are `ON DELETE CASCADE`, which is what the old single `depends_on_task_id` column got from `ON DELETE SET NULL`: deleting a predecessor drops the edge so the dependent becomes pickable instead of blocked on a task that's gone. Written **only** by `TaskStateService.SetDependsOnAsync`.
|
||||
- **MissionControlSessionEntity** — SessionId (PK), Cwd, TaskId (no FK), Title, StartedAt, PredecessorSessionId, BriefPath → `mission_control_sessions`. Index behind Mission Control's "Recent sessions" flyout; written only by `InteractiveLaunchSpecService` (task/ad-hoc/handoff tile launches), insert-if-absent → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md).
|
||||
- **SubtaskEntity**, **AgentInfo** — subtasks / record for scanned agent files
|
||||
|
||||
### AppSettingsEntity
|
||||
@@ -120,6 +122,7 @@ Tables (one per line so parallel migrations don't collide on the same line):
|
||||
- `task_attachments`
|
||||
- `task_dependencies`
|
||||
- `list_folders`
|
||||
- `mission_control_sessions`
|
||||
|
||||
Managed by EF Core migrations in `Migrations/`. **The 40 incremental migrations were squashed into
|
||||
a single `20260826094154_InitialCreate` on 2026-08-26** — the schema history is git history now,
|
||||
|
||||
@@ -54,6 +54,7 @@ public class ClaudeDoDbContext : DbContext
|
||||
public DbSet<DailyNoteEntity> DailyNotes => Set<DailyNoteEntity>();
|
||||
public DbSet<WeekReportEntity> WeekReports => Set<WeekReportEntity>();
|
||||
public DbSet<SessionSkillEntity> SessionSkills => Set<SessionSkillEntity>();
|
||||
public DbSet<MissionControlSessionEntity> MissionControlSessions => Set<MissionControlSessionEntity>();
|
||||
|
||||
private static readonly ValueConverter<DateTime, DateTime> UtcConverter =
|
||||
new(v => v, v => DateTime.SpecifyKind(v, DateTimeKind.Utc));
|
||||
|
||||
@@ -43,6 +43,9 @@ public class AppSettingsEntityConfiguration : IEntityTypeConfiguration<AppSettin
|
||||
builder.Property(s => s.RepoImportFolders)
|
||||
.HasColumnName("repo_import_folders");
|
||||
|
||||
builder.Property(s => s.SshWorkspaces)
|
||||
.HasColumnName("ssh_workspaces");
|
||||
|
||||
builder.Property(s => s.ReportExcludedPaths).HasColumnName("report_excluded_paths");
|
||||
builder.Property(s => s.StandupWeekday).HasColumnName("standup_weekday")
|
||||
.IsRequired().HasDefaultValue((int)DayOfWeek.Wednesday);
|
||||
@@ -81,6 +84,9 @@ public class AppSettingsEntityConfiguration : IEntityTypeConfiguration<AppSettin
|
||||
builder.Property(s => s.ListHandlerMaxHandoffs)
|
||||
.HasColumnName("list_handler_max_handoffs").IsRequired().HasDefaultValue(4);
|
||||
|
||||
builder.Property(s => s.TicketFallbackListId)
|
||||
.HasColumnName("ticket_fallback_list_id");
|
||||
|
||||
builder.HasData(new AppSettingsEntity { Id = AppSettingsEntity.SingletonId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,5 +21,6 @@ public class ListConfigEntityConfiguration : IEntityTypeConfiguration<ListConfig
|
||||
builder.Property(c => c.PermissionMode).HasColumnName("permission_mode");
|
||||
builder.Property(c => c.TicketProjectId).HasColumnName("ticket_project_id");
|
||||
builder.Property(c => c.PowerUseAllowed).HasColumnName("power_use_allowed");
|
||||
builder.Property(c => c.BaseBranch).HasColumnName("base_branch");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ 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.Color).HasColumnName("color");
|
||||
builder.Property(l => l.FolderId).HasColumnName("folder_id");
|
||||
|
||||
builder.HasIndex(l => l.SortOrder).HasDatabaseName("idx_lists_sort");
|
||||
|
||||
@@ -15,9 +15,17 @@ public class ListFolderEntityConfiguration : IEntityTypeConfiguration<ListFolder
|
||||
builder.Property(f => f.Name).HasColumnName("name").IsRequired();
|
||||
builder.Property(f => f.CreatedAt).HasColumnName("created_at").IsRequired();
|
||||
builder.Property(f => f.Icon).HasColumnName("icon");
|
||||
builder.Property(f => f.Color).HasColumnName("color");
|
||||
builder.Property(f => f.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0);
|
||||
builder.Property(f => f.IsCollapsed).HasColumnName("is_collapsed").IsRequired().HasDefaultValue(false);
|
||||
builder.Property(f => f.ParentFolderId).HasColumnName("parent_folder_id");
|
||||
|
||||
builder.HasIndex(f => f.SortOrder).HasDatabaseName("idx_list_folders_sort");
|
||||
builder.HasIndex(f => f.ParentFolderId).HasDatabaseName("idx_list_folders_parent");
|
||||
|
||||
builder.HasOne<ListFolderEntity>()
|
||||
.WithMany()
|
||||
.HasForeignKey(f => f.ParentFolderId)
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ClaudeDo.Data.Configuration;
|
||||
|
||||
public class MissionControlSessionEntityConfiguration : IEntityTypeConfiguration<MissionControlSessionEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<MissionControlSessionEntity> builder)
|
||||
{
|
||||
builder.ToTable("mission_control_sessions");
|
||||
builder.HasKey(s => s.SessionId);
|
||||
builder.Property(s => s.SessionId).HasColumnName("session_id").ValueGeneratedNever();
|
||||
builder.Property(s => s.Cwd).HasColumnName("cwd").IsRequired();
|
||||
builder.Property(s => s.TaskId).HasColumnName("task_id");
|
||||
builder.Property(s => s.Title).HasColumnName("title").IsRequired();
|
||||
builder.Property(s => s.StartedAt).HasColumnName("started_at").IsRequired();
|
||||
builder.Property(s => s.PredecessorSessionId).HasColumnName("predecessor_session_id");
|
||||
builder.Property(s => s.BriefPath).HasColumnName("brief_path");
|
||||
builder.HasIndex(s => s.StartedAt);
|
||||
}
|
||||
}
|
||||
@@ -11,12 +11,14 @@ public class PrimeScheduleEntityConfiguration : IEntityTypeConfiguration<PrimeSc
|
||||
=> v == PrimeActionKind.Ping ? "ping"
|
||||
: v == PrimeActionKind.FillMyDay ? "fill_my_day"
|
||||
: v == PrimeActionKind.Custom ? "custom"
|
||||
: v == PrimeActionKind.ImportTickets ? "import_tickets"
|
||||
: throw new ArgumentOutOfRangeException(nameof(v));
|
||||
|
||||
private static PrimeActionKind KindFromString(string v)
|
||||
=> v == "ping" ? PrimeActionKind.Ping
|
||||
: v == "fill_my_day" ? PrimeActionKind.FillMyDay
|
||||
: v == "custom" ? PrimeActionKind.Custom
|
||||
: v == "import_tickets" ? PrimeActionKind.ImportTickets
|
||||
: throw new ArgumentOutOfRangeException(nameof(v));
|
||||
|
||||
private static readonly ValueConverter<PrimeActionKind, string> KindConverter =
|
||||
|
||||
@@ -36,6 +36,7 @@ public class WorktreeEntityConfiguration : IEntityTypeConfiguration<WorktreeEnti
|
||||
builder.Property(w => w.HeadCommit).HasColumnName("head_commit");
|
||||
builder.Property(w => w.DiffStat).HasColumnName("diff_stat");
|
||||
builder.Property(w => w.MergeCommit).HasColumnName("merge_commit");
|
||||
builder.Property(w => w.PullRequestUrl).HasColumnName("pull_request_url");
|
||||
builder.Property(w => w.State).HasColumnName("state").IsRequired()
|
||||
.HasDefaultValue(WorktreeState.Active)
|
||||
.HasConversion(StateConverter);
|
||||
|
||||
@@ -414,6 +414,13 @@ public sealed class GitService
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>URL des Remotes <paramref name="remote"/>, oder null wenn es keins gibt.</summary>
|
||||
public async Task<string?> GetRemoteUrlAsync(string repoDir, string remote = "origin", CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["remote", "get-url", remote], ct);
|
||||
return exitCode == 0 && !string.IsNullOrWhiteSpace(stdout) ? stdout.Trim() : null;
|
||||
}
|
||||
|
||||
public async Task<bool> IsMidMergeAsync(string repoDir, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["rev-parse", "--git-dir"], ct);
|
||||
@@ -443,6 +450,60 @@ public sealed class GitService
|
||||
return (exitCode, stderr);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>git fetch origin</c> for one branch into <c>refs/remotes/origin/<branch></c>. The explicit
|
||||
/// refspec works without a configured fetch refspec. <paramref name="env"/> carries the auth
|
||||
/// header (never the command line) and must disable the terminal prompt.
|
||||
/// </summary>
|
||||
public async Task<(int ExitCode, string Stderr)> FetchBranchAsync(
|
||||
string repoDir, string branch, IReadOnlyDictionary<string, string>? env, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, _, stderr) = await RunGitAsync(repoDir,
|
||||
["fetch", "origin", $"+refs/heads/{branch}:refs/remotes/origin/{branch}"], ct, env: env);
|
||||
return (exitCode, stderr);
|
||||
}
|
||||
|
||||
/// <summary>Moves the local, not checked-out <paramref name="branch"/> to <c>origin/<branch></c>; git itself refuses a non-fast-forward. No network.</summary>
|
||||
public async Task<(int ExitCode, string Stderr)> FastForwardLocalBranchAsync(
|
||||
string repoDir, string branch, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, _, stderr) = await RunGitAsync(repoDir,
|
||||
["fetch", ".", $"refs/remotes/origin/{branch}:refs/heads/{branch}"], ct);
|
||||
return (exitCode, stderr);
|
||||
}
|
||||
|
||||
/// <summary><c>git push -u origin <branch></c> from <paramref name="worktreePath"/>; auth via <paramref name="env"/>.</summary>
|
||||
public async Task<(int ExitCode, string Stderr)> PushBranchAsync(
|
||||
string worktreePath, string branch, IReadOnlyDictionary<string, string>? env, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, _, stderr) = await RunGitAsync(worktreePath, ["push", "-u", "origin", branch], ct, env: env);
|
||||
return (exitCode, stderr);
|
||||
}
|
||||
|
||||
/// <summary>Branch that <c>refs/remotes/origin/HEAD</c> points to (without <c>origin/</c>), or null.</summary>
|
||||
public async Task<string?> GetRemoteHeadBranchAsync(string repoDir, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], ct);
|
||||
if (exitCode != 0 || string.IsNullOrWhiteSpace(stdout)) return null;
|
||||
var head = stdout.Trim();
|
||||
return head.StartsWith("origin/", StringComparison.Ordinal) ? head["origin/".Length..] : head;
|
||||
}
|
||||
|
||||
/// <summary>Absolute path of the <c>.git</c> dir shared by all worktrees of the repo owning <paramref name="dir"/>, or null.</summary>
|
||||
public async Task<string?> GetCommonDirAsync(string dir, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, stdout, _) = await RunGitAsync(dir, ["rev-parse", "--path-format=absolute", "--git-common-dir"], ct);
|
||||
return exitCode == 0 && !string.IsNullOrWhiteSpace(stdout) ? stdout.Trim() : null;
|
||||
}
|
||||
|
||||
/// <summary><c>git merge --ff-only</c>: moves the checked-out branch to <paramref name="sourceRef"/> or fails without touching it.</summary>
|
||||
public async Task<(int ExitCode, string Stderr)> MergeFastForwardOnlyAsync(
|
||||
string worktreePath, string sourceRef, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, _, stderr) = await RunGitAsync(worktreePath, ["merge", "--ff-only", sourceRef], ct);
|
||||
return (exitCode, stderr);
|
||||
}
|
||||
|
||||
public async Task MergeAbortAsync(string repoDir, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["merge", "--abort"], ct);
|
||||
@@ -558,6 +619,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.
|
||||
@@ -696,7 +767,8 @@ public sealed class GitService
|
||||
|
||||
|
||||
private static async Task<(int ExitCode, string Stdout, string Stderr)> RunGitAsync(
|
||||
string workDir, IEnumerable<string> args, CancellationToken ct, string? stdinData = null, bool trimOutput = true)
|
||||
string workDir, IEnumerable<string> args, CancellationToken ct, string? stdinData = null, bool trimOutput = true,
|
||||
IReadOnlyDictionary<string, string>? env = null)
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
@@ -717,6 +789,8 @@ public sealed class GitService
|
||||
// of the OS locale git for Windows picks up.
|
||||
psi.Environment["LC_ALL"] = "C";
|
||||
psi.Environment["LANGUAGE"] = "C";
|
||||
if (env is not null)
|
||||
foreach (var (key, value) in env) psi.Environment[key] = value;
|
||||
psi.ArgumentList.Add("-C");
|
||||
psi.ArgumentList.Add(workDir);
|
||||
foreach (var a in args) psi.ArgumentList.Add(a);
|
||||
|
||||
+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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1160
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddTicketFallbackList : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ticket_fallback_list_id",
|
||||
table: "app_settings",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "app_settings",
|
||||
keyColumn: "id",
|
||||
keyValue: 1,
|
||||
column: "ticket_fallback_list_id",
|
||||
value: null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ticket_fallback_list_id",
|
||||
table: "app_settings");
|
||||
}
|
||||
}
|
||||
}
|
||||
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 AddListColor : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "color",
|
||||
table: "lists",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "color",
|
||||
table: "lists");
|
||||
}
|
||||
}
|
||||
}
|
||||
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 AddListBaseBranch : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "base_branch",
|
||||
table: "list_config",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "base_branch",
|
||||
table: "list_config");
|
||||
}
|
||||
}
|
||||
}
|
||||
+1172
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 AddWorktreePullRequestUrl : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "pull_request_url",
|
||||
table: "worktrees",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "pull_request_url",
|
||||
table: "worktrees");
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSshWorkspaces : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "ssh_workspaces",
|
||||
table: "app_settings",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "app_settings",
|
||||
keyColumn: "id",
|
||||
keyValue: 1,
|
||||
column: "ssh_workspaces",
|
||||
value: null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "ssh_workspaces",
|
||||
table: "app_settings");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -126,12 +126,20 @@ namespace ClaudeDo.Data.Migrations
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_skills");
|
||||
|
||||
b.Property<string>("SshWorkspaces")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("ssh_workspaces");
|
||||
|
||||
b.Property<int>("StandupWeekday")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(3)
|
||||
.HasColumnName("standup_weekday");
|
||||
|
||||
b.Property<string>("TicketFallbackListId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("ticket_fallback_list_id");
|
||||
|
||||
b.Property<int>("UsageGateFiveHourPct")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
@@ -264,6 +272,10 @@ namespace ClaudeDo.Data.Migrations
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("agent_path");
|
||||
|
||||
b.Property<string>("BaseBranch")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("base_branch");
|
||||
|
||||
b.Property<int?>("MaxTurns")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("max_turns");
|
||||
@@ -307,6 +319,10 @@ namespace ClaudeDo.Data.Migrations
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("Color")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("color");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
@@ -370,6 +386,10 @@ namespace ClaudeDo.Data.Migrations
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("Color")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("color");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
@@ -389,6 +409,10 @@ namespace ClaudeDo.Data.Migrations
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<string>("ParentFolderId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("parent_folder_id");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
@@ -397,12 +421,54 @@ namespace ClaudeDo.Data.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ParentFolderId")
|
||||
.HasDatabaseName("idx_list_folders_parent");
|
||||
|
||||
b.HasIndex("SortOrder")
|
||||
.HasDatabaseName("idx_list_folders_sort");
|
||||
|
||||
b.ToTable("list_folders", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.MissionControlSessionEntity", b =>
|
||||
{
|
||||
b.Property<string>("SessionId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_id");
|
||||
|
||||
b.Property<string>("BriefPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("brief_path");
|
||||
|
||||
b.Property<string>("Cwd")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("cwd");
|
||||
|
||||
b.Property<string>("PredecessorSessionId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("predecessor_session_id");
|
||||
|
||||
b.Property<DateTime>("StartedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("started_at");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.HasKey("SessionId");
|
||||
|
||||
b.HasIndex("StartedAt");
|
||||
|
||||
b.ToTable("mission_control_sessions", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -957,6 +1023,10 @@ namespace ClaudeDo.Data.Migrations
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("path");
|
||||
|
||||
b.Property<string>("PullRequestUrl")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("pull_request_url");
|
||||
|
||||
b.Property<string>("State")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
@@ -988,6 +1058,14 @@ namespace ClaudeDo.Data.Migrations
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListFolderEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.ListFolderEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ParentFolderId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
|
||||
@@ -29,6 +29,8 @@ public sealed class AppSettingsEntity
|
||||
|
||||
// JSON array of parent folders remembered by the repo-import modal.
|
||||
public string? RepoImportFolders { get; set; }
|
||||
// JSON array of SshWorkspace rows offered as remote quick sessions in Mission Control.
|
||||
public string? SshWorkspaces { get; set; }
|
||||
// JSON array of path prefixes whose sessions are excluded from the weekly report.
|
||||
public string? ReportExcludedPaths { get; set; }
|
||||
// DayOfWeek the standup happens on; default Wednesday. Drives the report's default range.
|
||||
@@ -83,4 +85,8 @@ public sealed class AppSettingsEntity
|
||||
// 4 = two full wait/merge cycles, the long-standing default. Raise it for a run meant to keep
|
||||
// going for hours. It exists to bound runaway handoff loops, so it must stay >= 1.
|
||||
public int ListHandlerMaxHandoffs { get; set; } = 4;
|
||||
|
||||
// List that receives imported ticket items Claude could not place (import_ticket without a
|
||||
// list). Null = such items are skipped. No FK: a deleted list simply reads as unset at import.
|
||||
public string? TicketFallbackListId { get; set; }
|
||||
}
|
||||
|
||||
@@ -23,6 +23,12 @@ public sealed class ListConfigEntity
|
||||
// false, also opt-in. ⚠️ Gleiche Falle wie TicketProjectId: SetConfigAsync kopiert verbatim.
|
||||
public bool PowerUseAllowed { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Basis neuer Worktrees und Default-Merge-/PR-Ziel (z.B. <c>dev</c>). Null = ausgecheckter Branch
|
||||
/// (privat) bzw. Default-Branch des Remotes (Firma). ⚠️ SetConfigAsync kopiert verbatim.
|
||||
/// </summary>
|
||||
public string? BaseBranch { get; set; }
|
||||
|
||||
// Navigation property
|
||||
public ListEntity List { get; set; } = null!;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ 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; }
|
||||
// Dot colour: a swatch key ("Swatch3") or a literal "#RRGGBB"; null = the hash-derived default.
|
||||
public string? Color { 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; }
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace ClaudeDo.Data.Models;
|
||||
/// A sidebar folder grouping user lists. Deliberately its own table rather than a flag on
|
||||
/// <see cref="ListEntity"/>: every place that enumerates lists (MCP list_task_lists, "move to
|
||||
/// list", the queue picker, repo import) would otherwise have to filter folders out.
|
||||
/// One level only — a folder holds lists, never other folders.
|
||||
/// Folders nest arbitrarily deep via <see cref="ParentFolderId"/>.
|
||||
/// </summary>
|
||||
public sealed class ListFolderEntity
|
||||
{
|
||||
@@ -13,8 +13,13 @@ public sealed class ListFolderEntity
|
||||
public required DateTime CreatedAt { get; init; }
|
||||
// Icon key into the UI's icon library, same set as ListEntity.Icon; null = default folder icon.
|
||||
public string? Icon { get; set; }
|
||||
/// <summary>Hex colour (#RRGGBB) for the sidebar name and guide line; null = theme default.</summary>
|
||||
public string? Color { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
// Sidebar expand/collapse state. Lives here rather than in ui.config.json because the row is
|
||||
// already being read for the folder itself.
|
||||
public bool IsCollapsed { get; set; }
|
||||
// Parent folder, or null for a top-level folder. Self-FK, ON DELETE SET NULL — deleting a
|
||||
// folder releases its subfolders to the top level, same as it does for its lists.
|
||||
public string? ParentFolderId { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace ClaudeDo.Data.Models;
|
||||
|
||||
/// <summary>One claude session started in a Mission Control tile (ad-hoc, task or handoff) — the
|
||||
/// index behind "Recent sessions". The transcript itself stays with Claude Code.</summary>
|
||||
public sealed class MissionControlSessionEntity
|
||||
{
|
||||
/// <summary>The claude session id (`--session-id` / `--resume`).</summary>
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Working directory the session ran in — a resume must start there.</summary>
|
||||
public string Cwd { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Task the tile belonged to; null for an ad-hoc tile. No FK: the row outlives the task.</summary>
|
||||
public string? TaskId { get; set; }
|
||||
|
||||
/// <summary>Tile title at start (task title or folder name; a handoff inherits its predecessor's).</summary>
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>UTC time the session was first launched.</summary>
|
||||
public DateTime StartedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
/// <summary>Session this one was handed off from; null unless it is a handoff session.</summary>
|
||||
public string? PredecessorSessionId { get; set; }
|
||||
|
||||
/// <summary>brief.md the session started from; non-null exactly for handoff sessions.</summary>
|
||||
public string? BriefPath { get; set; }
|
||||
}
|
||||
@@ -8,5 +8,7 @@ public enum PrimeActionKind
|
||||
/// <summary>Run the daily prep prompt and let Claude fill MyDay.</summary>
|
||||
FillMyDay,
|
||||
/// <summary>Run the schedule's own prompt with the ClaudeDo MCP tools available.</summary>
|
||||
Custom
|
||||
Custom,
|
||||
/// <summary>Import the assigned ticket-system items into lists by content, then fill MyDay.</summary>
|
||||
ImportTickets
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace ClaudeDo.Data.Models;
|
||||
|
||||
/// <summary>A server an interactive claude session can be started on via ssh; connection details live in the user's ssh config.</summary>
|
||||
public sealed record SshWorkspace(string Id, string Name, string Host, string RemotePath)
|
||||
{
|
||||
// A leading '-' would make ssh parse the alias as an option (-oProxyCommand=... runs local code).
|
||||
private static readonly Regex HostPattern = new(@"^[A-Za-z0-9._@][A-Za-z0-9._@-]*$", RegexOptions.Compiled);
|
||||
|
||||
/// <summary>Returns the first invalid field ("name", "host", "path") or null when valid.</summary>
|
||||
public static string? Validate(SshWorkspace w)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(w.Name)) return "name";
|
||||
if (string.IsNullOrEmpty(w.Host) || !HostPattern.IsMatch(w.Host)) return "host";
|
||||
var p = w.RemotePath ?? "";
|
||||
if (!(p.StartsWith('/') || p.StartsWith('~')) || p.Contains('\'') || p.Contains('\n') || p.Contains('\r'))
|
||||
return "path";
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Single-quotes a validated remote path for a POSIX shell, leaving a leading <c>~</c> unquoted so it still expands.</summary>
|
||||
public static string QuoteRemotePath(string path)
|
||||
{
|
||||
if (path == "~") return "~";
|
||||
if (path.StartsWith("~/")) return "~/'" + path[2..] + "'";
|
||||
return "'" + path + "'";
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@ public sealed class WorktreeEntity
|
||||
public string? HeadCommit { get; set; }
|
||||
public string? DiffStat { get; set; }
|
||||
public string? MergeCommit { get; set; }
|
||||
/// <summary>Draft-PR auf Azure DevOps, gesetzt vom Approve im Firmen-Repo.</summary>
|
||||
public string? PullRequestUrl { get; set; }
|
||||
public WorktreeState State { get; set; } = WorktreeState.Active;
|
||||
public required DateTime CreatedAt { get; init; }
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ using System.Text.RegularExpressions;
|
||||
|
||||
namespace ClaudeDo.Data;
|
||||
|
||||
public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport, ImprovementChild, Refine, MergeHelperTriage, MergeHelperWait, MergeHelperMerge, MergeHelperInitial, MergeHelperHandoff, PowerUseAddendum }
|
||||
public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport, ImprovementChild, Refine, MergeHelperTriage, MergeHelperWait, MergeHelperMerge, MergeHelperInitial, MergeHelperHandoff, PowerUseAddendum, TicketImport }
|
||||
|
||||
/// <summary>
|
||||
/// How a prompt kind's on-disk override (if any) relates to the bundled default.
|
||||
@@ -45,6 +45,7 @@ public static class PromptFiles
|
||||
PromptKind.MergeHelperInitial => "merge-helper-initial.md",
|
||||
PromptKind.MergeHelperHandoff => "merge-helper-handoff.md",
|
||||
PromptKind.PowerUseAddendum => "power-use.md",
|
||||
PromptKind.TicketImport => "ticket-import.md",
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind))
|
||||
};
|
||||
|
||||
@@ -223,6 +224,7 @@ public static class PromptFiles
|
||||
PromptKind.MergeHelperInitial => MergeHelperInitialDefault,
|
||||
PromptKind.MergeHelperHandoff => MergeHelperHandoffDefault,
|
||||
PromptKind.PowerUseAddendum => PowerUseAddendumDefault,
|
||||
PromptKind.TicketImport => TicketImportDefault,
|
||||
_ => ""
|
||||
};
|
||||
|
||||
@@ -252,10 +254,7 @@ public static class PromptFiles
|
||||
it, name the follow-up in your final report instead of trying to file it.
|
||||
|
||||
## Working in the repo
|
||||
- Read a file before editing it. Match the conventions already in this codebase —
|
||||
they override generic defaults.
|
||||
- Prefer editing existing files to creating new ones. Don't write comments that
|
||||
just restate the code.
|
||||
- Match the conventions already in this codebase — they override generic defaults.
|
||||
- Validate only at real boundaries (user input, external APIs).
|
||||
|
||||
## Reading efficiently
|
||||
@@ -319,11 +318,7 @@ public static class PromptFiles
|
||||
renames, reformatting, or "while I'm here" cleanup beyond what is asked.
|
||||
- Touch as few files as possible. Do not restructure unrelated code.
|
||||
- Do NOT file further improvements — improvements are one layer deep.
|
||||
- Verify the build and relevant tests before finishing, and report what you ran.
|
||||
- Make one focused commit in this task's own worktree using the repository's
|
||||
commit-message convention — that commit is the deliverable, not an unrequested auto-commit.
|
||||
Report the real outcome, including the commit SHA; don't say no commit was
|
||||
made when there was.
|
||||
- Make one focused commit; the Finishing rules above apply unchanged.
|
||||
""";
|
||||
|
||||
private const string PlanningSystemDefault = """
|
||||
@@ -385,6 +380,22 @@ public static class PromptFiles
|
||||
(build + tests) before you stop.
|
||||
""";
|
||||
|
||||
private const string TicketImportDefault = """
|
||||
You are sorting my assigned items from the company ticket system into my ClaudeDo lists.
|
||||
|
||||
1. Call mcp__claudedo__get_ticket_import_candidates. If available is false, say why and stop.
|
||||
If items is empty, say so and stop.
|
||||
2. For every item, pick the list whose purpose fits the item's CONTENT — its title, its
|
||||
parent and its project. A list's ticketProject is only a hint: one ticket project can
|
||||
hold work for several repositories, and several lists can share one project.
|
||||
3. Import each item exactly once with mcp__claudedo__import_ticket(ticketId, list).
|
||||
If no list clearly fits, omit list — the item then goes to the fallback list.
|
||||
Never force a weak match just to avoid the fallback.
|
||||
4. Do not create, edit or move anything else.
|
||||
5. Finish with a short summary: imported items per list, items that went to the fallback
|
||||
list, and skipped items with their reason.
|
||||
""";
|
||||
|
||||
private const string DailyPrepDefault = """
|
||||
You are preparing my workday for {date}.
|
||||
|
||||
@@ -457,7 +468,7 @@ public static class PromptFiles
|
||||
|
||||
Group what's left into waves: batches that can run in parallel, where a later wave depends only on tasks in an earlier one. Report this as a wave plan (e.g. "[3] → [4,5,6] → [7]"), not a flat list — the Wait/Merge sessions after you need the batching, not just which ids survived.
|
||||
|
||||
Then check the wave count against the handoff cap. This list-handler chain allows a limited number of handoffs (wait, merge, wait_final, merge_final) — by default 4, i.e. exactly two merge rounds, but it is configurable and may be higher for this run. Assume two rounds unless the kickoff tells you otherwise. If your wave plan needs more rounds than that to clear, say so explicitly when you hand off — name which waves won't fit — so the Merge session ends by telling the user "the rest needs manual approval in the UI" instead of silently stranding mid-chain.
|
||||
Then check the wave count against the handoff cap: call get_handoff_status with your own handler task id — it reports how many handoffs (wait, merge, wait_final, merge_final) this run still has, i.e. how many merge rounds. If your wave plan needs more rounds than that to clear, say so explicitly when you hand off — name which waves won't fit — so the Merge session ends by telling the user "the rest needs manual approval in the UI" instead of silently stranding mid-chain.
|
||||
|
||||
## Phase 2 — Enhance for execution
|
||||
Before touching any task, check the list's verify command ONCE for this whole run (not per task): call get_list_config. If verifyCommand is already set, leave it alone — never overwrite an existing one — and move straight to sharpening tasks below.
|
||||
@@ -482,7 +493,7 @@ public static class PromptFiles
|
||||
Once every surviving task is enhanced, print your triage summary — one line per task from the brief:
|
||||
title — dedupe action (kept / merged into X / cancelled as duplicate of X) — enhanced (yes/no).
|
||||
|
||||
Then print the wave plan from Phase 1.5, including any "won't fit in two rounds" note, and call handoff_list_handler(taskId, survivingTaskIds, nextPhase: "wait"). That opens a fresh session to carry out the wait/merge phases with just that list, without dragging along this session's dedupe/rewrite context. Say a short goodbye line, then stop — do not continue into phase 3 yourself.
|
||||
Then print the wave plan from Phase 1.5, including any "won't fit in two rounds" note, and call handoff_list_handler(taskId, survivingTaskIds, nextPhase: "wait"). That opens a fresh session for the wait/merge phases. Say a short goodbye line, then stop — do not continue into phase 3 yourself.
|
||||
""";
|
||||
|
||||
private const string MergeHelperWaitDefault = """
|
||||
@@ -497,8 +508,6 @@ public static class PromptFiles
|
||||
|
||||
Touch nothing else: do not read a diff, merge, reset, restart, or cancel any task. A task that lands in Failed is simply noted and passed on — deciding what to do about it belongs to the Merge session, not you.
|
||||
|
||||
MaxParallelExecutions is deliberately not reported here — the queue enforces it on its own; that reporting was the old combined phase's job and no longer belongs to this role.
|
||||
|
||||
## Handoff
|
||||
Once every task has left Queued/Running/WaitingForChildren, call handoff_list_handler(taskId, <every task id from this round>, nextPhase: "merge") — or nextPhase: "merge_final" if your own phase was "wait_final". Then stop your turn immediately.
|
||||
""";
|
||||
@@ -523,7 +532,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.
|
||||
|
||||
@@ -534,6 +543,10 @@ public static class PromptFiles
|
||||
- Prefer the MCP tools whenever they apply. Only if the MCP tools cannot reach an in-progress merge may you finish it by hand: resolve the markers, then `git add -- <the resolved paths>` and `git commit` — NEVER `git add -A` or a bare commit, because the checkout is shared with other sessions.
|
||||
- Never use raw `git merge`, `git reset`, or `git checkout` to force a merge. Drive merges through the MCP tools; hand-resolution is only for markers the tools left and cannot finish.
|
||||
|
||||
## Company repos (Azure DevOps)
|
||||
|
||||
If the list's repo is a company repo (origin on the Azure DevOps Server), approve does not merge anything locally: review_task approve runs the verify command in the task's own worktree, pushes its branch and opens a draft PR (or reuses the one already open for that branch). mergeStatus is "pr_created" or "pr_updated" and mergeMessage is the PR URL; the task deliberately stays WaitingForReview until the user merges the PR on Azure DevOps and ticks it Done. There are no merge conflicts to resolve and main does not move, so skip preview_merge_set's merge-order reasoning, do NOT call verify_merges (the verify already ran inside approve, skipVerify is ignored there), and the merge-before-rerun rule above does not apply. A blocked result names what failed (no Azure DevOps PAT, push rejected, or "gepusht, PR fehlgeschlagen: …" when the push landed but the PR call did not) — report it, do not retry blindly. In the Phase 5 summary, list every PR link.
|
||||
|
||||
## Verify once — after the last merge
|
||||
|
||||
When every merge above is done (including conflict resolutions), call verify_merges with the ids of ALL tasks that reported merged_verify_pending. It runs the list's verify command a single time and promotes them all to Done on success — that is the per-merge gate you skipped, paid once.
|
||||
@@ -582,7 +595,7 @@ public static class PromptFiles
|
||||
|
||||
## Phase 5 — Summary
|
||||
Print one line per task from the brief:
|
||||
title — final status — merge commit (if any) — conflicts resolved (if any) — verify-failed (yes/no).
|
||||
title — final status — merge commit or PR link (if any) — conflicts resolved (if any) — verify-failed (yes/no).
|
||||
|
||||
Then list anything you skipped or left for the user and why, and any follow-ups worth turning into new tasks.
|
||||
""";
|
||||
@@ -640,7 +653,7 @@ public static class PromptFiles
|
||||
Specifically:
|
||||
- **Duplicate pairs (Triage Phase 1):** decide them. Clear duplicate — same goal, or one fully contained in the other — fold the loser's unique content into the survivor and cancel the loser. Genuinely different intent that merely touches the same area — keep both and chain them with dependsOnTaskId so they cannot run against the same files at once.
|
||||
- **A task too vague to sharpen (Triage Phase 2):** cancel it with a recorded reason rather than guessing a specification for someone else's half-sentence. Guessing burns a whole slot on work nobody asked for.
|
||||
- **Wave plan vs. handoff cap (Triage Phase 1.5):** do not assume two rounds, and never trim the wave plan to fit a cap or defer waves to "manual approval in the UI" — nobody will be there. Call get_handoff_status with your own handler task id to read this run's real cap, then plan the waves the work actually needs and hand off the first one.
|
||||
- **Wave plan vs. handoff cap (Triage Phase 1.5):** never trim the wave plan to fit the cap or defer waves to "manual approval in the UI" — nobody will be there. Plan the waves the work actually needs and hand off the first one.
|
||||
- **A task that bundles several independent features (Triage Phase 2):** split it yourself — create the pieces, chain them with dependsOnTaskId where one builds on another, cancel or narrow the original, and record what you did.
|
||||
- **Verify command not set (Triage Phase 2):** determine it, run it once via Bash to prove it works, then set it. Never write a command you have not actually run.
|
||||
- **A risky or wrong-looking diff (Merge Phase 4):** if it contradicts the task's own acceptance criteria, call review_task(taskId, decision="reject_rerun", feedback=...) with concrete feedback naming what was missing. If it merely looks unusual but meets the criteria, merge it and say why. If it looks actively destructive — deletes code no task asked to delete, drops a migration, rewrites unrelated files — leave it unmerged and record it as HELD FOR REVIEW. Never merge something you would describe as destructive.
|
||||
@@ -697,20 +710,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
|
||||
|
||||
@@ -127,4 +127,34 @@ public sealed class AppSettingsRepository
|
||||
row.RepoImportFolders = list.Count == 0 ? null : JsonSerializer.Serialize(list);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>Reads the configured SSH workspaces; missing or corrupt JSON yields an empty list.</summary>
|
||||
public async Task<List<SshWorkspace>> GetSshWorkspacesAsync(CancellationToken ct = default)
|
||||
{
|
||||
var json = await _context.AppSettings.AsNoTracking()
|
||||
.Where(s => s.Id == AppSettingsEntity.SingletonId)
|
||||
.Select(s => s.SshWorkspaces)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(json)) return new List<SshWorkspace>();
|
||||
try { return JsonSerializer.Deserialize<List<SshWorkspace>>(json) ?? new List<SshWorkspace>(); }
|
||||
catch (JsonException) { return new List<SshWorkspace>(); }
|
||||
}
|
||||
|
||||
/// <summary>Writes only the SSH workspaces column; an empty list clears it.</summary>
|
||||
public async Task SetSshWorkspacesAsync(IEnumerable<SshWorkspace> workspaces, CancellationToken ct = default)
|
||||
{
|
||||
var list = workspaces.ToList();
|
||||
var row = await GetOrCreateTrackedRowAsync(ct);
|
||||
row.SshWorkspaces = list.Count == 0 ? null : JsonSerializer.Serialize(list);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>Writes only the ticket fallback list; blank clears it.</summary>
|
||||
public async Task SetTicketFallbackListIdAsync(string? listId, CancellationToken ct = default)
|
||||
{
|
||||
var row = await GetOrCreateTrackedRowAsync(ct);
|
||||
row.TicketFallbackListId = string.IsNullOrWhiteSpace(listId) ? null : listId.Trim();
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,16 +109,17 @@ public sealed class ListRepository
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>Patches just the two editable fields — a whole-entity Update would need
|
||||
/// <summary>Patches just the editable fields — a whole-entity Update would need
|
||||
/// <c>CreatedAt</c>/<c>SortOrder</c>/<c>IsCollapsed</c> carried along and silently reset
|
||||
/// whichever the caller forgot (see the SetConfigAsync note in CLAUDE.md).</summary>
|
||||
public async Task RenameFolderAsync(string folderId, string name, string? icon, CancellationToken ct = default)
|
||||
public async Task RenameFolderAsync(string folderId, string name, string? icon, string? color, CancellationToken ct = default)
|
||||
{
|
||||
await _context.ListFolders
|
||||
.Where(f => f.Id == folderId)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(f => f.Name, name)
|
||||
.SetProperty(f => f.Icon, icon), ct);
|
||||
.SetProperty(f => f.Icon, icon)
|
||||
.SetProperty(f => f.Color, color), ct);
|
||||
}
|
||||
|
||||
/// <summary>Deletes the folder only — its lists survive and fall back to the top level via
|
||||
@@ -135,6 +136,14 @@ public sealed class ListRepository
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(f => f.IsCollapsed, collapsed), ct);
|
||||
}
|
||||
|
||||
/// <summary>Moves a folder under <paramref name="parentFolderId"/>, or to the top level when null.</summary>
|
||||
public async Task SetFolderParentAsync(string folderId, string? parentFolderId, CancellationToken ct = default)
|
||||
{
|
||||
await _context.ListFolders
|
||||
.Where(f => f.Id == folderId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(f => f.ParentFolderId, parentFolderId), ct);
|
||||
}
|
||||
|
||||
/// <summary>Moves a list into <paramref name="folderId"/>, or back to the top level when null.</summary>
|
||||
public async Task SetListFolderAsync(string listId, string? folderId, CancellationToken ct = default)
|
||||
{
|
||||
@@ -166,6 +175,7 @@ public sealed class ListRepository
|
||||
existing.PermissionMode = config.PermissionMode;
|
||||
existing.TicketProjectId = config.TicketProjectId;
|
||||
existing.PowerUseAllowed = config.PowerUseAllowed;
|
||||
existing.BaseBranch = config.BaseBranch;
|
||||
}
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
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>Sets the session's title; a no-op for an unknown id. <see cref="RecordAsync"/> never overwrites it.</summary>
|
||||
public async Task RenameAsync(string sessionId, string title, CancellationToken ct = default) =>
|
||||
await _context.MissionControlSessions.Where(s => s.SessionId == sessionId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(x => x.Title, title), 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);
|
||||
}
|
||||
@@ -36,7 +36,7 @@ public sealed class WorktreeRepository
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(w => w.State, state), ct);
|
||||
}
|
||||
|
||||
public async Task SetMergedAsync(string taskId, string mergeCommit, CancellationToken ct = default)
|
||||
public async Task SetMergedAsync(string taskId, string? mergeCommit, CancellationToken ct = default)
|
||||
{
|
||||
await _context.Worktrees
|
||||
.Where(w => w.TaskId == taskId)
|
||||
@@ -45,6 +45,14 @@ public sealed class WorktreeRepository
|
||||
.SetProperty(w => w.MergeCommit, mergeCommit), ct);
|
||||
}
|
||||
|
||||
/// <summary>Stores the draft PR an approve in a company repo created or reused.</summary>
|
||||
public async Task SetPullRequestUrlAsync(string taskId, string url, CancellationToken ct = default)
|
||||
{
|
||||
await _context.Worktrees
|
||||
.Where(w => w.TaskId == taskId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(w => w.PullRequestUrl, url), ct);
|
||||
}
|
||||
|
||||
/// <summary>Records a successful auto-rebase: the branch's fork point moves to the rebased-onto
|
||||
/// commit and its head moves to the new tip <c>git rebase</c> produced.</summary>
|
||||
public async Task SetRebasedAsync(string taskId, string baseCommit, string headCommit, CancellationToken ct = default)
|
||||
|
||||
@@ -13,6 +13,21 @@ namespace ClaudeDo.Data.Wire;
|
||||
|
||||
public record ActiveTaskDto(string Slot, string TaskId, DateTime StartedAt);
|
||||
|
||||
/// <summary>A Worker operation running right now (verify, merge, rebase, worktree creation).
|
||||
/// <paramref name="Title"/> is the task title, or the list name for a batch verify.</summary>
|
||||
public record RunningOperationDto(
|
||||
string Id, string Kind, string? TaskId, int? TaskNumber, string? ListId, string Title, DateTime StartedAt);
|
||||
|
||||
/// <summary>Stable <see cref="RunningOperationDto.Kind"/> tokens, localized by the UI.</summary>
|
||||
public static class OperationKinds
|
||||
{
|
||||
public const string VerifyMerge = "verify-merge";
|
||||
public const string VerifyBatch = "verify-batch";
|
||||
public const string Merging = "merging";
|
||||
public const string Rebasing = "rebasing";
|
||||
public const string CreatingWorktree = "creating-worktree";
|
||||
}
|
||||
|
||||
public record AppSettingsDto(
|
||||
string DefaultClaudeInstructions,
|
||||
string DefaultModel,
|
||||
@@ -72,7 +87,9 @@ public record WorktreeOverviewDto(
|
||||
WorktreeState State,
|
||||
string? DiffStat,
|
||||
DateTime CreatedAt,
|
||||
bool PathExistsOnDisk);
|
||||
bool PathExistsOnDisk,
|
||||
int TaskNumber = 0,
|
||||
bool ContentInMain = false);
|
||||
|
||||
public record ForceRemoveResultDto(bool Removed, string? Reason);
|
||||
|
||||
@@ -80,7 +97,7 @@ public record PlanningMergeConflictStateDto(string PlanningTaskId, string Subtas
|
||||
|
||||
public record PendingQuestionDto(string TaskId, string QuestionId, string Question);
|
||||
|
||||
public record MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage);
|
||||
public record MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage, string? PullRequestUrl = null);
|
||||
|
||||
public record BaseDirtyWarningDto(int ModifiedCount, int UntrackedCount);
|
||||
|
||||
@@ -91,7 +108,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, bool IsCompanyRepo = false);
|
||||
|
||||
public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDocumentDto> Files);
|
||||
|
||||
@@ -99,18 +117,19 @@ public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList<Merg
|
||||
|
||||
public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs);
|
||||
|
||||
public record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType, bool IsManual = false, bool FindingsTracked = false, string? Icon = null);
|
||||
public record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType, bool IsManual = false, bool FindingsTracked = false, string? Icon = null, string? Color = null);
|
||||
|
||||
// TicketProjectId is tri-state on purpose: a caller that doesn't own the field (anything but the
|
||||
// list-settings modal) must not be able to clear it by omission — SetConfigAsync copies the entity
|
||||
// verbatim. Its scale: null = keep stored, <= 0 = clear the link, > 0 = set it.
|
||||
// PowerUseAllowed is nullable for the same reason: null = keep stored, false = revoke the opt-in.
|
||||
// A non-nullable bool here would let any caller that omits it silently revoke PowerUse for the list.
|
||||
public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null, string? VerifyCommand = null, string? PermissionMode = null, int? TicketProjectId = null, bool? PowerUseAllowed = null);
|
||||
// BaseBranch: null = keep stored, "" = clear, value = set.
|
||||
public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null, string? VerifyCommand = null, string? PermissionMode = null, int? TicketProjectId = null, bool? PowerUseAllowed = null, string? BaseBranch = null);
|
||||
|
||||
public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null, string? PermissionMode = null);
|
||||
|
||||
public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null, string? VerifyCommand = null, string? PermissionMode = null, int? TicketProjectId = null, bool PowerUseAllowed = false);
|
||||
public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null, string? VerifyCommand = null, string? PermissionMode = null, int? TicketProjectId = null, bool PowerUseAllowed = false, string? BaseBranch = null);
|
||||
|
||||
public record SeedResultDto(int Copied, int Skipped);
|
||||
|
||||
@@ -220,7 +239,10 @@ public sealed record LaunchSpec(
|
||||
// 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 TicketSettingsDto(string? ApiBaseUrl, bool TokenSet, string? FallbackListId = null);
|
||||
public record TicketConnectionDto(bool Ok, string? UserName, IReadOnlyList<string> Scopes, string? Error);
|
||||
public record AzureDevOpsSettingsDto(bool TokenSet);
|
||||
// Repo = the company repo the test ran against (first list whose origin matches), DefaultBranch = its ADO default.
|
||||
public record AzureDevOpsConnectionDto(bool Ok, string? Repo, string? DefaultBranch, string? Error);
|
||||
public record TicketProjectDto(int Id, string Title, string DepartmentName);
|
||||
public record TicketImportResultDto(int Examined, int Created);
|
||||
|
||||
@@ -16,7 +16,9 @@
|
||||
"categoryFiles": "Dateien & Prompts",
|
||||
"categorySkills": "Session Skills",
|
||||
"categoryReports": "Berichte",
|
||||
"categoryTickets": "Ticketsystem"
|
||||
"categoryTickets": "Externe Dienste",
|
||||
"categorySsh": "SSH-Workspaces",
|
||||
"categoryCustomTiles": "Eigene Tiles"
|
||||
},
|
||||
"repoHint": {
|
||||
"text": "Wirkt erst mit verknüpftem Repo.",
|
||||
@@ -112,10 +114,11 @@
|
||||
"actionPing": "Ping",
|
||||
"actionFillMyDay": "Mein Tag füllen",
|
||||
"actionCustom": "Eigener Prompt",
|
||||
"actionHint": "Ping öffnet nur das Nutzungsfenster. „Mein Tag füllen“ startet die Tagesplanung. „Eigener Prompt“ führt deinen eigenen Prompt mit den ClaudeDo-Tools aus.",
|
||||
"actionImportTickets": "Tickets + Tag planen",
|
||||
"actionHint": "Ping öffnet nur das Nutzungsfenster. „Mein Tag füllen“ startet die Tagesplanung. „Tickets + Tag planen“ importiert erst deine zugewiesenen Tickets in die passenden Listen und startet dann die Tagesplanung. „Eigener Prompt“ führt deinen eigenen Prompt mit den ClaudeDo-Tools aus.",
|
||||
"customPromptLabel": "Prompt",
|
||||
"customPromptPlaceholder": "Der vollständige Prompt für diesen Zeitplan (Pflichtfeld)",
|
||||
"maxTasksDisabledHint": "Gilt nur für Zeitpläne mit „Mein Tag füllen“.",
|
||||
"maxTasksDisabledHint": "Gilt nur für Zeitpläne mit „Mein Tag füllen“ oder „Tickets + Tag planen“.",
|
||||
"promptOverrideLabel": "Prompt-Zusatz",
|
||||
"promptOverridePlaceholder": "Zusätzliche Anweisungen, die für diesen Zeitplan an den Prime-Prompt angehängt werden (optional)",
|
||||
"dayMo": "Mo",
|
||||
@@ -147,12 +150,46 @@
|
||||
"saveButton": "Konfiguration speichern"
|
||||
},
|
||||
"tickets": {
|
||||
"ticketSection": "TICKETSYSTEM",
|
||||
"azdoSection": "AZURE DEVOPS",
|
||||
"azdoToken": "Personal Access Token (Scope: Code Read & Write)",
|
||||
"azdoHint": "Gilt in Firmen-Repos (origin auf dem Azure DevOps Server): Fetch vor dem Task-Start, Push und Draft-PR beim Genehmigen.",
|
||||
"baseUrl": "API-Base-URL",
|
||||
"baseUrlHint": "Leer lassen, um die Ticket-Anbindung komplett zu deaktivieren.",
|
||||
"token": "Personal Access Token",
|
||||
"tokenSet": "gespeichert",
|
||||
"testConnection": "Verbindung testen",
|
||||
"clearToken": "Token entfernen"
|
||||
"clearToken": "Token entfernen",
|
||||
"fallbackList": "Fallback-Liste",
|
||||
"fallbackListHint": "Ticket-Items, die Claude keiner Liste zuordnen kann, landen hier. Keine = sie werden übersprungen.",
|
||||
"fallbackNone": "(keine)"
|
||||
},
|
||||
"ssh": {
|
||||
"hint": "Server, auf denen Mission Control eine interaktive Claude-Session starten kann. Host ist ein Alias aus ~/.ssh/config (User, Key und Port kommen von dort); Claude muss auf dem Server installiert und eingeloggt sein.",
|
||||
"name": "Name",
|
||||
"host": "Host-Alias",
|
||||
"remotePath": "Pfad auf dem Server (/… oder ~/…)",
|
||||
"add": "Workspace hinzufügen",
|
||||
"remove": "Entfernen",
|
||||
"invalid": {
|
||||
"name": "SSH-Workspace: Name fehlt.",
|
||||
"host": "SSH-Workspace „{0}“: Host muss ein ssh-Alias sein (Buchstaben, Ziffern, . _ @ -, nicht mit - beginnend).",
|
||||
"path": "SSH-Workspace „{0}“: Pfad muss mit / oder ~ beginnen und darf kein ' enthalten."
|
||||
}
|
||||
},
|
||||
"customTiles": {
|
||||
"hint": "Konsolenprogramme, die Mission Control als Tile öffnen kann (z. B. pwsh, ssh lenovo-server, lazygit). Argumente werden an Leerzeichen getrennt; ein Argument mit Leerzeichen in Anführungszeichen setzen. Ordner leer lassen, um ihn beim Start auszuwählen.",
|
||||
"name": "Name",
|
||||
"program": "Programm",
|
||||
"arguments": "Argumente",
|
||||
"folder": "Ordner (optional)",
|
||||
"pickFolder": "Ordner wählen",
|
||||
"add": "Tile hinzufügen",
|
||||
"remove": "Entfernen",
|
||||
"invalid": {
|
||||
"name": "Eigenes Tile: Name fehlt.",
|
||||
"program": "Eigenes Tile „{0}“: Programm fehlt."
|
||||
}
|
||||
},
|
||||
"inherit": {
|
||||
"inheritedFromList": "geerbt · Liste",
|
||||
@@ -201,6 +238,7 @@
|
||||
"ctxMarkAs": "Markieren als",
|
||||
"ctxMarkDone": "Erledigt",
|
||||
"ctxMarkCancelled": "Abgebrochen",
|
||||
"ctxMarkIdle": "Offen",
|
||||
"ctxOpenConPtySession": "Interaktive Sitzung öffnen",
|
||||
"ctxOpenWorktree": "Worktree öffnen",
|
||||
"ctxOpenPlanningSession": "Planungssitzung öffnen",
|
||||
@@ -285,6 +323,7 @@
|
||||
"copyFormattedTip": "Titel, Beschreibung und offene Schritte kopieren",
|
||||
"toggleEditPreviewTip": "Bearbeiten/Vorschau umschalten",
|
||||
"planDay": "Tag planen",
|
||||
"importTicketsAndPlanDay": "Tickets + Tag planen",
|
||||
"prepEmpty": "Heute noch keine Vorbereitung — klick Tag planen",
|
||||
"roadblockReply": {
|
||||
"placeholder": "Auf den Roadblock antworten…",
|
||||
@@ -328,7 +367,12 @@
|
||||
"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",
|
||||
"createDraftPr": "Draft-PR erstellen",
|
||||
"updatePr": "PR aktualisieren",
|
||||
"openPr": "Pull Request öffnen",
|
||||
"companyTargetHint": "Firmen-Repo — Genehmigen pusht den Branch und erstellt einen Draft-PR auf Azure DevOps.",
|
||||
"park": "Parken",
|
||||
"parkTip": "Zurückstellen — zurück auf Leerlauf, Worktree bleibt erhalten",
|
||||
"cancel": "Abbrechen",
|
||||
@@ -369,16 +413,37 @@
|
||||
"empty": "Keine laufenden Aufgaben",
|
||||
"settings": "Einstellungen",
|
||||
"activity": "Aktivität",
|
||||
"runningNow": "Läuft gerade",
|
||||
"opVerifyMerge": "Prüfung nach Merge",
|
||||
"opVerifyBatch": "Verify (Batch)",
|
||||
"opMerging": "Merge",
|
||||
"opRebasing": "Rebase",
|
||||
"opCreatingWorktree": "Worktree wird erstellt",
|
||||
"operationsRefreshFailed": "Laufende Operationen konnten nicht geladen werden: {0}",
|
||||
"blocked": "Blockiert",
|
||||
"running": "Läuft",
|
||||
"review": "Im Review",
|
||||
"verifying": "Wird geprüft",
|
||||
"failed": "Fehlgeschlagen",
|
||||
"roadblock": "Blockade gemeldet",
|
||||
"openSession": "Sitzung öffnen",
|
||||
"homeFolder": "Home (Schnellsitzung)",
|
||||
"sshTarget": "{0} (SSH)",
|
||||
"customTarget": "{0} (eigenes Tile)",
|
||||
"customProgramNotFound": "Programm „{0}“ nicht gefunden — Namen prüfen oder vollen Pfad angeben (Einstellungen → Eigene Tiles).",
|
||||
"browseForFolder": "Ordner wählen…",
|
||||
"recentSessions": "Letzte Sitzungen",
|
||||
"recentSessionsEmpty": "Keine Sitzungen in den letzten 14 Tagen",
|
||||
"resumeSession": "Fortsetzen",
|
||||
"showBrief": "Brief",
|
||||
"renameSession": "Doppelklick zum Umbenennen",
|
||||
"renameFailed": "Sitzung konnte nicht umbenannt werden: {0}",
|
||||
"handoffSession": "Übergabe",
|
||||
"briefOpenFailed": "Brief konnte nicht geöffnet werden: {0}",
|
||||
"focusMode": "Fokus",
|
||||
"overviewMode": "Übersicht",
|
||||
"columns": "Spalten",
|
||||
"columnsAuto": "Auto",
|
||||
"closeSession": "Sitzung hart beenden",
|
||||
"closeSessionConfirm": "Laufende Sitzung beenden? Der Claude-Prozess wird abgebrochen.",
|
||||
"conptyLaunchFailed": "Interaktive Sitzung konnte nicht geöffnet werden: {0}",
|
||||
@@ -386,6 +451,7 @@
|
||||
"conptyExited": "Sitzung beendet (Code {0}).",
|
||||
"usageWarningNotDelivered": "Die Listen-Handler-Sitzung konnte nicht vor dem Usage-Limit gewarnt werden.",
|
||||
"terminalSendFailed": "Konnte nicht an die Terminal-Sitzung gesendet werden.",
|
||||
"deferredHandoffTitleSuffix": "warte auf Limit-Zurücksetzen ({0})",
|
||||
"mergeHelperTitle": "Merge-Helfer",
|
||||
"mergeHelperHandoffTitleSuffix": " (Übergabe)",
|
||||
"mergeHelperTaskTitle": "Listen-Handler: {0}",
|
||||
@@ -452,8 +518,14 @@
|
||||
"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",
|
||||
"color": "Farbe",
|
||||
"colorHint": "Färbt den Punkt einer Liste bzw. Namen und Führungslinie eines Ordners. Der leere Ring stellt den Standard wieder her.",
|
||||
"colorCustom": "Eigene",
|
||||
"workingDirectory": "Arbeitsverzeichnis",
|
||||
"workingDirectoryPlaceholder": "(keines)",
|
||||
"baseBranch": "Basis-Branch",
|
||||
"baseBranchPlaceholder": "(ausgecheckter Branch)",
|
||||
"baseBranchHint": "Neue Task-Branches starten hier, und freigegebene Arbeit wird hierhin gemergt, z.B. dev. Leer = der gerade ausgecheckte Branch.",
|
||||
"browse": "Durchsuchen...",
|
||||
"defaultCommitType": "Standard-Commit-Typ",
|
||||
"manualList": "Manuelle Liste (Erinnerungen)",
|
||||
@@ -465,6 +537,8 @@
|
||||
"sectionIcon": "ICON",
|
||||
"icon": "Listen-Icon",
|
||||
"iconHint": "Wird in der Seitenleiste neben dem Listennamen angezeigt.",
|
||||
"iconSearchWatermark": "Alle Icons durchsuchen (Englisch, z.B. rocket, car, bug)",
|
||||
"iconSearchTruncated": "Die ersten 200 Treffer werden angezeigt — Suche verfeinern.",
|
||||
"sectionAgent": "AGENT",
|
||||
"resetAgentSettings": "Agent-Einstellungen zurücksetzen",
|
||||
"sectionVerify": "VERIFIKATION",
|
||||
@@ -482,7 +556,8 @@
|
||||
"removeWorktree": "Worktree nach dem Mergen entfernen",
|
||||
"commitMessage": "Commit-Nachricht",
|
||||
"conflictedFiles": "Konfliktdateien:",
|
||||
"stashAndMerge": "Änderungen stashen und mergen"
|
||||
"stashAndMerge": "Änderungen stashen und mergen",
|
||||
"resolveConflicts": "Konflikte lösen"
|
||||
},
|
||||
"diff": {
|
||||
"title": "DIFF",
|
||||
@@ -514,6 +589,8 @@
|
||||
"resolve": "Lösen",
|
||||
"phantom": "Phantom",
|
||||
"phantomTooltip": "Verzeichnis fehlt auf der Festplatte",
|
||||
"mergedExternal": "gemergt (extern)",
|
||||
"mergedExternalTooltip": "Die Commits sind schon im Default-Branch (lokaler origin-Stand, ohne Fetch), z.B. per Squash-PR. \"Abgeschlossene aufräumen\" entfernt ihn.",
|
||||
"ctxShowDiff": "Diff anzeigen",
|
||||
"ctxOpenInExplorer": "Im Explorer öffnen",
|
||||
"ctxJumpToTask": "Zur Aufgabe springen",
|
||||
@@ -805,6 +882,14 @@
|
||||
"done": "Fertig"
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
"cancel": "Abbrechen",
|
||||
"confirm": "Bestätigen",
|
||||
"delete": "Löschen",
|
||||
"ok": "OK",
|
||||
"confirmTitle": "Bestätigen",
|
||||
"errorTitle": "Fehler"
|
||||
},
|
||||
"shell": {
|
||||
"menu": {
|
||||
"help": "Hilfe",
|
||||
@@ -839,7 +924,7 @@
|
||||
},
|
||||
"vm": {
|
||||
"connection": { "online": "Online", "connecting": "Verbinden…", "offline": "Offline" },
|
||||
"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?" },
|
||||
"shell": { "unexpectedError": "Unerwarteter Fehler: {0}", "updatingClaudeCli": "Claude CLI wird aktualisiert…", "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", "waiting": "Wartet" },
|
||||
@@ -851,10 +936,10 @@
|
||||
"tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "statusPill": "{0} laufend · {1} Prüfung", "openTaskOne": "1 offene Aufgabe", "openTasks": "{0} offene Aufgaben", "planningResumeFailed": "Planungssitzung konnte nicht fortgesetzt werden: {0}", "approveFailed": "Genehmigen & Mergen fehlgeschlagen: {0}", "cancelReviewFailed": "Prüfung abbrechen fehlgeschlagen: {0}", "sendToQueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "queuePlanBlockedInteractive": "Plan kann nicht in die Warteschlange gestellt werden — {0} hat eine offene interaktive Sitzung und muss zuerst geschlossen werden.", "moveRunningRejected": "Ein laufender Task kann nicht in eine andere Liste verschoben werden.", "moveWorktreeRejected": "Verschieben nicht möglich — dieser Task hat einen aktiven Worktree, der auf sein aktuelles Repo zeigt.", "moveRepoConfirm": "Unterschiedliche Repos — {0} → {1}. Task trotzdem verschieben?", "moveConfirmUnavailable": "Verschieben nicht möglich — der Bestätigungsdialog ist nicht verfügbar.", "quickClaudeNoWorkingDir": "Für diese Liste ist kein Arbeitsverzeichnis konfiguriert.", "quickClaudeDirMissing": "Arbeitsverzeichnis existiert nicht mehr: {0}", "setStatusFailed": "Status konnte nicht aktualisiert werden: {0}", "cancelFailed": "Abbrechen fehlgeschlagen: {0}", "rejectToQueueFailed": "Zurückweisen in die Warteschlange fehlgeschlagen: {0}", "rejectToIdleFailed": "Zurückweisen fehlgeschlagen: {0}", "deleteTaskConfirm": "\"{0}\" löschen? Dies kann nicht rückgängig gemacht werden.", "deleteTaskFailed": "Löschen fehlgeschlagen: {0}", "toggleDoneFailed": "Erledigt-Status konnte nicht aktualisiert werden: {0}", "removeFromQueueFailed": "Aus der Warteschlange entfernen fehlgeschlagen: {0}", "openWorktreeFailed": "Worktree konnte nicht geöffnet werden: {0}" },
|
||||
"diff": { "loadFailed": "Diff konnte nicht geladen werden: {0}", "noChanges": "Keine Änderungen anzuzeigen.", "unavailable": "Diff nicht mehr verfügbar — Commit-Bereich unvollständig." },
|
||||
"planningDiff": { "hubError": "Kombinierte Vorschau konnte nicht erstellt werden (Hub-Fehler).", "conflict": "Kombinierte Vorschau nicht möglich: Teilaufgabe {0} steht im Konflikt mit einer früheren Teilaufgabe ({1} Dateien).", "buildFailed": "Kombinierte Vorschau konnte nicht erstellt werden: {0}" },
|
||||
"merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Wird zusammengeführt…", "progressVerifying": "Verify-Kommando der Liste läuft… ({0})", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "blocked": "Blockiert: {0}", "verifyFailed": "Merge ist gelandet, aber das Verify-Kommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf Erledigt gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "dirtyTarget": "Merge abgelehnt — das Ziel-Arbeitsverzeichnis hat uncommittete Änderungen.", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" },
|
||||
"merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Wird zusammengeführt…", "progressVerifying": "Verify-Kommando der Liste läuft… ({0})", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "blocked": "Blockiert: {0}", "verifyFailed": "Merge ist gelandet, aber das Verify-Kommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf Erledigt gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "dirtyTarget": "Merge abgelehnt — das Ziel-Arbeitsverzeichnis hat uncommittete Änderungen.", "targetDirty": "Das Ziel-Arbeitsverzeichnis hat bereits uncommittete Änderungen — stashen und mergen?", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" },
|
||||
"settingsModal": { "workerOffline": "Worker offline — Einstellungen schreibgeschützt.", "saveFailed": "Speichern fehlgeschlagen: {0}" },
|
||||
"onlineInbox": { "workerOffline": "Worker offline — Konfiguration kann nicht geladen werden.", "saved": "Konfiguration gespeichert.", "saveFailed": "Speichern fehlgeschlagen: {0}", "signedIn": "Erfolgreich angemeldet.", "signedInNoRole": "Angemeldet, aber diesem Konto fehlt die Rolle 'user' in Zitadel — die Online-Synchronisierung wird abgelehnt, bis die Rolle im ClaudeDo-Projekt zugewiesen wird.", "signInFailed": "Anmeldung fehlgeschlagen: {0}", "signedOut": "Abgemeldet.", "signOutFailed": "Abmeldung fehlgeschlagen: {0}" },
|
||||
"tickets": { "connectionOk": "Verbunden als {0}.", "connectionMissingScopes": "Verbunden als {0}, aber es fehlen Scopes: {1}", "tokenCleared": "Token entfernt.", "workerOffline": "Worker nicht erreichbar." },
|
||||
"tickets": { "azdoConnectionOk": "Verbunden — Repo {0}, Default-Branch {1}.", "connectionOk": "Verbunden als {0}.", "connectionMissingScopes": "Verbunden als {0}, aber es fehlen Scopes: {1}", "tokenCleared": "Token entfernt.", "workerOffline": "Worker nicht erreichbar." },
|
||||
"weeklyReport": { "invalidRange": "Ungültiger Datumsbereich.", "error": "Fehler: {0}" },
|
||||
"usageMonitor": {
|
||||
"loadFailed": "Nutzungsdaten konnten nicht geladen werden: {0}",
|
||||
@@ -870,7 +955,7 @@
|
||||
"sessionSkillsTab": { "installed": "Installiert: {0}", "installFailed": "Installation fehlgeschlagen: {0}", "updated": "Aktualisiert.", "updateFailed": "Aktualisierung fehlgeschlagen: {0}", "removed": "Entfernt.", "removeFailed": "Entfernen fehlgeschlagen: {0}" },
|
||||
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "{0} Worktree(s) entfernt.", "blocked": "Zwangsentfernung nicht möglich: {0} Aufgabe(n) laufen noch. Brich sie zuerst ab.", "removedFrom": "{0} Worktree(s) von {1} Aufgabe(n) entfernt.", "cleanupFailed": "Aufräumen fehlgeschlagen: {0}", "resetFailed": "Zurücksetzen fehlgeschlagen: {0}" },
|
||||
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "Liste", "cleanupFailed": "Aufräumen fehlgeschlagen.", "cleanupFailedDetailed": "Aufräumen fehlgeschlagen: {0}", "removed": "{0} Worktree(s) entfernt.", "discardConfirm": "Worktree für \"{0}\" verwerfen? Nicht committete Arbeit geht verloren.", "discardFailed": "Worktree konnte nicht verworfen werden.", "keepFailed": "Worktree konnte nicht behalten werden.", "cannotForceRunning": "Eine laufende Aufgabe kann nicht zwangsweise entfernt werden.", "forceRemoveFailed": "Zwangsentfernung fehlgeschlagen.", "forceRemoveFailedDetailed": "Zwangsentfernung fehlgeschlagen: {0}", "batchDone": "{0} gemergt, {1} zu lösen.", "openInExplorerFailed": "Pfad konnte nicht geöffnet werden: {0}" },
|
||||
"listSettings": { "untitled": "Unbenannt", "ticketProjectNone": "— keins —", "loadFailed": "Listeneinstellungen konnten nicht geladen werden: {0}", "ticketProjectsLoadFailed": "Ticket-Projekte konnten nicht geladen werden: {0}", "confirmDeleteFolder": "Ordner \"{0}\" löschen? Die Listen darin bleiben erhalten und wandern auf die oberste Ebene.", "confirmDeleteList": "Liste \"{0}\" und alle ihre Aufgaben löschen? Das kann nicht rückgängig gemacht werden.", "deleteBlockedPlanning": "Diese Liste hat Planungssitzungen mit Kind-Aufgaben. Erst diese verwerfen, dann die Liste löschen.", "workingDirMissing": "Dieser Ordner existiert nicht.", "workingDirNotRepo": "Dieser Ordner ist noch kein Git-Repository. Führe darin zuerst `git init` aus, oder leere das Arbeitsverzeichnis, um dies als manuelle Liste zu speichern." },
|
||||
"listSettings": { "untitled": "Unbenannt", "ticketProjectNone": "— keins —", "loadFailed": "Listeneinstellungen konnten nicht geladen werden: {0}", "ticketProjectsLoadFailed": "Ticket-Projekte konnten nicht geladen werden: {0}", "confirmDeleteFolder": "Ordner \"{0}\" löschen? Die Listen und Unterordner darin bleiben erhalten und wandern auf die oberste Ebene.", "confirmDeleteList": "Liste \"{0}\" und alle ihre Aufgaben löschen? Das kann nicht rückgängig gemacht werden.", "deleteBlockedPlanning": "Diese Liste hat Planungssitzungen mit Kind-Aufgaben. Erst diese verwerfen, dann die Liste löschen.", "workingDirMissing": "Dieser Ordner existiert nicht.", "workingDirNotRepo": "Dieser Ordner ist noch kein Git-Repository. Führe darin zuerst `git init` aus, oder leere das Arbeitsverzeichnis, um dies als manuelle Liste zu speichern." },
|
||||
"detailsIsland": { "verifyFailed": "Merge ist erfolgt, aber das Verifikationskommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf 'Erledigt' gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "dirtyTarget": "Merge abgelehnt — das Ziel-Arbeitsverzeichnis hat uncommittete Änderungen.", "stashAndMergeConfirm": "Das Ziel-Arbeitsverzeichnis hat uncommittete Änderungen.\nStashen, mergen und danach wiederherstellen?", "stopOffline": "Worker offline — Task kann nicht gestoppt werden.", "stopFailed": "Stoppen fehlgeschlagen: {0}", "enqueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "dequeueFailed": "Aus der Warteschlange entfernen fehlgeschlagen: {0}", "resetAndRetryFailed": "Zurücksetzen & erneut versuchen fehlgeschlagen: {0}", "toggleDoneFailed": "Erledigt-Status konnte nicht aktualisiert werden: {0}" },
|
||||
"mergeSection": { "openWorktreeFailed": "Worktree konnte nicht geöffnet werden: {0}" },
|
||||
"about": { "openPathFailed": "Pfad konnte nicht geöffnet werden: {0}" },
|
||||
@@ -881,7 +966,8 @@
|
||||
"stalledHint": "Läuft weiter — Worker antwortet noch",
|
||||
"merge": {
|
||||
"merging": "Wird zusammengeführt…",
|
||||
"verifying": "Verify-Kommando der Liste läuft… ({0})"
|
||||
"verifying": "Verify-Kommando der Liste läuft… ({0})",
|
||||
"publishing": "Push und Draft-PR laufen…"
|
||||
},
|
||||
"diff": {
|
||||
"parsing": "Diff wird geparst…"
|
||||
@@ -915,7 +1001,8 @@
|
||||
},
|
||||
"reports": {
|
||||
"generatingWeekly": "Bericht wird erstellt…",
|
||||
"runningDailyPrep": "Tagesvorbereitung läuft…"
|
||||
"runningDailyPrep": "Tagesvorbereitung läuft…",
|
||||
"runningTicketImport": "Tickets werden importiert, danach Tagesplanung…"
|
||||
},
|
||||
"planning": {
|
||||
"finalizing": "Plan wird finalisiert…",
|
||||
|
||||
@@ -16,7 +16,9 @@
|
||||
"categoryFiles": "Files & Prompts",
|
||||
"categorySkills": "Session Skills",
|
||||
"categoryReports": "Reports",
|
||||
"categoryTickets": "Ticket system"
|
||||
"categoryTickets": "External services",
|
||||
"categorySsh": "SSH workspaces",
|
||||
"categoryCustomTiles": "Custom tiles"
|
||||
},
|
||||
"repoHint": {
|
||||
"text": "Only takes effect once a repo is linked.",
|
||||
@@ -112,10 +114,11 @@
|
||||
"actionPing": "Ping",
|
||||
"actionFillMyDay": "Fill My Day",
|
||||
"actionCustom": "Custom prompt",
|
||||
"actionHint": "Ping only opens the usage window. Fill My Day runs the daily prep. Custom runs your own prompt with the ClaudeDo tools.",
|
||||
"actionImportTickets": "Tickets + plan day",
|
||||
"actionHint": "Ping only opens the usage window. Fill My Day runs the daily prep. Tickets + plan day first imports your assigned tickets into the matching lists, then runs the daily prep. Custom runs your own prompt with the ClaudeDo tools.",
|
||||
"customPromptLabel": "Prompt",
|
||||
"customPromptPlaceholder": "The full prompt to run for this schedule (required)",
|
||||
"maxTasksDisabledHint": "Only applies to Fill My Day schedules.",
|
||||
"maxTasksDisabledHint": "Only applies to Fill My Day and Tickets + plan day schedules.",
|
||||
"promptOverrideLabel": "Prompt addition",
|
||||
"promptOverridePlaceholder": "Extra instructions appended to the daily prep prompt for this schedule (optional)",
|
||||
"dayMo": "Mo",
|
||||
@@ -147,12 +150,46 @@
|
||||
"saveButton": "Save config"
|
||||
},
|
||||
"tickets": {
|
||||
"ticketSection": "TICKET SYSTEM",
|
||||
"azdoSection": "AZURE DEVOPS",
|
||||
"azdoToken": "Personal access token (scope: Code read & write)",
|
||||
"azdoHint": "Used in company repos (origin on the Azure DevOps Server): fetch before a task starts, push and draft PR on approve.",
|
||||
"baseUrl": "API base URL",
|
||||
"baseUrlHint": "Leave empty to disable the ticket integration entirely.",
|
||||
"token": "Personal access token",
|
||||
"tokenSet": "stored",
|
||||
"testConnection": "Test connection",
|
||||
"clearToken": "Remove token"
|
||||
"clearToken": "Remove token",
|
||||
"fallbackList": "Fallback list",
|
||||
"fallbackListHint": "Imported ticket items Claude can't match to a list land here. None = they are skipped.",
|
||||
"fallbackNone": "(none)"
|
||||
},
|
||||
"ssh": {
|
||||
"hint": "Servers you can start an interactive Claude session on from Mission Control. Host is an alias from ~/.ssh/config (user, key and port come from there); Claude must be installed and logged in on the server.",
|
||||
"name": "Name",
|
||||
"host": "Host alias",
|
||||
"remotePath": "Remote path (/… or ~/…)",
|
||||
"add": "Add workspace",
|
||||
"remove": "Remove",
|
||||
"invalid": {
|
||||
"name": "SSH workspace: name is required.",
|
||||
"host": "SSH workspace \"{0}\": host must be an ssh alias (letters, digits, . _ @ -, not starting with -).",
|
||||
"path": "SSH workspace \"{0}\": remote path must start with / or ~ and must not contain '."
|
||||
}
|
||||
},
|
||||
"customTiles": {
|
||||
"hint": "Console programs Mission Control can open as a tile (e.g. pwsh, ssh lenovo-server, lazygit). Arguments are split on spaces; quote an argument that contains one. Leave the folder empty to pick it when starting the tile.",
|
||||
"name": "Name",
|
||||
"program": "Program",
|
||||
"arguments": "Arguments",
|
||||
"folder": "Folder (optional)",
|
||||
"pickFolder": "Choose folder",
|
||||
"add": "Add tile",
|
||||
"remove": "Remove",
|
||||
"invalid": {
|
||||
"name": "Custom tile: name is missing.",
|
||||
"program": "Custom tile \"{0}\": program is missing."
|
||||
}
|
||||
},
|
||||
"inherit": {
|
||||
"inheritedFromList": "inherited · List",
|
||||
@@ -201,6 +238,7 @@
|
||||
"ctxMarkAs": "Mark as",
|
||||
"ctxMarkDone": "Done",
|
||||
"ctxMarkCancelled": "Cancelled",
|
||||
"ctxMarkIdle": "Idle",
|
||||
"ctxOpenConPtySession": "Open interactive session",
|
||||
"ctxOpenWorktree": "Open worktree",
|
||||
"ctxOpenPlanningSession": "Open planning Session",
|
||||
@@ -285,6 +323,7 @@
|
||||
"copyFormattedTip": "Copy title, description and open steps",
|
||||
"toggleEditPreviewTip": "Toggle edit/preview",
|
||||
"planDay": "Plan day",
|
||||
"importTicketsAndPlanDay": "Tickets + plan day",
|
||||
"prepEmpty": "No prep run today yet — click Plan day",
|
||||
"roadblockReply": {
|
||||
"placeholder": "Reply to the roadblock…",
|
||||
@@ -328,7 +367,12 @@
|
||||
"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",
|
||||
"createDraftPr": "Create draft PR",
|
||||
"updatePr": "Update PR",
|
||||
"openPr": "Open pull request",
|
||||
"companyTargetHint": "Company repo — approve pushes the branch and opens a draft PR on Azure DevOps.",
|
||||
"park": "Park",
|
||||
"parkTip": "Set aside — back to Idle, keeps the worktree",
|
||||
"cancel": "Cancel",
|
||||
@@ -369,16 +413,37 @@
|
||||
"empty": "No running tasks",
|
||||
"settings": "Settings",
|
||||
"activity": "Activity",
|
||||
"runningNow": "Running now",
|
||||
"opVerifyMerge": "Verifying after merge",
|
||||
"opVerifyBatch": "Verify (batch)",
|
||||
"opMerging": "Merging",
|
||||
"opRebasing": "Rebasing",
|
||||
"opCreatingWorktree": "Creating worktree",
|
||||
"operationsRefreshFailed": "Couldn't load running operations: {0}",
|
||||
"blocked": "Blocked",
|
||||
"running": "Running",
|
||||
"review": "In review",
|
||||
"verifying": "Verifying",
|
||||
"failed": "Failed",
|
||||
"roadblock": "Roadblock reported",
|
||||
"openSession": "Open session",
|
||||
"homeFolder": "Home (quick session)",
|
||||
"sshTarget": "{0} (SSH)",
|
||||
"customTarget": "{0} (custom)",
|
||||
"customProgramNotFound": "Program \"{0}\" not found — check the name or use a full path (Settings → Custom tiles).",
|
||||
"browseForFolder": "Choose folder…",
|
||||
"recentSessions": "Recent sessions",
|
||||
"recentSessionsEmpty": "No sessions in the last 14 days",
|
||||
"resumeSession": "Resume",
|
||||
"showBrief": "Brief",
|
||||
"renameSession": "Double-click to rename",
|
||||
"renameFailed": "Couldn't rename the session: {0}",
|
||||
"handoffSession": "Handoff",
|
||||
"briefOpenFailed": "Couldn't open the brief: {0}",
|
||||
"focusMode": "Focus",
|
||||
"overviewMode": "Overview",
|
||||
"columns": "Columns",
|
||||
"columnsAuto": "Auto",
|
||||
"closeSession": "Kill session",
|
||||
"closeSessionConfirm": "End the running session? The Claude process will be aborted.",
|
||||
"conptyLaunchFailed": "Couldn't open interactive session: {0}",
|
||||
@@ -386,6 +451,7 @@
|
||||
"conptyExited": "Session ended (code {0}).",
|
||||
"usageWarningNotDelivered": "Couldn't warn the list-handler session about the usage limit.",
|
||||
"terminalSendFailed": "Couldn't send that to the terminal session.",
|
||||
"deferredHandoffTitleSuffix": "waiting for limit reset ({0})",
|
||||
"mergeHelperTitle": "Merge Helper",
|
||||
"mergeHelperHandoffTitleSuffix": " (Handoff)",
|
||||
"mergeHelperTaskTitle": "List handler: {0}",
|
||||
@@ -452,8 +518,14 @@
|
||||
"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",
|
||||
"color": "Colour",
|
||||
"colorHint": "Tints the dot of a list, or the name and guide line of a folder. The empty ring restores the default.",
|
||||
"colorCustom": "Custom",
|
||||
"workingDirectory": "Working directory",
|
||||
"workingDirectoryPlaceholder": "(none)",
|
||||
"baseBranch": "Base branch",
|
||||
"baseBranchPlaceholder": "(checked-out branch)",
|
||||
"baseBranchHint": "New task branches start here and approved work merges back here, e.g. dev. Leave empty to use whatever branch is checked out.",
|
||||
"browse": "Browse...",
|
||||
"defaultCommitType": "Default commit type",
|
||||
"manualList": "Manual list (reminders)",
|
||||
@@ -465,6 +537,8 @@
|
||||
"sectionIcon": "ICON",
|
||||
"icon": "List icon",
|
||||
"iconHint": "Shown next to the list name in the sidebar.",
|
||||
"iconSearchWatermark": "Search all icons (English, e.g. rocket, car, bug)",
|
||||
"iconSearchTruncated": "Showing the first 200 matches — refine the search.",
|
||||
"sectionAgent": "AGENT",
|
||||
"resetAgentSettings": "Reset agent settings",
|
||||
"sectionVerify": "VERIFICATION",
|
||||
@@ -482,7 +556,8 @@
|
||||
"removeWorktree": "Remove worktree after merge",
|
||||
"commitMessage": "Commit message",
|
||||
"conflictedFiles": "Conflicted files:",
|
||||
"stashAndMerge": "Stash changes and merge"
|
||||
"stashAndMerge": "Stash changes and merge",
|
||||
"resolveConflicts": "Resolve conflicts"
|
||||
},
|
||||
"diff": {
|
||||
"title": "DIFF",
|
||||
@@ -514,6 +589,8 @@
|
||||
"resolve": "Resolve",
|
||||
"phantom": "phantom",
|
||||
"phantomTooltip": "Directory missing on disk",
|
||||
"mergedExternal": "merged (external)",
|
||||
"mergedExternalTooltip": "Its commits are already in the default branch (local origin ref, not fetched) — e.g. a squash PR. \"Cleanup finished\" removes it.",
|
||||
"ctxShowDiff": "Show diff",
|
||||
"ctxOpenInExplorer": "Open in Explorer",
|
||||
"ctxJumpToTask": "Jump to task",
|
||||
@@ -805,6 +882,14 @@
|
||||
"done": "Done"
|
||||
}
|
||||
},
|
||||
"dialog": {
|
||||
"cancel": "Cancel",
|
||||
"confirm": "Confirm",
|
||||
"delete": "Delete",
|
||||
"ok": "OK",
|
||||
"confirmTitle": "Confirm",
|
||||
"errorTitle": "Error"
|
||||
},
|
||||
"shell": {
|
||||
"menu": {
|
||||
"help": "Help",
|
||||
@@ -839,7 +924,7 @@
|
||||
},
|
||||
"vm": {
|
||||
"connection": { "online": "Online", "connecting": "Connecting…", "offline": "Offline" },
|
||||
"shell": { "unexpectedError": "Unexpected error: {0}", "shutdownConfirm": "This stops the ClaudeDo worker (any running task or interactive session is interrupted) and closes the app. Continue?" },
|
||||
"shell": { "unexpectedError": "Unexpected error: {0}", "updatingClaudeCli": "Updating Claude CLI…", "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", "waiting": "Waiting" },
|
||||
@@ -851,10 +936,10 @@
|
||||
"tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "statusPill": "{0} running · {1} review", "openTaskOne": "1 open task", "openTasks": "{0} open tasks", "planningResumeFailed": "Couldn't resume planning session: {0}", "approveFailed": "Approve & merge failed: {0}", "cancelReviewFailed": "Cancel review failed: {0}", "sendToQueueFailed": "Send to queue failed: {0}", "queuePlanBlockedInteractive": "Can't queue the plan — {0} has an open interactive session and must be closed first.", "moveRunningRejected": "Can't move a running task to another list.", "moveWorktreeRejected": "Can't move — this task has an active worktree pointing at its current repo.", "moveRepoConfirm": "Different repos — {0} → {1}. Move the task anyway?", "moveConfirmUnavailable": "Can't move — the confirmation dialog isn't available.", "quickClaudeNoWorkingDir": "This list has no working directory configured.", "quickClaudeDirMissing": "Working directory no longer exists: {0}", "setStatusFailed": "Failed to update status: {0}", "cancelFailed": "Cancel failed: {0}", "rejectToQueueFailed": "Reject to queue failed: {0}", "rejectToIdleFailed": "Reject failed: {0}", "deleteTaskConfirm": "Delete \"{0}\"? This cannot be undone.", "deleteTaskFailed": "Delete failed: {0}", "toggleDoneFailed": "Couldn't update done state: {0}", "removeFromQueueFailed": "Remove from queue failed: {0}", "openWorktreeFailed": "Couldn't open worktree: {0}" },
|
||||
"diff": { "loadFailed": "Failed to load diff: {0}", "noChanges": "No changes to show.", "unavailable": "Diff no longer available — commit range incomplete." },
|
||||
"planningDiff": { "hubError": "Could not build combined preview (hub error).", "conflict": "Cannot build combined preview: subtask {0} conflicts with an earlier subtask ({1} files).", "buildFailed": "Could not build combined preview: {0}" },
|
||||
"merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Merging…", "progressVerifying": "Running the list's verify command… ({0})", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "blocked": "Blocked: {0}", "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "dirtyTarget": "Merge refused — the target working directory has uncommitted changes.", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" },
|
||||
"merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Merging…", "progressVerifying": "Running the list's verify command… ({0})", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "blocked": "Blocked: {0}", "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "dirtyTarget": "Merge refused — the target working directory has uncommitted changes.", "targetDirty": "The target working directory already has uncommitted changes — stash them and merge?", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" },
|
||||
"settingsModal": { "workerOffline": "Worker offline — settings read-only.", "saveFailed": "Save failed: {0}" },
|
||||
"onlineInbox": { "workerOffline": "Worker offline — cannot load config.", "saved": "Config saved.", "saveFailed": "Save failed: {0}", "signedIn": "Signed in successfully.", "signedInNoRole": "Signed in, but this account is missing the 'user' role in Zitadel — online sync will be rejected until the role is granted in the ClaudeDo project.", "signInFailed": "Sign-in failed: {0}", "signedOut": "Signed out.", "signOutFailed": "Sign-out failed: {0}" },
|
||||
"tickets": { "connectionOk": "Connected as {0}.", "connectionMissingScopes": "Connected as {0}, but missing scopes: {1}", "tokenCleared": "Token removed.", "workerOffline": "Worker not reachable." },
|
||||
"tickets": { "azdoConnectionOk": "Connected — repo {0}, default branch {1}.", "connectionOk": "Connected as {0}.", "connectionMissingScopes": "Connected as {0}, but missing scopes: {1}", "tokenCleared": "Token removed.", "workerOffline": "Worker not reachable." },
|
||||
"weeklyReport": { "invalidRange": "Invalid date range.", "error": "Error: {0}" },
|
||||
"usageMonitor": {
|
||||
"loadFailed": "Couldn't load usage data: {0}",
|
||||
@@ -870,7 +955,7 @@
|
||||
"sessionSkillsTab": { "installed": "Installed: {0}", "installFailed": "Install failed: {0}", "updated": "Updated.", "updateFailed": "Update failed: {0}", "removed": "Removed.", "removeFailed": "Remove failed: {0}" },
|
||||
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "Removed {0} worktree(s).", "blocked": "Cannot force-remove: {0} task(s) still running. Cancel them first.", "removedFrom": "Removed {0} worktree(s) from {1} task(s).", "cleanupFailed": "Cleanup failed: {0}", "resetFailed": "Reset failed: {0}" },
|
||||
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "list", "cleanupFailed": "Cleanup failed.", "cleanupFailedDetailed": "Cleanup failed: {0}", "removed": "Removed {0} worktree(s).", "discardConfirm": "Discard the worktree for \"{0}\"? Uncommitted work will be lost.", "discardFailed": "Failed to discard worktree.", "keepFailed": "Failed to keep worktree.", "cannotForceRunning": "Cannot force-remove a running task.", "forceRemoveFailed": "Force remove failed.", "forceRemoveFailedDetailed": "Force remove failed: {0}", "batchDone": "Merged {0}, {1} need resolution.", "openInExplorerFailed": "Couldn't open path: {0}" },
|
||||
"listSettings": { "untitled": "Untitled", "ticketProjectNone": "— none —", "loadFailed": "Couldn't load list settings: {0}", "ticketProjectsLoadFailed": "Couldn't load ticket projects: {0}", "confirmDeleteFolder": "Delete folder \"{0}\"? The lists inside it are kept and move back to the top level.", "confirmDeleteList": "Delete list \"{0}\" and all its tasks? This cannot be undone.", "deleteBlockedPlanning": "This list has planning sessions with child tasks. Discard those first, then delete the list.", "workingDirMissing": "This folder doesn't exist.", "workingDirNotRepo": "This folder isn't a git repository yet. Run `git init` in it first, or clear the working directory to save this as a manual list." },
|
||||
"listSettings": { "untitled": "Untitled", "ticketProjectNone": "— none —", "loadFailed": "Couldn't load list settings: {0}", "ticketProjectsLoadFailed": "Couldn't load ticket projects: {0}", "confirmDeleteFolder": "Delete folder \"{0}\"? The lists and subfolders inside it are kept and move back to the top level.", "confirmDeleteList": "Delete list \"{0}\" and all its tasks? This cannot be undone.", "deleteBlockedPlanning": "This list has planning sessions with child tasks. Discard those first, then delete the list.", "workingDirMissing": "This folder doesn't exist.", "workingDirNotRepo": "This folder isn't a git repository yet. Run `git init` in it first, or clear the working directory to save this as a manual list." },
|
||||
"detailsIsland": { "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "dirtyTarget": "Merge refused — the target working directory has uncommitted changes.", "stashAndMergeConfirm": "The target working directory has uncommitted changes.\nStash them, merge, and restore them afterwards?", "stopOffline": "Worker offline — can't stop the task.", "stopFailed": "Stop failed: {0}", "enqueueFailed": "Queue failed: {0}", "dequeueFailed": "Remove from queue failed: {0}", "resetAndRetryFailed": "Reset & retry failed: {0}", "toggleDoneFailed": "Couldn't update done state: {0}" },
|
||||
"mergeSection": { "openWorktreeFailed": "Couldn't open worktree: {0}" },
|
||||
"about": { "openPathFailed": "Couldn't open path: {0}" },
|
||||
@@ -881,7 +966,8 @@
|
||||
"stalledHint": "Still running — worker hasn't responded in a while",
|
||||
"merge": {
|
||||
"merging": "Merging…",
|
||||
"verifying": "Running the list's verify command… ({0})"
|
||||
"verifying": "Running the list's verify command… ({0})",
|
||||
"publishing": "Pushing and opening the draft PR…"
|
||||
},
|
||||
"diff": {
|
||||
"parsing": "Parsing diff…"
|
||||
@@ -915,7 +1001,8 @@
|
||||
},
|
||||
"reports": {
|
||||
"generatingWeekly": "Generating report…",
|
||||
"runningDailyPrep": "Running daily prep…"
|
||||
"runningDailyPrep": "Running daily prep…",
|
||||
"runningTicketImport": "Importing tickets, then planning the day…"
|
||||
},
|
||||
"planning": {
|
||||
"finalizing": "Finalizing plan…",
|
||||
|
||||
@@ -5,20 +5,30 @@ using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace ClaudeDo.Ui;
|
||||
|
||||
public sealed record AccentPreset(string Name, string Accent, string Dim, string Soft, string Glow);
|
||||
/// <summary>An accent theme; <paramref name="Swatches"/> holds one hex per <c>SwatchNBrush</c> token (index 0 = Swatch1).</summary>
|
||||
public sealed record AccentPreset(string Name, string Accent, string Dim, string Soft, string Glow, string[] Swatches);
|
||||
|
||||
public static class AccentPresets
|
||||
{
|
||||
// Hue 88 — moss/sage green (original)
|
||||
public static readonly AccentPreset Moss = new("moss", "#FF7C9166", "#FF64785A", "#FF3E4B39", "#387C9166");
|
||||
public static readonly AccentPreset Moss = new("moss", "#FF7C9166", "#FF64785A", "#FF3E4B39", "#387C9166",
|
||||
["#7C9166", "#D4A574", "#C87060", "#D9C27A", "#6FB3A8", "#7FA7C9", "#A38BC9", "#C77FA0", "#8B9D7A", "#B58B6A", "#5C78B8", "#9AA0A6"]);
|
||||
// Hue ~40 — warm earthy brown/terra
|
||||
public static readonly AccentPreset Peat = new("peat", "#FF9A7B5C", "#FF7F6449", "#FF4D3C2C", "#389A7B5C");
|
||||
public static readonly AccentPreset Peat = new("peat", "#FF9A7B5C", "#FF7F6449", "#FF4D3C2C", "#389A7B5C",
|
||||
["#9A7B5C", "#D9A066", "#C4604F", "#DBBF6A", "#7FA890", "#7C9AB8", "#A98BB8", "#C98A8A", "#A39A6E", "#B8825A", "#6E80AE", "#A09A90"]);
|
||||
// Hue ~180 — cool teal/sea-green
|
||||
public static readonly AccentPreset Sea = new("sea", "#FF5B8F8C", "#FF4A7573", "#FF263D3C", "#385B8F8C");
|
||||
public static readonly AccentPreset Sea = new("sea", "#FF5B8F8C", "#FF4A7573", "#FF263D3C", "#385B8F8C",
|
||||
["#5B8F8C", "#C9A87C", "#BF7468", "#CBC27F", "#4FA396", "#5FA8C8", "#8E8BCC", "#B87FA5", "#7C9A8E", "#A8907A", "#5C78B8", "#8FA0A8"]);
|
||||
|
||||
public static readonly IReadOnlyList<AccentPreset> All = [Moss, Peat, Sea];
|
||||
public static AccentPreset Default => Moss;
|
||||
|
||||
/// <summary>Number of <c>SwatchNBrush</c> tokens every preset must define.</summary>
|
||||
public const int SwatchCount = 12;
|
||||
|
||||
/// <summary>Swatch keys ("Swatch1".."SwatchN") as stored on lists and folders.</summary>
|
||||
public static readonly string[] SwatchKeys = Enumerable.Range(1, SwatchCount).Select(i => $"Swatch{i}").ToArray();
|
||||
|
||||
public static AccentPreset Find(string? name) =>
|
||||
All.FirstOrDefault(p => p.Name == name) ?? Default;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,12 @@ 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; }
|
||||
|
||||
/// <summary>User-defined console programs offered in Mission Control's new-session flyout.</summary>
|
||||
public List<CustomTile> CustomTiles { get; set; } = new();
|
||||
|
||||
private static readonly string DefaultConfigPath = Paths.Expand("~/.claudeDo/ui.config.json");
|
||||
|
||||
/// Where this instance persists. Instance-level (not static) so tests can redirect it —
|
||||
@@ -55,3 +61,35 @@ public sealed class AppSettings
|
||||
File.WriteAllText(ConfigPath, json);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>A console program the user can open as a Mission Control tile. An empty
|
||||
/// <see cref="Folder"/> means the folder is picked at launch.</summary>
|
||||
public sealed class CustomTile
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string Program { get; set; } = "";
|
||||
public string Arguments { get; set; } = "";
|
||||
public string Folder { get; set; } = "";
|
||||
|
||||
/// <summary>Splits an argument string into tokens: whitespace separates, double quotes group and
|
||||
/// are dropped. The terminal control quotes every token itself, so the raw string can't pass through.</summary>
|
||||
public static List<string> SplitArgs(string? args)
|
||||
{
|
||||
var tokens = new List<string>();
|
||||
var current = new System.Text.StringBuilder();
|
||||
bool inQuotes = false, hasToken = false;
|
||||
foreach (var c in args ?? "")
|
||||
{
|
||||
if (c == '"') { inQuotes = !inQuotes; hasToken = true; }
|
||||
else if (char.IsWhiteSpace(c) && !inQuotes)
|
||||
{
|
||||
if (hasToken) tokens.Add(current.ToString());
|
||||
current.Clear();
|
||||
hasToken = false;
|
||||
}
|
||||
else { current.Append(c); hasToken = true; }
|
||||
}
|
||||
if (hasToken) tokens.Add(current.ToString());
|
||||
return tokens;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,14 +34,14 @@ Views/ — mirrors the VM layout; Islands/Detail/ holds TaskHeaderBar,
|
||||
Views/Controls/ — MarkdownView, ModalShell, ThemedDatePicker, DiffTextView, DiffEditorSetup,
|
||||
InheritedBadge, AgentConfigEditor, UsagePill, UsageGaugeBar
|
||||
Design/ — Tokens.axaml (design tokens; merged before styles)
|
||||
+ IslandStyles.axaml (component styles + the filled icon geometry library)
|
||||
+ IslandStyles.axaml (component styles; icons are Material Design Icons, no local geometry)
|
||||
```
|
||||
|
||||
## Core ViewModels
|
||||
|
||||
- **IslandsShellViewModel** — root coordinator. Owns the three island VMs and the `WorkerClient`, wires cross-island events (selection, notes/prep mode, conflict resolution), connection state, the update banner, the inline worker-log strip (clickable → Log Visualizer overlay; `FlashFooterError` surfaces UI-action failures + the worker's Warn/Error there), responsive-layout flags (`ShowLists`/`ShowDetails` by window width), `PrimeStatus` flash, the modal openers, and `RestartWorkerAsync`/`CheckForUpdatesAsync`. Hosts `UpdateCheckService`.
|
||||
- **ListsIslandViewModel** — smart lists (My Day, Important, Planned, virtual queued/running/review), user lists, selection, list CRUD, drag-reorder, badge counts, opens list settings / repo import / worktrees overview, `OpenInExplorer`/`OpenInTerminal`.
|
||||
- **Folders:** the sidebar's top level is **one mixed collection**, `TopLevel` (`ObservableCollection<object>`: `ListFolderNavItemViewModel` rows, each with its own `Lists`, interleaved with folder-less `ListNavItemViewModel` rows) — that's what lets a list sit *above* a folder. `Folders`/`RootLists` are `OfType` **projections** of it, not collections to keep in sync. `UserLists` stays the **flat superset** every count refresh, row refresh and the no-repo banner work over — a row lives in both. `ContainerOf(row)` resolves which collection renders it (non-generic `IList`, since the top level is mixed). The view is a single `ItemsControl` over `TopLevel` with **implicit, type-matched `DataTemplate`s in `UserControl.DataTemplates`** — no keyed row template, and the folder's child `ItemsControl` resolves the same list template by walking up to the UserControl. A folder is never selectable: tapping the header runs `ToggleFolderCommand` (persisted to `list_folders.is_collapsed`), and its badge is the sum of its children. `DropAsync(source, target, placeBelow)` handles all four source/target combinations and doubles as the move: a drop position implies a container, so dropping a list onto a row in another folder — or next to *anything* at the top level, folder headers included — reassigns `FolderId`. A folder dropped onto a row inside a folder is ignored; folders never nest. `MoveToFolderAsync` is the drop-into-the-header path (reaches collapsed/empty folders, bound to the middle half of the header — the outer quarters insert *next to* the folder instead); `RemoveFromFolderCommand` is its context-menu counterpart. Both end in `PersistOrderAsync` → `ListRepository.ReorderSidebarAsync`, one `sort_order` sequence spanning **both tables**. ⚠️ A **folder** drag starts from `PointerMoved` past a 4px threshold using the *stored* `PointerPressedEventArgs` (`DoDragDropAsync` only accepts those) — starting it on press would capture the pointer and swallow the `Tapped` that folds the folder.
|
||||
- **Folders:** the sidebar's top level is **one mixed collection**, `TopLevel` (`ObservableCollection<object>`: `ListFolderNavItemViewModel` rows, each with its own `Children`, interleaved with folder-less `ListNavItemViewModel` rows) — that's what lets a list sit *above* a folder. Folders nest arbitrarily deep — a folder's `Children` mixes lists and subfolders the same way `TopLevel` mixes folders and folder-less lists, via `ParentFolderId`. `Folders` is a **recursive** `OfType` projection over the whole tree (not just the top level); `RootLists` stays a `TopLevel`-only projection. `UserLists` stays the **flat superset** every count refresh, row refresh and the no-repo banner work over — a row lives in both. `ContainerOf(row-or-folder)` resolves which collection renders it (non-generic `IList`, since every container is mixed). The view is a single `ItemsControl` over `TopLevel` with **implicit, type-matched `DataTemplate`s in `UserControl.DataTemplates`** — no keyed row template, and the folder's child `ItemsControl` resolves the same templates recursively by walking up to the UserControl (which is also what indents nested folders: each level adds its template's fixed margin). A folder is never selectable: tapping the header runs `ToggleFolderCommand` (persisted to `list_folders.is_collapsed`), and its badge is the sum of all descendant lists, recursive. `DropAsync(source, target, placeBelow)` handles list and folder sources against any target and doubles as the move: a drop position implies a container, so dropping a row onto a row in another folder — or next to *anything* at the top level, folder headers included — reassigns `FolderId`/`ParentFolderId`. A folder dropped onto or next to a target inside its own subtree is silently ignored (self/descendant guard), never nested into itself. `MoveToFolderAsync` is the drop-into-the-header path (reaches collapsed/empty folders, bound to the middle half of the header — the outer quarters insert *next to* the folder instead; also takes a folder source now); `RemoveFromFolderCommand` is its context-menu counterpart. Both end in `PersistOrderAsync` → `ListRepository.ReorderSidebarAsync`/`SetFolderParentAsync`, one `sort_order` sequence spanning **both tables**, walked depth-first. ⚠️ A **folder** drag starts from `PointerMoved` past a 4px threshold using the *stored* `PointerPressedEventArgs` (`DoDragDropAsync` only accepts those) — starting it on press would capture the pointer and swallow the `Tapped` that folds the folder.
|
||||
- **TasksIslandViewModel** — open/overdue/completed groups for the selected list with hierarchy-aware regrouping; task CRUD, drag-reorder, toggle done/star, schedule, enqueue/dequeue, cancel; review actions; planning session lifecycle; `RefineTask`, `OpenConPtySessionRequested`, `ToggleManual`, `SyncInteractiveSessions`; MyDay extras (`IsMyDayList`, `ClearDayCommand`, `ShowPrepLogCommand`) and the pinned Notes pseudo-row. Raises `NotesRequested`/`PrepRequested` for the shell.
|
||||
- **DetailsIslandViewModel** — the detail pane for a bound `TaskRowViewModel`. Owns live-log streaming (`Log` via `StreamLineFormatter`), debounced title/description editing, subtasks, the session-outcome/roadblock split, the three-tab work console (`output`/`git`/`session`), child surfacing (`ChildOutcomes` + `ChildrenNeedingAttention` drive an attention band on the Session tab), attachments, and the modes `IsNotesMode`/`IsPrepMode`/computed `IsTaskDetailVisible`. Failures raise `ErrorReported`, wired by the shell into `FlashFooterError`.
|
||||
- Three concerns are extracted into section VMs exposed as properties: `AgentSettings` (`AgentConfigEditorViewModel`, scope=Task), `MergeSectionViewModel`, `PrepPanelViewModel`. Helper rows live in the same file.
|
||||
@@ -55,7 +55,7 @@ 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`. |
|
||||
| `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 four-way action radio group (Ping / Fill My Day / Tickets + plan 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` or `ImportTickets`. The Tickets tab (`TicketSettingsTabViewModel`) also holds the fallback-list dropdown for the ticket import; it reads the lists straight from the DB and only writes the fallback back after a successful load. |
|
||||
| `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). |
|
||||
@@ -63,7 +63,7 @@ Design/ — Tokens.axaml (design tokens; merged before styles)
|
||||
|
||||
Self-explanatory: `RepoImportModalViewModel` (bulk-create lists from git repos; already-wired
|
||||
repos disabled), `MergeModalViewModel`, `WorktreesOverviewModalViewModel`,
|
||||
`UnfinishedPlanningModalViewModel`, `LogVisualizerViewModel` (last 30 min, all levels + a
|
||||
`UnfinishedPlanningModalViewModel`, `LogVisualizerViewModel` (last 30 min, all levels, merged with the shell's footer-strip history — worker Warn/Error copies deduped by record equality — + a
|
||||
warn/error filter), `WorkerConnectionModalViewModel`, `AboutModalViewModel`.
|
||||
|
||||
`CheatSheetsViewModel` backs the modeless `CheatSheetsWindow` (Help → Cheat Sheets): content is
|
||||
@@ -92,7 +92,7 @@ single-task and planning unit-merge conflicts. Full detail →
|
||||
The two surfaces are **not** variants of one control (read-only 2-way renderer over aligned rows
|
||||
vs. 3-way editor over a writable document), but they share their AvaloniaEdit host plumbing via
|
||||
`Views/Controls/DiffEditorSetup.cs`: the process-wide TextMate `Registry`, `InstallHighlighting`,
|
||||
`ApplyGrammar`, the `Brush` resource fallback, the `Seg` segment, and `VerticalScrollSync`. Put
|
||||
`ApplyGrammar`, the `Brush` resource fallback, the `Seg` segment, and `ScrollSync`. Put
|
||||
new editor boilerplate there rather than copying it a third time.
|
||||
|
||||
## Services
|
||||
@@ -107,7 +107,7 @@ new editor boilerplate there rather than copying it a third time.
|
||||
|
||||
## Converters
|
||||
|
||||
In `Converters/` — grep rather than list: log-level brush, dot brush,
|
||||
In `Converters/` — `ColorBrushConverter` (in `SimpleConverters.cs`) is the one resolver for stored list/folder colours (swatch key → `SwatchNBrush`, `#RRGGBB` → brush, else the `ConverterParameter` resource); the `Swatch1-12Brush` tokens are rewritten in place per accent preset by `AccentPresetService`, so every `AccentPreset` must carry all `AccentPresets.SwatchCount` hexes. Otherwise grep rather than list: log-level brush, dot brush,
|
||||
status equality, icon key, `KeepLastNumber`. The one-expression ones
|
||||
(strike/italic/opacity toggles, uppercase, not-a-header-row) share
|
||||
`SimpleConverters.cs`; put new trivial ones there instead of a new file.
|
||||
@@ -130,7 +130,7 @@ snaps `CanResize="True"` windows, which is the opt-in), and it insets itself by
|
||||
## Gotchas
|
||||
|
||||
- **Never set `Foreground`/`Stroke` on an icon inline.** A local value outranks every style, so the button's hover/checked/disabled states can't reach the icon and it looks dead on hover — this is what broke the icons that were "fixed" to the right colour inline. Colour is picked with a class; the single `ICON PAINTING` block in `IslandStyles.axaml` owns all states (`.danger`, `.star-btn.on`, `.active`, `:checked`, `:disabled`). Avalonia has no specificity — that block's **declaration order** is the mechanism, so add new variants in the right section, not at the end.
|
||||
- **`PathIcon` *fills* its geometry.** Line-art/stroke icons must be authored as filled geometry or rendered as `<Path Classes="icon-stroke">` inside a `Viewbox` (e.g. `Icon.PlanDay`, `Icon.GitBranch`). A pure stroke path in a `PathIcon` is **invisible**. `Path.icon-stroke` takes the same colour classes/states as `PathIcon`.
|
||||
- **Icons are Material Design Icons, nothing hand-drawn.** Use `<PathIcon Data="{Binding Source='mdi:Name', Converter={StaticResource IconKey}}"/>` (`IconKeyConverter`); legacy bare list keys stored in the DB resolve through `IconKeyConverter.LegacyKinds`. A misspelled kind makes the converter return null and the icon **silently vanishes** — `MdiIconNamesTests` scans every `mdi:Name` in `src/ClaudeDo.Ui` and fails on an unknown one. Never set `Foreground` inline (see above).
|
||||
- **`NumericUpDown.Value` is `decimal?` and goes null while the box is empty** — i.e. every time the user clears a value to type a new one. Bound TwoWay to a non-nullable `int`/`decimal`, that null throws `InvalidCastException`. Either bind a `decimal?` property (as `AgentConfigEditorViewModel.MaxTurns` does) or add `Converter={StaticResource KeepLastNumber}`, which drops the null via `BindingOperations.DoNothing`.
|
||||
- **`ScrollViewer.Padding` is excluded from the Extent (Avalonia 12).** At max scroll the content still sticks out below the viewport by the padding height, so the last line(s) are clipped and unreachable — even after `ScrollToEnd()`. Put the inset on the *content* (`Margin` on the inner `ItemsControl`/`StackPanel`) instead. Verified headlessly: 12,8,12,4 on the viewer → last item 20px below the viewport; same inset as content margin → fully visible. **`ListBox` does *not* have this trap** — its Fluent control-theme template forwards `Padding` onto `PART_ItemsPresenter`'s margin (content side), not the raw `ScrollViewer.Padding`; verified headlessly that `ListBox.Padding="10,4"` and a `Margin="10,4"` on the `ItemsPanelTemplate` panel produce an *identical* Extent and leave the last row fully visible at max scroll. Still fine to put the inset on the panel for consistency with the other scrollables above, just don't assume it's fixing a real bug there.
|
||||
- **Re-templating a `TabControl` must keep `PART_ItemsPresenter`.** `TabControl.SelectedContent` is resolved via `ContainerFromIndex`, and containers are only realized by the `ItemsPresenter`. A template that renders just `PART_SelectedContentHost` (to hide the tab strip) leaves `SelectedContent` permanently `null` → an empty body. Keep the presenter with `IsVisible="False"`; that still realizes containers (verified headlessly, Avalonia 12.0.4). This broke the whole Settings modal body in 56c3719e.
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="12.0.4" />
|
||||
<PackageReference Include="Avalonia.AvaloniaEdit" Version="12.0.0" />
|
||||
<PackageReference Include="Avalonia.Controls.ColorPicker" Version="12.0.4" />
|
||||
<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 +19,7 @@
|
||||
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
|
||||
<PackageReference Include="Duende.IdentityModel.OidcClient" Version="7.1.0" />
|
||||
<PackageReference Include="Iciclecreek.Avalonia.Terminal" Version="2.0.3" />
|
||||
<PackageReference Include="Serilog" Version="4.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -2,27 +2,95 @@ 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: "mdi:RocketLaunch" → the Material Design Icons path of that
|
||||
/// <see cref="MaterialIconKind"/>; a legacy bare key ("Rocket") → its <see cref="LegacyKinds"/> entry;
|
||||
/// any other bare key → the "Icon.<key>" StreamGeometry resource.
|
||||
/// </summary>
|
||||
public class IconKeyConverter : IValueConverter
|
||||
{
|
||||
/// <summary>Key prefix marking a Material Design Icons kind.</summary>
|
||||
public const string MdiPrefix = "mdi:";
|
||||
|
||||
/// <summary>Bare keys stored in the DB by older versions → the MDI kind that replaces their hand-drawn geometry.</summary>
|
||||
public static readonly IReadOnlyDictionary<string, MaterialIconKind> LegacyKinds = new Dictionary<string, MaterialIconKind>
|
||||
{
|
||||
["Dot"] = MaterialIconKind.CircleMedium,
|
||||
["Folder"] = MaterialIconKind.FolderOutline,
|
||||
["Star"] = MaterialIconKind.StarOutline,
|
||||
["Sun"] = MaterialIconKind.WeatherSunny,
|
||||
["Calendar"] = MaterialIconKind.CalendarOutline,
|
||||
["Clock"] = MaterialIconKind.ClockOutline,
|
||||
["Inbox"] = MaterialIconKind.InboxOutline,
|
||||
["Mail"] = MaterialIconKind.EmailOutline,
|
||||
["Text"] = MaterialIconKind.NoteTextOutline,
|
||||
["Search"] = MaterialIconKind.Magnify,
|
||||
["Grid"] = MaterialIconKind.ViewGridOutline,
|
||||
["Code"] = MaterialIconKind.CodeTags,
|
||||
["Terminal"] = MaterialIconKind.Console,
|
||||
["Database"] = MaterialIconKind.DatabaseOutline,
|
||||
["Server"] = MaterialIconKind.ServerOutline,
|
||||
["Chip"] = MaterialIconKind.Chip,
|
||||
["Bolt"] = MaterialIconKind.LightningBoltOutline,
|
||||
["Wand"] = MaterialIconKind.AutoFix,
|
||||
["Flask"] = MaterialIconKind.FlaskOutline,
|
||||
["Shield"] = MaterialIconKind.ShieldOutline,
|
||||
["Activity"] = MaterialIconKind.Pulse,
|
||||
["Eye"] = MaterialIconKind.EyeOutline,
|
||||
["Check"] = MaterialIconKind.CheckCircleOutline,
|
||||
["Warning"] = MaterialIconKind.AlertOutline,
|
||||
["Chart"] = MaterialIconKind.ChartLineVariant,
|
||||
["Rocket"] = MaterialIconKind.RocketLaunchOutline,
|
||||
["Bulb"] = MaterialIconKind.LightbulbOutline,
|
||||
["Broom"] = MaterialIconKind.Broom,
|
||||
["AgentSuggested"] = MaterialIconKind.CreationOutline,
|
||||
["Settings"] = MaterialIconKind.Cog,
|
||||
["Box"] = MaterialIconKind.PackageVariantClosed,
|
||||
["Truck"] = MaterialIconKind.TruckOutline,
|
||||
["MapPin"] = MaterialIconKind.MapMarkerOutline,
|
||||
["Camera"] = MaterialIconKind.CameraOutline,
|
||||
["Image"] = MaterialIconKind.ImageOutline,
|
||||
["Barcode"] = MaterialIconKind.Barcode,
|
||||
["Skull"] = MaterialIconKind.SkullOutline,
|
||||
};
|
||||
|
||||
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..]);
|
||||
|
||||
if (LegacyKinds.TryGetValue(key, out var legacy))
|
||||
return MdiGeometry(legacy.ToString());
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -24,15 +24,17 @@ public sealed class LogKindForegroundConverter : IValueConverter
|
||||
{
|
||||
var key = value is LogKind kind ? kind switch
|
||||
{
|
||||
LogKind.Sys => "TextMuteBrush",
|
||||
LogKind.Tool => "SageBrush",
|
||||
LogKind.Claude => "TextBrush",
|
||||
LogKind.Stdout => "TextDimBrush",
|
||||
LogKind.Stderr => "BloodBrush",
|
||||
LogKind.Done => "MossBrightBrush",
|
||||
LogKind.Msg => "TextDimBrush",
|
||||
LogKind.User => "AccentBrush",
|
||||
_ => "TextDimBrush",
|
||||
LogKind.Sys => "TextMuteBrush",
|
||||
LogKind.Tool => "SageBrush",
|
||||
LogKind.Claude => "TextBrush",
|
||||
LogKind.Stdout => "TextDimBrush",
|
||||
LogKind.Stderr => "BloodBrush",
|
||||
LogKind.Done => "MossBrightBrush",
|
||||
LogKind.Msg => "TextDimBrush",
|
||||
LogKind.User => "AccentBrush",
|
||||
LogKind.Edit => "AmberBrush",
|
||||
LogKind.ToolResult => "TextMuteBrush",
|
||||
_ => "TextDimBrush",
|
||||
} : "TextDimBrush";
|
||||
|
||||
return Resolve(key) ?? AvaloniaProperty.UnsetValue;
|
||||
|
||||
@@ -9,6 +9,42 @@ 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>
|
||||
/// A stored list/folder colour to a brush: "#RRGGBB" becomes a <see cref="SolidColorBrush"/>, a swatch key
|
||||
/// ("Swatch3") resolves to the app resource "Swatch3Brush"; null, empty or unknown falls back to the resource named by the parameter.
|
||||
/// </summary>
|
||||
public sealed class ColorBrushConverter : OneWayConverter
|
||||
{
|
||||
public static ColorBrushConverter Instance { get; } = new();
|
||||
public override object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> Resolve(value as string, parameter as string, LookupResource);
|
||||
|
||||
private static object? LookupResource(string key)
|
||||
=> Avalonia.Application.Current?.TryGetResource(key, null, out var res) == true ? res : null;
|
||||
|
||||
/// <summary>Resolves a stored colour against <paramref name="lookup"/> (app resources in production); split out so tests need no Avalonia app.</summary>
|
||||
internal static object? Resolve(string? value, string? fallbackKey, Func<string, object?> lookup)
|
||||
{
|
||||
if (value is { Length: > 0 })
|
||||
{
|
||||
if (value[0] == '#')
|
||||
{
|
||||
if (Color.TryParse(value, out var c)) return new SolidColorBrush(c);
|
||||
}
|
||||
else if (lookup(value + "Brush") is { } swatch) return swatch;
|
||||
}
|
||||
return fallbackKey is null ? null : lookup(fallbackKey);
|
||||
}
|
||||
}
|
||||
|
||||
/// <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();
|
||||
|
||||
@@ -15,179 +15,9 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- ICON GEOMETRY RESOURCES -->
|
||||
<!-- SHARED RESOURCES -->
|
||||
<!-- ============================================================ -->
|
||||
<Styles.Resources>
|
||||
<!-- Window control icons — filled geometries (PathIcon fills, not strokes) -->
|
||||
<StreamGeometry x:Key="Icon.WinMin">M4 9 H16 V11 H4 Z</StreamGeometry>
|
||||
<StreamGeometry x:Key="Icon.WinMax">M4 4 H16 V6 H4 Z M4 14 H16 V16 H4 Z M4 4 H6 V16 H4 Z M14 4 H16 V16 H14 Z</StreamGeometry>
|
||||
<!-- Icon.Grid (four filled panes — Mission Control launcher) -->
|
||||
<StreamGeometry x:Key="Icon.Grid">M3 3 H9 V9 H3 Z M11 3 H17 V9 H11 Z M3 11 H9 V17 H3 Z M11 11 H17 V17 H11 Z</StreamGeometry>
|
||||
<StreamGeometry x:Key="Icon.WinRestore">M4 7 H13 V9 H4 Z M4 14 H13 V16 H4 Z M4 7 H6 V16 H4 Z M11 7 H13 V16 H11 Z M7 4 H16 V6 H7 Z M14 4 H16 V13 H14 Z</StreamGeometry>
|
||||
<StreamGeometry x:Key="Icon.WinClose">M4 5 L5 4 L16 15 L15 16 Z M15 4 L16 5 L5 16 L4 15 Z</StreamGeometry>
|
||||
<!-- Brand check glyph — filled rounded square with inset tick -->
|
||||
<StreamGeometry x:Key="Icon.BrandCheck">M3 3 H21 V21 H3 Z M6 12 L7 11 L10 14 L17 7 L18 8 L10 16 Z</StreamGeometry>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Icons — central icon library (Phase B) -->
|
||||
<!-- All d-strings sourced from icons.jsx -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<!-- Icon.Sun — filled disc + 8 rays. The old stroke-only version rendered as a bare dot in a
|
||||
PathIcon (verified by rendering it); rays are kept clear of the disc so EvenOdd is safe. -->
|
||||
<StreamGeometry x:Key="Icon.Sun">M7.5 12 C7.5 9.51 9.51 7.5 12 7.5 C14.49 7.5 16.5 9.51 16.5 12 C16.5 14.49 14.49 16.5 12 16.5 C9.51 16.5 7.5 14.49 7.5 12 Z M11 2 H13 V5.5 H11 Z M11 18.5 H13 V22 H11 Z M2 11 H5.5 V13 H2 Z M18.5 11 H22 V13 H18.5 Z M4.22 5.64 L5.64 4.22 L8.11 6.69 L6.69 8.11 Z M18.36 4.22 L19.78 5.64 L17.31 8.11 L15.89 6.69 Z M5.64 19.78 L4.22 18.36 L6.69 15.89 L8.11 17.31 Z M19.78 18.36 L18.36 19.78 L15.89 17.31 L17.31 15.89 Z</StreamGeometry>
|
||||
|
||||
<!-- Icon.Activity (pulse waveform) — filled outline of the polyline; PathIcon fills, a stroke-only pulse renders as blobs -->
|
||||
<StreamGeometry x:Key="Icon.Activity">M3,11 L6.28,11 L9,2.84 L12.86,14.43 L14.46,8.04 L17.41,11 L21,11 L21,13 L16.59,13 L15.54,11.96 L13.14,21.57 L9,9.16 L7.72,13 L3,13 Z</StreamGeometry>
|
||||
|
||||
<!-- Icon.Star -->
|
||||
<StreamGeometry x:Key="Icon.Star">M12 3.5l2.6 5.3 5.8.8-4.2 4.1 1 5.8-5.2-2.7-5.2 2.7 1-5.8-4.2-4.1 5.8-.8z</StreamGeometry>
|
||||
|
||||
<!-- Icon.Calendar — solid header band, hollow body, two binder posts and a date block.
|
||||
The old stroke version filled into a plain black square. -->
|
||||
<StreamGeometry x:Key="Icon.Calendar">F0 M3 5 H21 V21 H3 Z M5 11 H19 V19 H5 Z M7 2 H9 V5 H7 Z M15 2 H17 V5 H15 Z M7.5 13 H10 V15.5 H7.5 Z</StreamGeometry>
|
||||
|
||||
<!-- Icon.Eye -->
|
||||
<StreamGeometry x:Key="Icon.Eye">M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6z</StreamGeometry>
|
||||
|
||||
<!-- Icon.Inbox — tray frame with an arrow dropping into the upper compartment.
|
||||
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.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>
|
||||
|
||||
<!-- Icon.Search — hollow ring + handle. The old stroke version filled into a solid disc with
|
||||
no handle at all (this one is also the magnifier in the lists search box). -->
|
||||
<StreamGeometry x:Key="Icon.Search">F0 M3 10.5 C3 6.36 6.36 3 10.5 3 C14.64 3 18 6.36 18 10.5 C18 14.64 14.64 18 10.5 18 C6.36 18 3 14.64 3 10.5 Z M5.2 10.5 C5.2 7.57 7.57 5.2 10.5 5.2 C13.43 5.2 15.8 7.57 15.8 10.5 C15.8 13.43 13.43 15.8 10.5 15.8 C7.57 15.8 5.2 13.43 5.2 10.5 Z M14.88 16.58 L16.58 14.88 L22.05 20.35 L20.35 22.05 Z</StreamGeometry>
|
||||
|
||||
<!-- Icon.Plus — filled cross (PathIcon fills, so a stroke-only plus renders invisible) -->
|
||||
<StreamGeometry x:Key="Icon.Plus">M10.6 5 H13.4 V10.6 H19 V13.4 H13.4 V19 H10.6 V13.4 H5 V10.6 H10.6 Z</StreamGeometry>
|
||||
|
||||
<!-- Icon.MoreHorizontal (three filled dots) — uses fill so rendered via PathIcon with fill brush -->
|
||||
<StreamGeometry x:Key="Icon.MoreHorizontal">M5 12m-1.3 0a1.3 1.3 0 1 0 2.6 0 1.3 1.3 0 1 0-2.6 0 M12 12m-1.3 0a1.3 1.3 0 1 0 2.6 0 1.3 1.3 0 1 0-2.6 0 M19 12m-1.3 0a1.3 1.3 0 1 0 2.6 0 1.3 1.3 0 1 0-2.6 0</StreamGeometry>
|
||||
|
||||
<!-- Icon.GitBranch -->
|
||||
<StreamGeometry x:Key="Icon.GitBranch">M6 3a2 2 0 1 0 0 4 2 2 0 0 0 0-4z M6 19a2 2 0 1 0 0 4 2 2 0 0 0 0-4z M18 5a2 2 0 1 0 0 4 2 2 0 0 0 0-4z M6 7v10M6 13c0-4 12-2 12-4</StreamGeometry>
|
||||
|
||||
<!-- Icon.Copy -->
|
||||
<StreamGeometry x:Key="Icon.Copy">M8 8h12a1.5 1.5 0 0 1 1.5 1.5v12A1.5 1.5 0 0 1 20 23H8a1.5 1.5 0 0 1-1.5-1.5v-12A1.5 1.5 0 0 1 8 8z M16 8V5a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3</StreamGeometry>
|
||||
|
||||
<!-- Icon.Trash -->
|
||||
<StreamGeometry x:Key="Icon.Trash">M4 7h16M10 11v6M14 11v6M5 7l1 13a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1l1-13M9 7V4h6v3</StreamGeometry>
|
||||
|
||||
<!-- Icon.Broom (filled: handle + binding band + flared bristles) -->
|
||||
<StreamGeometry x:Key="Icon.Broom">M11 3 H13 V10 H11 Z M8.5 10 H15.5 V12 H8.5 Z M9 12 H15 L17 21 H7 Z</StreamGeometry>
|
||||
|
||||
<!-- Icon.PlanDay (stroke-rendered via Path.icon-stroke — sun over horizon) -->
|
||||
<StreamGeometry x:Key="Icon.PlanDay">M3,20 L21,20 M8.4,11 a3.6,3.6 0 1,0 7.2,0 a3.6,3.6 0 1,0 -7.2,0 M12,4.5 L12,3 M6,11 L4.5,11 M18,11 L19.5,11 M7.5,6.5 L6.4,5.4 M16.5,6.5 L17.6,5.4</StreamGeometry>
|
||||
|
||||
<!-- Icon.Refine (stroke-rendered via Path.icon-stroke — list lines + two sparkles) -->
|
||||
<StreamGeometry x:Key="Icon.Refine">M3,6 L13,6 M3,11 L11,11 M3,16 L9,16 M18.5,3 L19.28,5.22 L21.5,6 L19.28,6.78 L18.5,9 L17.72,6.78 L15.5,6 L17.72,5.22 Z M19.5,14.9 L19.85,16.15 L21.1,16.5 L19.85,16.85 L19.5,18.1 L19.15,16.85 L17.9,16.5 L19.15,16.15 Z</StreamGeometry>
|
||||
|
||||
<!-- Icon.X — filled X outline (PathIcon fills, so a stroke-only X renders invisible) -->
|
||||
<StreamGeometry x:Key="Icon.X">M6.4 4.6 L12 10.2 L17.6 4.6 L19.4 6.4 L13.8 12 L19.4 17.6 L17.6 19.4 L12 13.8 L6.4 19.4 L4.6 17.6 L10.2 12 L4.6 6.4 Z</StreamGeometry>
|
||||
|
||||
<!-- Icon.Stop — filled square (stop / interrupt) -->
|
||||
<StreamGeometry x:Key="Icon.Stop">M4 4 H20 V20 H4 Z</StreamGeometry>
|
||||
|
||||
<!-- Icon.Check — filled tick. The old stroke version filled into a solid wedge. -->
|
||||
<StreamGeometry x:Key="Icon.Check">M9.6 18.2 L3.4 12 L5.5 9.9 L9.6 14 L18.5 5.1 L20.6 7.2 Z</StreamGeometry>
|
||||
|
||||
<!-- Icon.ArrowOut — filled arrow for "open external" button -->
|
||||
<StreamGeometry x:Key="Icon.ArrowOut">M13 4 H20 V11 H18 V7.4 L11.4 14 L10 12.6 L16.6 6 H13 Z M4 6 H10 V8 H6 V18 H16 V14 H18 V20 H4 Z</StreamGeometry>
|
||||
|
||||
<!-- Icon.ChevronRight / Icon.ChevronDown — filled expand/collapse chevrons (PathIcon fills) -->
|
||||
<StreamGeometry x:Key="Icon.ChevronRight">M9 5 L16 12 L9 19 L6.8 16.8 L11.6 12 L6.8 7.2 Z</StreamGeometry>
|
||||
<StreamGeometry x:Key="Icon.ChevronDown">M5 9 L12 16 L19 9 L16.8 6.8 L12 11.6 L7.2 6.8 Z</StreamGeometry>
|
||||
|
||||
<!-- Icon.Text — three filled horizontal bars (paragraph / description icon) -->
|
||||
<StreamGeometry x:Key="Icon.Text">M4 6 H20 V8 H4 Z M4 11 H20 V13 H4 Z M4 16 H14 V18 H4 Z</StreamGeometry>
|
||||
|
||||
<!-- Icon.Warning — filled triangle with exclamation (roadblock badge) -->
|
||||
<StreamGeometry x:Key="Icon.Warning">F0 M12 3 L22 20 H2 Z M11 9 H13 V14 H11 Z M11 16 H13 V18 H11 Z</StreamGeometry>
|
||||
|
||||
<!-- Icon.AgentSuggested — filled diamond (agent-suggested child badge) -->
|
||||
<StreamGeometry x:Key="Icon.AgentSuggested">M12 3 L20 12 L12 21 L4 12 Z</StreamGeometry>
|
||||
|
||||
<!-- Icon.Settings (gear) -->
|
||||
<StreamGeometry x:Key="Icon.Settings">M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65a.5.5 0 0 0 .12-.64l-2-3.46a.5.5 0 0 0-.61-.22l-2.49 1a7.03 7.03 0 0 0-1.69-.98l-.38-2.65a.5.5 0 0 0-.5-.42h-4a.5.5 0 0 0-.5.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1a.5.5 0 0 0-.61.22l-2 3.46a.5.5 0 0 0 .12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65a.5.5 0 0 0-.12.64l2 3.46a.5.5 0 0 0 .61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65a.5.5 0 0 0 .5.42h4a.5.5 0 0 0 .5-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1a.5.5 0 0 0 .61-.22l2-3.46a.5.5 0 0 0-.12-.64l-2.11-1.65z</StreamGeometry>
|
||||
|
||||
<!-- Icon.Wrap — a full-width line above a line that wraps back with an arrowhead -->
|
||||
<StreamGeometry x:Key="Icon.Wrap">M3 5 H21 V7 H3 Z M3 11 H17 A4 4 0 0 1 17 19 H12 V21 L7 18 L12 15 V17 H17 A2 2 0 0 0 17 13 H3 Z</StreamGeometry>
|
||||
|
||||
<!-- Icon.Skull — filled silhouette: rounded cranium + eye holes (EvenOdd) + jaw -->
|
||||
<StreamGeometry x:Key="Icon.Skull">F0 M12 2 C7 2 4 5.5 4 10 C4 13.5 6 16 8 17.5 L8 19 C8 20 8.9 21 10 21 L10 18.5 L14 18.5 L14 21 C15.1 21 16 20 16 19 L16 17.5 C18 16 20 13.5 20 10 C20 5.5 17 2 12 2 Z M8.5 8 L8.5 12 L11 12 L11 8 Z M13 8 L13 12 L15.5 12 L15.5 8 Z</StreamGeometry>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- List icons — pickable in list settings → Icon tab. -->
|
||||
<!-- All filled outlines (PathIcon fills; a stroke-only path is -->
|
||||
<!-- invisible). Holes rely on the EvenOdd default; the two that -->
|
||||
<!-- want overlapping subpaths to merge declare F1 explicitly. -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<!-- Icon.Box — carton: solid lid band, open body, seam -->
|
||||
<StreamGeometry x:Key="Icon.Box">F0 M3 5 H21 V20 H3 Z M5 11 H19 V18 H5 Z M11 11 H13 V14 H11 Z</StreamGeometry>
|
||||
|
||||
<!-- Icon.Truck — box body + cab + two wheels (F1: wheels overlap the body) -->
|
||||
<StreamGeometry x:Key="Icon.Truck">F1 M2 6 H13.5 V16 H2 Z M13.5 9.5 H17.4 L21 13.1 V16 H13.5 Z M7 15.5 a2.5 2.5 0 1 0 0 5 a2.5 2.5 0 1 0 0-5 Z M17 15.5 a2.5 2.5 0 1 0 0 5 a2.5 2.5 0 1 0 0-5 Z</StreamGeometry>
|
||||
|
||||
<!-- Icon.MapPin — location pin with punched-out centre -->
|
||||
<StreamGeometry x:Key="Icon.MapPin">F0 M12 2 C8.1 2 5 5.1 5 9 C5 14.2 12 22 12 22 C12 22 19 14.2 19 9 C19 5.1 15.9 2 12 2 Z M12 6.5 a2.5 2.5 0 1 0 0 5 a2.5 2.5 0 1 0 0-5 Z</StreamGeometry>
|
||||
|
||||
<!-- Icon.Chart — three rising bars -->
|
||||
<StreamGeometry x:Key="Icon.Chart">M4 13 H8 V20 H4 Z M10 8 H14 V20 H10 Z M16 4 H20 V20 H16 Z</StreamGeometry>
|
||||
|
||||
<!-- Icon.Server — two rack units with punched-out status LEDs -->
|
||||
<StreamGeometry x:Key="Icon.Server">F0 M3 4 H21 V10 H3 Z M3 14 H21 V20 H3 Z M5.5 6 H7.5 V8 H5.5 Z M5.5 16 H7.5 V18 H5.5 Z</StreamGeometry>
|
||||
|
||||
<!-- Icon.Chip — CPU die with hollow core and eight pins -->
|
||||
<StreamGeometry x:Key="Icon.Chip">F0 M7 7 H17 V17 H7 Z M9.5 9.5 H14.5 V14.5 H9.5 Z M9 3 H10.5 V6.5 H9 Z M13.5 3 H15 V6.5 H13.5 Z M9 17.5 H10.5 V21 H9 Z M13.5 17.5 H15 V21 H13.5 Z M3 9 H6.5 V10.5 H3 Z M3 13.5 H6.5 V15 H3 Z M17.5 9 H21 V10.5 H17.5 Z M17.5 13.5 H21 V15 H17.5 Z</StreamGeometry>
|
||||
|
||||
<!-- Icon.Bolt — lightning bolt -->
|
||||
<StreamGeometry x:Key="Icon.Bolt">M13 2 L4 14 H10 L9 22 L20 9 H13.6 Z</StreamGeometry>
|
||||
|
||||
<!-- Icon.Rocket — hull with porthole hole + exhaust -->
|
||||
<StreamGeometry x:Key="Icon.Rocket">F0 M12 2 C15.5 5 17.5 9.5 17.5 14 V17 L14.5 19 H9.5 L6.5 17 V14 C6.5 9.5 8.5 5 12 2 Z M12 7.5 a2.2 2.2 0 1 0 0 4.4 a2.2 2.2 0 1 0 0-4.4 Z M9.5 19.5 H14.5 L13 22.5 H11 Z</StreamGeometry>
|
||||
|
||||
<!-- Icon.Bulb — bulb over two screw-base bands -->
|
||||
<StreamGeometry x:Key="Icon.Bulb">F0 M12 2 C8.1 2 5 5.1 5 9 C5 11.6 6.4 13.9 8.5 15.1 V17 H15.5 V15.1 C17.6 13.9 19 11.6 19 9 C19 5.1 15.9 2 12 2 Z M8.5 18.2 H15.5 V19.8 H8.5 Z M9.8 20.9 H14.2 V22.3 H9.8 Z</StreamGeometry>
|
||||
|
||||
<!-- Icon.Flask — Erlenmeyer flask, single closed outline -->
|
||||
<StreamGeometry x:Key="Icon.Flask">M9 2 H15 V4 H14 V9.3 L19.4 19.4 A1.5 1.5 0 0 1 18.1 21.6 H5.9 A1.5 1.5 0 0 1 4.6 19.4 L10 9.3 V4 H9 Z</StreamGeometry>
|
||||
|
||||
<!-- Icon.Shield — shield with a punched-out check -->
|
||||
<StreamGeometry x:Key="Icon.Shield">F0 M12 2 L4 5 V11 C4 16 7.4 20.3 12 21.5 C16.6 20.3 20 16 20 11 V5 Z M11 16.2 L7.2 12.4 L8.9 10.7 L11 12.8 L15.4 8.4 L17.1 10.1 Z</StreamGeometry>
|
||||
|
||||
<!-- Icon.Camera — body with viewfinder bump and punched-out lens -->
|
||||
<StreamGeometry x:Key="Icon.Camera">F0 M9 3 H15 L16.5 5.5 H20 A2 2 0 0 1 22 7.5 V19 A2 2 0 0 1 20 21 H4 A2 2 0 0 1 2 19 V7.5 A2 2 0 0 1 4 5.5 H7.5 Z M12 9 a4.5 4.5 0 1 0 0 9 a4.5 4.5 0 1 0 0-9 Z</StreamGeometry>
|
||||
|
||||
<!-- Icon.Image — frame with mountains and sun inside the cut-out -->
|
||||
<StreamGeometry x:Key="Icon.Image">F0 M3 4 H21 V20 H3 Z M5 6 H19 V18 H5 Z M6.5 16.5 L10.5 11 L13.5 14.5 L15.5 12.5 L17.5 16.5 Z M8 8 a1.6 1.6 0 1 0 0 3.2 a1.6 1.6 0 1 0 0-3.2 Z</StreamGeometry>
|
||||
|
||||
<!-- Icon.Barcode — six bars of varying width -->
|
||||
<StreamGeometry x:Key="Icon.Barcode">M3 4 H5 V20 H3 Z M6.5 4 H7.5 V20 H6.5 Z M9 4 H11.5 V20 H9 Z M13 4 H14 V20 H13 Z M15.5 4 H17.5 V20 H15.5 Z M19 4 H21 V20 H19 Z</StreamGeometry>
|
||||
|
||||
<!-- Icon.Mail — envelope: frame cut out, flap drawn back inside it -->
|
||||
<StreamGeometry x:Key="Icon.Mail">F0 M2 5 H22 V19 H2 Z M4 7 H20 V17 H4 Z M4 7 L12 13.5 L20 7 L20 8.7 L12 15.2 L4 8.7 Z</StreamGeometry>
|
||||
|
||||
<!-- Icon.Terminal — console frame with prompt chevron and caret -->
|
||||
<StreamGeometry x:Key="Icon.Terminal">F0 M2 4 H22 V20 H2 Z M4 6 H20 V18 H4 Z M6 8.4 L7.4 7 L11.4 11 L7.4 15 L6 13.6 L8.6 11 Z M12.5 13.4 H17 V15.2 H12.5 Z</StreamGeometry>
|
||||
|
||||
<!-- Icon.Code — angle brackets around a slash -->
|
||||
<StreamGeometry x:Key="Icon.Code">M8.6 5.4 L10 6.8 L5.8 11 L10 15.2 L8.6 16.6 L3 11 Z M15.4 5.4 L21 11 L15.4 16.6 L14 15.2 L18.2 11 L14 6.8 Z M13.6 3.4 L15.4 3.9 L10.4 20.6 L8.6 20.1 Z</StreamGeometry>
|
||||
|
||||
<!-- Icon.Database — cylinder with the top ellipse punched out. Béziers, not arcs: an arc
|
||||
spanning exactly its own diameter degenerates to a straight line here (measured). -->
|
||||
<StreamGeometry x:Key="Icon.Database">F0 M5.3 5.2 C5.3 3.43 8.3 2 12 2 C15.7 2 18.7 3.43 18.7 5.2 V18.8 C18.7 20.57 15.7 22 12 22 C8.3 22 5.3 20.57 5.3 18.8 Z M5.3 5.2 C5.3 3.43 8.3 2 12 2 C15.7 2 18.7 3.43 18.7 5.2 C18.7 6.97 15.7 8.4 12 8.4 C8.3 8.4 5.3 6.97 5.3 5.2 Z</StreamGeometry>
|
||||
|
||||
<!-- Icon.Wand — wand shaft plus three sparkles (kept clear of the shaft) -->
|
||||
<StreamGeometry x:Key="Icon.Wand">M3.3 19.3 L13.3 9.3 L14.7 10.7 L4.7 20.7 Z M18 2.5 L19.1 5.9 L22.5 7 L19.1 8.1 L18 11.5 L16.9 8.1 L13.5 7 L16.9 5.9 Z M6 3 L6.7 4.8 L8.5 5.5 L6.7 6.2 L6 8 L5.3 6.2 L3.5 5.5 L5.3 4.8 Z M19.5 15 L20.2 16.8 L22 17.5 L20.2 18.2 L19.5 20 L18.8 18.2 L17 17.5 L18.8 16.8 Z</StreamGeometry>
|
||||
|
||||
<!-- Icon.Clock — ring with hands; the hands only touch at x=12.8, never overlap (EvenOdd).
|
||||
Béziers, not arcs: see Icon.Database. -->
|
||||
<StreamGeometry x:Key="Icon.Clock">F0 M2 12 C2 6.48 6.48 2 12 2 C17.52 2 22 6.48 22 12 C22 17.52 17.52 22 12 22 C6.48 22 2 17.52 2 12 Z M4 12 C4 7.58 7.58 4 12 4 C16.42 4 20 7.58 20 12 C20 16.42 16.42 20 12 20 C7.58 20 4 16.42 4 12 Z M11.2 6.5 H12.8 V12.8 H11.2 Z M12.8 11.2 H16.5 V12.8 H12.8 Z</StreamGeometry>
|
||||
|
||||
<!-- Badge brushes -->
|
||||
<SolidColorBrush x:Key="DraftBadgeBrush" Color="{StaticResource TextMuteColor}"/>
|
||||
<SolidColorBrush x:Key="PlanningBadgeBrush" Color="{StaticResource PeatColor}"/>
|
||||
@@ -440,27 +270,14 @@
|
||||
<Setter Property="TextElement.Foreground" Value="{StaticResource AccentBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- Line-art icon geometry (PathIcon would fill the lines away). Declared before the
|
||||
icon-painting block below because Avalonia resolves ties by declaration order,
|
||||
not specificity — a later rule always wins. -->
|
||||
<Style Selector="Path.icon-stroke">
|
||||
<Setter Property="Stroke" Value="{StaticResource TextDimBrush}" />
|
||||
<Setter Property="StrokeThickness" Value="1.7" />
|
||||
<Setter Property="StrokeLineCap" Value="Round" />
|
||||
<Setter Property="StrokeJoin" Value="Round" />
|
||||
</Style>
|
||||
|
||||
<!-- ══ ICON PAINTING — the one place icon colour is decided ══════════════════
|
||||
NEVER set Foreground/Stroke on an icon inline. A local value outranks every
|
||||
style, so the button's hover/checked/disabled states can't reach the icon and
|
||||
it stays dead on hover. Pick the colour with a class instead:
|
||||
|
||||
<PathIcon Data="{StaticResource Icon.X}"/> neutral (mute → text)
|
||||
<PathIcon Classes="danger" .../> destructive (blood → bright)
|
||||
<Viewbox><Path Classes="icon-stroke" .../></Viewbox> line-art geometry
|
||||
<PathIcon Data="{Binding Source='mdi:Close', Converter={StaticResource IconKey}}"/> neutral (mute → text)
|
||||
<PathIcon Classes="danger" .../> destructive (blood → bright)
|
||||
|
||||
PathIcon *fills* its geometry — line-art icons must use Path.icon-stroke or
|
||||
they render as blobs/invisible. Both forms take the same classes and states.
|
||||
Selectors are class-only (".icon-btn"), so Button and ToggleButton share them.
|
||||
Order is the whole mechanism (Avalonia has no specificity — the last matching
|
||||
rule wins), so the sequence below is: rest → hover → lit (.on/.active/:checked
|
||||
@@ -470,21 +287,32 @@
|
||||
<Style Selector=".icon-btn PathIcon, .new-list-btn PathIcon">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
|
||||
</Style>
|
||||
<Style Selector=".icon-btn Path.icon-stroke">
|
||||
<Setter Property="Stroke" Value="{StaticResource TextMuteBrush}" />
|
||||
</Style>
|
||||
<!-- an unset star is barely there until you hover the row -->
|
||||
<Style Selector=".icon-btn.star-btn PathIcon">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaintBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- diff file tree: folders stay neutral, file icons take the change-kind colour via Tag (A/D/M/R) -->
|
||||
<Style Selector="PathIcon.tree-icon">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
|
||||
</Style>
|
||||
<Style Selector="PathIcon.tree-icon[Tag=A]">
|
||||
<Setter Property="Foreground" Value="{StaticResource MossBrush}" />
|
||||
</Style>
|
||||
<Style Selector="PathIcon.tree-icon[Tag=D]">
|
||||
<Setter Property="Foreground" Value="{StaticResource BloodBrush}" />
|
||||
</Style>
|
||||
<Style Selector="PathIcon.tree-icon[Tag=M]">
|
||||
<Setter Property="Foreground" Value="{StaticResource PeatBrush}" />
|
||||
</Style>
|
||||
<Style Selector="PathIcon.tree-icon[Tag=R]">
|
||||
<Setter Property="Foreground" Value="{StaticResource SageBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- hover -->
|
||||
<Style Selector=".icon-btn:pointerover PathIcon, .new-list-btn:pointerover PathIcon">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
</Style>
|
||||
<Style Selector=".icon-btn:pointerover Path.icon-stroke">
|
||||
<Setter Property="Stroke" Value="{StaticResource TextBrush}" />
|
||||
</Style>
|
||||
<!-- lit: on/active/checked keep their colour under the pointer -->
|
||||
<Style Selector=".icon-btn.star-btn.on PathIcon">
|
||||
<Setter Property="Foreground" Value="{StaticResource PeatBrush}" />
|
||||
@@ -492,32 +320,20 @@
|
||||
<Style Selector=".icon-btn.active PathIcon, .icon-btn:checked PathIcon">
|
||||
<Setter Property="Foreground" Value="{StaticResource AccentBrush}" />
|
||||
</Style>
|
||||
<Style Selector=".icon-btn.active Path.icon-stroke, .icon-btn:checked Path.icon-stroke">
|
||||
<Setter Property="Stroke" Value="{StaticResource AccentBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- disabled greys out every neutral variant -->
|
||||
<Style Selector=".icon-btn:disabled PathIcon">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextFaintBrush}" />
|
||||
</Style>
|
||||
<Style Selector=".icon-btn:disabled Path.icon-stroke">
|
||||
<Setter Property="Stroke" Value="{StaticResource TextFaintBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- …except danger, which is last on purpose: a destructive action stays red in every state,
|
||||
including disabled. Greying it out makes the button read as broken rather than blocked. -->
|
||||
<Style Selector=".icon-btn PathIcon.danger">
|
||||
<Setter Property="Foreground" Value="{StaticResource BloodBrush}" />
|
||||
</Style>
|
||||
<Style Selector=".icon-btn Path.icon-stroke.danger">
|
||||
<Setter Property="Stroke" Value="{StaticResource BloodBrush}" />
|
||||
</Style>
|
||||
<Style Selector=".icon-btn:pointerover PathIcon.danger">
|
||||
<Setter Property="Foreground" Value="{StaticResource BloodBrightBrush}" />
|
||||
</Style>
|
||||
<Style Selector=".icon-btn:pointerover Path.icon-stroke.danger">
|
||||
<Setter Property="Stroke" Value="{StaticResource BloodBrightBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- Segmented switch: one control, mutually exclusive segments, active one highlighted.
|
||||
Use for picking a mode (not for independent on/off flags — those are icon toggles). -->
|
||||
@@ -854,7 +670,7 @@
|
||||
<!-- LIST NAV ITEM -->
|
||||
<!-- ============================================================ -->
|
||||
<Style Selector="Border.list-item">
|
||||
<Setter Property="Padding" Value="10,7" />
|
||||
<Setter Property="Padding" Value="10,4" />
|
||||
<Setter Property="CornerRadius" Value="8" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
@@ -863,6 +679,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>
|
||||
@@ -1018,7 +838,7 @@
|
||||
<Style Selector="TextBox.add-task-input">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="Padding" Value="0,0,2,0" />
|
||||
<Setter Property="FontSize" Value="{StaticResource FontSizeTaskTitle}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
<Setter Property="CaretBrush" Value="{StaticResource AccentBrush}" />
|
||||
@@ -1268,6 +1088,19 @@
|
||||
<Setter Property="FontSize" Value="{StaticResource FontSizeMono}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDimBrush}" />
|
||||
</Style>
|
||||
<!-- diff file tree names: change-kind colour; must stay after TextBlock.meta -->
|
||||
<Style Selector="TextBlock.tree-name[Tag=A]">
|
||||
<Setter Property="Foreground" Value="{StaticResource MossBrush}" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.tree-name[Tag=D]">
|
||||
<Setter Property="Foreground" Value="{StaticResource BloodBrush}" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.tree-name[Tag=M]">
|
||||
<Setter Property="Foreground" Value="{StaticResource PeatBrush}" />
|
||||
</Style>
|
||||
<Style Selector="TextBlock.tree-name[Tag=R]">
|
||||
<Setter Property="Foreground" Value="{StaticResource SageBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- Default body / list values / descriptions -->
|
||||
<Style Selector="TextBlock.body">
|
||||
|
||||
@@ -104,6 +104,31 @@
|
||||
<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" />
|
||||
|
||||
<!-- Default colour swatches for lists and folders. Hexes are the Moss preset's; AccentPresetService
|
||||
rewrites them in place on a preset switch, so everything bound to a swatch re-tints live. -->
|
||||
<SolidColorBrush x:Key="Swatch1Brush" Color="#7C9166" />
|
||||
<SolidColorBrush x:Key="Swatch2Brush" Color="#D4A574" />
|
||||
<SolidColorBrush x:Key="Swatch3Brush" Color="#C87060" />
|
||||
<SolidColorBrush x:Key="Swatch4Brush" Color="#D9C27A" />
|
||||
<SolidColorBrush x:Key="Swatch5Brush" Color="#6FB3A8" />
|
||||
<SolidColorBrush x:Key="Swatch6Brush" Color="#7FA7C9" />
|
||||
<SolidColorBrush x:Key="Swatch7Brush" Color="#A38BC9" />
|
||||
<SolidColorBrush x:Key="Swatch8Brush" Color="#C77FA0" />
|
||||
<SolidColorBrush x:Key="Swatch9Brush" Color="#8B9D7A" />
|
||||
<SolidColorBrush x:Key="Swatch10Brush" Color="#B58B6A" />
|
||||
<SolidColorBrush x:Key="Swatch11Brush" Color="#5C78B8" />
|
||||
<SolidColorBrush x:Key="Swatch12Brush" Color="#9AA0A6" />
|
||||
|
||||
<!-- 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 +173,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 -->
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
|
||||
namespace ClaudeDo.Ui.Helpers;
|
||||
|
||||
/// <summary>Turns Claude CLI stream-json lines into kind-tagged log entries for the terminal views.</summary>
|
||||
public class StreamLineFormatter
|
||||
{
|
||||
private const int MaxLength = 50_000;
|
||||
private const int MaxArgChars = 120;
|
||||
private const int MaxArgChars = 500;
|
||||
|
||||
public string? FormatLine(string line)
|
||||
/// <summary>Formats one stdout line; null when the event is filtered. Entry text may span several lines.</summary>
|
||||
public IReadOnlyList<(LogKind Kind, string Text)>? FormatLine(string line)
|
||||
{
|
||||
JsonDocument doc;
|
||||
try
|
||||
@@ -17,7 +20,7 @@ public class StreamLineFormatter
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return line;
|
||||
return [(LogKind.Stdout, line)];
|
||||
}
|
||||
|
||||
using (doc)
|
||||
@@ -28,7 +31,7 @@ public class StreamLineFormatter
|
||||
if (!root.TryGetProperty("type", out var typeProp))
|
||||
return null;
|
||||
|
||||
return typeProp.GetString() switch
|
||||
var entries = typeProp.GetString() switch
|
||||
{
|
||||
"system" => FormatSystem(root),
|
||||
"assistant" => FormatAssistant(root),
|
||||
@@ -36,10 +39,11 @@ public class StreamLineFormatter
|
||||
"result" => FormatResult(root),
|
||||
_ => null,
|
||||
};
|
||||
return entries is { Count: > 0 } ? entries : null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? FormatSystem(JsonElement root)
|
||||
private static List<(LogKind, string)>? FormatSystem(JsonElement root)
|
||||
{
|
||||
if (!root.TryGetProperty("subtype", out var subtypeProp))
|
||||
return null;
|
||||
@@ -48,7 +52,7 @@ public class StreamLineFormatter
|
||||
switch (subtype)
|
||||
{
|
||||
case "api_retry":
|
||||
return "[Retrying API call...]\n";
|
||||
return [(LogKind.Sys, "[Retrying API call...]")];
|
||||
|
||||
case "init":
|
||||
{
|
||||
@@ -61,7 +65,7 @@ public class StreamLineFormatter
|
||||
? sessionId[..8]
|
||||
: sessionId ?? "?";
|
||||
var modelPart = string.IsNullOrEmpty(model) ? "" : $" · {model}";
|
||||
return $"[session {shortId}{modelPart}]\n";
|
||||
return [(LogKind.Sys, $"[session {shortId}{modelPart}]")];
|
||||
}
|
||||
|
||||
default:
|
||||
@@ -69,12 +73,12 @@ public class StreamLineFormatter
|
||||
}
|
||||
}
|
||||
|
||||
private static string? FormatAssistant(JsonElement root)
|
||||
private static List<(LogKind, string)>? FormatAssistant(JsonElement root)
|
||||
{
|
||||
if (!TryGetContentArray(root, out var content))
|
||||
return null;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
var entries = new List<(LogKind, string)>();
|
||||
foreach (var block in content.EnumerateArray())
|
||||
{
|
||||
if (block.ValueKind != JsonValueKind.Object) continue;
|
||||
@@ -87,16 +91,12 @@ public class StreamLineFormatter
|
||||
{
|
||||
var text = textProp.GetString();
|
||||
if (!string.IsNullOrEmpty(text))
|
||||
{
|
||||
sb.Append(text);
|
||||
if (!text.EndsWith('\n')) sb.Append('\n');
|
||||
}
|
||||
entries.Add((LogKind.Claude, text));
|
||||
}
|
||||
break;
|
||||
|
||||
case "tool_use":
|
||||
sb.Append(FormatToolUse(block));
|
||||
sb.Append('\n');
|
||||
entries.Add(FormatToolUse(block));
|
||||
break;
|
||||
|
||||
case "thinking":
|
||||
@@ -106,7 +106,7 @@ public class StreamLineFormatter
|
||||
}
|
||||
}
|
||||
|
||||
return sb.Length == 0 ? null : sb.ToString();
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static bool TryGetContentArray(JsonElement root, out JsonElement content)
|
||||
@@ -120,35 +120,30 @@ public class StreamLineFormatter
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string? FormatUser(JsonElement root)
|
||||
private static List<(LogKind, string)>? FormatUser(JsonElement root)
|
||||
{
|
||||
if (!TryGetContentArray(root, out var content))
|
||||
return null;
|
||||
|
||||
var sb = new StringBuilder();
|
||||
var entries = new List<(LogKind, string)>();
|
||||
foreach (var block in content.EnumerateArray())
|
||||
{
|
||||
if (block.ValueKind != JsonValueKind.Object) continue;
|
||||
if (!block.TryGetProperty("type", out var blockTypeProp)) continue;
|
||||
if (blockTypeProp.GetString() != "tool_result") continue;
|
||||
|
||||
var summary = BuildToolResultSummary(root, block);
|
||||
var isError = block.TryGetProperty("is_error", out var errProp)
|
||||
&& errProp.ValueKind == JsonValueKind.True;
|
||||
var summary = BuildToolResultSummary(root, block, isError);
|
||||
if (!string.IsNullOrEmpty(summary))
|
||||
{
|
||||
sb.Append("→ ");
|
||||
sb.Append(summary);
|
||||
sb.Append('\n');
|
||||
}
|
||||
entries.Add((isError ? LogKind.Stderr : LogKind.ToolResult, "→ " + summary));
|
||||
}
|
||||
|
||||
return sb.Length == 0 ? null : sb.ToString();
|
||||
return entries;
|
||||
}
|
||||
|
||||
private static string BuildToolResultSummary(JsonElement root, JsonElement block)
|
||||
private static string BuildToolResultSummary(JsonElement root, JsonElement block, bool isError)
|
||||
{
|
||||
var isError = block.TryGetProperty("is_error", out var errProp)
|
||||
&& errProp.ValueKind == JsonValueKind.True;
|
||||
|
||||
var contentText = ResolveContentText(block);
|
||||
|
||||
if (isError)
|
||||
@@ -215,14 +210,14 @@ public class StreamLineFormatter
|
||||
return "";
|
||||
}
|
||||
|
||||
private static string? FormatResult(JsonElement root)
|
||||
private static List<(LogKind, string)>? FormatResult(JsonElement root)
|
||||
{
|
||||
if (root.TryGetProperty("result", out var resultProp))
|
||||
return $"\n--- Result ---\n{resultProp.GetString()}\n";
|
||||
return [(LogKind.Done, $"--- Result ---\n{resultProp.GetString()}")];
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string FormatToolUse(JsonElement block)
|
||||
private static (LogKind, string) FormatToolUse(JsonElement block)
|
||||
{
|
||||
var name = block.TryGetProperty("name", out var nameProp)
|
||||
? nameProp.GetString() ?? "?"
|
||||
@@ -241,10 +236,9 @@ public class StreamLineFormatter
|
||||
}
|
||||
|
||||
string? arg = hasInput ? BuildToolArg(name, input) : null;
|
||||
var kind = name is "Edit" or "Write" or "NotebookEdit" ? LogKind.Edit : LogKind.Tool;
|
||||
|
||||
return string.IsNullOrEmpty(arg)
|
||||
? $"[{label}]"
|
||||
: $"[{label}] {arg}";
|
||||
return (kind, string.IsNullOrEmpty(arg) ? $"[{label}]" : $"[{label}] {arg}");
|
||||
}
|
||||
|
||||
private static string? BuildToolArg(string toolName, JsonElement input)
|
||||
|
||||
@@ -14,6 +14,8 @@ public static class AccentPresetService
|
||||
SetBrushColor(app, "AccentSoftBrush", preset.Soft);
|
||||
SetBrushColor(app, "AccentGlowBrush", preset.Glow);
|
||||
SetBrushColor(app, "MossBrush", preset.Accent);
|
||||
for (var i = 0; i < preset.Swatches.Length; i++)
|
||||
SetBrushColor(app, $"Swatch{i + 1}Brush", preset.Swatches[i]);
|
||||
}
|
||||
|
||||
private static void SetBrushColor(Application app, string key, string hex)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Media.Imaging;
|
||||
|
||||
namespace ClaudeDo.Ui.Services;
|
||||
|
||||
/// <summary>Reads a bitmap off the clipboard; shared by the terminal view and the description editor.</summary>
|
||||
public static class ClipboardBitmap
|
||||
{
|
||||
/// <summary>The clipboard bitmap (caller disposes), or null if there is none or the read failed.</summary>
|
||||
public static async Task<Bitmap?> TryGetAsync(TopLevel? topLevel)
|
||||
{
|
||||
if (topLevel?.Clipboard is not { } clipboard) return null;
|
||||
try
|
||||
{
|
||||
using var data = await clipboard.TryGetDataAsync();
|
||||
return data is null ? null : await AsyncDataTransferExtensions.TryGetBitmapAsync(data);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
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, NewPngFileName(utcNow));
|
||||
|
||||
/// <summary>A fresh, collision-free <c>paste-….png</c> file name (also used as an attachment name).</summary>
|
||||
public static string NewPngFileName(DateTime utcNow) =>
|
||||
$"paste-{utcNow:yyyyMMdd-HHmmssfff}-{Guid.NewGuid():N}.png";
|
||||
|
||||
/// <summary>Test seam for <see cref="DeleteOlderThan(TimeSpan,DateTime)"/> — takes the target dir
|
||||
/// explicitly instead of the real app data root.</summary>
|
||||
internal static void DeleteOlderThan(string dir, TimeSpan maxAge, DateTime utcNow)
|
||||
{
|
||||
if (!System.IO.Directory.Exists(dir)) return;
|
||||
|
||||
foreach (var file in System.IO.Directory.EnumerateFiles(dir))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (utcNow - File.GetLastWriteTimeUtc(file) > maxAge)
|
||||
File.Delete(file);
|
||||
}
|
||||
catch (IOException) { /* best effort */ }
|
||||
catch (UnauthorizedAccessException) { /* best effort */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,9 @@ public interface IDialogService
|
||||
Task ShowConflictResolverAsync(ConflictResolverViewModel vm);
|
||||
Task ShowLogVisualizerAsync(LogVisualizerViewModel vm);
|
||||
|
||||
/// <summary>Modal yes/no confirmation. Returns true only when confirmed.</summary>
|
||||
Task<bool> ConfirmAsync(string message);
|
||||
/// <summary>Modal yes/no confirmation. Returns true only when confirmed. <paramref name="confirmLabel"/>
|
||||
/// overrides the default localized "Confirm" button text (e.g. a localized "Delete" for destructive actions).</summary>
|
||||
Task<bool> ConfirmAsync(string message, string? confirmLabel = null);
|
||||
|
||||
/// <summary>Modal error notice with a single dismiss button.</summary>
|
||||
Task ShowErrorAsync(string message);
|
||||
|
||||
@@ -30,6 +30,10 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
/// task to carry out nextPhase ("wait" | "merge" | "wait_final" | "merge_final").</summary>
|
||||
event Action<string, IReadOnlyList<string>, string>? HandoffRequestedEvent;
|
||||
|
||||
/// <summary>A Mission Control session called handoff_session: (paneId, briefPath). The UI replaces
|
||||
/// the tile whose IdentityKey is paneId with a fresh session starting from the brief.</summary>
|
||||
event Action<string, string>? SessionHandoffRequestedEvent;
|
||||
|
||||
/// <summary>start_list_handler was called through the external MCP: (listId, taskIds). The UI
|
||||
/// starts a list-handler run for them exactly like the "Let Claude handle it" button.</summary>
|
||||
event Action<string, IReadOnlyList<string>>? ListHandlerStartRequestedEvent;
|
||||
@@ -37,6 +41,9 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
/// (handlerTaskId, message). Mission Control types the message into that task's ConPTY pane so
|
||||
/// the session can hand off cleanly; no pane for the task means nothing happens.</summary>
|
||||
event Action<string, string>? HandlerUsageWarningEvent;
|
||||
/// <summary>A list-handler handoff is deferred because the 5h usage window is exhausted:
|
||||
/// (handlerTaskId, dueAt). Mission Control updates the pane title to show the reset time.</summary>
|
||||
event Action<string, DateTimeOffset>? HandlerHandoffDeferredEvent;
|
||||
|
||||
/// <summary>An agent added a note through the MCP tool — an open notes editor reloads.</summary>
|
||||
event Action? NotesUpdatedEvent;
|
||||
@@ -52,6 +59,11 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
/// total.</summary>
|
||||
event Action<string, string, int, int>? OperationProgressEvent;
|
||||
|
||||
/// <summary>A Worker operation (verify, merge, rebase, worktree creation) began.</summary>
|
||||
event Action<RunningOperationDto>? OperationStartedEvent;
|
||||
/// <summary>The operation with this id finished, failed or threw.</summary>
|
||||
event Action<string>? OperationEndedEvent;
|
||||
|
||||
event Action<string, string>? PlanningMergeStartedEvent;
|
||||
event Action<string, string>? PlanningSubtaskMergedEvent;
|
||||
/// <summary>(planningTaskId, subtaskId, conflictedFiles, externallyDriven). externallyDriven
|
||||
@@ -92,6 +104,10 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
Task<List<string>> GetRepoImportFoldersAsync();
|
||||
/// <summary>Persists the repo-import folder list. Throws on a failed hub call.</summary>
|
||||
Task SetRepoImportFoldersAsync(List<string> folders);
|
||||
/// <summary>The configured SSH workspaces (quick-session targets in Mission Control).</summary>
|
||||
Task<List<SshWorkspace>> GetSshWorkspacesAsync();
|
||||
/// <summary>Replaces the SSH workspace list; the worker rejects invalid entries.</summary>
|
||||
Task SetSshWorkspacesAsync(List<SshWorkspace> workspaces);
|
||||
Task<List<SessionSkillDto>> GetSessionSkillsAsync();
|
||||
Task<List<string>> InstallSessionSkillAsync(string url);
|
||||
Task UpdateSessionSkillAsync(string sourceUrl);
|
||||
@@ -120,7 +136,7 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
Task CancelReviewAsync(string taskId);
|
||||
|
||||
// ── Conflict resolution (worker hub side implemented by Layer C) ──
|
||||
Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch);
|
||||
Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch, bool autostash = false);
|
||||
Task<MergeConflictDocumentsDto> GetMergeConflictDocumentsAsync(string taskId);
|
||||
Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent);
|
||||
Task<MergeResultDto> ContinueConflictMergeAsync(string taskId);
|
||||
@@ -133,8 +149,14 @@ 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 an interactive claude session on an SSH workspace's server, as tile paneId.</summary>
|
||||
Task<LaunchSpec> GetSshLaunchSpecAsync(string workspaceId, 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
|
||||
@@ -179,6 +201,8 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
Task<string?> GetWeekReportAsync(DateOnly start, DateOnly end);
|
||||
Task<string> GenerateWeekReportAsync(DateOnly start, DateOnly end);
|
||||
Task<bool> RunDailyPrepNowAsync();
|
||||
/// <summary>Ticket import, then the daily prep, in one Prime run.</summary>
|
||||
Task<bool> RunTicketImportNowAsync();
|
||||
Task RefineTaskAsync(string taskId);
|
||||
|
||||
event Action<string>? RefineStartedEvent;
|
||||
@@ -195,6 +219,9 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
Task<string> GetLastPrepLogAsync();
|
||||
Task<IReadOnlyList<WorkerLogEntry>> GetRecentLogsAsync();
|
||||
|
||||
/// <summary>Operations running on the Worker right now — the state to start from before applying started/ended events.</summary>
|
||||
Task<IReadOnlyList<RunningOperationDto>> GetActiveOperationsAsync();
|
||||
|
||||
Task<List<PrimeScheduleDto>> GetPrimeSchedulesAsync();
|
||||
Task<PrimeScheduleDto?> UpsertPrimeScheduleAsync(PrimeScheduleDto dto);
|
||||
Task DeletePrimeScheduleAsync(Guid id);
|
||||
@@ -233,10 +260,16 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
Task<TokenTrackerDashboardDto?> OpenTokenTrackerDashboardAsync();
|
||||
|
||||
Task<TicketSettingsDto?> GetTicketSettingsAsync();
|
||||
/// <summary>Sets the fallback list for imported ticket items; null clears it.</summary>
|
||||
Task SetTicketFallbackListAsync(string? listId);
|
||||
Task SetTicketApiBaseUrlAsync(string? baseUrl);
|
||||
Task SetTicketTokenAsync(string token);
|
||||
Task ClearTicketTokenAsync();
|
||||
Task<TicketConnectionDto?> TestTicketConnectionAsync();
|
||||
Task<AzureDevOpsSettingsDto?> GetAzureDevOpsSettingsAsync();
|
||||
Task SetAzureDevOpsTokenAsync(string token);
|
||||
Task ClearAzureDevOpsTokenAsync();
|
||||
Task<AzureDevOpsConnectionDto?> TestAzureDevOpsConnectionAsync();
|
||||
Task<List<TicketProjectDto>> GetTicketProjectsAsync();
|
||||
/// <summary>Imports open tickets assigned to the token owner into the given list. Throws
|
||||
/// HubException (with a user-readable message) on failure so the caller can surface it via
|
||||
|
||||
@@ -14,8 +14,10 @@ namespace ClaudeDo.Ui.Services;
|
||||
public sealed class MergeCoordinator
|
||||
{
|
||||
/// Set once at composition to the shell's resolver entry. Null (headless/tests) ⇒ no-op.
|
||||
public Func<string, string, Task>? Handler { get; set; }
|
||||
public Func<string, string, bool, Task>? Handler { get; set; }
|
||||
|
||||
public Task ResolveConflictAsync(string taskId, string targetBranch) =>
|
||||
Handler?.Invoke(taskId, targetBranch) ?? Task.CompletedTask;
|
||||
/// <summary>Opens the resolver for <paramref name="taskId"/>; <paramref name="autostash"/> is the
|
||||
/// caller's stash consent, repeated because the resolver restarts the merge on its own.</summary>
|
||||
public Task ResolveConflictAsync(string taskId, string targetBranch, bool autostash = false) =>
|
||||
Handler?.Invoke(taskId, targetBranch, autostash) ?? Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -192,12 +192,24 @@ public sealed class PtyTerminalSession : IDisposable
|
||||
return (Task)s_sendToPty.Invoke(view, new object[] { data, CancellationToken.None })!;
|
||||
}
|
||||
|
||||
/// <summary>Kills the child and closes its pseudo console; falls back to a plain kill if the private seam is gone.</summary>
|
||||
public void Kill()
|
||||
{
|
||||
try { _control?.Kill(); }
|
||||
try
|
||||
{
|
||||
// TerminalControl.Kill() leaves the pseudo console open (one orphaned conhost.exe per tile);
|
||||
// only the private CleanupProcess disposes it, and BeginReparent suppressed its detach-time call.
|
||||
if (_control is { } control && s_terminalViewField?.GetValue(control) is TerminalView view && s_cleanupProcess is not null)
|
||||
s_cleanupProcess.Invoke(view, null);
|
||||
else
|
||||
_control?.Kill();
|
||||
}
|
||||
catch (Exception) { /* already exited */ }
|
||||
}
|
||||
|
||||
private static readonly MethodInfo? s_cleanupProcess =
|
||||
typeof(TerminalView).GetMethod("CleanupProcess", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
@@ -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,6 +69,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
public event Action<UsageSnapshotDto>? UsageUpdatedEvent;
|
||||
|
||||
public event Action<string, string, int, int>? OperationProgressEvent;
|
||||
public event Action<RunningOperationDto>? OperationStartedEvent;
|
||||
public event Action<string>? OperationEndedEvent;
|
||||
|
||||
public event Action<string, string>? PlanningMergeStartedEvent;
|
||||
public event Action<string, string>? PlanningSubtaskMergedEvent;
|
||||
@@ -160,6 +164,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
Dispatcher.UIThread.Post(() => HandoffRequestedEvent?.Invoke(taskId, survivingTaskIds, nextPhase));
|
||||
});
|
||||
|
||||
_hub.On<string, string>("SessionHandoffRequested", (paneId, briefPath) =>
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => SessionHandoffRequestedEvent?.Invoke(paneId, briefPath));
|
||||
});
|
||||
|
||||
_hub.On<string, IReadOnlyList<string>>("ListHandlerStartRequested", (listId, taskIds) =>
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => ListHandlerStartRequestedEvent?.Invoke(listId, taskIds));
|
||||
@@ -170,6 +179,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));
|
||||
@@ -191,6 +205,16 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
Dispatcher.UIThread.Post(() => OperationProgressEvent?.Invoke(opKey, phase, current, total));
|
||||
});
|
||||
|
||||
_hub.On<RunningOperationDto>("OperationStarted", op =>
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => OperationStartedEvent?.Invoke(op));
|
||||
});
|
||||
|
||||
_hub.On<string>("OperationEnded", id =>
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => OperationEndedEvent?.Invoke(id));
|
||||
});
|
||||
|
||||
_hub.On<string, string>("PlanningMergeStarted", (planningTaskId, targetBranch) =>
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => PlanningMergeStartedEvent?.Invoke(planningTaskId, targetBranch));
|
||||
@@ -298,6 +322,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
ok = true;
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
Serilog.Log.Warning(ex, "Hub call {method_name} failed", method);
|
||||
return default;
|
||||
}
|
||||
catch { return default; }
|
||||
finally { OperationTiming.Shared.Record("hub", method, sw.Elapsed, ok); }
|
||||
}
|
||||
@@ -312,6 +341,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
await invoke();
|
||||
ok = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Warning(ex, "Hub call {method_name} failed", method);
|
||||
throw;
|
||||
}
|
||||
finally { OperationTiming.Shared.Record("hub", method, sw.Elapsed, ok); }
|
||||
}
|
||||
|
||||
@@ -326,6 +360,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); }
|
||||
}
|
||||
|
||||
@@ -360,8 +399,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
"MergeTask", taskId, targetBranch, removeWorktree, commitMessage, autostash));
|
||||
}
|
||||
|
||||
public Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch)
|
||||
=> InvokeTimedAsync<MergeResultDto>("StartConflictMerge", () => _hub.InvokeAsync<MergeResultDto>("StartConflictMerge", taskId, targetBranch));
|
||||
public Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch, bool autostash = false)
|
||||
=> InvokeTimedAsync<MergeResultDto>("StartConflictMerge", () => _hub.InvokeAsync<MergeResultDto>("StartConflictMerge", taskId, targetBranch, autostash));
|
||||
|
||||
public Task<MergeConflictDocumentsDto> GetMergeConflictDocumentsAsync(string taskId)
|
||||
=> InvokeTimedAsync<MergeConflictDocumentsDto>("GetMergeConflictDocuments", () => _hub.InvokeAsync<MergeConflictDocumentsDto>("GetMergeConflictDocuments", taskId));
|
||||
@@ -477,6 +516,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
public Task<bool> RunDailyPrepNowAsync()
|
||||
=> InvokeTimedAsync<bool>("RunDailyPrepNow", () => _hub.InvokeAsync<bool>("RunDailyPrepNow"));
|
||||
|
||||
public Task<bool> RunTicketImportNowAsync()
|
||||
=> InvokeTimedAsync<bool>("RunTicketImportNow", () => _hub.InvokeAsync<bool>("RunTicketImportNow"));
|
||||
|
||||
public Task RefineTaskAsync(string taskId) => InvokeTimedAsync("RefineTask", () => _hub.InvokeAsync("RefineTask", taskId));
|
||||
|
||||
public Task ClearMyDayAsync()
|
||||
@@ -500,6 +542,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
public async Task<string> GetLastPrepLogAsync()
|
||||
=> await TryInvokeAsync<string>("GetLastPrepLog") ?? string.Empty;
|
||||
|
||||
public async Task<IReadOnlyList<RunningOperationDto>> GetActiveOperationsAsync()
|
||||
=> await TryInvokeAsync<List<RunningOperationDto>>("GetActiveOperations") ?? new List<RunningOperationDto>();
|
||||
|
||||
public async Task<IReadOnlyList<WorkerLogEntry>> GetRecentLogsAsync()
|
||||
=> await TryInvokeAsync<List<WorkerLogEntry>>("GetRecentLogs") ?? new List<WorkerLogEntry>();
|
||||
|
||||
@@ -527,6 +572,12 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
public Task SetRepoImportFoldersAsync(List<string> folders)
|
||||
=> InvokeTimedAsync("SetRepoImportFolders", () => _hub.InvokeAsync("SetRepoImportFolders", folders));
|
||||
|
||||
public async Task<List<SshWorkspace>> GetSshWorkspacesAsync()
|
||||
=> await InvokeTimedAsync<List<SshWorkspace>>("GetSshWorkspaces", () => _hub.InvokeAsync<List<SshWorkspace>>("GetSshWorkspaces"));
|
||||
|
||||
public Task SetSshWorkspacesAsync(List<SshWorkspace> workspaces)
|
||||
=> InvokeTimedAsync("SetSshWorkspaces", () => _hub.InvokeAsync("SetSshWorkspaces", workspaces));
|
||||
|
||||
public async Task<List<SessionSkillDto>> GetSessionSkillsAsync()
|
||||
=> await TryInvokeAsync<List<SessionSkillDto>>("GetSessionSkills") ?? [];
|
||||
|
||||
@@ -620,8 +671,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> GetSshLaunchSpecAsync(string workspaceId, string paneId, CancellationToken ct = default)
|
||||
=> await InvokeTimedAsync<LaunchSpec>("GetSshLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetSshLaunchSpec", workspaceId, paneId, ct));
|
||||
|
||||
public async Task<LaunchSpec> GetSessionHandoffLaunchSpecAsync(string paneId, string cwd, string briefPath, string? taskId, CancellationToken ct = default)
|
||||
=> await InvokeTimedAsync<LaunchSpec>("GetSessionHandoffLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetSessionHandoffLaunchSpec", paneId, cwd, briefPath, taskId, ct));
|
||||
|
||||
public async Task<LaunchSpec> GetResumeSessionLaunchSpecAsync(string sessionId, string paneId, CancellationToken ct = default)
|
||||
=> await InvokeTimedAsync<LaunchSpec>("GetResumeSessionLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetResumeSessionLaunchSpec", sessionId, paneId, ct));
|
||||
|
||||
public async Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, string? model = null, CancellationToken ct = default)
|
||||
=> await InvokeTimedAsync<LaunchSpec>("GetMergeHelperLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetMergeHelperLaunchSpec", taskIds, listId, handlerTaskId, model, ct));
|
||||
@@ -725,6 +785,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
public Task<TicketSettingsDto?> GetTicketSettingsAsync()
|
||||
=> TryInvokeAsync<TicketSettingsDto>("GetTicketSettings");
|
||||
|
||||
public Task SetTicketFallbackListAsync(string? listId)
|
||||
=> InvokeTimedAsync("SetTicketFallbackList", () => _hub.InvokeAsync("SetTicketFallbackList", listId));
|
||||
|
||||
public Task SetTicketApiBaseUrlAsync(string? baseUrl)
|
||||
=> InvokeTimedAsync("SetTicketApiBaseUrl", () => _hub.InvokeAsync("SetTicketApiBaseUrl", baseUrl));
|
||||
|
||||
@@ -737,6 +800,18 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
public Task<TicketConnectionDto?> TestTicketConnectionAsync()
|
||||
=> TryInvokeAsync<TicketConnectionDto>("TestTicketConnection");
|
||||
|
||||
public Task<AzureDevOpsSettingsDto?> GetAzureDevOpsSettingsAsync()
|
||||
=> TryInvokeAsync<AzureDevOpsSettingsDto>("GetAzureDevOpsSettings");
|
||||
|
||||
public Task SetAzureDevOpsTokenAsync(string token)
|
||||
=> InvokeTimedAsync("SetAzureDevOpsToken", () => _hub.InvokeAsync("SetAzureDevOpsToken", token));
|
||||
|
||||
public Task ClearAzureDevOpsTokenAsync()
|
||||
=> InvokeTimedAsync("ClearAzureDevOpsToken", () => _hub.InvokeAsync("ClearAzureDevOpsToken"));
|
||||
|
||||
public Task<AzureDevOpsConnectionDto?> TestAzureDevOpsConnectionAsync()
|
||||
=> TryInvokeAsync<AzureDevOpsConnectionDto>("TestAzureDevOpsConnection");
|
||||
|
||||
public async Task<List<TicketProjectDto>> GetTicketProjectsAsync()
|
||||
=> await TryInvokeAsync<List<TicketProjectDto>>("GetTicketProjects") ?? [];
|
||||
|
||||
|
||||
@@ -208,13 +208,13 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
|
||||
catch { }
|
||||
}
|
||||
|
||||
// verifyCommand, ticketProjectId and powerUseAllowed are List-only fields owned by
|
||||
// verifyCommand, ticketProjectId, powerUseAllowed and baseBranch are List-only fields owned by
|
||||
// ListSettingsModalViewModel (not this editor, which is also reused for Task scope); the caller
|
||||
// passes them through so the single UpdateListConfig call carries the full desired row instead
|
||||
// of clobbering it. powerUseAllowed stays null-by-default for the same reason ticketProjectId
|
||||
// does: a caller that doesn't own it must not revoke the list's opt-in by omission.
|
||||
public async System.Threading.Tasks.Task SaveAsync(
|
||||
string? verifyCommand = null, int? ticketProjectId = null, bool? powerUseAllowed = null)
|
||||
string? verifyCommand = null, int? ticketProjectId = null, bool? powerUseAllowed = null, string? baseBranch = null)
|
||||
{
|
||||
if (TargetId is null) return;
|
||||
var model = string.IsNullOrWhiteSpace(Model) ? null : Model;
|
||||
@@ -227,7 +227,7 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
|
||||
if (_scope == AgentConfigScope.Task)
|
||||
await _worker.UpdateTaskAgentSettingsAsync(new UpdateTaskAgentSettingsDto(TargetId, model, sp, ap, turns, skills, permission));
|
||||
else
|
||||
await _worker.UpdateListConfigAsync(new UpdateListConfigDto(TargetId, model, sp, ap, turns, skills, verifyCommand, permission, ticketProjectId, powerUseAllowed));
|
||||
await _worker.UpdateListConfigAsync(new UpdateListConfigDto(TargetId, model, sp, ap, turns, skills, verifyCommand, permission, ticketProjectId, powerUseAllowed, baseBranch));
|
||||
}
|
||||
|
||||
private List<string>? SelectedSessionSkillNames()
|
||||
|
||||
@@ -122,6 +122,9 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
|
||||
public string TaskId => _taskId;
|
||||
public Action? CloseRequested { get; set; }
|
||||
|
||||
/// <summary>True once the merge was continued or aborted; a window closed before that must abort, or the repo stays mid-merge with an autostash pending.</summary>
|
||||
public bool IsSettled { get; private set; }
|
||||
|
||||
/// <summary>Raised when the current conflict changes so the view can reload its editors.</summary>
|
||||
public event Action? CurrentChanged;
|
||||
|
||||
@@ -133,14 +136,15 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
|
||||
}
|
||||
|
||||
/// <summary>Starts the conflict merge and loads the conflicted files as line-level segments.
|
||||
/// Returns true when there is something to resolve (caller should show the dialog).</summary>
|
||||
public async Task<bool> OpenAsync(string targetBranch)
|
||||
/// Returns true when there is something to resolve (caller should show the dialog).
|
||||
/// <paramref name="autostash"/> carries the caller's stash consent: a dirty target is otherwise refused.</summary>
|
||||
public async Task<bool> OpenAsync(string targetBranch, bool autostash = false)
|
||||
{
|
||||
IsBusy = true;
|
||||
Error = null;
|
||||
try
|
||||
{
|
||||
var start = await _worker.StartConflictMergeAsync(_taskId, targetBranch);
|
||||
var start = await _worker.StartConflictMergeAsync(_taskId, targetBranch, autostash);
|
||||
if (!string.Equals(start.Status, "conflict", StringComparison.Ordinal))
|
||||
{
|
||||
// Any non-conflict outcome here means nothing was left mid-merge for this dialog
|
||||
@@ -276,13 +280,17 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
|
||||
// Hand back to the orchestrator: it commits this subtask and drains the rest.
|
||||
// A later subtask conflict re-opens this editor via the PlanningMergeConflict broadcast.
|
||||
await _worker.ContinuePlanningMergeAsync(_planningParentId);
|
||||
IsSettled = true;
|
||||
CloseRequested?.Invoke();
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await _worker.ContinueConflictMergeAsync(_taskId);
|
||||
if (string.Equals(result.Status, "merged", StringComparison.Ordinal))
|
||||
{
|
||||
IsSettled = true;
|
||||
CloseRequested?.Invoke();
|
||||
}
|
||||
else
|
||||
Error = result.ErrorMessage ?? "Conflicts not fully resolved — review and retry.";
|
||||
}
|
||||
@@ -296,6 +304,7 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
|
||||
[RelayCommand]
|
||||
private async Task AbortAsync()
|
||||
{
|
||||
IsSettled = true;
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
|
||||
@@ -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]
|
||||
@@ -583,6 +581,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
Bind(null, "prep");
|
||||
IsPrepMode = true;
|
||||
_ = Prep.LoadLastPrepLogIfEmptyAsync();
|
||||
_ = Prep.RefreshTicketsConfiguredAsync();
|
||||
}
|
||||
|
||||
/// <param name="source">What triggered the selection (<see cref="TasksIslandViewModel.SelectionSource"/>);
|
||||
@@ -668,7 +667,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
WorktreeBaseCommit = entity.Worktree?.BaseCommit ?? entity.HandlerBaseCommit;
|
||||
WorktreeHeadCommit = entity.Worktree?.HeadCommit ?? entity.HandlerHeadCommit;
|
||||
WorktreeStateLabel = entity.Worktree?.State.ToString();
|
||||
BranchLine = entity.Worktree is { } w ? $"{w.BranchName} ← main" : null;
|
||||
BranchLine = entity.Worktree?.BranchName;
|
||||
var (add, del) = ParseDiffStat(entity.Worktree?.DiffStat);
|
||||
DiffAdditions = add;
|
||||
DiffDeletions = del;
|
||||
@@ -677,6 +676,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
Merge.SyncTaskContext(row.Id, row.Title, row.IsPlanningParent);
|
||||
Merge.SyncWorktree(WorktreePath, WorktreeBaseCommit, WorktreeHeadCommit,
|
||||
WorktreeStateLabel, _listWorkingDir);
|
||||
Merge.PullRequestUrl = entity.Worktree?.PullRequestUrl;
|
||||
|
||||
await AgentSettings.LoadForTaskAsync(entity, ct);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
@@ -728,6 +728,8 @@ 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;
|
||||
Merge.IsCompanyRepo = targets.IsCompanyRepo;
|
||||
}
|
||||
}
|
||||
await Merge.RefreshMergePreviewAsync();
|
||||
@@ -778,6 +780,8 @@ 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;
|
||||
Merge.IsCompanyRepo = targets.IsCompanyRepo;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -824,6 +828,8 @@ 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;
|
||||
Merge.IsCompanyRepo = targets.IsCompanyRepo;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -928,7 +934,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
WorktreeBaseCommit = entity.Worktree?.BaseCommit ?? entity.HandlerBaseCommit;
|
||||
WorktreeHeadCommit = entity.Worktree?.HeadCommit ?? entity.HandlerHeadCommit;
|
||||
WorktreeStateLabel = entity.Worktree?.State.ToString();
|
||||
BranchLine = entity.Worktree is { } w ? $"{w.BranchName} ← main" : null;
|
||||
BranchLine = entity.Worktree?.BranchName;
|
||||
if (Task is { } row && entity.Worktree?.DiffStat is { } stat)
|
||||
row.DiffStat = stat;
|
||||
var (add, del) = ParseDiffStat(entity.Worktree?.DiffStat);
|
||||
@@ -937,6 +943,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
|
||||
Merge.SyncWorktree(WorktreePath, WorktreeBaseCommit, WorktreeHeadCommit,
|
||||
WorktreeStateLabel, _listWorkingDir);
|
||||
Merge.PullRequestUrl = entity.Worktree?.PullRequestUrl;
|
||||
}
|
||||
catch { /* best-effort refresh */ }
|
||||
}
|
||||
@@ -1042,9 +1049,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 +1067,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 +1223,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 branchName = BranchLine ?? "the task branch";
|
||||
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;
|
||||
|
||||
@@ -1259,7 +1266,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
if (Task is null || !_worker.IsConnected) return;
|
||||
var taskId = Task.Id;
|
||||
using var op = ApproveOp.Begin(Loc.T("ops.merge.merging"));
|
||||
// Company repo: approve = verify + push + draft PR; nothing merges locally.
|
||||
var company = Merge.IsCompanyRepo;
|
||||
using var op = ApproveOp.Begin(Loc.T(company ? "ops.merge.publishing" : "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 OnOperationProgress(string opKey, string phase, int current, int total)
|
||||
@@ -1274,36 +1283,47 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
try
|
||||
{
|
||||
var hasChildren = Subtasks.Count > 0 || ChildOutcomes.Count > 0;
|
||||
var result = await _worker.ApproveReviewAsync(taskId, Merge.SelectedMergeTarget ?? "");
|
||||
var result = await _worker.ApproveReviewAsync(taskId, company ? "" : Merge.SelectedMergeTarget ?? "");
|
||||
|
||||
if (company)
|
||||
{
|
||||
if (result?.PullRequestUrl is { } url) Merge.PullRequestUrl = url;
|
||||
else if (result?.Status == "verify_failed") ErrorReported?.Invoke(result.ErrorMessage ?? Loc.T("vm.detailsIsland.verifyFailed"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Nothing landed -- the target working dir has uncommitted changes (often another
|
||||
// session's WIP in the shared main checkout). Offer the one-shot stash retry; git
|
||||
// restores the changes itself once the merge is through.
|
||||
if (result?.Status == "dirty_target" && ConfirmAsync != null
|
||||
&& await ConfirmAsync(Loc.T("vm.detailsIsland.stashAndMergeConfirm")))
|
||||
var stashed = false;
|
||||
if (result?.Status == "dirty_target" && Dialogs != null
|
||||
&& await Dialogs.ConfirmAsync(Loc.T("vm.detailsIsland.stashAndMergeConfirm")))
|
||||
{
|
||||
stashed = true;
|
||||
ApproveOp.Report(Loc.T("ops.merge.merging"));
|
||||
result = await _worker.ApproveReviewAsync(
|
||||
taskId, Merge.SelectedMergeTarget ?? "", autostash: true);
|
||||
}
|
||||
|
||||
if (result?.Status == "dirty_target" && ShowErrorAsync != null)
|
||||
await ShowErrorAsync(result.ErrorMessage ?? Loc.T("vm.detailsIsland.dirtyTarget"));
|
||||
if (result?.Status == "dirty_target" && Dialogs != null)
|
||||
await Dialogs.ShowErrorAsync(result.ErrorMessage ?? Loc.T("vm.detailsIsland.dirtyTarget"));
|
||||
else if (!hasChildren && result?.Status == "conflict")
|
||||
await _merge.ResolveConflictAsync(taskId, Merge.SelectedMergeTarget ?? "");
|
||||
await _merge.ResolveConflictAsync(taskId, Merge.SelectedMergeTarget ?? "", stashed);
|
||||
// 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);
|
||||
// Company-repo failures (no PAT, push rejected, "gepusht, PR fehlgeschlagen") go to the footer strip.
|
||||
if (company) ErrorReported?.Invoke(ex.Message);
|
||||
else if (Dialogs != null)
|
||||
await Dialogs.ShowErrorAsync(ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -1348,7 +1368,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
try { await _worker.SubmitTaskForReviewAsync(Task.Id); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (ShowErrorAsync != null) await ShowErrorAsync(ex.Message);
|
||||
if (Dialogs != null) await Dialogs.ShowErrorAsync(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1379,9 +1399,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;
|
||||
var branchName = $"claudedo/{Task.Id.Replace("-", "")}";
|
||||
var ok = await ConfirmAsync(
|
||||
if (Task is null || !_worker.IsConnected || Dialogs is null) return;
|
||||
var branchName = BranchLine ?? "the task branch";
|
||||
var ok = await Dialogs.ConfirmAsync(
|
||||
$"Reset working tree?\nThis discards branch {branchName} (and all changes) and returns the task to Idle.");
|
||||
if (!ok) return;
|
||||
try { await _worker.ResetTaskAsync(Task.Id); }
|
||||
|
||||
@@ -6,16 +6,28 @@ namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||
/// <summary>
|
||||
/// A sidebar folder row. Not a <see cref="ListNavItemViewModel"/> and not a
|
||||
/// <see cref="ListKind"/> — a folder holds no tasks and is never selectable, so tapping it
|
||||
/// toggles <see cref="IsCollapsed"/> instead of changing the selection.
|
||||
/// toggles <see cref="IsCollapsed"/> instead of changing the selection. Folders nest arbitrarily
|
||||
/// deep via <see cref="ParentFolderId"/>; <see cref="Children"/> mixes lists and subfolders, the
|
||||
/// same way <see cref="ListsIslandViewModel.TopLevel"/> mixes folders and folder-less lists.
|
||||
/// </summary>
|
||||
public sealed partial class ListFolderNavItemViewModel : ViewModelBase
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public string? ParentFolderId { get; set; }
|
||||
[ObservableProperty] private string _name = "";
|
||||
[ObservableProperty] private string _iconKey = "Folder";
|
||||
[ObservableProperty] private bool _isCollapsed;
|
||||
// Sum of the child lists' counts — a collapsed folder still shows what's inside it.
|
||||
[ObservableProperty] private int _count;
|
||||
/// <summary>Hex colour for name + guide line; null = theme default.</summary>
|
||||
[ObservableProperty] private string? _color;
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ShowCount))]
|
||||
private bool _isCollapsed;
|
||||
// Sum of the descendant lists' counts, recursive — a collapsed folder still shows what's inside it.
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ShowCount))]
|
||||
private int _count;
|
||||
|
||||
/// <summary>Badge only while collapsed — expanded, the children show their own counts.</summary>
|
||||
public bool ShowCount => IsCollapsed && Count > 0;
|
||||
// Set while a dragged list row hovers over the middle of this folder header (move-into-folder).
|
||||
[ObservableProperty] private bool _isDropTarget;
|
||||
// Insert-position hints for a drop next to the folder instead of into it. Drawn around the
|
||||
@@ -23,5 +35,5 @@ public sealed partial class ListFolderNavItemViewModel : ViewModelBase
|
||||
[ObservableProperty] private bool _dropHintAbove;
|
||||
[ObservableProperty] private bool _dropHintBelow;
|
||||
|
||||
public ObservableCollection<ListNavItemViewModel> Lists { get; } = new();
|
||||
public ObservableCollection<object> Children { get; } = new();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Ui.ViewModels.MissionControl;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||
@@ -8,7 +9,12 @@ public sealed partial class ListNavItemViewModel : ViewModelBase
|
||||
public required string Id { get; init; }
|
||||
public required ListKind Kind { get; init; }
|
||||
[ObservableProperty] private string _name = "";
|
||||
[ObservableProperty] private int _count;
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(HasCount))]
|
||||
private int _count;
|
||||
|
||||
/// <summary>Hides a "0" badge — an empty list reads as noise next to the real counts.</summary>
|
||||
public bool HasCount => Count > 0;
|
||||
[ObservableProperty] private bool _isActive;
|
||||
[ObservableProperty] private string? _workingDir;
|
||||
[ObservableProperty] private string _defaultCommitType = CommitTypeRegistry.DefaultType;
|
||||
@@ -25,7 +31,23 @@ 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>Picked colour (swatch key or "#RRGGBB"); null = the hash-derived identity colour.</summary>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(DotColor))]
|
||||
private string? _color;
|
||||
|
||||
/// <summary>Identity colour of the dot, stable per list id across restarts.</summary>
|
||||
public string DotColorKey => $"Identity{ConPtyPaneViewModel.StableIndex(Id, 6) + 1}";
|
||||
|
||||
/// <summary>What the dot renders: the picked colour, else <see cref="DotColorKey"/>.</summary>
|
||||
public string DotColor => string.IsNullOrEmpty(Color) ? DotColorKey : Color;
|
||||
// The sidebar folder this row sits in (ListEntity.FolderId); null = top level.
|
||||
[ObservableProperty] private string? _folderId;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,9 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
/// <summary>Icon key a folder falls back to when it has none of its own.</summary>
|
||||
public const string ListFolderIcon = "Folder";
|
||||
|
||||
/// <summary>Icon key a list falls back to — deliberately not the folder icon, so the two stay distinguishable.</summary>
|
||||
public const string ListIcon = ListSettingsModalViewModel.DefaultIcon;
|
||||
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly IServiceProvider? _services;
|
||||
private readonly IWorkerClient? _worker;
|
||||
@@ -63,7 +66,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
var rawId = row.Id.StartsWith("user:", StringComparison.Ordinal) ? row.Id["user:".Length..] : row.Id;
|
||||
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
|
||||
vm.ErrorReported += msg => ErrorReported?.Invoke(msg);
|
||||
vm.BeginLoad(rawId, row.Name, row.WorkingDir, row.DefaultCommitType, row.IsManual, row.FindingsTracked, row.IconKey);
|
||||
vm.BeginLoad(rawId, row.Name, row.WorkingDir, row.DefaultCommitType, row.IsManual, row.FindingsTracked, row.IconKey, color: row.Color);
|
||||
await Dialogs.ShowListSettingsAsync(vm);
|
||||
if (vm.Deleted) await LoadAsync();
|
||||
else await RefreshRowAsync(row.Id);
|
||||
@@ -215,10 +218,20 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
/// This is what the view binds to; <see cref="UserLists"/> stays the flat superset.</summary>
|
||||
public ObservableCollection<object> TopLevel { get; } = new();
|
||||
|
||||
/// <summary>Sidebar folders in order — a projection of <see cref="TopLevel"/>, not a second
|
||||
/// collection to keep in sync.</summary>
|
||||
public IReadOnlyList<ListFolderNavItemViewModel> Folders =>
|
||||
TopLevel.OfType<ListFolderNavItemViewModel>().ToList();
|
||||
/// <summary>Every sidebar folder, at any depth — a recursive projection of
|
||||
/// <see cref="TopLevel"/>, not a second collection to keep in sync.</summary>
|
||||
public IReadOnlyList<ListFolderNavItemViewModel> Folders => AllFolders(TopLevel).ToList();
|
||||
|
||||
private static IEnumerable<ListFolderNavItemViewModel> AllFolders(IEnumerable<object> items)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (item is not ListFolderNavItemViewModel folder) continue;
|
||||
yield return folder;
|
||||
foreach (var descendant in AllFolders(folder.Children))
|
||||
yield return descendant;
|
||||
}
|
||||
}
|
||||
/// <summary>The user lists that sit outside any folder — likewise a projection.</summary>
|
||||
public IReadOnlyList<ListNavItemViewModel> RootLists =>
|
||||
TopLevel.OfType<ListNavItemViewModel>().ToList();
|
||||
@@ -302,13 +315,13 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
|
||||
var smart = new[]
|
||||
{
|
||||
new ListNavItemViewModel { Id = "smart:my-day", Name = Loc.T("vm.lists.smartMyDay"), Kind = ListKind.Smart, IconKey = "Sun" },
|
||||
new ListNavItemViewModel { Id = NotesListId, Name = Loc.T("vm.lists.smartNotes"), Kind = ListKind.Smart, IconKey = "Text" },
|
||||
new ListNavItemViewModel { Id = "smart:important", Name = Loc.T("vm.lists.smartImportant"), Kind = ListKind.Smart, IconKey = "Star" },
|
||||
new ListNavItemViewModel { Id = "smart:planned", Name = Loc.T("vm.lists.smartPlanned"), Kind = ListKind.Smart, IconKey = "Calendar" },
|
||||
new ListNavItemViewModel { Id = "virtual:queued", Name = Loc.T("vm.lists.virtualQueue"), Kind = ListKind.Virtual, IconKey = "Inbox" },
|
||||
new ListNavItemViewModel { Id = "virtual:running", Name = Loc.T("vm.lists.virtualRunning"), Kind = ListKind.Virtual, IconKey = "Activity" },
|
||||
new ListNavItemViewModel { Id = "virtual:review", Name = Loc.T("vm.lists.virtualReview"), Kind = ListKind.Virtual, IconKey = "Eye" },
|
||||
new ListNavItemViewModel { Id = "smart:my-day", Name = Loc.T("vm.lists.smartMyDay"), Kind = ListKind.Smart, IconKey = "mdi:WeatherSunny" },
|
||||
new ListNavItemViewModel { Id = NotesListId, Name = Loc.T("vm.lists.smartNotes"), Kind = ListKind.Smart, IconKey = "mdi:NoteTextOutline" },
|
||||
new ListNavItemViewModel { Id = "smart:important", Name = Loc.T("vm.lists.smartImportant"), Kind = ListKind.Smart, IconKey = "mdi:StarOutline" },
|
||||
new ListNavItemViewModel { Id = "smart:planned", Name = Loc.T("vm.lists.smartPlanned"), Kind = ListKind.Smart, IconKey = "mdi:CalendarOutline" },
|
||||
new ListNavItemViewModel { Id = "virtual:queued", Name = Loc.T("vm.lists.virtualQueue"), Kind = ListKind.Virtual, IconKey = "mdi:InboxOutline" },
|
||||
new ListNavItemViewModel { Id = "virtual:running", Name = Loc.T("vm.lists.virtualRunning"), Kind = ListKind.Virtual, IconKey = "mdi:Pulse" },
|
||||
new ListNavItemViewModel { Id = "virtual:review", Name = Loc.T("vm.lists.virtualReview"), Kind = ListKind.Virtual, IconKey = "mdi:EyeOutline" },
|
||||
};
|
||||
foreach (var s in smart) { Items.Add(s); SmartLists.Add(s); }
|
||||
|
||||
@@ -320,10 +333,18 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
var lists = new ListRepository(ctx);
|
||||
|
||||
// Folders and top-level lists share one sort_order space, so both go into a single
|
||||
// ordered bucket and get merged by it — that's what interleaves them.
|
||||
var topLevel = new List<(int Sort, object Item)>();
|
||||
// Every container (the top level, and each folder's children) gets its own ordered
|
||||
// bucket keyed by sort_order, since folders and lists at any depth share one sort_order
|
||||
// space. Buckets are keyed by parent folder id; null (via topLevelBucket) is the top level.
|
||||
var topLevelBucket = new List<(int Sort, object Item)>();
|
||||
var childBuckets = new Dictionary<string, List<(int Sort, object Item)>>();
|
||||
var foldersById = new Dictionary<string, ListFolderNavItemViewModel>();
|
||||
|
||||
List<(int Sort, object Item)> BucketFor(string? parentFolderId) =>
|
||||
parentFolderId is { } pid && foldersById.ContainsKey(pid)
|
||||
? childBuckets.TryGetValue(pid, out var b) ? b : childBuckets[pid] = new()
|
||||
: topLevelBucket;
|
||||
|
||||
foreach (var f in await lists.GetFoldersAsync(ct))
|
||||
{
|
||||
var folder = new ListFolderNavItemViewModel
|
||||
@@ -331,10 +352,14 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
Id = f.Id,
|
||||
Name = f.Name,
|
||||
IconKey = f.Icon ?? ListFolderIcon,
|
||||
Color = f.Color,
|
||||
IsCollapsed = f.IsCollapsed,
|
||||
ParentFolderId = f.ParentFolderId,
|
||||
};
|
||||
foldersById[f.Id] = folder;
|
||||
topLevel.Add((f.SortOrder, folder));
|
||||
// Parent always precedes its children in sort_order (PersistOrderAsync walks
|
||||
// depth-first), so the parent bucket already exists by the time a child is seen.
|
||||
BucketFor(f.ParentFolderId).Add((f.SortOrder, folder));
|
||||
}
|
||||
|
||||
var seedNames = new HashSet<string>(new[] { "My Day", "Important", "Planned" });
|
||||
@@ -347,7 +372,8 @@ 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,
|
||||
Color = l.Color,
|
||||
WorkingDir = l.WorkingDir,
|
||||
DefaultCommitType = l.DefaultCommitType,
|
||||
IsManual = l.IsManual,
|
||||
@@ -357,16 +383,21 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
};
|
||||
Items.Add(item);
|
||||
UserLists.Add(item);
|
||||
if (item.FolderId is { } fid && foldersById.TryGetValue(fid, out var owner))
|
||||
owner.Lists.Add(item);
|
||||
else
|
||||
topLevel.Add((l.SortOrder, item));
|
||||
BucketFor(item.FolderId).Add((l.SortOrder, item));
|
||||
}
|
||||
|
||||
// OrderBy is stable, so legacy rows (folders and lists each numbered from 0
|
||||
// independently) keep the old look — folders first — until the first drag.
|
||||
foreach (var (_, item) in topLevel.OrderBy(t => t.Sort))
|
||||
TopLevel.Add(item);
|
||||
void Materialize(List<(int Sort, object Item)> bucket, System.Collections.IList target)
|
||||
{
|
||||
foreach (var (_, item) in bucket.OrderBy(t => t.Sort))
|
||||
{
|
||||
target.Add(item);
|
||||
if (item is ListFolderNavItemViewModel folder && childBuckets.TryGetValue(folder.Id, out var childBucket))
|
||||
Materialize(childBucket, folder.Children);
|
||||
}
|
||||
}
|
||||
Materialize(topLevelBucket, TopLevel);
|
||||
ok = true;
|
||||
}
|
||||
finally { OperationTiming.Shared.Record("db", "ListsIsland.LoadAsync", sw.Elapsed, ok); }
|
||||
@@ -447,7 +478,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
Id = $"user:{entity.Id}",
|
||||
Name = entity.Name,
|
||||
Kind = ListKind.User,
|
||||
IconKey = "Folder",
|
||||
IconKey = ListIcon,
|
||||
WorkingDir = entity.WorkingDir,
|
||||
DefaultCommitType = entity.DefaultCommitType,
|
||||
};
|
||||
@@ -462,7 +493,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
|
||||
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);
|
||||
vm.BeginLoad(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType, entity.IsManual, entity.FindingsTracked, entity.Icon, canToggleFolder: true, color: entity.Color);
|
||||
await Dialogs.ShowListSettingsAsync(vm);
|
||||
if (!vm.Saved && !vm.Deleted)
|
||||
{
|
||||
@@ -517,10 +548,26 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
if (target is not null) target.IsDropTarget = true;
|
||||
}
|
||||
|
||||
/// <summary>The collection a row is rendered in: its folder's <c>Lists</c>, or
|
||||
/// <summary>The collection a row is rendered in: its folder's <c>Children</c>, or
|
||||
/// <see cref="TopLevel"/> when it has no folder. Non-generic because the top level is mixed.</summary>
|
||||
public System.Collections.IList ContainerOf(ListNavItemViewModel row) =>
|
||||
Folders.FirstOrDefault(f => f.Id == row.FolderId)?.Lists ?? (System.Collections.IList)TopLevel;
|
||||
Folders.FirstOrDefault(f => f.Id == row.FolderId)?.Children ?? (System.Collections.IList)TopLevel;
|
||||
|
||||
/// <summary>The collection a folder is rendered in: its parent's <c>Children</c>, or
|
||||
/// <see cref="TopLevel"/> when it has none.</summary>
|
||||
public System.Collections.IList ContainerOf(ListFolderNavItemViewModel folder) =>
|
||||
Folders.FirstOrDefault(f => f.Id == folder.ParentFolderId)?.Children ?? (System.Collections.IList)TopLevel;
|
||||
|
||||
/// <summary>True when <paramref name="candidateId"/> is <paramref name="folder"/> itself or
|
||||
/// nested anywhere under it — the guard against dropping a folder into its own subtree.</summary>
|
||||
private static bool IsSelfOrDescendant(ListFolderNavItemViewModel folder, string? candidateId)
|
||||
{
|
||||
if (candidateId is null) return false;
|
||||
if (candidateId == folder.Id) return true;
|
||||
foreach (var child in folder.Children.OfType<ListFolderNavItemViewModel>())
|
||||
if (IsSelfOrDescendant(child, candidateId)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Kept for the list→list case; <see cref="DropAsync"/> covers all four combinations.</summary>
|
||||
public Task ReorderAsync(ListNavItemViewModel source, ListNavItemViewModel target, bool placeBelow) =>
|
||||
@@ -528,10 +575,10 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
|
||||
/// <summary>
|
||||
/// Drops <paramref name="source"/> (a list row or a folder) at <paramref name="target"/>'s
|
||||
/// position. Since a drop position implies a container, dropping a list onto a row in a
|
||||
/// position. Since a drop position implies a container, dropping a row onto a row in a
|
||||
/// different folder (or next to anything at the top level) also *moves* it there — that's the
|
||||
/// only way to drag a list out of a folder. Folders live at the top level only, so a folder
|
||||
/// dropped onto a row inside a folder is ignored rather than nested.
|
||||
/// only way to drag something out of a folder. A folder dropped next to a target inside its own
|
||||
/// subtree is ignored rather than nested into itself.
|
||||
/// </summary>
|
||||
public async Task DropAsync(object source, object target, bool placeBelow)
|
||||
{
|
||||
@@ -540,17 +587,43 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
|
||||
if (source is ListFolderNavItemViewModel folder)
|
||||
{
|
||||
if (target is ListNavItemViewModel { FolderId: not null }) return;
|
||||
MoveWithinCollection(TopLevel, folder, target, placeBelow);
|
||||
await PersistOrderAsync(null);
|
||||
var newParentId = target switch
|
||||
{
|
||||
ListFolderNavItemViewModel tf => tf.ParentFolderId,
|
||||
ListNavItemViewModel tl => tl.FolderId,
|
||||
_ => null,
|
||||
};
|
||||
if (IsSelfOrDescendant(folder, newParentId)) return;
|
||||
|
||||
var targetContainer = target switch
|
||||
{
|
||||
ListFolderNavItemViewModel tf => ContainerOf(tf),
|
||||
ListNavItemViewModel tl => ContainerOf(tl),
|
||||
_ => (System.Collections.IList)TopLevel,
|
||||
};
|
||||
var parentChanged = folder.ParentFolderId != newParentId;
|
||||
if (parentChanged)
|
||||
{
|
||||
ContainerOf(folder).Remove(folder);
|
||||
folder.ParentFolderId = newParentId;
|
||||
targetContainer.Add(folder);
|
||||
}
|
||||
|
||||
MoveWithinCollection(targetContainer, folder, target, placeBelow);
|
||||
await PersistOrderAsync(folder);
|
||||
return;
|
||||
}
|
||||
|
||||
if (source is not ListNavItemViewModel row || row.Kind != ListKind.User) return;
|
||||
|
||||
// Next to a folder *header* means top level, not inside it — MoveToFolderAsync is the
|
||||
// drop-into-the-folder path.
|
||||
var targetFolderId = target is ListNavItemViewModel t ? t.FolderId : null;
|
||||
// Next to a folder *header* means that folder's own container, not inside it —
|
||||
// MoveToFolderAsync is the drop-into-the-folder path.
|
||||
var targetFolderId = target switch
|
||||
{
|
||||
ListNavItemViewModel t => t.FolderId,
|
||||
ListFolderNavItemViewModel tf => tf.ParentFolderId,
|
||||
_ => null,
|
||||
};
|
||||
var folderChanged = row.FolderId != targetFolderId;
|
||||
if (folderChanged)
|
||||
{
|
||||
@@ -563,40 +636,58 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
await PersistOrderAsync(folderChanged ? row : null);
|
||||
}
|
||||
|
||||
/// <summary>Appends a list to a folder (or to the top level when <paramref name="folder"/> is
|
||||
/// null) — the drop-onto-the-folder-header path, which also reaches collapsed and empty folders.</summary>
|
||||
public async Task MoveToFolderAsync(ListNavItemViewModel source, ListFolderNavItemViewModel? folder)
|
||||
/// <summary>Appends a row (list or folder) to <paramref name="folder"/> (or to the top level
|
||||
/// when null) — the drop-onto-the-folder-header path, which also reaches collapsed and empty
|
||||
/// folders. A folder source dropped into itself or one of its own subfolders is ignored.</summary>
|
||||
public async Task MoveToFolderAsync(object source, ListFolderNavItemViewModel? folder)
|
||||
{
|
||||
if (source.Kind != ListKind.User) return;
|
||||
if (source.FolderId == folder?.Id) return;
|
||||
if (source is ListFolderNavItemViewModel sourceFolder)
|
||||
{
|
||||
if (ReferenceEquals(sourceFolder, folder)) return;
|
||||
if (IsSelfOrDescendant(sourceFolder, folder?.Id)) return;
|
||||
if (sourceFolder.ParentFolderId == folder?.Id) return;
|
||||
|
||||
ContainerOf(source).Remove(source);
|
||||
source.FolderId = folder?.Id;
|
||||
ContainerOf(source).Add(source);
|
||||
await PersistOrderAsync(source);
|
||||
ContainerOf(sourceFolder).Remove(sourceFolder);
|
||||
sourceFolder.ParentFolderId = folder?.Id;
|
||||
ContainerOf(sourceFolder).Add(sourceFolder);
|
||||
await PersistOrderAsync(sourceFolder);
|
||||
return;
|
||||
}
|
||||
|
||||
if (source is not ListNavItemViewModel row || row.Kind != ListKind.User) return;
|
||||
if (row.FolderId == folder?.Id) return;
|
||||
|
||||
ContainerOf(row).Remove(row);
|
||||
row.FolderId = folder?.Id;
|
||||
ContainerOf(row).Add(row);
|
||||
await PersistOrderAsync(row);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the sidebar's visual order back as a single global <c>sort_order</c> sequence
|
||||
/// (folders top to bottom, then the top-level lists) and, when a list changed folder, its new
|
||||
/// <c>folder_id</c>.
|
||||
/// Writes the sidebar's visual order back as a single global <c>sort_order</c> sequence,
|
||||
/// walked depth-first (a folder, then its children, at any depth) and, when a row changed
|
||||
/// container, its new <c>folder_id</c>/<c>parent_folder_id</c>.
|
||||
/// </summary>
|
||||
private async Task PersistOrderAsync(ListNavItemViewModel? movedRow)
|
||||
private async Task PersistOrderAsync(object? movedItem)
|
||||
{
|
||||
var ordered = new List<(string Id, bool IsFolder)>();
|
||||
foreach (var item in TopLevel)
|
||||
void Walk(IEnumerable<object> items)
|
||||
{
|
||||
switch (item)
|
||||
foreach (var item in items)
|
||||
{
|
||||
case ListFolderNavItemViewModel f:
|
||||
ordered.Add((f.Id, true));
|
||||
ordered.AddRange(f.Lists.Select(l => (RawId(l.Id), false)));
|
||||
break;
|
||||
case ListNavItemViewModel l:
|
||||
ordered.Add((RawId(l.Id), false));
|
||||
break;
|
||||
switch (item)
|
||||
{
|
||||
case ListFolderNavItemViewModel f:
|
||||
ordered.Add((f.Id, true));
|
||||
Walk(f.Children);
|
||||
break;
|
||||
case ListNavItemViewModel l:
|
||||
ordered.Add((RawId(l.Id), false));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Walk(TopLevel);
|
||||
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var ok = false;
|
||||
@@ -604,8 +695,15 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
var lists = new ListRepository(ctx);
|
||||
if (movedRow is not null)
|
||||
await lists.SetListFolderAsync(RawId(movedRow.Id), movedRow.FolderId);
|
||||
switch (movedItem)
|
||||
{
|
||||
case ListNavItemViewModel movedRow:
|
||||
await lists.SetListFolderAsync(RawId(movedRow.Id), movedRow.FolderId);
|
||||
break;
|
||||
case ListFolderNavItemViewModel movedFolder:
|
||||
await lists.SetFolderParentAsync(movedFolder.Id, movedFolder.ParentFolderId);
|
||||
break;
|
||||
}
|
||||
await lists.ReorderSidebarAsync(ordered);
|
||||
ok = true;
|
||||
}
|
||||
@@ -622,16 +720,33 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
await MoveToFolderAsync(row, null);
|
||||
}
|
||||
|
||||
private readonly Dictionary<string, CancellationTokenSource> _folderCollapseCts = new();
|
||||
|
||||
// Synchronous on purpose: the fold is pure UI state and must never miss a click waiting on
|
||||
// the AsyncRelayCommand's own CanExecute lock while the DB write below is in flight. The
|
||||
// write is fire-and-forget with a per-folder cancellation token so a second toggle before the
|
||||
// first one lands cancels it — the DB always ends up with the last-clicked state, never a
|
||||
// stale one racing ahead of it.
|
||||
[RelayCommand]
|
||||
private async Task ToggleFolderAsync(ListFolderNavItemViewModel? folder)
|
||||
private void ToggleFolder(ListFolderNavItemViewModel? folder)
|
||||
{
|
||||
if (folder is null) return;
|
||||
folder.IsCollapsed = !folder.IsCollapsed;
|
||||
if (_folderCollapseCts.TryGetValue(folder.Id, out var previous)) previous.Cancel();
|
||||
var cts = new CancellationTokenSource();
|
||||
_folderCollapseCts[folder.Id] = cts;
|
||||
_ = PersistFolderCollapsedAsync(folder.Id, folder.IsCollapsed, cts.Token);
|
||||
}
|
||||
|
||||
private async Task PersistFolderCollapsedAsync(string folderId, bool isCollapsed, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
await new ListRepository(ctx).SetFolderCollapsedAsync(folder.Id, folder.IsCollapsed);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
await new ListRepository(ctx).SetFolderCollapsedAsync(folderId, isCollapsed);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch (Exception ex)
|
||||
{
|
||||
// The fold itself already happened in the UI; only its persistence failed.
|
||||
@@ -645,7 +760,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
if (folder is null || Dialogs is null || _services is null) return;
|
||||
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
|
||||
vm.ErrorReported += msg => ErrorReported?.Invoke(msg);
|
||||
vm.BeginLoadFolder(folder.Id, folder.Name, folder.IconKey);
|
||||
vm.BeginLoadFolder(folder.Id, folder.Name, folder.IconKey, folder.Color);
|
||||
await Dialogs.ShowListSettingsAsync(vm);
|
||||
// Rename, re-icon and delete all change the folder header (and delete releases its lists
|
||||
// back to the top level) — one reload covers every outcome.
|
||||
@@ -654,7 +769,22 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
|
||||
private void RecomputeFolderCounts()
|
||||
{
|
||||
foreach (var f in Folders) f.Count = f.Lists.Sum(l => l.Count);
|
||||
foreach (var item in TopLevel)
|
||||
if (item is ListFolderNavItemViewModel f) ComputeFolderCount(f);
|
||||
}
|
||||
|
||||
private static int ComputeFolderCount(ListFolderNavItemViewModel folder)
|
||||
{
|
||||
var sum = 0;
|
||||
foreach (var child in folder.Children)
|
||||
sum += child switch
|
||||
{
|
||||
ListNavItemViewModel l => l.Count,
|
||||
ListFolderNavItemViewModel f => ComputeFolderCount(f),
|
||||
_ => 0,
|
||||
};
|
||||
folder.Count = sum;
|
||||
return sum;
|
||||
}
|
||||
|
||||
private static string RawId(string rowId) =>
|
||||
@@ -715,7 +845,8 @@ 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.Color = entity.Color;
|
||||
row.HasTicketProject = hasTicketProject;
|
||||
RecomputeHasNoLinkedRepo();
|
||||
}
|
||||
@@ -761,7 +892,8 @@ 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,
|
||||
Color = entity.Color,
|
||||
WorkingDir = entity.WorkingDir,
|
||||
DefaultCommitType = entity.DefaultCommitType,
|
||||
IsManual = entity.IsManual,
|
||||
@@ -788,8 +920,8 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
.Select(l => l.Id)
|
||||
.ToList()
|
||||
.IndexOf(entity.Id);
|
||||
folder.Lists.Insert(
|
||||
containerIdx < 0 || containerIdx > folder.Lists.Count ? folder.Lists.Count : containerIdx,
|
||||
folder.Children.Insert(
|
||||
containerIdx < 0 || containerIdx > folder.Children.Count ? folder.Children.Count : containerIdx,
|
||||
item);
|
||||
}
|
||||
else TopLevel.Add(item);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||
|
||||
public enum LogKind { Sys, Tool, Claude, Stdout, Stderr, Done, Msg, User }
|
||||
public enum LogKind { Sys, Tool, Claude, Stdout, Stderr, Done, Msg, User, Edit, ToolResult }
|
||||
|
||||
public sealed class LogLineViewModel
|
||||
{
|
||||
@@ -9,26 +9,42 @@ public sealed class LogLineViewModel
|
||||
public string TimestampFormatted { get; } = DateTime.Now.ToString("HH:mm:ss");
|
||||
public string KindMarker => Kind switch
|
||||
{
|
||||
LogKind.Sys => "sys",
|
||||
LogKind.Tool => "tool",
|
||||
LogKind.Claude => "claude",
|
||||
LogKind.Stdout => "out",
|
||||
LogKind.Stderr => "err",
|
||||
LogKind.Done => "done",
|
||||
LogKind.Msg => "claude",
|
||||
LogKind.User => "you",
|
||||
_ => "",
|
||||
LogKind.Sys => "sys",
|
||||
LogKind.Tool => "tool",
|
||||
LogKind.Claude => "claude",
|
||||
LogKind.Stdout => "out",
|
||||
LogKind.Stderr => "err",
|
||||
LogKind.Done => "done",
|
||||
LogKind.Msg => "claude",
|
||||
LogKind.User => "you",
|
||||
LogKind.Edit => "edit",
|
||||
LogKind.ToolResult => "result",
|
||||
_ => "",
|
||||
};
|
||||
public string ClassName => Kind switch
|
||||
{
|
||||
LogKind.Sys => "log-sys",
|
||||
LogKind.Tool => "log-tool",
|
||||
LogKind.Claude => "log-claude",
|
||||
LogKind.Stdout => "log-stdout",
|
||||
LogKind.Stderr => "log-stderr",
|
||||
LogKind.Done => "log-done",
|
||||
LogKind.Msg => "log-msg",
|
||||
LogKind.User => "log-user",
|
||||
_ => "",
|
||||
LogKind.Sys => "log-sys",
|
||||
LogKind.Tool => "log-tool",
|
||||
LogKind.Claude => "log-claude",
|
||||
LogKind.Stdout => "log-stdout",
|
||||
LogKind.Stderr => "log-stderr",
|
||||
LogKind.Done => "log-done",
|
||||
LogKind.Msg => "log-msg",
|
||||
LogKind.User => "log-user",
|
||||
LogKind.Edit => "log-edit",
|
||||
LogKind.ToolResult => "log-result",
|
||||
_ => "",
|
||||
};
|
||||
|
||||
/// <summary>Splits formatter entries into one log line per non-blank text line, keeping each entry's kind.</summary>
|
||||
public static IEnumerable<LogLineViewModel> FromEntries(IEnumerable<(LogKind Kind, string Text)> entries)
|
||||
{
|
||||
foreach (var (kind, text) in entries)
|
||||
foreach (var raw in text.Split('\n'))
|
||||
{
|
||||
var piece = raw.TrimEnd('\r');
|
||||
if (!string.IsNullOrWhiteSpace(piece))
|
||||
yield return new LogLineViewModel { Kind = kind, Text = piece };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,26 @@ 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;
|
||||
|
||||
/// <summary>Mirrors MergeTargetsDto.IsCompanyRepo: approve pushes + opens a draft PR, there is no target to pick.</summary>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ShowApproveMerge), nameof(ShowCreatePr), nameof(ShowUpdatePr))]
|
||||
private bool _isCompanyRepo;
|
||||
|
||||
/// <summary>The worktree's draft PR (company repo), once an approve created it.</summary>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ShowCreatePr), nameof(ShowUpdatePr), nameof(HasPullRequest))]
|
||||
[NotifyCanExecuteChangedFor(nameof(OpenPullRequestCommand))]
|
||||
private string? _pullRequestUrl;
|
||||
|
||||
public bool ShowApproveMerge => !IsCompanyRepo;
|
||||
public bool ShowCreatePr => IsCompanyRepo && PullRequestUrl is null;
|
||||
public bool ShowUpdatePr => IsCompanyRepo && PullRequestUrl is not null;
|
||||
public bool HasPullRequest => PullRequestUrl is not null;
|
||||
|
||||
// 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,18 +161,28 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
|
||||
MergePreviewText = "";
|
||||
MergeIsClean = false;
|
||||
MergeIsConflict = false;
|
||||
TargetDirty = false;
|
||||
IsCompanyRepo = false;
|
||||
PullRequestUrl = null;
|
||||
SyncWorktree(null, null, null, null, null);
|
||||
SyncTaskContext(null, null, false);
|
||||
SyncChildOutcomes(false, 0);
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(HasPullRequest))]
|
||||
private void OpenPullRequest()
|
||||
{
|
||||
var (ok, error) = ShellOpen.Url(PullRequestUrl);
|
||||
if (!ok && error is not null) ErrorReported?.Invoke(error);
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanReviewDiff))]
|
||||
private async System.Threading.Tasks.Task ReviewCombinedDiffAsync()
|
||||
{
|
||||
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 +212,7 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
|
||||
}
|
||||
else return;
|
||||
|
||||
await vm.LoadAsync();
|
||||
_ = vm.LoadAsync();
|
||||
DiffViewed?.Invoke();
|
||||
await ShowDiffViewer(vm);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Text;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using ClaudeDo.Ui.Helpers;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.Modals.Settings;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||
|
||||
@@ -12,7 +12,6 @@ public sealed partial class PrepPanelViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
private readonly IWorkerClient _worker;
|
||||
private readonly StreamLineFormatter _formatter = new();
|
||||
private readonly StringBuilder _prepClaudeBuf = new();
|
||||
|
||||
private readonly Action _onPrepStartedHandler;
|
||||
private readonly Action<string> _onPrepLineHandler;
|
||||
@@ -20,6 +19,9 @@ public sealed partial class PrepPanelViewModel : ViewModelBase, IDisposable
|
||||
|
||||
[ObservableProperty] private bool _isPrepRunning;
|
||||
|
||||
// The button only makes sense with a configured ticket system — otherwise it would just plan the day.
|
||||
[ObservableProperty] private bool _ticketsConfigured;
|
||||
|
||||
// Covers the gap between the click and the worker's PrepStartedEvent broadcast — IsPrepRunning
|
||||
// stays false until that event arrives, so without this the button click looks like a no-op.
|
||||
public OperationStatus PrepOperation { get; } = new();
|
||||
@@ -57,6 +59,7 @@ public sealed partial class PrepPanelViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
if (e.PropertyName != nameof(OperationStatus.IsRunning)) return;
|
||||
PlanDayCommand.NotifyCanExecuteChanged();
|
||||
ImportTicketsAndPlanDayCommand.NotifyCanExecuteChanged();
|
||||
OnPropertyChanged(nameof(IsPlanDayEnabled));
|
||||
};
|
||||
}
|
||||
@@ -78,6 +81,25 @@ public sealed partial class PrepPanelViewModel : ViewModelBase, IDisposable
|
||||
catch { }
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanPlanDay))]
|
||||
private async System.Threading.Tasks.Task ImportTicketsAndPlanDayAsync()
|
||||
{
|
||||
using var op = PrepOperation.Begin(Loc.T("ops.reports.runningTicketImport"));
|
||||
try { await _worker.RunTicketImportNowAsync(); }
|
||||
catch { }
|
||||
}
|
||||
|
||||
/// <summary>Reads whether the ticket system has a base URL and a token; keeps the last value when the worker is unreachable.</summary>
|
||||
public async System.Threading.Tasks.Task RefreshTicketsConfiguredAsync()
|
||||
{
|
||||
if (!TicketSettingsTabViewModel.FeatureEnabled) { TicketsConfigured = false; return; }
|
||||
TicketSettingsDto? dto;
|
||||
try { dto = await _worker.GetTicketSettingsAsync(); }
|
||||
catch { return; }
|
||||
if (dto is null) return;
|
||||
TicketsConfigured = !string.IsNullOrWhiteSpace(dto.ApiBaseUrl) && dto.TokenSet;
|
||||
}
|
||||
|
||||
public async System.Threading.Tasks.Task LoadLastPrepLogIfEmptyAsync()
|
||||
{
|
||||
if (IsPrepRunning || PrepLog.Count > 0) return;
|
||||
@@ -104,24 +126,8 @@ public sealed partial class PrepPanelViewModel : ViewModelBase, IDisposable
|
||||
|
||||
private void AppendStdoutLine(string line)
|
||||
{
|
||||
var formatted = _formatter.FormatLine(line);
|
||||
if (formatted is null) return;
|
||||
AppendClaudeText(formatted);
|
||||
}
|
||||
|
||||
private void AppendClaudeText(string chunk)
|
||||
{
|
||||
_prepClaudeBuf.Append(chunk);
|
||||
while (true)
|
||||
{
|
||||
var text = _prepClaudeBuf.ToString();
|
||||
var nl = text.IndexOf('\n');
|
||||
if (nl < 0) break;
|
||||
var piece = text[..nl].TrimEnd('\r');
|
||||
if (!string.IsNullOrWhiteSpace(piece))
|
||||
PrepLog.Add(new LogLineViewModel { Kind = LogKind.Claude, Text = piece });
|
||||
_prepClaudeBuf.Clear();
|
||||
_prepClaudeBuf.Append(text[(nl + 1)..]);
|
||||
}
|
||||
if (_formatter.FormatLine(line) is not { } entries) return;
|
||||
foreach (var entry in LogLineViewModel.FromEntries(entries))
|
||||
PrepLog.Add(entry);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
@@ -18,7 +17,6 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
|
||||
private readonly IWorkerClient _worker;
|
||||
|
||||
private readonly StreamLineFormatter _formatter = new();
|
||||
private readonly StringBuilder _claudeBuf = new();
|
||||
private string? _subscribedTaskId;
|
||||
|
||||
public string? SubscribedTaskId => _subscribedTaskId;
|
||||
@@ -105,7 +103,6 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
|
||||
_onTaskFinished = (slot, taskId, status, finishedAt) =>
|
||||
{
|
||||
if (taskId != _subscribedTaskId) return;
|
||||
FlushClaudeBuffer();
|
||||
Log.Add(new LogLineViewModel
|
||||
{
|
||||
Kind = LogKind.Done,
|
||||
@@ -182,7 +179,6 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
|
||||
public void Reset()
|
||||
{
|
||||
Log.Clear();
|
||||
_claudeBuf.Clear();
|
||||
_subscribedTaskId = null;
|
||||
AgentState = "idle";
|
||||
SessionOutcome = null;
|
||||
@@ -272,7 +268,6 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
|
||||
var normalized = line.StartsWith("[", StringComparison.Ordinal) ? line : "[stdout] " + line;
|
||||
OnTaskMessage(_subscribedTaskId, normalized);
|
||||
}
|
||||
FlushClaudeBuffer();
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch { /* best-effort replay */ }
|
||||
@@ -289,8 +284,6 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
FlushClaudeBuffer();
|
||||
|
||||
var kind = line.StartsWith("[sys]", StringComparison.OrdinalIgnoreCase) ? LogKind.Sys
|
||||
: line.StartsWith("[tool]", StringComparison.OrdinalIgnoreCase) ? LogKind.Tool
|
||||
: line.StartsWith("[claude]", StringComparison.OrdinalIgnoreCase) ? LogKind.Claude
|
||||
@@ -302,29 +295,9 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
|
||||
|
||||
private void AppendStdoutLine(string line)
|
||||
{
|
||||
var formatted = _formatter.FormatLine(line);
|
||||
if (formatted is null) return;
|
||||
_claudeBuf.Append(formatted);
|
||||
while (true)
|
||||
{
|
||||
var text = _claudeBuf.ToString();
|
||||
var nl = text.IndexOf('\n');
|
||||
if (nl < 0) break;
|
||||
var piece = text[..nl].TrimEnd('\r');
|
||||
if (!string.IsNullOrWhiteSpace(piece))
|
||||
Log.Add(new LogLineViewModel { Kind = LogKind.Claude, Text = piece });
|
||||
_claudeBuf.Clear();
|
||||
_claudeBuf.Append(text[(nl + 1)..]);
|
||||
}
|
||||
}
|
||||
|
||||
private void FlushClaudeBuffer()
|
||||
{
|
||||
if (_claudeBuf.Length == 0) return;
|
||||
var piece = _claudeBuf.ToString().TrimEnd();
|
||||
_claudeBuf.Clear();
|
||||
if (!string.IsNullOrWhiteSpace(piece))
|
||||
Log.Add(new LogLineViewModel { Kind = LogKind.Claude, Text = piece });
|
||||
if (_formatter.FormatLine(line) is not { } entries) return;
|
||||
foreach (var entry in LogLineViewModel.FromEntries(entries))
|
||||
Log.Add(entry);
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task RefreshStatusAsync(string taskId)
|
||||
|
||||
@@ -512,7 +512,8 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
|
||||
filteredList = filteredList.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt).ToList();
|
||||
|
||||
var showListChip = list.Kind == ListKind.Virtual;
|
||||
// Smart (My Day, Important, Planned) and virtual lists span many lists — show the origin.
|
||||
var showListChip = list.Kind != ListKind.User;
|
||||
foreach (var t in filteredList)
|
||||
{
|
||||
TaskRowViewModel row;
|
||||
@@ -769,12 +770,16 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
|
||||
// A predecessor missing from both rowsById and the external cache counts as unmet -- mirrors
|
||||
// QueuePicker/Worker's TaskBlocking.Unmet: an edge pointing at an unresolved/vanished task
|
||||
// keeps blocking rather than being silently treated as satisfied.
|
||||
// keeps blocking rather than being silently treated as satisfied. A landed merge satisfies
|
||||
// the edge too (skipVerify parks a merged task in WaitingForReview until the batch verify);
|
||||
// the external cache carries no worktree state, so a cross-list predecessor still shows as
|
||||
// waiting until it reaches Done.
|
||||
private static bool IsUnmet(
|
||||
string id, Dictionary<string, TaskRowViewModel> rowsById,
|
||||
IReadOnlyDictionary<string, (int Number, string Title, TaskStatus Status)> external)
|
||||
{
|
||||
if (rowsById.TryGetValue(id, out var p)) return p.Status != TaskStatus.Done;
|
||||
if (rowsById.TryGetValue(id, out var p))
|
||||
return p.Status != TaskStatus.Done && p.WorktreeState != WorktreeState.Merged;
|
||||
if (external.TryGetValue(id, out var e)) return e.Status != TaskStatus.Done;
|
||||
return true;
|
||||
}
|
||||
@@ -1005,6 +1010,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
if (!CanReorder || _currentList is null) return;
|
||||
if (source.IsRunning || target.IsRunning) return;
|
||||
if (source.Done || target.Done) return;
|
||||
if (ReferenceEquals(source, target)) return;
|
||||
|
||||
// Master Items: single Move event (no Reset) so ItemsControls animate, not rebuild.
|
||||
@@ -1169,7 +1175,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
TasksChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
[RelayCommand(AllowConcurrentExecutions = true)]
|
||||
private async Task ToggleDoneAsync(TaskRowViewModel row)
|
||||
{
|
||||
if (_worker is null) return;
|
||||
@@ -1205,9 +1211,9 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
// block removing a completed planning parent together with its done children.
|
||||
var toDelete = completed.OrderByDescending(r => r.IsChild).ToList();
|
||||
|
||||
if (ConfirmAsync is not null)
|
||||
if (Dialogs is not null)
|
||||
{
|
||||
var ok = await ConfirmAsync($"Clear {toDelete.Count} completed task(s)? This cannot be undone.");
|
||||
var ok = await Dialogs.ConfirmAsync($"Clear {toDelete.Count} completed task(s)? This cannot be undone.", Loc.T("dialog.delete"));
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
@@ -1233,7 +1239,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
TasksChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
[RelayCommand(AllowConcurrentExecutions = true)]
|
||||
private async Task ToggleStarAsync(TaskRowViewModel row)
|
||||
{
|
||||
row.IsStarred = !row.IsStarred;
|
||||
@@ -1335,9 +1341,9 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
private async Task DeleteTaskAsync(TaskRowViewModel? row)
|
||||
{
|
||||
if (row is null || row.HasInteractiveSession || _worker is null) return;
|
||||
if (ConfirmAsync is not null)
|
||||
if (Dialogs is not null)
|
||||
{
|
||||
var ok = await ConfirmAsync(Loc.T("vm.tasksIsland.deleteTaskConfirm", row.Title));
|
||||
var ok = await Dialogs.ConfirmAsync(Loc.T("vm.tasksIsland.deleteTaskConfirm", row.Title), Loc.T("dialog.delete"));
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
@@ -1399,7 +1405,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.sendToQueueFailed", ex.Message)); }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
[RelayCommand(AllowConcurrentExecutions = true)]
|
||||
private async Task RemoveFromQueueAsync(TaskRowViewModel? row)
|
||||
{
|
||||
if (row is null || _worker is null) return;
|
||||
@@ -1649,8 +1655,8 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
|
||||
if (outcome.Result == DiscardPlanningResult.BlockedByQueuedChildren)
|
||||
{
|
||||
if (ConfirmAsync is null) return;
|
||||
var ok = await ConfirmAsync(
|
||||
if (Dialogs is null) return;
|
||||
var ok = await Dialogs.ConfirmAsync(
|
||||
$"{outcome.QueuedChildrenCount} child task(s) are queued.\n" +
|
||||
"Dequeue them and discard the planning session?");
|
||||
if (!ok) return;
|
||||
@@ -1659,18 +1665,13 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
}
|
||||
else if (outcome.Result == DiscardPlanningResult.BlockedByRunningChildren)
|
||||
{
|
||||
if (ConfirmAsync is null) return;
|
||||
await ConfirmAsync(
|
||||
if (Dialogs is null) return;
|
||||
await Dialogs.ConfirmAsync(
|
||||
$"{outcome.RunningChildrenCount} child task(s) are still running.\n" +
|
||||
"Cancel them first, then try again.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wired by the view via <see cref="ShowConfirmAsync"/>. Returns true when the user confirms.
|
||||
/// </summary>
|
||||
public Func<string, Task<bool>>? ConfirmAsync { get; set; }
|
||||
|
||||
private bool CanRunPlanningOperation() => !PlanningOperation.IsRunning;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanRunPlanningOperation))]
|
||||
@@ -1701,7 +1702,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
Regroup();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
[RelayCommand(AllowConcurrentExecutions = true)]
|
||||
private async Task RefineTask(TaskRowViewModel row)
|
||||
{
|
||||
if (row is null || !row.CanRefine) return;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Threading;
|
||||
using System;
|
||||
using System.Threading;
|
||||
@@ -66,7 +68,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
// Set by MainWindow so a reveal can bring the main window to the foreground.
|
||||
public Action? BringToFront { get; set; }
|
||||
|
||||
// Single dialog seam (set by MainWindow); propagated to the lists island.
|
||||
// Single dialog seam (set by MainWindow); propagated to the islands.
|
||||
private IDialogService? _dialogs;
|
||||
public IDialogService? Dialogs
|
||||
{
|
||||
@@ -76,6 +78,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
_dialogs = value;
|
||||
if (Lists is not null) Lists.Dialogs = value;
|
||||
if (Tasks is not null) Tasks.Dialogs = value;
|
||||
if (Details is not null) Details.Dialogs = value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,13 +109,15 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
BringToFront?.Invoke();
|
||||
}
|
||||
|
||||
public async Task RequestConflictResolutionAsync(string taskId, string targetBranch)
|
||||
public async Task RequestConflictResolutionAsync(string taskId, string targetBranch, bool autostash = false)
|
||||
{
|
||||
if (ConflictResolverFactory is null || Dialogs is null) return;
|
||||
var vm = ConflictResolverFactory(taskId);
|
||||
var hasConflicts = await vm.OpenAsync(targetBranch);
|
||||
var hasConflicts = await vm.OpenAsync(targetBranch, autostash);
|
||||
if (hasConflicts)
|
||||
await Dialogs.ShowConflictResolverAsync(vm);
|
||||
else if (vm.Error is { } error)
|
||||
FlashFooterError(error);
|
||||
}
|
||||
|
||||
[ObservableProperty] private bool _isUpdateBannerVisible;
|
||||
@@ -178,8 +183,23 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
OnPropertyChanged(nameof(TasksColumnSpan));
|
||||
}
|
||||
|
||||
private const int FooterHistoryCap = 500;
|
||||
|
||||
/// <summary>Every line the footer strip has shown this session, oldest-first, capped; UI thread only.</summary>
|
||||
private readonly List<WorkerLogEntry> _footerHistory = new();
|
||||
|
||||
internal IReadOnlyList<WorkerLogEntry> FooterHistorySnapshot() => _footerHistory.ToArray();
|
||||
|
||||
private void RecordFooter(WorkerLogEntry entry)
|
||||
{
|
||||
_footerHistory.Add(entry);
|
||||
if (_footerHistory.Count > FooterHistoryCap)
|
||||
_footerHistory.RemoveAt(0);
|
||||
}
|
||||
|
||||
public void OnWorkerLogReceived(WorkerLogEntry entry)
|
||||
{
|
||||
RecordFooter(entry);
|
||||
var hhmm = entry.TimestampUtc.ToLocalTime().ToString("HH:mm");
|
||||
WorkerLogText = $"{hhmm} · {entry.Message}";
|
||||
WorkerLogLevel = entry.Level;
|
||||
@@ -203,8 +223,15 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
// paint a good outcome red, which reads as a failure.
|
||||
public void FlashFooterSuccess(string message) => FlashFooter(message, WorkerLogLevel.Success);
|
||||
|
||||
// Same strip, for neutral in-progress text (e.g. a CLI's progress lines).
|
||||
public void FlashFooterInfo(string message) => FlashFooter(message, WorkerLogLevel.Info);
|
||||
|
||||
private void FlashFooter(string message, WorkerLogLevel level)
|
||||
{
|
||||
if (level == WorkerLogLevel.Error)
|
||||
Serilog.Log.Warning("Footer error: {message}", message);
|
||||
|
||||
RecordFooter(new WorkerLogEntry(message, level, DateTime.UtcNow));
|
||||
WorkerLogText = $"{DateTime.Now:HH:mm} · {message}";
|
||||
WorkerLogLevel = level;
|
||||
IsWorkerLogVisible = true;
|
||||
@@ -532,7 +559,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
private async Task OpenLogVisualizer()
|
||||
{
|
||||
if (Dialogs is null || Worker is null) return;
|
||||
var vm = new LogVisualizerViewModel(Worker);
|
||||
var vm = new LogVisualizerViewModel(Worker, FooterHistorySnapshot);
|
||||
await vm.RefreshAsync();
|
||||
await Dialogs.ShowLogVisualizerAsync(vm);
|
||||
}
|
||||
@@ -655,7 +682,15 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
|
||||
private void RestartWorkerService()
|
||||
{
|
||||
var exe = _workerLocator.Find();
|
||||
// Prefer the exe path of the worker that's actually running: _workerLocator.Find()
|
||||
// walks up from AppContext.BaseDirectory looking for install.json and falls back to
|
||||
// the registry's InstallLocation otherwise — on a debug build launched from the repo
|
||||
// there's no install.json above it, so it returns the *installed* worker's path. That
|
||||
// never matches the running debug process, so KillMatchingWorkerProcesses silently
|
||||
// skips it (it only kills a process whose path equals the given exe) and this either
|
||||
// launches a second worker that immediately dies on the already-bound port, or leaves
|
||||
// the real one running untouched while nothing visibly changes.
|
||||
var exe = RunningWorkerExePath() ?? _workerLocator.Find();
|
||||
if (exe is null) throw new InvalidOperationException("Worker executable not found.");
|
||||
|
||||
KillMatchingWorkerProcesses(exe);
|
||||
@@ -665,6 +700,20 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
throw new InvalidOperationException($"worker process exited immediately (code {started.ExitCode})");
|
||||
}
|
||||
|
||||
private static string? RunningWorkerExePath()
|
||||
{
|
||||
foreach (var p in System.Diagnostics.Process.GetProcessesByName("ClaudeDo.Worker"))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (p.MainModule?.FileName is { } path) return path;
|
||||
}
|
||||
catch { /* inaccessible */ }
|
||||
finally { p.Dispose(); }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only kill the worker belonging to THIS installation — not any other
|
||||
// ClaudeDo.Worker on the machine (e.g. a second install).
|
||||
private static void KillMatchingWorkerProcesses(string exe)
|
||||
@@ -686,10 +735,6 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
// Set by MainWindow: force-closes the window without re-triggering its own close
|
||||
// confirmation (the shutdown dialog above already confirmed).
|
||||
public Action? CloseWindow { get; set; }
|
||||
|
||||
// Test seam: overrides the real kill-worker + close-window body.
|
||||
internal Action? ShutdownOperation { get; set; }
|
||||
|
||||
@@ -706,7 +751,14 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
var exe = _workerLocator.Find();
|
||||
if (exe is not null) KillMatchingWorkerProcesses(exe);
|
||||
CloseWindow?.Invoke();
|
||||
|
||||
// Closing just the MainWindow relies on ShutdownMode.OnMainWindowClose, which asks
|
||||
// every open window to close and backs off if any of them cancels — and
|
||||
// MissionControlWindow always cancels its own close (it hides instead, see
|
||||
// MissionControlWindow.OnClosing), so the process never actually exited. Shutdown()
|
||||
// force-closes every window, ignoring that cancellation.
|
||||
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
desktop.Shutdown();
|
||||
}
|
||||
|
||||
private bool _updatingClaudeCli;
|
||||
@@ -718,8 +770,10 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
_updatingClaudeCli = true;
|
||||
UpdateClaudeCliCommand.NotifyCanExecuteChanged();
|
||||
var finished = false;
|
||||
try
|
||||
{
|
||||
FlashFooterInfo(Loc.T("vm.shell.updatingClaudeCli"));
|
||||
var dto = Worker is null ? null : await Worker.GetClaudeBinAsync();
|
||||
if (dto is null)
|
||||
{
|
||||
@@ -727,18 +781,24 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
var (success, message) = await RunClaudeUpdateAsync(dto);
|
||||
// `finished` is only touched on the UI thread: a queued line can't overwrite the final flash.
|
||||
var (success, message) = await RunClaudeUpdateAsync(dto, line => Dispatcher.UIThread.Post(() =>
|
||||
{
|
||||
if (!finished) FlashFooterInfo(line);
|
||||
}));
|
||||
finished = true;
|
||||
if (success) FlashFooterSuccess(message);
|
||||
else FlashFooterError(message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
finished = true;
|
||||
_updatingClaudeCli = false;
|
||||
UpdateClaudeCliCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<(bool Success, string Message)> RunClaudeUpdateAsync(ClaudeBinDto dto)
|
||||
private static async Task<(bool Success, string Message)> RunClaudeUpdateAsync(ClaudeBinDto dto, Action<string> onStdoutLine)
|
||||
{
|
||||
var resolved = ClaudeDo.Data.Environment.ExecutableResolver.Resolve(dto.Value);
|
||||
if (resolved is null) return (false, $"Update failed: '{dto.Value}' not found.");
|
||||
@@ -754,7 +814,12 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
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.OutputDataReceived += (_, e) =>
|
||||
{
|
||||
if (e.Data is null) return;
|
||||
stdout.AppendLine(e.Data);
|
||||
if (!string.IsNullOrWhiteSpace(e.Data)) onStdoutLine(e.Data.Trim());
|
||||
};
|
||||
process.ErrorDataReceived += (_, e) => { if (e.Data is not null) stderr.AppendLine(e.Data); };
|
||||
|
||||
try { process.Start(); }
|
||||
|
||||
@@ -19,11 +19,35 @@ 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; }
|
||||
/// the reviews") pane -- shows the "Abandon run" action and hides the manual submit button.</summary>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ShowSubmitForReview))]
|
||||
private bool _isListHandler;
|
||||
|
||||
/// <summary>Whether the header shows "Submit for review": task-based, and not a list handler
|
||||
/// (the handler submits its own task at the end of its merge phase).</summary>
|
||||
public bool ShowSubmitForReview => IsTaskBased && !IsListHandler;
|
||||
|
||||
[ObservableProperty] private string _displayTitle;
|
||||
|
||||
@@ -60,7 +84,7 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
|
||||
public event Action<string, int>? SessionDiedAfterStartup;
|
||||
|
||||
/// <summary>Task-based pane — dedup'd by <see cref="TaskId"/>. Pass null for an ad-hoc pane
|
||||
/// (no task, never deduped); prefer <see cref="CreateAdHoc"/> at ad-hoc call sites.
|
||||
/// (no task, never deduped) — its host sets <see cref="IdentityKey"/>, the tile's paneId.
|
||||
/// <paramref name="descriptorFactory"/> is resolved by the pane itself (in <see cref="Start"/>)
|
||||
/// so the tile — and its starting spinner — is visible while the launch spec is still being
|
||||
/// fetched. The host must wire its handlers and then call <see cref="Start"/>.</summary>
|
||||
@@ -70,6 +94,7 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
|
||||
Func<System.Threading.Tasks.Task<TerminalLaunchDescriptor>> descriptorFactory)
|
||||
{
|
||||
TaskId = taskId;
|
||||
IdentityKey = taskId ?? Guid.NewGuid().ToString("n");
|
||||
_displayTitle = displayTitle;
|
||||
_descriptorFactory = descriptorFactory;
|
||||
Subscribe(Terminal);
|
||||
@@ -112,11 +137,17 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
|
||||
/// <see cref="ErrorReported"/> so a failed launch is not swallowed.</summary>
|
||||
public void Start() => _ = StartAsync();
|
||||
|
||||
/// <summary>Working directory of the launched session, known once the launch spec resolved —
|
||||
/// a handoff reopens the fresh session there.</summary>
|
||||
public string? Cwd { get; private set; }
|
||||
|
||||
private async System.Threading.Tasks.Task StartAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
Terminal.Start(await _descriptorFactory());
|
||||
var descriptor = await _descriptorFactory();
|
||||
Cwd = descriptor.Cwd;
|
||||
Terminal.Start(descriptor);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -124,12 +155,6 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Ad-hoc pane — no task, no dedup.</summary>
|
||||
public static ConPtyPaneViewModel CreateAdHoc(
|
||||
string displayTitle,
|
||||
Func<System.Threading.Tasks.Task<TerminalLaunchDescriptor>> descriptorFactory)
|
||||
=> new(null, displayTitle, descriptorFactory);
|
||||
|
||||
private void OnTerminalPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(InteractiveTerminalViewModel.StartError) && Terminal.StartError is { Length: > 0 } error)
|
||||
|
||||
@@ -5,6 +5,8 @@ using System.Linq;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.MissionControl;
|
||||
@@ -16,13 +18,17 @@ 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;
|
||||
private readonly Action _onConnectionRestored;
|
||||
private readonly Action<RunningOperationDto> _onOperationStarted;
|
||||
private readonly Action<string> _onOperationEnded;
|
||||
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 +47,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;
|
||||
@@ -71,13 +80,26 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
public ObservableCollection<QueuedTaskViewModel> Queued { get; } = new();
|
||||
public bool HasQueued => Queued.Count > 0;
|
||||
|
||||
/// <summary>Worker operations running right now (verify, merge, rebase, worktree creation), oldest first.</summary>
|
||||
public ObservableCollection<RunningOperationViewModel> RunningOperations { get; } = new();
|
||||
public bool HasRunningOperations => RunningOperations.Count > 0;
|
||||
|
||||
/// <summary>The activity strip is shown while it has queue rows or running operations.</summary>
|
||||
public bool HasActivity => HasQueued || HasRunningOperations;
|
||||
|
||||
// "New session" targets: one entry per list with a working dir, plus a browse fallback, so a
|
||||
// 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; }
|
||||
// MissionControlView.PickFolderAsync). Returns the chosen path, or null when cancelled.
|
||||
public Func<System.Threading.Tasks.Task<string?>>? PickFolderAsync { 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.
|
||||
@@ -85,11 +107,13 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
|
||||
public UsagePillViewModel UsagePill { get; }
|
||||
|
||||
public MissionControlViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient worker, UsagePillViewModel usagePill)
|
||||
public MissionControlViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient worker, UsagePillViewModel usagePill, AppSettings? settings = null)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_worker = worker;
|
||||
UsagePill = usagePill;
|
||||
_settings = settings;
|
||||
_columnPreference = Math.Clamp(settings?.MissionControlColumns ?? 0, 0, MaxColumns);
|
||||
|
||||
ConPtySessions.CollectionChanged += OnConPtySessionsChanged;
|
||||
Panes.CollectionChanged += OnPanesChanged;
|
||||
@@ -103,21 +127,84 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
_onTaskUpdated = taskId => _ = RefreshQueueAsync();
|
||||
_worker.TaskUpdatedEvent += _onTaskUpdated;
|
||||
|
||||
_onConnectionRestored = () => { _ = RefreshQueueAsync(); };
|
||||
_onConnectionRestored = () => { _ = RefreshQueueAsync(); _ = RefreshOperationsAsync(); };
|
||||
_worker.ConnectionRestoredEvent += _onConnectionRestored;
|
||||
|
||||
_onOperationStarted = AddOperation;
|
||||
_worker.OperationStartedEvent += _onOperationStarted;
|
||||
_onOperationEnded = RemoveOperation;
|
||||
_worker.OperationEndedEvent += _onOperationEnded;
|
||||
|
||||
_onHandoffRequested = (taskId, survivingTaskIds, nextPhase) => { _ = OpenMergeHelperHandoffConPtySessionAsync(taskId, survivingTaskIds, nextPhase); };
|
||||
_worker.HandoffRequestedEvent += _onHandoffRequested;
|
||||
_worker.SessionHandoffRequestedEvent += OpenSessionHandoff;
|
||||
|
||||
_onListHandlerStartRequested = (listId, taskIds) => { OpenRequested?.Invoke(); _ = OpenMergeHelperConPtySessionAsync(listId, taskIds); };
|
||||
_worker.ListHandlerStartRequestedEvent += _onListHandlerStartRequested;
|
||||
_onHandlerUsageWarning = (taskId, message) => { _ = WarnHandlerSessionAsync(taskId, message); };
|
||||
_worker.HandlerUsageWarningEvent += _onHandlerUsageWarning;
|
||||
_onHandlerHandoffDeferred = (taskId, dueAt) => UpdateHandlerSessionTitleAsync(taskId, dueAt);
|
||||
_worker.HandlerHandoffDeferredEvent += _onHandlerHandoffDeferred;
|
||||
|
||||
_ = RefreshQueueAsync();
|
||||
_ = RefreshOperationsAsync();
|
||||
_ = RefreshSessionTargetsAsync();
|
||||
}
|
||||
|
||||
/// <summary>Replaces the running-operations list with the Worker's snapshot (open and reconnect).</summary>
|
||||
internal async System.Threading.Tasks.Task RefreshOperationsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = await _worker.GetActiveOperationsAsync();
|
||||
var hadVerify = RunningOperations.Any(IsVerifyMerge);
|
||||
RunningOperations.Clear();
|
||||
foreach (var op in snapshot.Where(o => !_endedOperationIds.Contains(o.Id)).OrderBy(o => o.StartedAt))
|
||||
RunningOperations.Add(new RunningOperationViewModel(op));
|
||||
NotifyOperationsChanged();
|
||||
if (hadVerify || RunningOperations.Any(IsVerifyMerge)) _ = RefreshQueueAsync();
|
||||
}
|
||||
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.operationsRefreshFailed", ex.Message)); }
|
||||
}
|
||||
|
||||
/// <summary>Ids already reported ended; guards against a late Started or a stale snapshot re-adding them. Op ids are unique, so it is never pruned (a few bytes per op).</summary>
|
||||
private readonly HashSet<string> _endedOperationIds = new();
|
||||
|
||||
private void AddOperation(RunningOperationDto op)
|
||||
{
|
||||
if (_endedOperationIds.Contains(op.Id) || RunningOperations.Any(r => r.Id == op.Id)) return;
|
||||
var row = new RunningOperationViewModel(op);
|
||||
RunningOperations.Add(row);
|
||||
NotifyOperationsChanged();
|
||||
if (IsVerifyMerge(row)) _ = RefreshQueueAsync();
|
||||
}
|
||||
|
||||
private void RemoveOperation(string id)
|
||||
{
|
||||
_endedOperationIds.Add(id);
|
||||
var row = RunningOperations.FirstOrDefault(r => r.Id == id);
|
||||
if (row is null) return;
|
||||
RunningOperations.Remove(row);
|
||||
NotifyOperationsChanged();
|
||||
if (IsVerifyMerge(row)) _ = RefreshQueueAsync();
|
||||
}
|
||||
|
||||
/// <summary>A post-merge verify keeps its task in WaitingForReview, so only this op marks the activity row "Verifying".</summary>
|
||||
private static bool IsVerifyMerge(RunningOperationViewModel r) => r.Kind == OperationKinds.VerifyMerge;
|
||||
|
||||
private void NotifyOperationsChanged()
|
||||
{
|
||||
OnPropertyChanged(nameof(HasRunningOperations));
|
||||
OnPropertyChanged(nameof(HasActivity));
|
||||
}
|
||||
|
||||
/// <summary>Refreshes every row's elapsed text; the view calls this once a second while open.</summary>
|
||||
public void TickOperations(DateTime? nowUtc = null)
|
||||
{
|
||||
var now = nowUtc ?? DateTime.UtcNow;
|
||||
foreach (var r in RunningOperations) r.Tick(now);
|
||||
}
|
||||
|
||||
internal async System.Threading.Tasks.Task RefreshQueueAsync()
|
||||
{
|
||||
try
|
||||
@@ -133,7 +220,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
: t.Status == ClaudeDo.Data.Models.TaskStatus.WaitingForReview ? 1
|
||||
: t.Status == ClaudeDo.Data.Models.TaskStatus.Running ? 2 : 3)
|
||||
.ThenBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
||||
.Select(t => new { t.Id, t.Title, t.BlockedByTaskId, t.Status, t.RoadblockCount })
|
||||
.Select(t => new { t.Id, t.Number, t.Title, t.BlockedByTaskId, t.Status, t.RoadblockCount })
|
||||
.ToListAsync();
|
||||
|
||||
Queued.Clear();
|
||||
@@ -141,10 +228,12 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
var id = r.Id;
|
||||
var running = r.Status == ClaudeDo.Data.Models.TaskStatus.Running;
|
||||
var (tint, colorKey, labelKey) = Decorate(r.Status, r.RoadblockCount, r.BlockedByTaskId != null);
|
||||
var (tint, colorKey, labelKey) = Decorate(r.Status, r.RoadblockCount, r.BlockedByTaskId != null,
|
||||
RunningOperations.Any(o => o.Kind == OperationKinds.VerifyMerge && o.TaskId == id));
|
||||
Queued.Add(new QueuedTaskViewModel
|
||||
{
|
||||
Id = id,
|
||||
Number = r.Number,
|
||||
Title = r.Title ?? string.Empty,
|
||||
IsRunning = running,
|
||||
TintKey = tint,
|
||||
@@ -155,6 +244,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
});
|
||||
}
|
||||
OnPropertyChanged(nameof(HasQueued));
|
||||
OnPropertyChanged(nameof(HasActivity));
|
||||
}
|
||||
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.queueRefreshFailed", ex.Message)); }
|
||||
}
|
||||
@@ -162,12 +252,14 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
// Row tint + status label for the activity strip. Keys are resolved to brushes by
|
||||
// DotBrushConverter ("<key>Brush"), so they must match tokens in Tokens.axaml.
|
||||
private static (string? Tint, string? ColorKey, string? LabelKey) Decorate(
|
||||
ClaudeDo.Data.Models.TaskStatus status, int roadblockCount, bool blocked)
|
||||
ClaudeDo.Data.Models.TaskStatus status, int roadblockCount, bool blocked, bool verifying)
|
||||
=> status switch
|
||||
{
|
||||
ClaudeDo.Data.Models.TaskStatus.Failed => ("ErrorTint", "StatusError", "missionControl.failed"),
|
||||
ClaudeDo.Data.Models.TaskStatus.WaitingForReview when roadblockCount > 0
|
||||
=> ("RoadblockTint", "Amber", "missionControl.roadblock"),
|
||||
ClaudeDo.Data.Models.TaskStatus.WaitingForReview when verifying
|
||||
=> ("RunningTint", "StatusRunning", "missionControl.verifying"),
|
||||
ClaudeDo.Data.Models.TaskStatus.WaitingForReview => ("DoneTint", "StatusReview", "missionControl.review"),
|
||||
ClaudeDo.Data.Models.TaskStatus.Running => ("RunningTint", "StatusRunning", "missionControl.running"),
|
||||
_ when blocked => (null, "Amber", "missionControl.blocked"),
|
||||
@@ -182,15 +274,16 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
// 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);
|
||||
var folders = new List<(string Label, string Dir)> { (Loc.T("missionControl.homeFolder"), home) };
|
||||
SessionTargets.Add(new SessionTargetViewModel
|
||||
{
|
||||
Label = Loc.T("missionControl.homeFolder"),
|
||||
Label = folders[0].Label,
|
||||
StartCommand = new RelayCommand(() => _ = OpenAdHocConPtySessionAsync(home)),
|
||||
});
|
||||
SessionTargets.Add(new SessionTargetViewModel
|
||||
{
|
||||
Label = Loc.T("missionControl.browseForFolder"),
|
||||
StartCommand = new RelayCommand(() => BrowseForSessionRequested?.Invoke()),
|
||||
StartCommand = new RelayCommand(() => _ = BrowseAndOpenAsync(OpenAdHocConPtySessionAsync)),
|
||||
});
|
||||
try
|
||||
{
|
||||
@@ -204,14 +297,183 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
foreach (var l in lists)
|
||||
{
|
||||
var dir = l.WorkingDir!;
|
||||
folders.Add((l.Name, dir));
|
||||
SessionTargets.Add(new SessionTargetViewModel
|
||||
{
|
||||
Label = l.Name,
|
||||
StartCommand = new RelayCommand(() => _ = OpenAdHocConPtySessionAsync(dir)),
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var ws in await new AppSettingsRepository(ctx).GetSshWorkspacesAsync())
|
||||
{
|
||||
var w = ws;
|
||||
SessionTargets.Add(new SessionTargetViewModel
|
||||
{
|
||||
Label = Loc.T("missionControl.sshTarget", w.Name),
|
||||
StartCommand = new RelayCommand(() => _ = OpenSshConPtySessionAsync(w)),
|
||||
});
|
||||
}
|
||||
}
|
||||
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.queueRefreshFailed", ex.Message)); }
|
||||
|
||||
// Outside the try: custom tiles come from ui.config.json and still work without the DB.
|
||||
foreach (var tile in _settings?.CustomTiles ?? [])
|
||||
{
|
||||
var t = tile;
|
||||
var label = Loc.T("missionControl.customTarget", t.Name);
|
||||
if (!string.IsNullOrWhiteSpace(t.Folder))
|
||||
{
|
||||
SessionTargets.Add(new SessionTargetViewModel
|
||||
{
|
||||
Label = label,
|
||||
StartCommand = new RelayCommand(() => OpenCustomTileSession(t, t.Folder)),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
var children = folders
|
||||
.Select(f => new SessionTargetViewModel
|
||||
{
|
||||
Label = f.Label,
|
||||
StartCommand = new RelayCommand(() => OpenCustomTileSession(t, f.Dir)),
|
||||
})
|
||||
.Append(new SessionTargetViewModel
|
||||
{
|
||||
Label = Loc.T("missionControl.browseForFolder"),
|
||||
StartCommand = new RelayCommand(() => _ = BrowseAndOpenAsync(dir =>
|
||||
{
|
||||
OpenCustomTileSession(t, dir);
|
||||
return System.Threading.Tasks.Task.CompletedTask;
|
||||
})),
|
||||
})
|
||||
.ToList();
|
||||
SessionTargets.Add(new SessionTargetViewModel { Label = label, Children = children });
|
||||
}
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task BrowseAndOpenAsync(Func<string, System.Threading.Tasks.Task> open)
|
||||
{
|
||||
if (PickFolderAsync is null) return;
|
||||
if (await PickFolderAsync() is { Length: > 0 } dir) await open(dir);
|
||||
}
|
||||
|
||||
/// <summary>Opens a user-defined program from ui.config.json as a tile. Never deduped, never
|
||||
/// recorded for resume — the UI builds the launch itself, the worker is not involved.</summary>
|
||||
internal void OpenCustomTileSession(CustomTile tile, string directory)
|
||||
{
|
||||
var paneId = Guid.NewGuid().ToString("n");
|
||||
AddConPtyPane(new ConPtyPaneViewModel(null, tile.Name, () =>
|
||||
{
|
||||
// Throws into the pane's start-failure line, same as a worker-side launch error.
|
||||
var resolved = ClaudeDo.Data.Environment.ExecutableResolver.Resolve(tile.Program.Trim())
|
||||
?? throw new InvalidOperationException(Loc.T("missionControl.customProgramNotFound", tile.Program));
|
||||
var args = CustomTile.SplitArgs(tile.Arguments);
|
||||
// .cmd/.bat shims can't be the process image of a pseudo console; run them through cmd.
|
||||
var (exe, argv) = resolved.IsShim
|
||||
? ("cmd.exe", (IReadOnlyList<string>)new[] { "/c", resolved.Path }.Concat(args).ToList())
|
||||
: (resolved.Path, args);
|
||||
return System.Threading.Tasks.Task.FromResult(new TerminalLaunchDescriptor(
|
||||
directory, exe, argv, new Dictionary<string, string>()));
|
||||
}) { IdentityKey = paneId });
|
||||
}
|
||||
|
||||
/// <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;
|
||||
RecentSessionViewModel row = null!;
|
||||
row = new RecentSessionViewModel
|
||||
{
|
||||
SessionId = s.SessionId,
|
||||
Title = s.Title,
|
||||
Meta = isHandoff ? $"{Loc.T("missionControl.handoffSession")} · {time}" : $"{time} · {s.Cwd}",
|
||||
IsHandoff = isHandoff,
|
||||
ResumeCommand = new RelayCommand(() => ResumeSession(s, row.Title, taskCurrent.Contains(s.SessionId))),
|
||||
ShowBriefCommand = briefPath is null ? null : new RelayCommand(() => ShowBrief(briefPath)),
|
||||
CommitRenameCommand = new RelayCommand(() => _ = RenameSessionAsync(row)),
|
||||
};
|
||||
RecentSessions.Add(row);
|
||||
}
|
||||
}
|
||||
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, string title, 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, title,
|
||||
() => DescribeAsync(() => _worker.GetResumeSessionLaunchSpecAsync(sessionId, paneId))) { IdentityKey = paneId });
|
||||
}
|
||||
|
||||
/// <summary>Commits an inline rename: trims, ignores empty/unchanged input, persists the title.</summary>
|
||||
internal async System.Threading.Tasks.Task RenameSessionAsync(RecentSessionViewModel row)
|
||||
{
|
||||
if (!row.IsEditing) return;
|
||||
row.IsEditing = false;
|
||||
|
||||
var title = row.EditText.Trim();
|
||||
if (title.Length == 0 || title == row.Title) return;
|
||||
try
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
await new MissionControlSessionRepository(ctx).RenameAsync(row.SessionId, title);
|
||||
row.Title = title;
|
||||
}
|
||||
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.renameFailed", ex.Message)); }
|
||||
}
|
||||
|
||||
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
|
||||
@@ -244,6 +506,32 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
[RelayCommand]
|
||||
private void ToggleLayout() => IsFocusMode = !IsFocusMode;
|
||||
|
||||
/// <summary>Upper bound of the column picker.</summary>
|
||||
public const int MaxColumns = 4;
|
||||
|
||||
/// <summary>Sets the column preference from the header picker ("0" = auto) and persists it.</summary>
|
||||
[RelayCommand]
|
||||
private void SetColumns(string value) => ColumnPreference = Math.Clamp(int.Parse(value), 0, MaxColumns);
|
||||
|
||||
partial void OnColumnPreferenceChanged(int value)
|
||||
{
|
||||
UpdateColumnCount();
|
||||
if (_settings is null) return;
|
||||
_settings.MissionControlColumns = value;
|
||||
try { _settings.Save(); }
|
||||
catch (Exception ex) { ErrorReported?.Invoke(ex.Message); }
|
||||
}
|
||||
|
||||
// Never more columns than panes, so a wide preference doesn't leave empty cells.
|
||||
private void UpdateColumnCount() => ColumnCount = ColumnPreference > 0
|
||||
? Math.Max(1, Math.Min(ColumnPreference, Panes.Count))
|
||||
: Panes.Count switch
|
||||
{
|
||||
<= 1 => 1,
|
||||
<= 4 => 2,
|
||||
_ => 3,
|
||||
};
|
||||
|
||||
// Fetches the launch spec for a task's worktree and hosts an embedded ConPTY session as a
|
||||
// Command Center pane (task-based only).
|
||||
public async System.Threading.Tasks.Task OpenConPtySessionAsync(string taskId)
|
||||
@@ -322,11 +610,40 @@ 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>Interactive session on an SSH workspace's server. Never deduped, never recorded for resume.</summary>
|
||||
public System.Threading.Tasks.Task OpenSshConPtySessionAsync(SshWorkspace workspace)
|
||||
{
|
||||
var paneId = Guid.NewGuid().ToString("n");
|
||||
AddConPtyPane(new ConPtyPaneViewModel(null, workspace.Name,
|
||||
() => DescribeAsync(() => _worker.GetSshLaunchSpecAsync(workspace.Id, paneId))) { IdentityKey = paneId });
|
||||
return System.Threading.Tasks.Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>A session called handoff_session: replaces its tile (same slot, same IdentityKey, so
|
||||
/// same colour) with a fresh session in the same cwd that starts from the brief. No matching tile
|
||||
/// (closed meanwhile, or another app instance) = nothing to replace.</summary>
|
||||
internal void OpenSessionHandoff(string paneId, string briefPath)
|
||||
{
|
||||
if (ConPtySessions.FirstOrDefault(s => s.IdentityKey == paneId) is not { Cwd: { } cwd } outgoing) return;
|
||||
OpenRequested?.Invoke();
|
||||
|
||||
var slot = ConPtySessions.IndexOf(outgoing);
|
||||
var taskId = outgoing.TaskId;
|
||||
var title = outgoing.DisplayTitle;
|
||||
CloseConPtySession(outgoing);
|
||||
|
||||
AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
|
||||
() => DescribeAsync(() => _worker.GetSessionHandoffLaunchSpecAsync(paneId, cwd, briefPath, taskId)))
|
||||
{ IdentityKey = paneId }, slot);
|
||||
}
|
||||
|
||||
// List-handler session over a hand-picked set of tasks ("Let Claude handle it"). Task-based:
|
||||
// creates one new ClaudeDo task per run to own the session (title/diff/result), deduped by
|
||||
// TaskId like OpenConPtySessionAsync. The handler still merges the tasks it handles itself
|
||||
@@ -408,6 +725,11 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
if (string.IsNullOrEmpty(taskId) || survivingTaskIds is not { Count: > 0 }) return;
|
||||
OpenRequested?.Invoke();
|
||||
|
||||
// Keep the tile where it was: the incoming phase re-enters at the outgoing pane's slot
|
||||
// instead of being appended, so a handoff doesn't reshuffle every other tile in the grid.
|
||||
var slot = ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is { } first
|
||||
? ConPtySessions.IndexOf(first)
|
||||
: (int?)null;
|
||||
foreach (var stale in ConPtySessions.Where(s => s.TaskId == taskId).ToList())
|
||||
CloseConPtySession(stale);
|
||||
|
||||
@@ -428,7 +750,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
|
||||
AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
|
||||
() => DescribeAsync(() => _worker.GetMergeHelperHandoffLaunchSpecAsync(taskId, survivingTaskIds, nextPhase)))
|
||||
{ IsListHandler = true });
|
||||
{ IsListHandler = true }, slot);
|
||||
}
|
||||
|
||||
// The 5h usage window is about to run out under this handler session: type the worker's warning
|
||||
@@ -444,16 +766,27 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
ErrorReported?.Invoke(Loc.T("missionControl.usageWarningNotDelivered"));
|
||||
}
|
||||
|
||||
// A handler session's handoff is deferred due to usage limits: update the pane title to show
|
||||
// the reset time. No pane = nothing to update, not an error.
|
||||
private void UpdateHandlerSessionTitleAsync(string taskId, DateTimeOffset dueAt)
|
||||
{
|
||||
if (string.IsNullOrEmpty(taskId)) return;
|
||||
if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is not { } pane) return;
|
||||
|
||||
var suffix = Loc.T("missionControl.deferredHandoffTitleSuffix", dueAt.LocalDateTime.ToString("HH:mm"));
|
||||
pane.DisplayTitle += $" — {suffix}";
|
||||
}
|
||||
|
||||
// Wires a freshly built pane and shows it immediately — the pane resolves its own launch spec,
|
||||
// so the tile is on screen (spinner running) while the worker is still preparing the worktree.
|
||||
private void AddConPtyPane(ConPtyPaneViewModel pane)
|
||||
private void AddConPtyPane(ConPtyPaneViewModel pane, int? index = null)
|
||||
{
|
||||
pane.ErrorReported += OnConPtyPaneError;
|
||||
pane.CloseRequested += OnPaneCloseRequested;
|
||||
pane.SubmitForReviewRequested += OnPaneSubmitForReview;
|
||||
pane.SessionDiedAfterStartup += OnPaneSessionDied;
|
||||
pane.AbandonRunRequested += OnPaneAbandonRun;
|
||||
ConPtySessions.Add(pane);
|
||||
ConPtySessions.Insert(Math.Min(index ?? ConPtySessions.Count, ConPtySessions.Count), pane);
|
||||
pane.Start();
|
||||
}
|
||||
|
||||
@@ -571,7 +904,18 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
pane.Dispose();
|
||||
}
|
||||
|
||||
// Mirrors ConPtySessions' add/remove 1:1 into Panes (typed as the pane abstraction).
|
||||
/// <summary>Swaps two tiles' slots (overview-grid drag and drop); also reorders the focus-mode tabs.</summary>
|
||||
public void SwapPanes(ConPtyPaneViewModel a, ConPtyPaneViewModel b)
|
||||
{
|
||||
var i = ConPtySessions.IndexOf(a);
|
||||
var j = ConPtySessions.IndexOf(b);
|
||||
if (i < 0 || j < 0 || i == j) return;
|
||||
if (i > j) (i, j) = (j, i);
|
||||
ConPtySessions.Move(i, j);
|
||||
ConPtySessions.Move(j - 1, i);
|
||||
}
|
||||
|
||||
// Mirrors ConPtySessions' add/remove/move 1:1 into Panes (typed as the pane abstraction).
|
||||
private void OnConPtySessionsChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
switch (e.Action)
|
||||
@@ -582,6 +926,9 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
case NotifyCollectionChangedAction.Remove:
|
||||
Panes.RemoveAt(e.OldStartingIndex);
|
||||
break;
|
||||
case NotifyCollectionChangedAction.Move:
|
||||
Panes.Move(e.OldStartingIndex, e.NewStartingIndex);
|
||||
break;
|
||||
default: // Reset
|
||||
Panes.Clear();
|
||||
break;
|
||||
@@ -590,12 +937,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;
|
||||
@@ -609,9 +951,13 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
_worker.TaskFinishedEvent -= _onTaskFinished;
|
||||
_worker.TaskUpdatedEvent -= _onTaskUpdated;
|
||||
_worker.ConnectionRestoredEvent -= _onConnectionRestored;
|
||||
_worker.OperationStartedEvent -= _onOperationStarted;
|
||||
_worker.OperationEndedEvent -= _onOperationEnded;
|
||||
_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())
|
||||
@@ -633,6 +979,9 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
public sealed class QueuedTaskViewModel
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
/// <summary>Task number (#N); 0 for rows created outside <c>TaskNumberAllocator</c>.</summary>
|
||||
public int Number { get; init; }
|
||||
public bool ShowNumber => Number > 0;
|
||||
public required string Title { get; init; }
|
||||
public bool IsRunning { get; init; }
|
||||
/// <summary>Brush key of the row tint (e.g. "RunningTint"); null = untinted.</summary>
|
||||
@@ -647,9 +996,89 @@ public sealed class QueuedTaskViewModel
|
||||
public IRelayCommand? OpenSessionCommand { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>One entry in the "new session" menu: a configured list's working dir, or browse.</summary>
|
||||
/// <summary>One row in the "Running now" section: a Worker operation with a ticking elapsed time.</summary>
|
||||
public sealed partial class RunningOperationViewModel : ObservableObject
|
||||
{
|
||||
private readonly DateTime _startedAtUtc;
|
||||
|
||||
public string Id { get; }
|
||||
/// <summary>Wire kind token (<see cref="OperationKinds"/>).</summary>
|
||||
public string Kind { get; }
|
||||
/// <summary>The task the operation runs for; null for a batch verify.</summary>
|
||||
public string? TaskId { get; }
|
||||
/// <summary>Localized operation kind ("Verifying after merge", "Rebasing", ...).</summary>
|
||||
public string KindLabel { get; }
|
||||
/// <summary><c>#Number Title</c> for a task operation, the list name for a batch verify.</summary>
|
||||
public string Subject { get; }
|
||||
|
||||
[ObservableProperty] private string _elapsed;
|
||||
|
||||
public RunningOperationViewModel(RunningOperationDto op)
|
||||
{
|
||||
Id = op.Id;
|
||||
Kind = op.Kind;
|
||||
TaskId = op.TaskId;
|
||||
_startedAtUtc = op.StartedAt;
|
||||
KindLabel = op.Kind switch
|
||||
{
|
||||
OperationKinds.VerifyMerge => Loc.T("missionControl.opVerifyMerge"),
|
||||
OperationKinds.VerifyBatch => Loc.T("missionControl.opVerifyBatch"),
|
||||
OperationKinds.Merging => Loc.T("missionControl.opMerging"),
|
||||
OperationKinds.Rebasing => Loc.T("missionControl.opRebasing"),
|
||||
OperationKinds.CreatingWorktree => Loc.T("missionControl.opCreatingWorktree"),
|
||||
_ => op.Kind,
|
||||
};
|
||||
Subject = op.TaskNumber is > 0 ? $"#{op.TaskNumber} {op.Title}" : op.Title;
|
||||
_elapsed = Format(DateTime.UtcNow - _startedAtUtc);
|
||||
}
|
||||
|
||||
public void Tick(DateTime nowUtc) => Elapsed = Format(nowUtc - _startedAtUtc);
|
||||
|
||||
private static string Format(TimeSpan t)
|
||||
{
|
||||
if (t < TimeSpan.Zero) t = TimeSpan.Zero;
|
||||
return t.TotalHours >= 1 ? t.ToString(@"h\:mm\:ss") : t.ToString(@"mm\:ss");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One row in the "Recent sessions" flyout.</summary>
|
||||
public sealed partial class RecentSessionViewModel : ObservableObject
|
||||
{
|
||||
public required string SessionId { get; init; }
|
||||
/// <summary>Display title; updated in place once a rename is persisted.</summary>
|
||||
[ObservableProperty] private string _title = "";
|
||||
/// <summary>True while the inline title TextBox is shown.</summary>
|
||||
[ObservableProperty] private bool _isEditing;
|
||||
/// <summary>The TextBox's working value, kept apart from <see cref="Title"/> so Escape needs no restore.</summary>
|
||||
[ObservableProperty] private string _editText = "";
|
||||
/// <summary>Commits <see cref="EditText"/> as the new title.</summary>
|
||||
public required IRelayCommand CommitRenameCommand { get; init; }
|
||||
|
||||
/// <summary>Switches the row into inline edit mode, seeded with the current title.</summary>
|
||||
public void BeginEdit()
|
||||
{
|
||||
EditText = Title;
|
||||
IsEditing = true;
|
||||
}
|
||||
|
||||
/// <summary>Leaves edit mode without saving.</summary>
|
||||
public void CancelEdit() => IsEditing = false;
|
||||
|
||||
/// <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 folder to start in, browse, an SSH workspace, or a
|
||||
/// custom tile — which, without a fixed folder, opens a submenu of folders instead of starting.</summary>
|
||||
public sealed class SessionTargetViewModel
|
||||
{
|
||||
public required string Label { get; init; }
|
||||
public required IRelayCommand StartCommand { get; init; }
|
||||
public IRelayCommand? StartCommand { get; init; }
|
||||
public IReadOnlyList<SessionTargetViewModel>? Children { get; init; }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using Material.Icons;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Modals;
|
||||
|
||||
@@ -63,7 +64,14 @@ public sealed partial class DiffTreeNodeViewModel : ViewModelBase
|
||||
public string RelativePath { get; init; } = "";
|
||||
public DiffFileViewModel? File { get; init; }
|
||||
public ObservableCollection<DiffTreeNodeViewModel> Children { get; } = new();
|
||||
[ObservableProperty] private bool _isExpanded = true;
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IconKey))]
|
||||
private bool _isExpanded = true;
|
||||
|
||||
/// <summary>The row's <c>mdi:</c> icon key: open/closed folder, or the file-type icon.</summary>
|
||||
public string IconKey => IsDirectory
|
||||
? (IsExpanded ? "mdi:FolderOpen" : "mdi:Folder")
|
||||
: DiffFileIcons.ForPath(Name);
|
||||
|
||||
public string? StatusCode => File?.StatusCode;
|
||||
public bool ShowStats => File is { IsBinary: false, IsPureRename: false };
|
||||
@@ -71,6 +79,36 @@ public sealed partial class DiffTreeNodeViewModel : ViewModelBase
|
||||
public int Deletions => File?.Deletions ?? 0;
|
||||
}
|
||||
|
||||
/// <summary>Maps a file name's extension to an <c>mdi:</c> icon key for the diff file tree.</summary>
|
||||
public static class DiffFileIcons
|
||||
{
|
||||
private const MaterialIconKind Fallback = MaterialIconKind.FileOutline;
|
||||
|
||||
private static readonly Dictionary<string, MaterialIconKind> ByExtension = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
[".cs"] = MaterialIconKind.LanguageCsharp,
|
||||
[".axaml"] = MaterialIconKind.CodeTags,
|
||||
[".xaml"] = MaterialIconKind.CodeTags,
|
||||
[".xml"] = MaterialIconKind.CodeTags,
|
||||
[".json"] = MaterialIconKind.CodeJson,
|
||||
[".md"] = MaterialIconKind.LanguageMarkdownOutline,
|
||||
[".csproj"] = MaterialIconKind.FileCogOutline,
|
||||
[".slnx"] = MaterialIconKind.FileCogOutline,
|
||||
[".ps1"] = MaterialIconKind.Powershell,
|
||||
[".sh"] = MaterialIconKind.Console,
|
||||
[".png"] = MaterialIconKind.FileImageOutline,
|
||||
[".jpg"] = MaterialIconKind.FileImageOutline,
|
||||
[".jpeg"] = MaterialIconKind.FileImageOutline,
|
||||
[".gif"] = MaterialIconKind.FileImageOutline,
|
||||
[".svg"] = MaterialIconKind.FileImageOutline,
|
||||
[".ico"] = MaterialIconKind.FileImageOutline,
|
||||
};
|
||||
|
||||
/// <summary>Icon key for a file name or path; unknown or missing extensions fall back to a generic file.</summary>
|
||||
public static string ForPath(string path) =>
|
||||
"mdi:" + (ByExtension.TryGetValue(System.IO.Path.GetExtension(path), out var kind) ? kind : Fallback);
|
||||
}
|
||||
|
||||
/// Builds a folder-grouped tree from a flat list of parsed diff files.
|
||||
public static class DiffTree
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
@@ -57,6 +59,8 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
// 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;
|
||||
// Basis neuer Worktrees und Default-Merge-Ziel; leer = ausgecheckter Branch.
|
||||
[ObservableProperty] private string _baseBranch = "";
|
||||
// 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;
|
||||
@@ -111,25 +115,124 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
|
||||
public ObservableCollection<string> CommitTypeOptions { get; } = new(CommitTypeRegistry.Types);
|
||||
|
||||
public const string DefaultIcon = "Folder";
|
||||
public const string DefaultIcon = "Dot";
|
||||
|
||||
// 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.
|
||||
// Grouped by theme so the picker grid reads in rows; every entry needs a matching
|
||||
// "Icon.<key>" StreamGeometry or it renders as an empty slot.
|
||||
public static readonly string[] IconOptions =
|
||||
[
|
||||
/// <summary>Picker swatches: "" (default) followed by the accent-aware palette keys.</summary>
|
||||
public static readonly string[] SwatchOptions = ["", .. AccentPresets.SwatchKeys];
|
||||
|
||||
/// <summary>Stored colour: "" (default), a swatch key, or a literal "#RRGGBB". Shared by lists and folders.</summary>
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsCustomColor))]
|
||||
private string _selectedColor = "";
|
||||
|
||||
/// <summary>True when <see cref="SelectedColor"/> is a custom hex rather than a swatch.</summary>
|
||||
public bool IsCustomColor => SelectedColor.StartsWith('#');
|
||||
|
||||
// Separate from SelectedColor: the ListBox pushes null when a custom hex (not in SwatchOptions) is current.
|
||||
[ObservableProperty] private string? _selectedSwatch = "";
|
||||
|
||||
/// <summary>Bound to the custom ColorPicker; writes back as "#RRGGBB".</summary>
|
||||
[ObservableProperty] private Avalonia.Media.Color _customColor = Avalonia.Media.Color.Parse(AccentPresets.Default.Accent);
|
||||
|
||||
private bool _syncingColor;
|
||||
|
||||
partial void OnSelectedColorChanged(string value)
|
||||
{
|
||||
_syncingColor = true;
|
||||
SelectedSwatch = SwatchOptions.Contains(value) ? value : null;
|
||||
if (IsCustomColor && Avalonia.Media.Color.TryParse(value, out var c)) CustomColor = c;
|
||||
_syncingColor = false;
|
||||
}
|
||||
|
||||
partial void OnSelectedSwatchChanged(string? value)
|
||||
{
|
||||
if (!_syncingColor && value is not null) SelectedColor = value;
|
||||
}
|
||||
|
||||
partial void OnCustomColorChanged(Avalonia.Media.Color value)
|
||||
{
|
||||
if (!_syncingColor) SelectedColor = $"#{value.R:X2}{value.G:X2}{value.B:X2}";
|
||||
}
|
||||
|
||||
/// <summary>Default icon for folders, kept apart from <see cref="DefaultIcon"/> so lists never look like folders.</summary>
|
||||
public const string DefaultFolderIcon = "Folder";
|
||||
|
||||
// Curated picker set, grouped by theme so the grid reads in rows. Each legacy key is offered as its
|
||||
// MDI replacement (IconKeyConverter.LegacyKinds); "Dot" and "Folder" stay bare because they are the
|
||||
// default-icon constants the sidebar compares against.
|
||||
public static readonly string[] IconOptions = new[]
|
||||
{
|
||||
// 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
|
||||
"Activity", "Eye", "Check", "Warning", "Chart", "Rocket", "Bulb", "Broom", "AgentSuggested", "Settings",
|
||||
// things
|
||||
"Box", "Truck", "MapPin", "Camera", "Image", "Barcode", "Skull",
|
||||
];
|
||||
}.Select(k => k is DefaultIcon or DefaultFolderIcon ? k : IconKeyConverter.MdiPrefix + IconKeyConverter.LegacyKinds[k]).ToArray();
|
||||
|
||||
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, a legacy bare key still stored in the DB, or an existing "mdi:" kind.</summary>
|
||||
public static bool IsKnownIcon(string? key) =>
|
||||
key is not null && (IconOptions.Contains(key)
|
||||
|| IconKeyConverter.LegacyKinds.ContainsKey(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).
|
||||
@@ -155,23 +258,25 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
bool isManual = false,
|
||||
bool findingsTracked = false,
|
||||
string? icon = null,
|
||||
bool canToggleFolder = false)
|
||||
bool canToggleFolder = false,
|
||||
string? color = null)
|
||||
{
|
||||
CanToggleFolder = canToggleFolder;
|
||||
_ = LoadAsync(listId, name, workingDir, defaultCommitType, isManual, findingsTracked, icon);
|
||||
_ = LoadAsync(listId, name, workingDir, defaultCommitType, isManual, findingsTracked, icon, color);
|
||||
}
|
||||
|
||||
/// <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)
|
||||
public void BeginLoadFolder(string folderId, string name, string? icon, string? color = null)
|
||||
{
|
||||
SelectedColor = color ?? "";
|
||||
_folderId = folderId;
|
||||
IsFolder = true;
|
||||
CanToggleFolder = false;
|
||||
Name = name;
|
||||
Icon = IconOptions.Contains(icon) ? icon! : DefaultIcon;
|
||||
Icon = IsKnownIcon(icon) ? icon! : DefaultFolderIcon;
|
||||
}
|
||||
|
||||
public async Task LoadAsync(
|
||||
@@ -182,6 +287,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
bool isManual = false,
|
||||
bool findingsTracked = false,
|
||||
string? icon = null,
|
||||
string? color = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ListConfigDto? cfg = null;
|
||||
@@ -195,7 +301,8 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
FindingsTracked = findingsTracked;
|
||||
// An unknown/legacy key would leave the picker with no selection and silently reset the
|
||||
// icon on the next save — fall back to the default instead.
|
||||
Icon = IconOptions.Contains(icon) ? icon! : DefaultIcon;
|
||||
Icon = IsKnownIcon(icon) ? icon! : DefaultIcon;
|
||||
SelectedColor = color ?? "";
|
||||
WorkingDir = workingDir ?? "";
|
||||
DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? CommitTypeRegistry.DefaultType : defaultCommitType;
|
||||
|
||||
@@ -203,6 +310,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
cfg = Agent.LastLoadedListConfig;
|
||||
VerifyCommand = cfg?.VerifyCommand ?? "";
|
||||
PowerUseAllowed = cfg?.PowerUseAllowed ?? false;
|
||||
BaseBranch = cfg?.BaseBranch ?? "";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -261,6 +369,13 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
// A manual list without a WorkingDir is untouched (empty is always valid) — this only fires
|
||||
// for the "picked a folder that turns out not to be a repo" case the audit found: it would
|
||||
// otherwise only surface as a Failed task the first time the worker tries to use it.
|
||||
// The create-flow toggle swaps the untouched default so a new folder doesn't get the list icon.
|
||||
partial void OnIsFolderChanged(bool value)
|
||||
{
|
||||
if (Icon == (value ? DefaultIcon : DefaultFolderIcon))
|
||||
Icon = value ? DefaultFolderIcon : DefaultIcon;
|
||||
}
|
||||
|
||||
partial void OnWorkingDirChanged(string value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) { WorkingDirWarning = ""; return; }
|
||||
@@ -285,7 +400,8 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
DefaultCommitType,
|
||||
IsManual,
|
||||
FindingsTracked,
|
||||
Icon));
|
||||
Icon,
|
||||
NullIfEmpty(SelectedColor)));
|
||||
|
||||
// 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
|
||||
@@ -298,12 +414,16 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
await Agent.SaveAsync(
|
||||
string.IsNullOrWhiteSpace(VerifyCommand) ? null : VerifyCommand,
|
||||
ticketProjectId,
|
||||
PowerUseAllowed);
|
||||
PowerUseAllowed,
|
||||
// Always explicit too: "" clears the stored base branch.
|
||||
BaseBranch.Trim());
|
||||
|
||||
Saved = true;
|
||||
CloseAction?.Invoke();
|
||||
}
|
||||
|
||||
private static string? NullIfEmpty(string s) => s.Length == 0 ? null : s;
|
||||
|
||||
private async Task SaveFolderAsync()
|
||||
{
|
||||
var name = string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name;
|
||||
@@ -317,6 +437,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
Name = name,
|
||||
Icon = Icon,
|
||||
Color = NullIfEmpty(SelectedColor),
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
// The create flow pre-creates a list row for this modal to save against; a folder
|
||||
@@ -327,7 +448,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
}
|
||||
else
|
||||
{
|
||||
await lists.RenameFolderAsync(_folderId, name, Icon);
|
||||
await lists.RenameFolderAsync(_folderId, name, Icon, NullIfEmpty(SelectedColor));
|
||||
}
|
||||
|
||||
Saved = true;
|
||||
|
||||
@@ -15,15 +15,16 @@ using CommunityToolkit.Mvvm.Input;
|
||||
namespace ClaudeDo.Ui.ViewModels.Modals;
|
||||
|
||||
/// <summary>
|
||||
/// Log Visualizer overlay — shows the worker's last 30 min of log records (all levels),
|
||||
/// fetched once on open via <see cref="IWorkerClient.GetRecentLogsAsync"/> with a manual
|
||||
/// Refresh and a "warnings & errors only" filter.
|
||||
/// Log Visualizer overlay — shows the worker's last 30 min of log records (all levels, via
|
||||
/// <see cref="IWorkerClient.GetRecentLogsAsync"/>) merged with the shell's footer-strip history,
|
||||
/// with a manual Refresh, a 4 s tick and a "warnings & errors only" filter.
|
||||
/// </summary>
|
||||
public sealed partial class LogVisualizerViewModel : ViewModelBase
|
||||
{
|
||||
private const int CopyLastCount = 40;
|
||||
|
||||
private readonly IWorkerClient _worker;
|
||||
private readonly Func<IReadOnlyList<WorkerLogEntry>> _footerHistory;
|
||||
private readonly Func<string, Task<bool>> _copyToClipboard;
|
||||
private IReadOnlyList<WorkerLogEntry> _all = Array.Empty<WorkerLogEntry>();
|
||||
|
||||
@@ -34,11 +35,15 @@ public sealed partial class LogVisualizerViewModel : ViewModelBase
|
||||
|
||||
public Action? CloseAction { get; set; }
|
||||
|
||||
public LogVisualizerViewModel(IWorkerClient worker) : this(worker, CopyToClipboardAsync) { }
|
||||
/// <summary>Creates the overlay; <paramref name="footerHistory"/> returns the shell's footer lines oldest-first.</summary>
|
||||
public LogVisualizerViewModel(IWorkerClient worker, Func<IReadOnlyList<WorkerLogEntry>> footerHistory)
|
||||
: this(worker, footerHistory, CopyToClipboardAsync) { }
|
||||
|
||||
internal LogVisualizerViewModel(IWorkerClient worker, Func<string, Task<bool>> copyToClipboard)
|
||||
internal LogVisualizerViewModel(IWorkerClient worker, Func<IReadOnlyList<WorkerLogEntry>> footerHistory,
|
||||
Func<string, Task<bool>> copyToClipboard)
|
||||
{
|
||||
_worker = worker;
|
||||
_footerHistory = footerHistory;
|
||||
_copyToClipboard = copyToClipboard;
|
||||
Rows.CollectionChanged += (_, _) => CopyLastCommand.NotifyCanExecuteChanged();
|
||||
// Phase 3 reconcile tick: this overlay is long-lived (stays open while the user reads
|
||||
@@ -55,7 +60,18 @@ public sealed partial class LogVisualizerViewModel : ViewModelBase
|
||||
public async Task RefreshAsync()
|
||||
{
|
||||
var latest = await _worker.GetRecentLogsAsync();
|
||||
Reconcile(latest);
|
||||
Reconcile(Merge(latest));
|
||||
}
|
||||
|
||||
// Worker Warn/Error lines reach the footer through BroadcastLogSink with the exact message,
|
||||
// level and timestamp of their ring-buffer copy, so record equality drops the duplicate.
|
||||
// OrderBy is stable: buffer entries precede footer entries on a timestamp tie, which keeps
|
||||
// the merged order identical across ticks (Reconcile's overlap match depends on that).
|
||||
private List<WorkerLogEntry> Merge(IReadOnlyList<WorkerLogEntry> buffer)
|
||||
{
|
||||
var known = buffer.ToHashSet();
|
||||
return buffer.Concat(_footerHistory().Where(e => !known.Contains(e)))
|
||||
.OrderBy(e => e.TimestampUtc).ToList();
|
||||
}
|
||||
|
||||
partial void OnWarnErrorOnlyChanged(bool value) => Apply();
|
||||
@@ -71,11 +87,13 @@ public sealed partial class LogVisualizerViewModel : ViewModelBase
|
||||
UpdateStatusText();
|
||||
}
|
||||
|
||||
// `_all` (oldest-first) mirrors the worker's LogRingBuffer: append-mostly, with the oldest
|
||||
// `_all` (oldest-first) is the merged buffer + footer list: append-mostly, with the oldest
|
||||
// entries falling off the front once they age past the 30-min window. A full Rows.Clear()
|
||||
// on every 4s tick would reset the user's scroll position/selection while they're mid-read,
|
||||
// so patch just the delta instead: entries still present keep their row, aged-out entries
|
||||
// are trimmed off the (oldest) tail of Rows, new entries are inserted at the (newest) head.
|
||||
// An entry merged into the middle shrinks the overlap, so more rows are dropped and
|
||||
// re-added — still correct, it just doesn't preserve scroll for that rare tick.
|
||||
private void Reconcile(IReadOnlyList<WorkerLogEntry> latest)
|
||||
{
|
||||
var previous = _all;
|
||||
|
||||
@@ -16,11 +16,11 @@ public sealed partial class MergeModalViewModel : ViewModelBase
|
||||
|
||||
public ObservableCollection<string> Branches { get; } = new();
|
||||
|
||||
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(SubmitCommand))] private string? _selectedBranch;
|
||||
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(SubmitCommand), nameof(ResolveConflictsCommand))] private string? _selectedBranch;
|
||||
[ObservableProperty] private bool _removeWorktree = true;
|
||||
[ObservableProperty] private string _commitMessage = "";
|
||||
|
||||
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(SubmitCommand))] private bool _isBusy;
|
||||
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(SubmitCommand), nameof(ResolveConflictsCommand))] private bool _isBusy;
|
||||
|
||||
/// What the pending merge is doing right now, fed by the worker's MergeProgress broadcast.
|
||||
/// A merge whose list has a verify command can occupy this call for minutes — without this the
|
||||
@@ -37,6 +37,11 @@ public sealed partial class MergeModalViewModel : ViewModelBase
|
||||
[ObservableProperty] private bool _showStashRetry;
|
||||
[ObservableProperty] private IReadOnlyList<string> _conflictFiles = Array.Empty<string>();
|
||||
|
||||
/// <summary>The merge preview for the selected branch predicts a conflict — offers the resolver up front.</summary>
|
||||
[ObservableProperty] private bool _previewHasConflict;
|
||||
|
||||
private bool _targetDirty;
|
||||
|
||||
public Action? CloseAction { get; set; }
|
||||
|
||||
/// True once a merge has succeeded — lets the caller (e.g. the diff window)
|
||||
@@ -76,6 +81,15 @@ 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.
|
||||
_targetDirty = targets.TargetDirty;
|
||||
if (targets.TargetDirty)
|
||||
{
|
||||
WarningMessage = Loc.T("vm.merge.targetDirty");
|
||||
ShowStashRetry = true;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -84,6 +98,29 @@ public sealed partial class MergeModalViewModel : ViewModelBase
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
partial void OnSelectedBranchChanged(string? value) => _ = RefreshPreviewAsync(value);
|
||||
|
||||
private async Task RefreshPreviewAsync(string? branch)
|
||||
{
|
||||
PreviewHasConflict = false;
|
||||
if (string.IsNullOrWhiteSpace(branch) || string.IsNullOrEmpty(TaskId)) return;
|
||||
try
|
||||
{
|
||||
var dto = await _worker.PreviewMergeAsync(TaskId, branch);
|
||||
if (SelectedBranch == branch) PreviewHasConflict = dto?.ConflictFiles.Count > 0;
|
||||
}
|
||||
catch { /* preview is a hint only; Merge still reports the real conflict */ }
|
||||
}
|
||||
|
||||
/// <summary>Hands the predicted conflict straight to the resolver; stashes a dirty target, as the warning above announces.</summary>
|
||||
[RelayCommand(CanExecute = nameof(CanSubmit))]
|
||||
private async Task ResolveConflictsAsync()
|
||||
{
|
||||
RoutedToResolver = true;
|
||||
CloseAction?.Invoke();
|
||||
await _merge.ResolveConflictAsync(TaskId, SelectedBranch!, _targetDirty);
|
||||
}
|
||||
|
||||
private bool CanSubmit() =>
|
||||
!IsBusy && !HasConflict && !string.IsNullOrWhiteSpace(SelectedBranch);
|
||||
|
||||
@@ -131,7 +168,7 @@ public sealed partial class MergeModalViewModel : ViewModelBase
|
||||
// which re-starts the merge leaving conflicts in the tree.
|
||||
RoutedToResolver = true;
|
||||
CloseAction?.Invoke();
|
||||
await _merge.ResolveConflictAsync(TaskId, SelectedBranch!);
|
||||
await _merge.ResolveConflictAsync(TaskId, SelectedBranch!, autostash);
|
||||
break;
|
||||
case "blocked":
|
||||
ErrorMessage = Loc.T("vm.merge.blocked", result.ErrorMessage ?? "");
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Modals.Settings;
|
||||
|
||||
/// <summary>Settings tab for user-defined programs offered as Mission Control tiles. Persists to
|
||||
/// ui.config.json (a UI-only preference), so it works without the worker.</summary>
|
||||
public sealed partial class CustomTilesSettingsTabViewModel : ViewModelBase
|
||||
{
|
||||
private readonly AppSettings _settings;
|
||||
|
||||
/// <summary>Configured tiles, edited in place.</summary>
|
||||
public ObservableCollection<CustomTileRowViewModel> Tiles { get; } = new();
|
||||
|
||||
public CustomTilesSettingsTabViewModel(AppSettings settings)
|
||||
{
|
||||
_settings = settings;
|
||||
foreach (var t in settings.CustomTiles)
|
||||
Tiles.Add(new CustomTileRowViewModel(Remove) { Name = t.Name, Program = t.Program, Arguments = t.Arguments, Folder = t.Folder });
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Add() => Tiles.Add(new CustomTileRowViewModel(Remove));
|
||||
|
||||
private void Remove(CustomTileRowViewModel row) => Tiles.Remove(row);
|
||||
|
||||
/// <summary>Localized error for the first row missing its name or program, or null.</summary>
|
||||
public string? Validate()
|
||||
{
|
||||
foreach (var row in Tiles)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(row.Name)) return Loc.T("settings.customTiles.invalid.name");
|
||||
if (string.IsNullOrWhiteSpace(row.Program)) return Loc.T("settings.customTiles.invalid.program", row.Name);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public void Save()
|
||||
{
|
||||
_settings.CustomTiles = Tiles.Select(r => r.ToModel()).ToList();
|
||||
_settings.Save();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>One editable custom tile row.</summary>
|
||||
public sealed partial class CustomTileRowViewModel : ObservableObject
|
||||
{
|
||||
private readonly Action<CustomTileRowViewModel> _remove;
|
||||
|
||||
[ObservableProperty] private string _name = "";
|
||||
[ObservableProperty] private string _program = "";
|
||||
[ObservableProperty] private string _arguments = "";
|
||||
|
||||
/// <summary>Start folder; empty = picked from a submenu at launch.</summary>
|
||||
[ObservableProperty] private string _folder = "";
|
||||
|
||||
public CustomTileRowViewModel(Action<CustomTileRowViewModel> remove) => _remove = remove;
|
||||
|
||||
[RelayCommand]
|
||||
private void Remove() => _remove(this);
|
||||
|
||||
/// <summary>Snapshot as the persisted model, trimmed.</summary>
|
||||
public CustomTile ToModel() => new()
|
||||
{
|
||||
Name = Name.Trim(), Program = Program.Trim(), Arguments = Arguments.Trim(), Folder = Folder.Trim(),
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user