Compare commits
115
Commits
v2.0.0
...
538b4ede09
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
538b4ede09 | ||
|
|
95918414a0 | ||
|
|
9349386675 | ||
|
|
083e1f3948 | ||
|
|
ecbc73495c | ||
|
|
327ae2b69c | ||
|
|
5ba0c09d8f | ||
|
|
78d4e1a46b | ||
|
|
c7d64e9c9b | ||
|
|
7517f2a9b3 | ||
|
|
f4f7c81059 | ||
|
|
2a3ab5504a | ||
|
|
962f68c92b | ||
|
|
d12a888683 | ||
|
|
5c3ec4810e | ||
|
|
49222a92a4 | ||
|
|
109a35c505 | ||
|
|
34b17537fc | ||
|
|
2aaaa23912 | ||
|
|
624ec7a668 | ||
|
|
3e9ea3ad58 | ||
|
|
ef285b21fd | ||
|
|
2612831a5e | ||
|
|
5e27486b18 | ||
|
|
04044bd115 | ||
|
|
798d100636 | ||
|
|
e8f7e3a47a | ||
|
|
8a7275a75f | ||
|
|
f4dd67d595 | ||
|
|
0226c98076 | ||
|
|
b9b3053051 | ||
|
|
671c886c75 | ||
|
|
ffff1ee183 | ||
|
|
da6a70aec1 | ||
|
|
85d0f9dcec | ||
|
|
ad2acddc9a | ||
|
|
efd7cc9b0a | ||
|
|
75a6e0efc4 | ||
|
|
9efc5c90f4 | ||
|
|
6ca8cac79f | ||
|
|
9a1fa3dbaf | ||
|
|
9b4d3431b0 | ||
|
|
a1ba3b6ebb | ||
|
|
9b041ba791 | ||
|
|
39fc594b98 | ||
|
|
b353ed6cfd | ||
|
|
416e47ecef | ||
|
|
e8b5e97a9b | ||
|
|
d19ef5403f | ||
|
|
ded068c564 | ||
|
|
9556e0e848 | ||
|
|
9d39a8fc69 | ||
|
|
b536b6fb66 | ||
|
|
1b80bb0a2d | ||
|
|
4394623bb0 | ||
|
|
f0b0582517 | ||
|
|
07de897147 | ||
|
|
21012283a9 | ||
|
|
8241bf8d41 | ||
|
|
255705d8bf | ||
|
|
3dfd75fba8 | ||
|
|
26c03a5a5f | ||
|
|
3211bfc0f9 | ||
|
|
79ce7afe46 | ||
|
|
0ad93f48b5 | ||
|
|
fee69998f8 | ||
|
|
451afc80f8 | ||
|
|
4b2667062f | ||
|
|
305eb6ee8b | ||
|
|
15bef2a2e3 | ||
|
|
d7ebafd556 | ||
|
|
14e4c086e2 | ||
|
|
0f2d202b01 | ||
|
|
2a4633fdd8 | ||
|
|
3d668da65c | ||
|
|
f18e03354a | ||
|
|
a1986dd485 | ||
|
|
110364aa6c | ||
|
|
0b86ccd74b | ||
|
|
d6891b8bd4 | ||
|
|
377409e633 | ||
|
|
941c8b98cc | ||
|
|
2452e39345 | ||
|
|
816f247d90 | ||
|
|
ad58129194 | ||
|
|
85c7e650c9 | ||
|
|
c412a84fdf | ||
|
|
d8194ad57e | ||
|
|
d9a4627a1f | ||
|
|
2b06ab0ab4 | ||
|
|
bb62740ac8 | ||
|
|
25922a2768 | ||
|
|
3feb08d9d9 | ||
|
|
9ab48d7094 | ||
|
|
0513265c49 | ||
|
|
d28c63d2df | ||
|
|
5f740c05d8 | ||
|
|
1245e75902 | ||
|
|
d91ad2d635 | ||
|
|
17235a6cde | ||
|
|
f33838d028 | ||
|
|
1cfd96c15f | ||
|
|
7c3c061428 | ||
|
|
b4c58087d2 | ||
|
|
4626481359 | ||
|
|
dea2b7db8b | ||
|
|
54cdaf89d5 | ||
|
|
dbaefe92c6 | ||
|
|
62b245aaea | ||
|
|
4e5057d3f6 | ||
|
|
1bf08eca27 | ||
|
|
eb88dc130c | ||
|
|
140ae2fda1 | ||
|
|
865e12c0de | ||
|
|
914fa5aa9f |
+222
@@ -1,5 +1,227 @@
|
||||
# Changelog
|
||||
|
||||
## v2.3.1 — 2026-07-24
|
||||
|
||||
### Fixes
|
||||
|
||||
- retry stashing app/worker through transient file locks on update (9591841)
|
||||
|
||||
### Documentation
|
||||
|
||||
- update for v2.3.0 (9349386)
|
||||
|
||||
## v2.3.0 — 2026-07-24
|
||||
|
||||
### Features
|
||||
|
||||
- open merge-helper ConPTY tile from selection (083e1f3)
|
||||
- add "Let Claude handle it" entry points (ecbc734)
|
||||
- add merge-helper task selection dialog (327ae2b)
|
||||
- expose merge-helper launch spec over hub + client (5ba0c09)
|
||||
- build merge-helper interactive launch spec (78d4e1a)
|
||||
- add merge-helper prompt templates (c7d64e9)
|
||||
- add continue_merge and abort_merge MCP tools (7517f2a)
|
||||
- let review_task/merge_task leave conflicts in tree via MCP (f4f7c81)
|
||||
|
||||
### Documentation
|
||||
|
||||
- helper handles all merges; manual conflict fallback (2a3ab55)
|
||||
- spec + implementation plan (962f68c)
|
||||
- update for v2.2.0 (d12a888)
|
||||
|
||||
## v2.2.0 — 2026-07-24
|
||||
|
||||
### Features
|
||||
|
||||
- submit interactive (ConPTY) work for review (109a35c)
|
||||
- gate Approve & Merge behind opening the diff (2aaaa23)
|
||||
- run interactive planning sessions via embedded ConPTY (ef285b2)
|
||||
- AskUser question banner in the detail island (798d100)
|
||||
- conflict resolver shows why Continue is disabled (da6a70a)
|
||||
- session-skills empty-state + neutral subtask terminology (85d0f9d)
|
||||
|
||||
### Fixes
|
||||
|
||||
- use default permission mode so MCP planning tools don't prompt (624ec7a)
|
||||
- hide misleading Idle chip on planning parents (e8f7e3a)
|
||||
- show structured-output summary instead of raw JSON in OUTCOME (8a7275a)
|
||||
- live-refresh child rows on parent planning transitions (f4dd67d)
|
||||
- restore turn/token counts on task reload (0226c98)
|
||||
- clearer rename display in diff viewer (b9b3053)
|
||||
- diagnostic error surfacing on attachment drop (671c886)
|
||||
- surface resume-planning-session failures (ffff1ee)
|
||||
- render Plus and agent-settings gear icons (ad2acdd)
|
||||
- kill cancelled runs' processes and make MCP approve actually merge (fee6999)
|
||||
- make external MCP filter params optional, surface tool errors (d7ebafd)
|
||||
- merge preflights ignore untracked files in target working tree (14e4c08)
|
||||
- UnifiedDiffParser mishandles paths with spaces and git-quoted paths (0f2d202)
|
||||
- validate conflict markers before staging in ContinueMergeAsync (377409e)
|
||||
- cascade cancel of a WaitingForChildren parent to its non-terminal children (941c8b9)
|
||||
- advance parent when the last non-terminal child is deleted (2452e39)
|
||||
- keep planning-chain cascade moving past an Idle middle link (816f247)
|
||||
|
||||
### Documentation
|
||||
|
||||
- spec for ConPTY planning sessions (2612831)
|
||||
- record session progress (A/B done, C#9+#11, D#13+#14; C#10/#12 + group E deferred) (04044bd)
|
||||
- add fix-plan for fresh session (findings grouped by fixability); defer §10, mark §11 OK per Mika (efd7cc9)
|
||||
- §3 UnfinishedPlanning modal (Finalize/Discard PASS, Resume BUG); generalize child-row live-refresh finding; edges done (75a6e0e)
|
||||
- finding — Resume planning session is broken (session_id never captured) + error swallowed by empty catch (9efc5c9)
|
||||
- §1 DiffModal error-state resolved via code analysis (defensive/unreachable, gates prevent it) (6ca8cac)
|
||||
- §4 merge-editor Abort PASS (tree clean, task stays WaitingForReview) (9a1fa3d)
|
||||
- §9 attachments drag&drop UI PASS (overlay/drop/picker/remove); finding — intermittent first-drop error (9b4d343)
|
||||
- §8 Session Skills complete (Remove PASS); refresh handoff summary + fixture state (a1ba3b6)
|
||||
- §8 per-task activation + no-leak counterprobe PASS; UI partial (cards/general-tab open) (9b041ba)
|
||||
- finding — agent-settings gear uses Unicode glyph, not Icon.Settings PathIcon (inconsistent) (39fc594)
|
||||
- §8 skill install PASS (6 skills, commit-pinned); finding — Skills tab has no empty-state (b353ed6)
|
||||
- note Mika explicitly wants AskUser interaction in detail island (416e47e)
|
||||
- §7 AskUser complete — timeout UI-cleanup visually verified (banner clears) (e8b5e97)
|
||||
- §7 AskUser PASS (happy-path + backend timeout); finding — banner only in Mission Control, absent in detail island (d19ef54)
|
||||
- note fixture cleanup (verif tasks/worktrees removed, ClaudeDoTests reset) (ded068c)
|
||||
- refresh handoff for next session — progress, remaining (§7-§11+edges), gotchas, fixture state (9556e0e)
|
||||
- trim to actively-verified 2026-07-24 findings; drop stale manual-verif/historical blocks (9d39a8f)
|
||||
- §5 ConPTY/Mission Control PASS (prompt-send, close kills proc); ad-hoc icon invisible + re-open re-sends noted (b536b6f)
|
||||
- §5 findings — invisible New-session icon (Icon.Plus stroke-only), re-open re-sends prompt (1b80bb0)
|
||||
- §3 PASS end-to-end (+§1 children-band, +§4 planning-conflict); dequeue-X UX nit (4394623)
|
||||
- §3 finalize findings — improvements-mislabel, child-badge live-refresh, chain not visualized (f0b0582)
|
||||
- planning session permission-prompt bug + planning-active parent shows Idle (UX) (07de897)
|
||||
- clean additive approve PASS (2101228)
|
||||
- §1 commit-range-after-merge PASS; blocked-merge silent-fail confirmed on clean path too (8241bf8)
|
||||
- §4 merge editor PASS end-to-end + UX findings (continue-btn, multi-file, blocked-merge) (255705d)
|
||||
- Approve & Merge silently swallows a blocked merge (no footer error) (3dfd75f)
|
||||
- §1 findings — raw-JSON outcome bug, rename/turns nits, session-tab expected (26c03a5)
|
||||
- correct permission finding — auto+haiku denies writes (not a CLI regression), §2 happy-path PASS (3211bfc)
|
||||
- log autonomous-batch results (§2/§6/§9/§12) (79ce7af)
|
||||
- track CLI 2.1.207 --permission-mode auto write-denial regression (0ad93f4)
|
||||
- remove chain-cascade bug bullet (fixed in 110364a) (3d668da)
|
||||
- explore-notes convention + verification handoff for manual checks (d6891b8)
|
||||
- update for v2.1.0 (ad58129)
|
||||
|
||||
## v2.1.0 — 2026-07-23
|
||||
|
||||
### Features
|
||||
|
||||
- seed fresh task session with the task prompt (d8194ad)
|
||||
- New session button for ad-hoc ConPTY sessions (3feb08d)
|
||||
- fresh-worktree-on-demand + ad-hoc launch specs (9ab48d7)
|
||||
- host task-based ConPTY sessions in Command Center (0513265)
|
||||
- embedded ConPTY terminal host in UI (5f740c0)
|
||||
- worker launch-spec for embedded ConPTY sessions (1245e75)
|
||||
- session skills registry tab + per-level selectors (7c3c061)
|
||||
- session skills SignalR surface + per-level persistence (b4c5808)
|
||||
- resolve and seed session skills before each run (4626481)
|
||||
- session skill registry (install/update/remove, pinned clone) (dea2b7d)
|
||||
- session skills entity, repository, and migration (54cdaf8)
|
||||
- pick up a task's session in a terminal (eb88dc1)
|
||||
- resume a task's claude session in a terminal (140ae2f)
|
||||
|
||||
### Fixes
|
||||
|
||||
- set monospace font + stretch on ConPTY terminal (d9a4627)
|
||||
- use library LaunchProcess instead of custom pty bypass (2b06ab0)
|
||||
- correct ConPTY terminal size + reduce lag (bb62740)
|
||||
- forward keyboard input to ConPTY terminal (25922a2)
|
||||
- seed planning brief via file to avoid newline truncation (865e12c)
|
||||
|
||||
### Refactoring
|
||||
|
||||
- remove streaming interactive stack (superseded by ConPTY) (c412a84)
|
||||
|
||||
### Documentation
|
||||
|
||||
- update ConPTY spec + open items to final state (85c7e65)
|
||||
- record ConPTY library + binding decision from spike (d28c63d)
|
||||
- ConPTY interactive sessions spec + plan (d91ad2d)
|
||||
- session skills verification items (f33838d)
|
||||
- mark cwd-skill discovery verified in headless mode (dbaefe9)
|
||||
- revise for multi-skill plugin repos (ponytail) (62b245a)
|
||||
- spec + plan for per-level session skills (4e5057d)
|
||||
- pick up a task's session in a terminal — verification (1bf08ec)
|
||||
- update for v2.0.0 (914fa5a)
|
||||
|
||||
## v2.0.0 — 2026-06-26
|
||||
|
||||
### Features
|
||||
|
||||
- remove a queued interactive message with a ✕ (afe7218)
|
||||
- remove a queued interactive message (fd1e38f)
|
||||
- show queued interactive messages above the composer (7c9ff18)
|
||||
- broadcast interactive message queue + delivery (84034e8)
|
||||
- highlight user chat messages + opt-in interrupt (stop) button (786eb28)
|
||||
- queue interactive messages by default, interrupt opt-in (bdda98e)
|
||||
- interactive chat composer in the session terminal + work console (1fe72a1)
|
||||
- interactive chat composer state on the session monitor VM (140b8e1)
|
||||
- worker client surface for in-app interactive sessions (9effdde)
|
||||
- in-app interactive session service, replacing the wt terminal launch (30e87e6)
|
||||
- persistent streaming Claude session + live session registry (d8a043f)
|
||||
- answer a running task's question inline in Mission Control (917301d)
|
||||
- AskUser MCP tool so a running task can ask the user mid-run (c7f8280)
|
||||
- replace OLE task-row drag with custom ghost drag (bec26b2)
|
||||
- ghost-window drag infrastructure for task rows (05aec8e)
|
||||
- drag a task into Mission Control to queue it (3b629c2)
|
||||
- read-only queue side strip in Mission Control (9eb54a0)
|
||||
- batch MCP tools for the external endpoint (1c94fbd)
|
||||
- open Settings from the Mission Control header (7f4dc8b)
|
||||
- drag-reorder Mission Control panes by their header (f6ecfc9)
|
||||
- mission control pane header actions + status tinting (e2fad88)
|
||||
- mission control detach/redock toggle, clear review panes, reorder helper (fbcffce)
|
||||
- detach a monitor into its own window (5f6e748)
|
||||
- open Mission Control from the title bar (b1bd912)
|
||||
- add MissionControl window + grid (283310a)
|
||||
- add MonitorPaneView (15a3e65)
|
||||
- reveal a task by id from anywhere (5a21d67)
|
||||
- add MissionControlViewModel (42da840)
|
||||
- extract TaskMonitorViewModel streaming core; DetailsIsland delegates (aa7a49f)
|
||||
- collapse parent task rows by default with granular row sync (38defee)
|
||||
- shell-style review prompt line in WorkConsole (0a119f1)
|
||||
- Log Visualizer overlay reachable from a clickable footer log line (c4f74a7)
|
||||
- route Serilog Warn/Error to footer + buffer recent logs for overlay (08a4f97)
|
||||
- segmented Description/Steps/Files header (9301bbc)
|
||||
- drag-and-drop file attachments on the detail pane (d8ff8cc)
|
||||
- MCP tools to attach/list/remove task files (f7e946e)
|
||||
- inject reference files into the run + clean up files on delete (6a0c0f5)
|
||||
- data layer for task file attachments (3f9f047)
|
||||
|
||||
### Fixes
|
||||
|
||||
- reap idle interactive sessions so they don't pile up (711374e)
|
||||
- kill spawned claude trees when the worker dies (faf6104)
|
||||
- scroll revealed task into view + stronger selection highlight (f63be28)
|
||||
- persist Online Inbox tab on settings save (66907d2)
|
||||
- paint accent buttons with moss tokens instead of Fluent blue (178fd25)
|
||||
- make worktree state chips readable with on-theme tints (df84fc3)
|
||||
- keep interactive & planning prompts intact past Windows Terminal (ea16da2)
|
||||
- honor runtime disable in sync loop to stop OIDC discovery (f86b785)
|
||||
- surface interactive/planning launch errors in footer (134b9fb)
|
||||
- render X remove icon as filled geometry (637886f)
|
||||
- hide batch Merge All in the global overview (5231ad6)
|
||||
|
||||
### Refactoring
|
||||
|
||||
- split LogLineViewModel into its own file (7b6a8f0)
|
||||
- single DiffViewer replaces DiffModal + WorktreeModal + PlanningDiff (167d2fe)
|
||||
- single AgentConfigEditor for list + task scopes (eb0ddb5)
|
||||
- drop self-update, publish stable-named ClaudeDo.Installer.exe (3cb4802)
|
||||
- single IMergeCoordinator replaces the 5 conflict seams (5be4b5c)
|
||||
- single IDialogService replaces scattered Show* dialog seams (d598a53)
|
||||
- route UI quick-add through TaskRepository.AddAsync (1fb2e34)
|
||||
- drop dead hunks conflict API (b3e099c)
|
||||
|
||||
### Documentation
|
||||
|
||||
- queued messages can be removed via ✕ (3eea2b7)
|
||||
- queued messages show in a pending strip above the composer (e7fa373)
|
||||
- interactive send=queue, interrupt opt-in via stop button (8e1732a)
|
||||
- manual-verification items for in-app interactive sessions (9c292e5)
|
||||
- spec + plan for in-app interactive sessions (10342bc)
|
||||
- spec + plan for answering Claude's mid-run questions in Mission Control (946d26c)
|
||||
- add Mission Control multi-task monitoring spec + plan (d80a578)
|
||||
- document footer log routing + Log Visualizer overlay (4022bd7)
|
||||
- spec + plan for worker-log footer routing and log visualizer overlay (60eb671)
|
||||
- document task file attachments across project docs (8716dd8)
|
||||
- spec + phased plan for one-component-per-feature (0993eb0)
|
||||
- update for v1.9.0 (bae8921)
|
||||
|
||||
## v1.9.0 — 2026-06-19
|
||||
|
||||
### Features
|
||||
|
||||
@@ -39,7 +39,7 @@ Each project has its own `CLAUDE.md` — those are the living per-project docs.
|
||||
- EF Core migrations manage schema (Migrations/ folder in ClaudeDo.Data)
|
||||
- `IDbContextFactory<ClaudeDoDbContext>` used by singleton consumers (e.g. Worker)
|
||||
- Entity configuration via `IEntityTypeConfiguration<T>` in Configuration/ folder
|
||||
- Task status flow: Idle | Queued -> Running -> WaitingForReview -> Done | Failed | Cancelled. A task that spawns/has children passes through WaitingForChildren first, then surfaces for review once every child is terminal — this is the single parent model for both planning and improvement parents (planning/improvement *children* themselves go straight to Done, only the parent is reviewed). From review you can approve, reject-rerun (Queued, resumes the session with feedback), reject-park (Idle), or cancel. Approve is the single review+merge action: a childless task merges its own worktree then Done (conflicts keep it in WaitingForReview); a task with children drives the unit merge (parent worktree if any + each Done child in order, with conflict continue/abort). Tasks with no active worktree (sandbox run) approve straight to Done.
|
||||
- Task status flow: Idle | Queued -> Running -> WaitingForReview -> Done | Failed | Cancelled. A task that spawns/has children passes through WaitingForChildren first, then surfaces for review once every child is terminal — this is the single parent model for both planning and improvement parents (planning/improvement *children* themselves go straight to Done, only the parent is reviewed). From review you can approve, reject-rerun (Queued, resumes the session with feedback), reject-park (Idle), or cancel. Approve is the single review+merge action: a childless task merges its own worktree then Done (conflicts keep it in WaitingForReview); a task with children drives the unit merge (parent worktree if any + each Done child in order, with conflict continue/abort). Tasks with no active worktree (sandbox run) approve straight to Done. In the detail pane, **Approve & Merge is gated behind opening the diff**: when there is something to inspect (worktree diff / merged range / children combined diff) the button stays disabled until the diff or combined-diff viewer has been opened once, and re-locks per run (any state change resets it); tasks with nothing to inspect are never gated. The row-level quick-approve in the task list is an intentional bypass.
|
||||
- Worktree state flow: Active -> Merged | Discarded | Kept
|
||||
- The queue picker claims tasks by `Status=Queued` (with `BlockedByTaskId IS NULL`); the legacy tag system was removed
|
||||
- Interfaces live in an `Interfaces/` subfolder beside their consumers (namespace unchanged)
|
||||
@@ -84,3 +84,4 @@ dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
|
||||
- `docs/improvement-plan.md` — improvement snapshot from 2026-04-13 (historical)
|
||||
- `docs/prompts-inventory.md`, `docs/mailbox-proposal.md` — reference material (mailbox integration is parked)
|
||||
- `CHANGELOG.md` — Keep a Changelog format, maintained on release
|
||||
- `docs/explore-notes/` — distilled maps of complex subsystems from deep exploration (detail too fine for a CLAUDE.md). **Before** deep-exploring a subsystem, check for a matching note first; **after** a deep explore, distill durable findings back and bump its "verified against" commit. Always verify against current code before trusting. See `docs/explore-notes/README.md`.
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Explore-notes
|
||||
|
||||
Distilled, reusable maps of complex subsystems, produced by deep code exploration.
|
||||
The goal: stop re-exploring the same subsystem from scratch in every new session.
|
||||
|
||||
These sit **between** the CLAUDE.md files and the code:
|
||||
|
||||
- **CLAUDE.md** — high-level orientation, hand-maintained, always-loaded.
|
||||
- **explore-notes** — deeper subsystem detail (flows, who-calls-whom, invariants) that is
|
||||
too fine-grained for a CLAUDE.md but stable enough to be worth caching. Read on demand.
|
||||
- **code** — the only source of truth.
|
||||
|
||||
## Rules
|
||||
|
||||
- **Only stable structure.** Flows, responsibilities, entry points, invariants, relative
|
||||
file paths. **No line numbers**, no exhaustive symbol dumps — those rot fastest.
|
||||
- **Verify before trusting.** A note is a starting map, not authority. Always confirm
|
||||
against current code before acting on it. Each note records the commit it was verified
|
||||
against so you can diff for drift.
|
||||
- **Not a substitute for CLAUDE.md.** If a fact belongs in orientation, put it there.
|
||||
|
||||
## Header every note must carry
|
||||
|
||||
```
|
||||
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
|
||||
> Last verified against commit `<short-hash>` (<date>).
|
||||
> Drift check: `git log --oneline <short-hash>..HEAD -- <paths this note covers>`
|
||||
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Before** deep-exploring a subsystem, check for a matching note here and read it first;
|
||||
explore only to fill gaps or confirm.
|
||||
2. **After** a deep explore, distill the durable findings into a new/updated note and bump
|
||||
its "verified against" commit line.
|
||||
3. If the drift check shows the covered paths changed a lot since the verified commit, treat
|
||||
the note as suspect and re-verify the parts you rely on.
|
||||
@@ -0,0 +1,124 @@
|
||||
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
|
||||
> Last verified against commit `896d4b5` (2026-07-23).
|
||||
> Drift check: `git log --oneline 896d4b5..HEAD -- src/ClaudeDo.Worker`
|
||||
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
|
||||
|
||||
# Worker: Task Execution Pipeline
|
||||
|
||||
How a task moves Queued → Running → terminal, across `src/ClaudeDo.Worker`
|
||||
(Queue, Runner, Lifecycle, State, Agents, Worktrees, Hub).
|
||||
|
||||
## End-to-End Flow (Queued → Terminal)
|
||||
|
||||
1. **Enqueue** — `ITaskStateService.EnqueueAsync()` (State/TaskStateService.cs)
|
||||
- Idle → Queued, then wakes the dispatcher via `IQueueWaker.Wake()`.
|
||||
|
||||
2. **Dispatch** — `QueueService` loop (Queue/QueueService.cs)
|
||||
- `BackgroundService`; waits for a wake signal or a backstop timer.
|
||||
- Reads the max-parallel limit from settings; claims a free slot if under limit.
|
||||
|
||||
3. **Atomic Claim** — `IQueuePicker.ClaimNextAsync()` (Queue/QueuePicker.cs)
|
||||
- Raw SQL `UPDATE ... RETURNING` in one transaction: picks an eligible Queued task
|
||||
(unblocked, due or unscheduled; sorted by sort_order/created_at), sets status→Running
|
||||
+ started_at, returns the row. Prevents two workers claiming the same task (TOCTOU).
|
||||
|
||||
4. **Slot Execution** — `QueueService.RunInSlotAsync()` (Queue/QueueService.cs)
|
||||
- For review feedback: resume the prior session if one exists, else fold feedback into
|
||||
the prompt. Calls `TaskRunner.RunAsync()` / `ContinueAsync()` with `alreadyClaimed=true`.
|
||||
|
||||
5. **Run Preparation** — `TaskRunner.RunAsync()` (Runner/TaskRunner.cs)
|
||||
- Loads task, list config, subtasks, attachments from the DB.
|
||||
- `PrepareRunDirectoryAsync()`: worktree (via WorktreeManager) if the list has a WorkingDir,
|
||||
else sandbox. Generates a per-run MCP token, writes MCP config to disk.
|
||||
- `StartRunningAsync()` (only if not pre-claimed): atomic Queued → Running. Broadcasts TaskStarted.
|
||||
|
||||
6. **Claude Execution** — `TaskRunner.RunOnceAsync()` (Runner/TaskRunner.cs)
|
||||
- Creates a TaskRunEntity, points the task at the run's log path.
|
||||
- Builds claude CLI args (ClaudeArgsBuilder), spawns the process via `IClaudeProcess.RunAsync()`
|
||||
with prompt + working dir + streaming callback.
|
||||
- Stream lines → NDJSON log + broadcast via TaskMessage. MCP tools (AskUser, SuggestImprovement)
|
||||
are scoped by the per-run token.
|
||||
|
||||
7. **Result Handling** — `TaskRunner.HandleSuccess()` / `MarkFailed()` (Runner/TaskRunner.cs)
|
||||
- Success (exit 0 + result markdown): if worktree, commit + broadcast WorktreeUpdated; then
|
||||
transition to Done / WaitingForReview / WaitingForChildren (CompleteAsync / SubmitForReviewAsync
|
||||
/ SubmitForChildrenAsync).
|
||||
- Failure: if a session exists, auto-retry once via ContinueAsync; else MarkFailed → FailAsync.
|
||||
- All terminal writes use `CancellationToken.None` so a task is never left Running.
|
||||
|
||||
8. **Terminal States** — `ITaskStateService` transitions (State/TaskStateService.cs)
|
||||
- **Done** CompleteAsync (Running → Done) — top-level success.
|
||||
- **WaitingForReview** SubmitForReviewAsync (Running → WaitingForReview) — review gate.
|
||||
- **WaitingForChildren** SubmitForChildrenAsync (Running → WaitingForChildren) — blocks on children.
|
||||
- **Failed** FailAsync (Running/Queued → Failed).
|
||||
- **Cancelled** CancelAsync (Running/Queued/WaitingForReview/WaitingForChildren → Cancelled).
|
||||
|
||||
## Component Responsibilities
|
||||
|
||||
**Queue/**
|
||||
- `QueueService` — main dispatch loop; slot limit; decides when to start tasks.
|
||||
- `QueuePicker` — atomic Queued→Running claim via raw SQL.
|
||||
- `QueueWaker` — semaphore for non-blocking, idempotent wake signals.
|
||||
- `OverrideSlotService` — owns the RunNow / ContinueTask slot (bypasses the queue).
|
||||
|
||||
**Runner/**
|
||||
- `TaskRunner` — orchestrates the run (prepare, execute, handle result).
|
||||
- `WorktreeManager` — creates/manages git worktrees; self-heals stale branches.
|
||||
- `ClaudeProcess` — spawns the claude CLI subprocess; manages streams/logs.
|
||||
- `TaskRunMcpService` — runtime MCP tools (AskUser, SuggestImprovement).
|
||||
- `TaskRunTokenRegistry` — per-run MCP identity for tool-access control.
|
||||
- `InteractiveLaunchSpecService` — config for the task's claude run.
|
||||
|
||||
**State/**
|
||||
- `TaskStateService` — all task status transitions; guards preconditions; signals queue/hub.
|
||||
|
||||
**Lifecycle/** (startup recovery)
|
||||
- `StaleTaskRecovery` — tasks stuck Running after a crash/restart → Failed.
|
||||
- `OrphanRecovery` — dequeues children whose parent is no longer planning (stays attached).
|
||||
- `AttachmentOrphanRecovery` — cleans orphaned attachment files.
|
||||
- `TaskResetService` — manual reset to Idle.
|
||||
- `TaskMergeService` — conflict resolution for worktree merges.
|
||||
|
||||
**Hub/**
|
||||
- `HubBroadcaster` — single SignalR broadcast point (TaskStarted/TaskUpdated/TaskMessage/RunCreated…).
|
||||
- `WorkerHub` — SignalR hub + client methods.
|
||||
|
||||
**Agents/**
|
||||
- `AgentFileService` — file I/O for custom agents.
|
||||
- `DefaultAgentSeeder` — seeds built-in agents on startup.
|
||||
|
||||
**Worktrees/**
|
||||
- `WorktreeMaintenanceService` — cleanup, state tracking, overview reporting.
|
||||
|
||||
## Entry Points & Call Chain
|
||||
|
||||
```
|
||||
Program.cs (DI setup)
|
||||
├─ QueueService (BackgroundService) → ExecuteAsync loop
|
||||
│ ├─ waits: IQueueWaker.WaitAsync() or timer
|
||||
│ ├─ claims: IQueuePicker.ClaimNextAsync()
|
||||
│ └─ runs: TaskRunner.RunAsync() / ContinueAsync()
|
||||
├─ Hub clients → WorkerHub methods
|
||||
│ ├─ Enqueue → ITaskStateService.EnqueueAsync() → Wake()
|
||||
│ ├─ RunNow → OverrideSlotService.RunNow() → TaskRunner.RunAsync()
|
||||
│ ├─ ContinueTask→ OverrideSlotService.ContinueTask()→ TaskRunner.ContinueAsync()
|
||||
│ └─ CancelTask → QueueService.CancelTask()
|
||||
├─ Lifecycle recovery (startup): StaleTaskRecovery / OrphanRecovery / AttachmentOrphanRecovery
|
||||
└─ State transitions → HubBroadcaster.TaskUpdated()
|
||||
```
|
||||
|
||||
## Invariants & Conventions
|
||||
|
||||
- **Atomic claiming** — QueuePicker's `UPDATE ... RETURNING` makes Queued→Running atomic.
|
||||
- **Slot limit** — respects MaxParallelExecutions; a backstop timer wakes even if a Wake() is missed.
|
||||
- **Pre-claimed tasks** — the dispatcher pre-claims via the picker; the override slot
|
||||
(RunNow/ContinueTask) must call StartRunningAsync if a task is not pre-claimed.
|
||||
- **Terminal writes** — use `CancellationToken.None`; a task is never left Running after crash/cancel.
|
||||
- **Per-run MCP tokens** — each run gets a unique token scoping tool access; unregistered on end.
|
||||
- **Auto-retry** — one automatic retry if a session exists and the first run failed.
|
||||
- **Worktree self-heal** — on branch collision, remove phantom worktrees, prune, delete branch, retry add.
|
||||
- **Review feedback** — stored on the task; consumed once a run reaches a terminal state; a re-queued
|
||||
task resumes the session or folds feedback into the prompt.
|
||||
- **Child tasks** — planning creates draft children; finalization requires no Queued children remain;
|
||||
OrphanRecovery dequeues children if the parent is not planning.
|
||||
- **Lifecycle recovery** runs at startup: stale-Running → Failed; orphaned children → dequeued but attached.
|
||||
@@ -0,0 +1,189 @@
|
||||
# Fix-Plan — Verifikations-Findings (Stand 2026-07-24)
|
||||
|
||||
Einstiegspunkt für eine **frische Fix-Session**. Sammelt die in der manuellen Verifikation
|
||||
(§7–§9 + Kanten §1/§3/§4) gefundenen Probleme, gruppiert nach **Fixbarkeit**. Volltext je
|
||||
Finding (mit Kontext/Wiederholschritten) steht in `docs/open.md`; hier steht der Fix-Blick:
|
||||
Root-Cause, konkreter Ansatz, Loc/Test-Hinweise, und **welche Punkte vor der Umsetzung eine
|
||||
Entscheidung brauchen**.
|
||||
|
||||
**Immer zuerst:** file:line-Angaben gegen den aktuellen Code prüfen (können minimal driften).
|
||||
Build/Test-Regeln + Gotchas s. Projekt-`CLAUDE.md` (u.a. `.slnx` braucht .NET 9 → einzelne
|
||||
`.csproj -c Release`; Localization.Tests erzwingt en/de-Parität; Subagents `sonnet`, Dateien
|
||||
pfad-scoped stagen). Pro Fix ein Conventional Commit.
|
||||
|
||||
---
|
||||
|
||||
## Bearbeitungsstand (Session 2026-07-24, nicht gepusht)
|
||||
|
||||
**Erledigt & committed:**
|
||||
- Gruppe A #1–5 + beide Optional-Nits (Icon.Plus gefüllt, Gear-PathIcon, Skills-Empty-State,
|
||||
„Subtasks"-Terminologie, Conflict-Continue-Hinweis, Rename-Darstellung, Turns/Tokens-Reload).
|
||||
- Gruppe B #7 (Resume-Fehler surfacen) + #8 (Attachment-Drop-Diagnose).
|
||||
- Gruppe C #9 (AskUser-Banner in Detail-Insel via geteiltem `TaskMonitorViewModel`),
|
||||
#11 (OUTCOME zeigt `summary` statt rohem JSON: Worker-Unwrap + UI-Sicherheitsnetz).
|
||||
- Gruppe D #13 (Kind-Rows live-refresh bei Parent-Planning-Transitionen) + #14 (Idle-Chip auf
|
||||
Planning-Parents ausgeblendet). *Visual-Verification für #13 (Finalize/Discard live) offen.*
|
||||
|
||||
**#6** war im aktuellen Code bereits abgedeckt (Worker wirft `HubException` bei `blocked`
|
||||
→ UI-Dialog); zusätzlich als ClaudeDo-Task `f9809a93` erfasst. Nicht angefasst.
|
||||
|
||||
**Offen:**
|
||||
- **#10 + #12** — bewusst gebündelt mit dem ConPTY-Planning-Task `5d627df8` (dort lässt sich
|
||||
die Session-Id sauber greifen bzw. das MCP-Permission-Verhalten klären). Sofort-Schutz für
|
||||
#10 (Resume ausgrauen wenn keine Id) wurde NICHT gebaut — bräuchte Worker-Plumbing, das der
|
||||
ConPTY-Umbau ohnehin liefert; der #7-Fix verhindert bereits das stille Scheitern.
|
||||
- **Gruppe E** — nur noch design-/feature-behaftete Punkte. Der scheinbare Quick-Win
|
||||
„Dequeue-X auf blockierten Kettengliedern" wurde bewusst NICHT umgesetzt: einzelnes Dequeue
|
||||
eines Kettenglieds hinterlässt hängende Nachfolger (deren `BlockedByTaskId` zeigt weiter auf
|
||||
das nun idle Glied) → braucht Chain-Repair-Design.
|
||||
|
||||
---
|
||||
|
||||
## Gruppe A — Mechanisch, sofort fixbar (keine Entscheidung nötig)
|
||||
|
||||
Ideale Kandidaten für den Start / parallele Subagents (disjunkte Dateien).
|
||||
|
||||
1. **„New session"-Button unsichtbar (Icon.Plus strich-only)**
|
||||
`IslandStyles.axaml` (`Icon.Plus` = `M12 5v14M5 12h14`, reine Linie) wird in einem
|
||||
`<PathIcon>` (MissionControlView.axaml, füllt Geometrie) unsichtbar gerendert.
|
||||
**Fix:** `Icon.Plus` als gefüllte Geometrie authoren ODER als gestricheltes `Path`
|
||||
rendern (vgl. `Path.plan-icon`). **Andere `Icon.Plus`-Verwendungen mitprüfen.**
|
||||
|
||||
2. **Agent-Settings-Gear weicht vom Listen-Gear ab**
|
||||
`TaskHeaderBar.axaml:68` rendert `<TextBlock Text="⚙">`; überall sonst `Icon.Settings`
|
||||
(PathIcon, IslandStyles.axaml:110). **Fix:** den `⚙`-TextBlock durch
|
||||
`<PathIcon Data="{StaticResource Icon.Settings}" Width=".." Height=".."/>` ersetzen.
|
||||
|
||||
3. **Session-Skills-Tab ohne Empty-State**
|
||||
Bei 0 Skills nur nackte Fläche. **Fix:** Empty-State-Text unter der Install-Zeile
|
||||
(z.B. „No skills installed — paste a GitHub URL above"). **Loc:** neue Keys in en.json
|
||||
**und** de.json (Parität!). Datei: `SessionSkillsSettingsTab*`.
|
||||
|
||||
4. **„Waiting for Improvements" für Planning-Parents (Terminologie)**
|
||||
`en.json` `taskStatus.waitingForChildren`/`agentStatus.children`/`childOutcomesLabel`
|
||||
sagen „Improvements". Seit unified-parent gilt `WaitingForChildren` auch für Planning.
|
||||
**Fix:** auf neutrales „Waiting for Subtasks"/„Subtasks"/„SUBTASKS" umstellen — en **und**
|
||||
de (Parität).
|
||||
|
||||
5. **Conflict-Resolver: Continue-Button klickbar trotz offener Konflikte**
|
||||
Merge passiert korrekt erst nach Auflösung, aber der Button ist nicht disabled → früher
|
||||
Klick = stummer No-op. **Fix:** `CanContinue`/`AllResolved` an `IsEnabled` binden (ggf.
|
||||
Hinweis „N Konflikte in M Dateien offen"). Datei: `ConflictResolverView(.axaml)` +
|
||||
`ConflictResolverViewModel`.
|
||||
|
||||
Optional-Nits (gleiche Gruppe, niedrige Prio):
|
||||
- **Diff-Viewer Rename schwach dargestellt** (alt→neu-Pfad + „renamed"-Label statt „+0 −0").
|
||||
- **Header-TurnsText `0/max`** bei terminalem Reload — `Turns` aus `task_runs.turnCount`
|
||||
restaurieren.
|
||||
|
||||
---
|
||||
|
||||
## Gruppe B — Error-Surfacing (klare Richtung: kein stiller/leerer Fehlerpfad)
|
||||
|
||||
Leitlinie `feedback_ui_error_surfacing`: User-Action-Fehler in den Footer
|
||||
(`FlashFooterError`) bzw. Dialog, nie leerer `catch`/stiller No-op.
|
||||
|
||||
6. **Approve & Merge schluckt „blocked" still**
|
||||
`DetailsIslandViewModel.ApproveReviewAsync` reagiert nur auf `Status == "conflict"`; bei
|
||||
`"blocked"` (z.B. dirty Ziel-Tree) passiert nichts. **Fix:** bei `blocked`/unerwartetem
|
||||
Status `result.ErrorMessage` surfacen. *(Als ClaudeDo-Task `f9809a93` erfasst — koppelt
|
||||
„Approve erzwingt Diff/Review vor Merge".)*
|
||||
|
||||
7. **„Resume planning session" verschluckt den Fehler (Teil-Fix hier, Rest → Gruppe C #10)**
|
||||
`TasksIslandViewModel.ResumePlanningSessionAsync` (~Zeile 870) hüllt alles in `catch { }`.
|
||||
**Sofort-Fix:** den Fehler surfacen statt schlucken. Der eigentliche Resume-Defekt braucht
|
||||
eine Entscheidung → #10.
|
||||
|
||||
8. **Attachments: intermittenter erster-Drop-Fehler („An error occurred")**
|
||||
Einmalig beobachtet (erster Drop der Session, nichts persistiert), nicht reproduzierbar.
|
||||
**Fix (diagnostisch):** `AddFilesAsync`/`OnDrop` robustes Error-Logging geben (die genaue
|
||||
Exception fehlt, weil `DropStatus` nur `{fileName}: {ex.Message}` zeigt) — damit der
|
||||
nächste Fall auswertbar ist. Kandidaten-Ursachen: SQLite-Contention (UI schreibt `todo.db`
|
||||
direkt, während der Worker sie hält) oder Drop-Stream-Pfad (`IStorageFile.OpenReadAsync`
|
||||
im Code-Behind, außerhalb des `try`).
|
||||
|
||||
---
|
||||
|
||||
## Gruppe C — Erst Entscheidung/Brainstorm, DANN umsetzen (nicht blind fixen)
|
||||
|
||||
9. **AskUser-Interaktion auch in der Detail-Insel** *(von Mika ausdrücklich gewünscht)*
|
||||
Der `ask_user`-Banner + Inline-Antwort existiert nur in Mission Control
|
||||
(`MonitorPaneView`); die Detail-Insel zeigt für den laufenden Task nichts.
|
||||
**Entscheidung:** wie den Zustand teilen — `TaskMonitorViewModel` hält ihn bereits; für
|
||||
die Detail-Insel replizieren, teilen, oder ein gemeinsames Banner-Control? Danach:
|
||||
Banner + `AnswerDraft`/`SubmitAnswer` in `DetailsIslandView(Model)` einhängen.
|
||||
|
||||
10. **„Resume planning session" grundsätzlich kaputt (Session-Id nie erfasst)**
|
||||
`PlanningSessionManager.ResumeAsync:238` wirft immer „No Claude session ID captured yet",
|
||||
weil `TaskRepository.UpdatePlanningSessionIdAsync:322` **keinen Aufrufer** hat →
|
||||
`planning_session_id` bleibt NULL. **Entscheidung:** (a) claude-Session-Id der
|
||||
wt-Planning-Session erfassen + via `UpdatePlanningSessionIdAsync` persistieren (echtes
|
||||
Resume) ODER (b) Resume entfernen/deaktivieren, wenn keine Id vorliegt. Hängt mit der
|
||||
Design-Entscheidung „Planning über embedded ConPTY statt wt" zusammen (ClaudeDo-Task
|
||||
`5d627df8`) — dort ließe sich die Session-Id sauber greifen.
|
||||
|
||||
11. **OUTCOME-Karte rendert rohes Structured-Output-JSON**
|
||||
`TaskMonitorViewModel.ApplyOutcome` setzt bei Tasks ohne Roadblock `SessionOutcome`
|
||||
= `task.Result` wörtlich; der Worker legt dort rohes `{"summary":…}` ab.
|
||||
**Entscheidung:** (a) UI parst JSON-Result und zeigt `summary`, oder (b) Worker schreibt
|
||||
`summary`/`resultMarkdown` statt JSON in `task.Result`.
|
||||
|
||||
12. **Planning-Session prompted nach MCP-Tool-Permission**
|
||||
Trotz `--allowedTools "mcp__claudedo__*,…"` + `--permission-mode plan` prompted die
|
||||
wt-Planning-Session beim ersten `create_child_task`. **Untersuchen/Entscheiden:** matcht
|
||||
der `mcp__claudedo__*`-Glob in CLI 2.1.207 nicht (Syntax evtl. ganzer Server-Name), oder
|
||||
gated Plan-Mode MCP-Writes generell? Gekoppelt an ConPTY-Planning-Task `5d627df8`.
|
||||
|
||||
---
|
||||
|
||||
## Gruppe D — Erst Root-Cause pinnen (Investigation)
|
||||
|
||||
13. **Kind-Rows aktualisieren nach Parent-Planning-Transitionen nicht live**
|
||||
Nach **Finalize** bleiben Kind-Badges „Draft" statt „Planned"; nach **Discard** bleiben
|
||||
die (in der DB gelöschten) Kind-Rows sichtbar — bis Listen-Reload. Doppelt verifiziert §3.
|
||||
**Untersuchen:** wie wird die Kinderliste/-gruppierung auf ein Parent-`TaskUpdated`
|
||||
reagierend neu aufgelöst? Vermutlich fehlt ein Regroup/Refetch der Children beim
|
||||
Parent-Broadcast (`TasksIslandViewModel` hierarchie-Regrouping). Fix danach: Children bei
|
||||
Parent-Transition live neu auflösen.
|
||||
|
||||
14. **Planning-aktiver Parent zeigt weiter „Idle"**
|
||||
Parent `planning_phase=active` hat `Status=Idle` (korrekt im Modell), aber der Row-Chip
|
||||
zeigt „Idle"; `PlanningBadge` überschreibt das nicht sichtbar. **Untersuchen/Design:** ein
|
||||
klarer „Planning/Draft aktiv"-Zustand, der den Idle-Chip überschreibt. (Verwandt mit #13 —
|
||||
Row-Statusdarstellung.)
|
||||
|
||||
---
|
||||
|
||||
## Gruppe E — UX-Nits / Feature-Wünsche (niedrige Prio, sammeln)
|
||||
|
||||
- **Conflict-Resolver: mehrere Konfliktdateien schlecht erkennbar** — prominentere Datei-Liste
|
||||
/ „x von y Dateien".
|
||||
- **Blocked-by-Kette nicht visualisiert** — Reihenfolge/Abhängigkeit darstellen
|
||||
(„wartet auf <Vorgänger>").
|
||||
- **Dequeue-„X" fehlt auf blockierten Kettengliedern** — `CanRemoveFromQueue` erweitern
|
||||
(`IsWaiting` einschließen).
|
||||
- **„Open ConPTY session" erneut = Prompt wird neu gesendet** — Resume-Affordance / Re-Open-
|
||||
Warnung (bewusst kein Session-Persist).
|
||||
- **Conflict-Resolver: farbliches Hervorheben übernommener Zeilen im Result-Pane** (Feature).
|
||||
|
||||
---
|
||||
|
||||
## Nicht anfassen / Kontext
|
||||
|
||||
- **§1 DiffModal-Fehler-State** (`vm.diff.unavailable`) ist **defensiver, über die UI
|
||||
unerreichbarer** Code — alle Aufrufer sind gegated (`CanDiffMergedRange` verlangt base+head
|
||||
non-null; `ConfigureWorktree` nur mit existierendem Pfad). Kein Fix nötig.
|
||||
- **`--permission-mode auto` + `haiku` denied Writes** — modellabhängiges Verhalten, keine
|
||||
Regression; Default (sonnet) unbetroffen. Beobachten (Memory `auto_permission_haiku_footgun`).
|
||||
- **§10 Daily Prep/Weekly** — Verifikation zurückgestellt bis zum geplanten Rework.
|
||||
|
||||
---
|
||||
|
||||
## Empfohlene Reihenfolge
|
||||
|
||||
1. **Gruppe A** (mechanisch, schnell, teils parallel) → sofort sichtbare Wins.
|
||||
2. **Gruppe B** (Error-Surfacing, klein & risikoarm).
|
||||
3. **Gruppe C** — pro Punkt kurz brainstormen/entscheiden, dann umsetzen (#10 + #12 zusammen
|
||||
mit der ConPTY-Planning-Entscheidung betrachten).
|
||||
4. **Gruppe D** — Investigation, dann Fix (#13 zuerst — betrifft mehrere Planning-Flows).
|
||||
5. **Gruppe E** — nach Bedarf.
|
||||
+30
-45
@@ -1,64 +1,49 @@
|
||||
# ClaudeDo — Offene Punkte
|
||||
|
||||
Stand: 2026-06-10. **Nur noch offene Punkte.** Was erledigt ist, steht in den Commits und im Code — nicht hier.
|
||||
Stand: 2026-07-24. Diese Datei listet die **aktiv verifizierten Findings** aus der Verifikations-Session vom 2026-07-24. Die laufende Verifikations-Checkliste (Pass/Fail je Abschnitt, inkl. noch offener Abschnitte §7–§11 und Kanten) lebt in `docs/verification-handoff.md`. Erledigtes steht in den Commits/im Code, nicht hier.
|
||||
|
||||
---
|
||||
|
||||
## Manuelle Verifikation (offen)
|
||||
## Bugs (verifiziert 2026-07-24)
|
||||
|
||||
Kein Code-Aufwand, nur Durchspielen mit explizit notiertem Pass-Kriterium. Der Großteil der Pipeline ist laut User bereits in der Praxis getestet; hier das, was noch ein falsifizierbares Observable braucht.
|
||||
- **OUTCOME-Karte rendert rohes Structured-Output-JSON:** `TaskMonitorViewModel.ApplyOutcome` (ClaudeDo.Ui) setzt bei Tasks ohne Roadblock-Marker `SessionOutcome = result` wörtlich (Zeile ~261). Der Worker legt in `task.Result` das rohe `{"summary":…,"files_changed":[…]}` ab (die lesbare Fassung steht in `task_runs.resultMarkdown`), also zeigt die Detail-Insel OUTCOME als JSON-Blob statt als Text. Fix: (a) UI parst ein JSON-Result und zeigt `summary`, oder (b) Worker schreibt `summary`/`resultMarkdown` statt des JSON in `task.Result`. Verifiziert am Task `verif §1 diff matrix`. Deckt sich mit Memory `worker_testing_findings`.
|
||||
- **Approve & Merge schluckt einen „blocked"-Merge still:** `DetailsIslandViewModel.ApproveReviewAsync` reagiert nur auf `result.Status == "conflict"` (öffnet den Resolver); bei `"blocked"` (z.B. Ziel-Working-Tree hat uncommittete getrackte Änderungen) und anderen Nicht-`merged`/Nicht-`conflict`-Status passiert **nichts** — kein Footer-Fehler, kein Dialog (der `catch` greift nur bei Exceptions, „blocked" ist aber ein normaler Rückgabewert mit `ErrorMessage`). User sieht „Klick tut nichts". Doppelt verifiziert (Konflikt-Approve UND sauberer additiver Approve `verif §1b`, beide bei dirty `main`-Checkout still). Fix: bei `blocked`/unerwartetem Status `result.ErrorMessage` via `ShowErrorAsync`/`FlashFooterError` surfacen. Verstößt gegen `feedback_ui_error_surfacing`. (Der Konflikt-Resolver + 3-Pane-Editor funktionieren, sobald der Ziel-Tree sauber ist.)
|
||||
- **„Waiting for Improvements" für Planning-Parents (Terminologie):** `taskStatus.waitingForChildren` = „Waiting for Improvements" (en.json:504), `agentStatus.children` dito (503), `childOutcomesLabel` = „IMPROVEMENTS" (191). Seit dem unified-parent-Modell gilt `WaitingForChildren` für Planning **und** Improvement-Parents — „Improvements" ist für einen Planning-Parent falsch. Auf neutrales „Waiting for Subtasks"/„Subtasks" umstellen (en **und** de — Localization.Tests-Parität). Verifiziert §3.
|
||||
- **Kind-Rows aktualisieren nach Parent-Planning-Transitionen nicht live:** Parent-getriebene Änderungen propagieren nicht auf die Kind-`TaskRowViewModel`s ohne Listen-Reload. (a) Nach „Finalize planning" bleiben Kind-Rows auf „Draft" (`IsDraft`) statt „Planned" (`IsPlanned`) — der geänderte `ParentFinalized` kommt nicht über das Parent-`TaskUpdated`-Broadcast an. (b) Nach „Discard" bleiben die (in der DB gelöschten) Draft-Kind-Rows sichtbar, bis die Liste neu geladen wird. Gemeinsame Ursache: die Kinderliste/-zustände werden bei Parent-Transitionen nicht live neu aufgelöst. Doppelt verifiziert §3 (Finalize **und** Discard, 2026-07-24).
|
||||
- **AskUser-Frage erscheint NUR in Mission Control, nicht in der Task-Detail-Insel:** Der `ask_user`-Banner (Frage + Antwort-Eingabe) lebt ausschließlich in `MonitorPaneView` (Mission Control). Die Detail-Insel (`DetailsIslandView`) streamt zwar den Live-Log eines laufenden Tasks, zeigt aber **kein** Frage-Banner — ein Nutzer, der nur die Detailansicht offen hat, sieht nicht, dass der Run auf eine Antwort wartet, und läuft nach 3 min in den Timeout-Fallback. Verifiziert §7 (User schaute Detailansicht, Frage war unsichtbar; erst in Mission Control sichtbar). Fix: Frage-Banner + Inline-Antwort auch in der Detail-Insel für den gebundenen laufenden Task surfacen (VM-Zustand liegt bereits in `TaskMonitorViewModel`, müsste für die Detail-Insel repliziert/geteilt werden). **Von Mika bei der §7-Verifikation ausdrücklich gewünscht** („vermisse diese Interaktion in der Detail-Ansicht"). §7.
|
||||
- **„New session"-Button (Mission Control) unsichtbar — Icon.Plus ist Strich-Only:** `Icon.Plus` = `M12 5v14M5 12h14` (IslandStyles.axaml:61) ist reine Linien-Geometrie ohne Fläche; im `<PathIcon>` (MissionControlView.axaml:38, füllt Geometrie) rendert sie **unsichtbar** → der Ad-hoc-„New session"-Button erscheint leer und ist nicht auffindbar (Feature + VM `OpenAdHocConPtySessionAsync` + Tests funktionieren, nur das Icon fehlt sichtbar). Fix: Icon.Plus als gefüllte Geometrie authoren ODER als gestricheltes `Path` rendern (Icon-Gotcha in CLAUDE.md). **Andere `Icon.Plus`-Verwendungen mitprüfen.** Verifiziert §5.
|
||||
|
||||
- **Worktree-Pipeline:**
|
||||
- Worktree-Happy-Path → `worktrees.state='active'`, `head_commit` gesetzt, `diff_stat` non-empty, Branch `claudedo/<id>` auf Disk.
|
||||
- No-Changes-Run → `status='Done'`, `head_commit IS NULL`, `diff_stat IS NULL`.
|
||||
- Kein Git-Repo (`working_dir=C:\Temp`) → `status='Failed'`, **keine** `worktrees`-Row, Git-Fehler im Log.
|
||||
- **Feature-Walkthroughs:** Planning-Session-Flow (Draft→Finalize→Chain), Prime/Daily-Prep-Trigger, Weekly-Report-Generierung, Self-Update (Banner → Update → „up to date").
|
||||
- **UI-Sichtprüfung (neu, 2026-06-09):** Diff-Viewer (Dateiliste, Added/Deleted/Renamed/Binary-Erkennung, Commit-Range-Diff nach Merge) und das „children need attention"-Band auf dem Session-Tab des Parents.
|
||||
- **UI-Sichtprüfung (neu, 2026-06-10, nach Refactoring-Merges):** Detail-Insel komplett durchklicken (Output/Git/Session-Tabs, Merge-Sektion, Agent-Settings-Overrides, Prep-Panel) — `DetailsIslandViewModel` wurde in Sektions-VMs aufgeteilt, Bindings angepasst. Außerdem: DiffModal-Fehler-State „Diff nicht mehr verfügbar" (Commit-Range ohne aufgezeichnete Commits) und der In-App-Konflikt-Resolver (Hub-Methoden umbenannt).
|
||||
- **UI-Sichtprüfung (neu, 2026-06-19, Rider-Style 3-Pane Merge-Editor):** Echten Konflikt auslösen (Single-Task-Approve mit Konflikt **und** Planning-Unit-Merge) und prüfen: drei Panes (Ours read-only | Result editierbar | Theirs read-only), Konfliktblöcke rot / aufgelöst grün in allen Panes, Inline-Accept `›`/`‹` in den Zwischen-Guttern landen die jeweilige Seite im Result, nur Konfliktregionen im Result editierbar (Stable read-only), synchrones vertikales Scrollen, File-Switcher bei mehreren Dateien, `M conflicts · K resolved`-Readout, Continue erst bei allen Konflikten gelöst, Binär-Guard. **Bekannte Kanten:** (1) Konflikt mit leerer Ours-Seite → Result-Region ist null-lang (Gutter via 1-Zeichen-Probe positioniert, Accept funktioniert; nur Hand-Tippen in die leere Region ist fummelig). (2) Gutter-Y nutzt `TranslatePoint` vom Result-`TextView` — bei sehr hohen Fenstern / großen Scrollständen die Ausrichtung gegenprüfen. (3) Blöcke richten sich nur über Stable-Text aus; nach einem Konflikt mit unterschiedlicher Zeilenzahl je Seite driften nachfolgende Blöcke vertikal (aligned/virtual-space Scroll ist bewusst zurückgestellt).
|
||||
- **Worker-Autostart am Gerät:** Logoff/Logon-Autostart, Update-Pfad, Uninstall entfernt die Startup-`.lnk`.
|
||||
- **In-App Interactive Sessions (neu, 2026-06-26):** ersetzt den externen `wt`-„Run interactively"-Launch durch einen In-App-Streaming-Chat (`StreamingClaudeSession`, `claude --input-format stream-json`). Real-CLI-Smoke (kein xUnit, kein Claude in Tests):
|
||||
- Task rechtsklick → „Run interactively" startet **keinen** Terminal mehr; der Stream erscheint im Detail-Output-Tab des (selektierten) Tasks und als Monitor in Mission Control.
|
||||
- Composer: Nachricht tippen + Enter/Send → erscheint sofort als `log-user`-Zeile in **Akzentfarbe** (via `LogKindForegroundConverter`, lokale Bindung schlägt den dim Style), Claude antwortet im selben Prozess.
|
||||
- **Senden während Claude arbeitet = Queue (Default):** die Nachricht wird gepuffert und beim `result` des laufenden Turns abgeschickt (kein Interrupt). Mehrere Queue-Nachrichten FIFO, eine pro Turn. Gequeute Nachrichten erscheinen in einem **Pending-Streifen über der Eingabezeile** (⧗-Liste, via `InteractiveQueueChanged`); eine Nachricht landet erst im Transkript (`log-user`-Zeile via `InteractiveMessageSent`), wenn sie tatsächlich an Claude zugestellt wird. Der seeded Erst-Prompt erscheint als erste User-Zeile. Jede gequeute Zeile hat ein **✕ zum Entfernen** (`RemoveQueuedInteractiveMessage`, by-text first-match; Worker re-broadcastet die Queue).
|
||||
- **Interrupt opt-in:** der kleine ■-Stop-Button neben Send unterbricht den laufenden Turn (`control_request`/`interrupt`, verifiziert mit CLI 2.1.191; Abbruch-`result` = `error_during_execution`, als Turn-Ende behandelt) — danach flusht die ggf. gequeute Nachricht im selben Prozess mit erhaltenem Kontext. Stop-Button ist immer sichtbar solange live (Interrupt im Idle ist ein No-op; Turn-in-flight wird nicht in die UI gebroadcastet).
|
||||
- Session-Ende: Prozess-Exit/Stop → `InteractiveSessionEnded`, Composer verschwindet, Monitor wird „done".
|
||||
- **Sicht-Konsistenz:** Mission-Control-Composer (SessionTerminalView-Bottom-Row mit Send-Button) vs. Detail-Composer (WorkConsole-Shell-Prompt `❯ … [Send]`) sehen unterschiedlich aus — ggf. angleichen.
|
||||
- **Drag-and-drop file attachments on the detail pane:** verify the "Drop to attach" hover overlay, drop round-trip (file appears in the list), "Add file…" picker, remove button, and that files land under `~/.todo-app/attachments/<taskId>/`. Also verify the MCP `AddTaskAttachment`/`ListTaskAttachments`/`RemoveTaskAttachment` tools and that a Running task refuses add/remove. (Manual; can't be unit-tested.)
|
||||
## UX / Nits (verifiziert 2026-07-24)
|
||||
|
||||
## Offene Code-Punkte
|
||||
- **„Resume planning session" ist grundsätzlich kaputt (Session-Id wird nie erfasst) + Fehler wird verschluckt:** Im UnfinishedPlanning-Modal macht **Resume** nichts. Ursache: `PlanningSessionManager.ResumeAsync` (PlanningSessionManager.cs:238) bricht mit `InvalidOperationException("No Claude session ID captured yet; cannot resume.")` ab, wenn `task.PlanningSessionId` leer ist — und der Setter `TaskRepository.UpdatePlanningSessionIdAsync` (TaskRepository.cs:322) hat **keinen einzigen Aufrufer** im Worker, d.h. `planning_session_id` wird nie befüllt (verifiziert an `verif §3 resume-discard`: `planning_session_id=NULL`, Token gesetzt, Drafts angelegt). Resume kann also **nie** erfolgreich sein. Zusätzlich verschluckt `TasksIslandViewModel.ResumePlanningSessionAsync` den ganzen Block in einem leeren `catch { }` (~Zeile 870) → der Button ist ein **stummer No-op**, kein Footer-Fehler, kein Dialog (verstößt gegen `feedback_ui_error_surfacing`). Hintergrund: die wt-Planning-Session ist interaktiv; ClaudeDo erfasst die claude-Session-Id dort nicht (analog zur bewusst nicht persistierten ConPTY-Session-Id, §5). Fix-Optionen: (a) beim Planning-Start/-Lauf die claude-Session-Id erfassen und via `UpdatePlanningSessionIdAsync` persistieren, damit Resume echt resumen kann; ODER (b) Resume entfernen/deaktivieren, wenn keine Session-Id vorliegt; in JEDEM Fall den Fehler statt des leeren `catch` surfacen. Verifiziert §3. §3.
|
||||
- **Attachments: erster Drag&Drop der Session schlug einmalig fehl („An error occurred"):** Beim allerersten Drop-to-attach einer UI-Session zeigte die `DropStatus`-Zeile inline einen generischen Fehler und es wurde nichts persistiert (kein File, keine DB-Row); alle folgenden Drops derselben Session + der „Add file…"-Picker + Remove funktionierten fehlerfrei. Nicht reproduzierbar nach dem ersten Mal (App-Neustart nötig, um die „erster-Drop"-Bedingung wiederherzustellen). Kandidaten: transiente SQLite-Contention (der UI-Prozess schreibt `todo.db` direkt via `new TaskAttachmentRepository`, während der Worker dieselbe DB hält) ODER ein Fehler im Drop-Stream-Pfad (`IStorageFile.OpenReadAsync` im Code-Behind, außerhalb des `try` in `AddFilesAsync`). Falls es erneut auftritt: `AddFilesAsync`/`OnDrop` mit robusterem Error-Logging versehen (die genaue Exception-Message fehlt, weil `DropStatus` nur `{fileName}: {ex.Message}` zeigt). Verifiziert §9 (einmalig beobachtet). §9.
|
||||
- **Agent-Settings-Gear weicht vom übrigen Settings-Icon ab:** Der Agent-Settings-Flyout-Button (`TaskHeaderBar.axaml:68`) rendert ein Unicode-Glyph `⚙` (`<TextBlock Text="⚙">`), während die Listen-Nav (`ListsIslandView.axaml:56`) und die Listen-Settings (`TasksIslandView.axaml:47`) das gefüllte `Icon.Settings`-PathIcon (Gear-StreamGeometry, IslandStyles.axaml:110) nutzen → optisch ein anderes Zahnrad. Angleichen: den `⚙`-TextBlock durch `<PathIcon Data="{StaticResource Icon.Settings}" .../>` ersetzen. Verifiziert §8 (User-Sichtprüfung). §8.
|
||||
- **Session-Skills-Tab hat keinen Empty-State:** Bei 0 installierten Skills zeigt der Skills-Tab (Settings) nur eine nackte leere Fläche unter der Install-Zeile — kein erklärender Hinweis (z.B. „Noch keine Skills installiert — GitHub-URL oben einfügen"). Verifiziert §8 (frischer Zustand vor ponytail-Install). Kleiner Empty-State-Text ergänzen.
|
||||
|
||||
- **Status-Bar Live-Update:** Prüfen, ob `RunNow`-Enable/Disable pro Task-Row bei Connection-Change sauber re-evaluiert. Connection-Status lebt in `IslandsShellViewModel` / `WorkerConnectionModalViewModel` (es gibt keinen `StatusBarViewModel` mehr). Erst messen, dann ggf. fixen. Klein.
|
||||
- **`AgentMcpTools` liegt in `LifecycleMcpTools.cs`** — beim Suchen irreführend; in eigene Datei verschieben. Ein-Minuten-Fix, lohnt keinen Agent-Lauf — beim nächsten Worker-Touch mitnehmen.
|
||||
- **Diff-Viewer: reiner Rename schwach dargestellt:** Rename korrekt erkannt (`UnifiedDiffParser` → `DiffFileStatus.Renamed`, Badge `StatusCode="R"`), aber der File-Tree zeigt nur den neuen Namen + „+0 −0" ohne „alt → neu"-Pfad; liest sich wie „keine Änderung". Optional: alten Pfad + „renamed"-Label anzeigen. §1.
|
||||
- **Header-TurnsText zeigt `0/max` für abgeschlossene Runs:** `TurnsText => {Turns}/{EffectiveMaxTurns}` — `Turns` wird beim Laden eines terminalen Tasks nicht aus `task_runs.turnCount` restauriert (nur live gefüllt). Kosmetisch. §1.
|
||||
- **Conflict-Resolver: Continue/Merge-Button klickbar trotz offener Konflikte:** Gate greift funktional (merged erst wenn alle Konflikte in allen Dateien gelöst), aber der Button ist nicht disabled/gegraut → früher Klick ist ein stummer No-op. Besser: disabled bis `AllResolved`, oder Hinweis „N Konflikte in M Dateien offen". §4.
|
||||
- **Conflict-Resolver: mehrere Konfliktdateien schlecht erkennbar:** Beim 2-Datei-Konflikt schwer zu sehen, dass zwei Dateien betroffen sind (File-Switcher/Anzahl zu unauffällig). Prominentere Datei-Liste / „x von y Dateien". §4.
|
||||
- **Planning-aktiver Parent zeigt weiter „Idle":** Parent in `planning_phase=active` hat `Status=Idle` (korrekt im Modell), aber der Row-Status-Chip zeigt „Idle"; der `PlanningBadge` ersetzt das nicht sichtbar → liest sich wie ein normaler Idle-Task. Wunsch: klarer „Planning/Draft aktiv"-Zustand, der Idle überschreibt. (Kinder zeigen korrekt „Draft".) §3.
|
||||
- **Blocked-by-Kette nicht sichtbar:** Nach Finalize ist die sequentielle Kette korrekt gesetzt (child[i] blocked-by child[i-1]), aber die UI stellt die Reihenfolge/Abhängigkeit nicht dar. Wunsch: Kette visualisieren (z.B. „wartet auf <Vorgänger>"), nicht nur der „waiting"-Chip nach dem Queueen. §3.
|
||||
- **Dequeue-„X" fehlt auf wartenden (blockierten) Kettengliedern:** `CanRemoveFromQueue = IsQueued || HasQueuedSubtasks`, `IsQueued` verlangt leeres `blocked_by`. Ein gequeuetes, aber blockiertes Kind (`IsWaiting`) bekommt daher kein Remove-from-queue-X — nur Parent + erstes (entsperrtes) Kind. §3.
|
||||
- **„Open ConPTY session" erneut = Prompt wird neu gesendet, kein Resume:** Da die ConPTY-Session-Id bewusst nicht persistiert wird, startet ein erneutes „Open ConPTY session" auf demselben Task eine frische Session und sendet den Task-Prompt erneut (re-runt die Arbeit im selben Worktree) statt zu resumen. So designt, aber UX-Falle — evtl. „Resume"-Affordance oder Re-Open-Warnung. §5.
|
||||
|
||||
## Nachklapp Refactoring-/Bug-Runde (2026-06-09/10)
|
||||
## Feature-Wünsche (aus der Session)
|
||||
|
||||
Alle 9 Review-Tasks (5 Refactorings, 4 Bugfixes) sind umgesetzt und gemerged; Details in den Commits. Offen geblieben:
|
||||
- **Conflict-Resolver: farbliches Hervorheben eingefügter Zeilen im Result-Pane** (grüner „flow" der übernommenen Zeilen). §4.
|
||||
- Als ClaudeDo-Tasks in Liste „Claude do" erfasst: **Approve erzwingt Diff/Review vor Merge** (koppelt den blocked-Merge-Silent-Fail-Fix) und **interaktive Planning-Session über embedded ConPTY statt externem wt-Fenster** (koppelt den Planning-Permission-Prompt).
|
||||
|
||||
- **`DetailsIslandViewModel` ist nach dem Split noch 1258 Zeilen** (Ziel war ~800) — die drei Sektions-VMs (AgentSettings, Merge, Prep) sind extrahiert, weitere Extraktion (z.B. ChildOutcomes/Subtasks-Sektion) lohnt erst, wenn die Datei wieder wächst.
|
||||
- **Bewusst zurückgestellt:** WorkerHub-Split nach Concern (~60 Methoden in einer Hub-Klasse). Die Interface-Parität löst das akute Testbarkeits-Problem; ein Hub-Split ist eine größere Architekturentscheidung → erst besprechen.
|
||||
- **Lessons learned:** Der `StartRunningAsync`-Guard-Task hat isoliert grün getestet, aber den Queue-Pfad gebrochen (Picker claimt vor dem Dispatch) — Integrationsfix `74ca2e0`. Bei parallelen Tasks, die denselben Pfad berühren, nach JEDEM Merge-Schwung die volle Suite auf main fahren.
|
||||
## Beobachtung (offen — Entscheidung Mika)
|
||||
|
||||
## Bug-Befunde (Korrektheits-Review 2026-06-09)
|
||||
|
||||
**Plausibel, noch nicht einzeln verifiziert (bei Gelegenheit prüfen):**
|
||||
|
||||
- Cancel eines `WaitingForChildren`-Parents kaskadiert nicht auf laufende/queued Kinder (verwaiste Worktree-Commits).
|
||||
- Ketten-Kaskade stoppt an einem `Idle`-Mittelglied (`OnChildFinishedAsync` prüft `CancelAsync`-Ergebnis nicht) → Rest bleibt `Queued+blocked`.
|
||||
- Delete des *letzten* nicht-terminalen Kindes triggert kein `TryAdvanceParentAsync` → Parent kann in `WaitingForChildren` hängen (FK `SET NULL` rettet nur die Blocked-Kette).
|
||||
- `ContinueMergeAsync` staged per `git add -A` vor dem Konflikt-Check (Marker im Index, Abort danach ggf. unsauber).
|
||||
- `HasChangesAsync` zählt untracked Files → blockiert Merges unnötig (`--untracked-files=no`).
|
||||
- `UnifiedDiffParser`: Pfade mit Leerzeichen / git-gequotete Pfade aus `diff --git` falsch geparst.
|
||||
- Kleinkram: MergePreview-Race bei schnellem Target-Wechsel, CTS-Dispose-Leak in Debounce-Saves, `Environment.CurrentDirectory`-Fallback im Konflikt-Dialog, Doppel-Continue-Fenster im Orchestrator.
|
||||
|
||||
**Geprüft und verworfen (keine Bugs):** ReviewFeedback-„Endlosschleife" (Fallback existiert), Cross-Thread-Crashes im DetailsIslandViewModel (Dispatcher-Marshalling im WorkerClient), Chain-Wedge nach Child-Delete (FK `ON DELETE SET NULL`), `\ No newline`-Parsing.
|
||||
- **`--permission-mode auto` + Modell `haiku` → Writes werden denied:** Kontrolliert verifiziert (CLI 2.1.207): unter dem Default-Mode `auto` bekommt **sonnet** Writes auto-approved (`permission_denials:[]`), **haiku** wird `denied` (`permission_denials:[Write]`, keine Datei) — eine haiku-Task macht unter `auto` still nichts und landet ohne Änderung in `WaitingForReview`. Normalbetrieb (Default = sonnet) nicht betroffen. KEINE CLI-Regression, sondern modellabhängiges `auto`-Verhalten. Optionen falls es nervt: haiku aus der Auswahl nehmen, ODER Runner auf `acceptEdits`/`bypassPermissions` (modell-unabhängig). Mika: erstmal beobachten. Siehe Memory `auto_permission_haiku_footgun`.
|
||||
|
||||
---
|
||||
|
||||
## Bewusst verworfen (nicht erneut vorschlagen)
|
||||
|
||||
- **CI-Build/Test-Pipeline** — push-to-main + release-on-push deckt das ab; Tests laufen am Ende jeder Session.
|
||||
- **Real-`claude`-Smoke-Test als xUnit-Test** — kein Claude in `dotnet test`; bleibt manueller Check (siehe oben). Tests nutzen `FakeClaudeProcess`.
|
||||
- **`architecture.md` / ADRs** — die per-Projekt-`CLAUDE.md`-Dateien sind die lebende Doku; ADRs lohnen solo nicht.
|
||||
- **Task-Mailbox-Integration** — geparkt; das generische `mcp__mailbox__*`-Plugin reicht (Begründung in `mailbox-proposal.md`).
|
||||
- **Tag-Negation, Tag-Multi-Select, Notes-`lists.kind`-Switch, Install-Service-Skript** — durch die aktuelle Architektur überholt (Tag-System entfernt, Notes/Autostart anders gelöst).
|
||||
- **Real-`claude`-Smoke-Test als xUnit-Test** — kein Claude in `dotnet test`; bleibt manueller Check. Tests nutzen `FakeClaudeProcess`.
|
||||
- **`architecture.md` / ADRs** — die per-Projekt-`CLAUDE.md`-Dateien sind die lebende Doku.
|
||||
- **Task-Mailbox-Integration** — geparkt; das generische `mcp__mailbox__*`-Plugin reicht (`mailbox-proposal.md`).
|
||||
- **Tag-Negation, Tag-Multi-Select, Notes-`lists.kind`-Switch, Install-Service-Skript** — durch die aktuelle Architektur überholt.
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
# Session Skills — Implementation Plan
|
||||
|
||||
Spec: `docs/superpowers/specs/2026-07-03-session-skills-design.md`
|
||||
Approach: subagent-driven TDD (sonnet), build + test + commit per task, stage files by
|
||||
path (never `git add -A`).
|
||||
|
||||
**Pre-flight (do first, before building anything):** manual smoke test — drop a skill
|
||||
into a scratch worktree's `.claude/skills/` and run `claude -p` to confirm cwd skills are
|
||||
discovered in headless mode. The whole feature rests on this. If it fails, stop and
|
||||
redesign around `CLAUDE_CONFIG_DIR`.
|
||||
|
||||
---
|
||||
|
||||
## Task 1 — Data layer: columns + registry table + migration
|
||||
|
||||
- Add nullable `SessionSkills` (string, JSON array) to `TaskEntity`, `ListConfigEntity`,
|
||||
`AppSettingsEntity`; map `session_skills` columns in their `*Configuration.cs`.
|
||||
- New `SessionSkillEntity` (`name` PK, `source_url`, `pinned_ref`, `subpath`,
|
||||
`description`, `added_at`) — one row per skill; a multi-skill repo writes N rows sharing
|
||||
`source_url`/`pinned_ref` — + configuration + `session_skills` table.
|
||||
- New `SessionSkillRepository` (async, CancellationToken): `ListAsync`, `GetAsync(name)`,
|
||||
`UpsertAsync`, `DeleteAsync(name)`, `DeleteBySourceAsync(url)`, `ListBySourceAsync(url)`.
|
||||
- EF migration `AddSessionSkills` (columns + table).
|
||||
- **Tests (Data.Tests):** repository CRUD on real SQLite; JSON column round-trips a
|
||||
name list.
|
||||
|
||||
## Task 2 — Registry service (install / update / remove)
|
||||
|
||||
- `Skills/SessionSkillRegistry` + `Skills/Interfaces/ISessionSkillRegistry`,
|
||||
`IRepoCloner` (clone abstraction so tests inject a local source dir).
|
||||
- `GitRepoCloner` (production) does `git clone` + resolves HEAD SHA.
|
||||
- Install: clone → **detect layout** (`skills/*/SKILL.md` bundle → each subskill; else
|
||||
root `SKILL.md` → single; else reject) → per skill parse YAML frontmatter (`name`,
|
||||
`description`), copy its dir **flat** to `~/.todo-app/session-skills/<name>/`, upsert a
|
||||
row with `subpath`. Reject collision with a skill from a different source; reinstalling
|
||||
the same source refreshes.
|
||||
- Update(sourceUrl) / Remove(sourceUrl) per spec (act on all of a source's skills).
|
||||
- **Tests (Worker.Tests):** install a **multi-skill** fixture (fake cloner, mirrors
|
||||
ponytail's `skills/*/SKILL.md`) → N rows + N flat dirs; install a root-`SKILL.md`
|
||||
fixture → 1 row; neither → rejected; cross-source name collision rejected;
|
||||
remove-by-source deletes all its dirs + rows. **No real network / no real claude CLI.**
|
||||
|
||||
## Task 3 — Resolution: union into ClaudeRunConfig
|
||||
|
||||
- Add `IReadOnlyList<string> SkillNames` to `ClaudeRunConfig` (default empty).
|
||||
- In `TaskRunner.ResolveConfigAsync`: parse each level's `session_skills`, union + dedup,
|
||||
filter to registry-existing names (drop + log missing).
|
||||
- **Tests (Worker.Tests):** union across the three levels; dedup; unknown name dropped.
|
||||
|
||||
## Task 4 — Seeder
|
||||
|
||||
- `Skills/SessionSkillSeeder` + interface. `SeedAsync(cwd, skillNames, isWorktree, ct)`:
|
||||
copy each installed skill dir → `<cwd>/.claude/skills/<name>/`; if worktree, append
|
||||
`/.claude/skills/<name>/` to `git rev-parse --git-path info/exclude` target if absent.
|
||||
- Wire into `TaskRunner` after run-dir resolution, before `ClaudeProcess.RunAsync`
|
||||
(both worktree and sandbox paths).
|
||||
- **Tests (Worker.Tests):** seeds into real temp dir; idempotent re-seed; worktree
|
||||
exclude line written once and not duplicated; seeded path is git-ignored (real git
|
||||
temp repo → `git status` clean for the seeded dir).
|
||||
|
||||
## Task 5 — Hub + DTOs + client
|
||||
|
||||
- `WorkerHub`: `GetSessionSkills`, `InstallSessionSkill(url)`, `UpdateSessionSkill(name)`,
|
||||
`RemoveSessionSkill(name)`.
|
||||
- New `SessionSkillDto`; extend `AppSettingsDto`, `ListConfigDto`, `UpdateListConfigDto`,
|
||||
`UpdateTaskAgentSettingsDto` with skill-name lists; map in the update handlers.
|
||||
- `IWorkerClient` + `WorkerClient` additions.
|
||||
- **Update hand-rolled fakes** in Worker.Tests + Ui.Tests (memory
|
||||
`iworkerclient_fakes_sync`).
|
||||
- **Tests:** hub method round-trip via existing hub test harness where present.
|
||||
|
||||
## Task 6 — UI: registry tab + selectors
|
||||
|
||||
- `SessionSkillsSettingsTabViewModel` + a **Skills** tab in `SettingsModalView.axaml`:
|
||||
installed list, Add (URL), Update, Remove, status line. Mirror
|
||||
`FilesSettingsTabViewModel`.
|
||||
- Global multi-select in General settings tab → `AppSettings.SessionSkills`.
|
||||
- Skills multi-select in shared `AgentConfigEditor` (covers List + Task) with inheritance
|
||||
badge, wired through `AgentConfigEditorViewModel`.
|
||||
- Localization: add EN + DE keys in parity (Localization.Tests enforces).
|
||||
- **Tests (Ui.Tests / Localization.Tests):** VM load/save of selections; locale parity.
|
||||
- **Visual verification is Mika's** — flag the gaps.
|
||||
|
||||
## Task 7 — Wiring, build, end-to-end smoke
|
||||
|
||||
- DI registration (registry, cloner, seeder) in `Program.cs`.
|
||||
- Build all touched projects `-c Release`; run Worker/Data/Ui/Localization test projects.
|
||||
- Manual E2E: install ponytail via the UI, enable per-task, run a task, confirm the skill
|
||||
is available to the agent and **not** committed and **not** in interactive sessions.
|
||||
|
||||
---
|
||||
|
||||
Commit per task with Conventional Commits (`feat(worker|ui|data): …`). Commit the
|
||||
spec + plan docs first.
|
||||
@@ -0,0 +1,88 @@
|
||||
# Plan — ConPTY Interactive Sessions
|
||||
|
||||
Spec: `docs/superpowers/specs/2026-07-23-conpty-interactive-sessions-design.md`
|
||||
Date: 2026-07-23
|
||||
|
||||
Execution: subagent-driven-development, sonnet model, TDD where meaningful,
|
||||
build + test + commit per task. Stage files explicitly by path (never
|
||||
`git add -A`). Terminal rendering is visual — flagged for the user's visual pass.
|
||||
|
||||
## Task 0 — Spike: embed a ConPTY terminal running `claude`
|
||||
|
||||
Not TDD; a throwaway proof. Add a temporary window/view that embeds each
|
||||
candidate control and launches `claude` in a known worktree.
|
||||
|
||||
- Evaluate **SvcSystems.UI.Terminal** and **Iciclecreek.Avalonia.Terminal**.
|
||||
- Acceptance: the real `claude` TUI renders correctly — colors, resize/reflow,
|
||||
and a live permission prompt is usable; input reaches the CLI.
|
||||
- Output: pick one library; note the control API (start with cwd/exe/args/env,
|
||||
process-exited event, dispose/kill). Record the decision in the spec.
|
||||
- Remove the throwaway harness before Task 1 (or keep as a manual dev sample,
|
||||
not wired into the app).
|
||||
|
||||
**Stop for the user's visual verification of the spike before continuing.**
|
||||
|
||||
## Task 1 — Worker: interactive launch-spec endpoint
|
||||
|
||||
- Add a Worker service/hub method that, given a taskId, prepares the worktree
|
||||
(session-skills seeding, agent files, MCP config, env — reuse the autonomous
|
||||
run prep path) and returns a `LaunchSpec { cwd, exe, args, env }`.
|
||||
- Reuse `WindowsTerminalLauncher.BuildResumeCommand` for exe/args.
|
||||
- Guards mirror `ResumeTaskInTerminal` (not Running/Queued, persisted SessionId,
|
||||
worktree Active/Kept). Never-run task → spec without `--resume` (fresh start).
|
||||
- Tests (Worker.Tests, real SQLite/git): guard cases, spec contents for a
|
||||
resumable task, fresh-start case. No real `claude` in tests.
|
||||
|
||||
## Task 2 — Worker: ad-hoc launch-spec
|
||||
|
||||
- Method to build a `LaunchSpec` for a free session in a given directory:
|
||||
MCP config + env set up, no task/session-skills seeding.
|
||||
- Tests: env/MCP presence, arbitrary cwd.
|
||||
|
||||
## Task 3 — UI: terminal host control + view model
|
||||
|
||||
- Wrap the chosen library in an app control/view (e.g. `InteractiveTerminalView`
|
||||
+ `InteractiveTerminalViewModel`) that starts from a `LaunchSpec` and exposes
|
||||
running/exited state.
|
||||
- `IWorkerClient`: add methods to fetch the task and ad-hoc launch specs; wire
|
||||
the SignalR client + hub method.
|
||||
- Update hand-rolled `IWorkerClient`/hub fakes in BOTH test projects.
|
||||
- Tests: view model starts/stops lifecycle with a fake terminal backend;
|
||||
fake worker returns a spec.
|
||||
|
||||
## Task 4 — Command Center: host interactive panes + entry points
|
||||
|
||||
- `MonitorPaneView`: autonomous panes keep the streamed log; interactive panes
|
||||
host the terminal control.
|
||||
- Entry points: "Open interactive session" from a task (task-based) and a
|
||||
"New session" action (ad-hoc, pick directory).
|
||||
- Layout toggle: focus (tabs) ↔ overview (grid); reuse/extend the existing
|
||||
`UniformGrid` column logic for the grid mode.
|
||||
- Tests: view-model level (pane kind selection, layout toggle state). Rendering
|
||||
is a visual-pass item.
|
||||
|
||||
## Task 5 — Remove the streaming interactive stack
|
||||
|
||||
Only after Tasks 1–4 land and the terminal path works.
|
||||
|
||||
- Worker: delete `StreamingClaudeSession`, `InteractiveSessionService`,
|
||||
interactive `WorkerHub` methods + broadcast events, DI registrations.
|
||||
Verify `LiveSessionRegistry` / `IdleSessionReaper` usage first; remove only if
|
||||
unreferenced.
|
||||
- UI: remove composer bits on `TaskMonitorViewModel`, the composer/queued portion
|
||||
of `SessionTerminalView`, `IWorkerClient` interactive methods.
|
||||
- Update fakes and delete now-dead tests. Full build + all test projects green.
|
||||
|
||||
## Task 6 — Docs
|
||||
|
||||
- Update `docs/open.md` with visual-verification items (spike render, terminal
|
||||
resize/focus, grid vs tabs).
|
||||
- Update affected per-project `CLAUDE.md` (Worker interactive removal, UI new
|
||||
terminal host).
|
||||
|
||||
## Verification gates
|
||||
|
||||
- After Task 0: user visual pass on the spike.
|
||||
- After Task 4: user visual pass on Command Center (task + ad-hoc, tabs + grid,
|
||||
permission prompt round-trip).
|
||||
- Never claim the terminal UI works without the user running it.
|
||||
@@ -0,0 +1,79 @@
|
||||
# Merge Helper — Implementation Plan
|
||||
|
||||
Spec: `docs/superpowers/specs/2026-07-24-merge-helper-design.md`
|
||||
Approach: subagent-driven (one subagent per task, `sonnet`, TDD, stage files by path — never `git add -A`). Build with `-c Release` per-csproj (a running Worker locks `Debug`). Commit per task, Conventional Commits.
|
||||
|
||||
---
|
||||
|
||||
## Phase A — Worker MCP conflict tools
|
||||
|
||||
Independently useful; merges first. All in `src/ClaudeDo.Worker/External/ExternalMcpService.cs` + tests in `tests/ClaudeDo.Worker.Tests/`.
|
||||
|
||||
### A1 — Verify engine surface (spike, no commit)
|
||||
Read `TaskMergeService.MergeAsync` / `ContinueMergeAsync` / `AbortMergeAsync` and the hub conflict flow (`WorkerHub.StartConflictMerge`/`ContinueConflictMerge`/`AbortConflictMerge`). Pin down:
|
||||
- exact `ContinueMergeAsync` / `AbortMergeAsync` signatures and how in-progress-merge state is located (repo + target branch from task/list, not shared hub state);
|
||||
- how the childless approve path (`ApproveAndMergeAsync`) threads `leaveConflictsInTree`.
|
||||
Record findings in the task notes; feeds A2/A3.
|
||||
|
||||
### A2 — `leaveConflictsInTree` on review_task / merge_task
|
||||
- TDD: tests in `Worker.Tests` (real git) — clean merge → Done; conflict + flag → `conflict_in_tree`, markers present, task stays `WaitingForReview`, `repoPath` returned.
|
||||
- Add optional param `leaveConflictsInTree = false` to `MergeTask` and `ReviewTask` (approve branch). When true, call the `leaveConflictsInTree:true` engine path and map the conflict result to `{ mergeStatus/merged, conflicts, repoPath }`.
|
||||
- Keep default behaviour (abort-on-conflict) byte-identical when the flag is absent/false.
|
||||
- Commit: `feat(worker): let review_task/merge_task leave conflicts in tree via MCP`
|
||||
|
||||
### A3 — `continue_merge` + `abort_merge` MCP tools
|
||||
- TDD: continue after on-disk resolution → committed, task Done, worktree merged; continue with markers remaining → returns conflicts; abort → markers gone, task `WaitingForReview`; both on no-active-merge → clean MCP error; `TaskUpdated` fired.
|
||||
- Add `[McpServerTool] continue_merge(taskId)` → `ContinueMergeAsync`; `abort_merge(taskId)` → `AbortMergeAsync`. Locate the merge from the task's repo/target. Emit `TaskUpdated`.
|
||||
- **Route both single-task and orchestrated (parent/children) in-progress merges** where locatable from the task (per A1 findings): detect the kind and call the matching engine continue/abort (`TaskMergeService` vs `PlanningMergeOrchestrator.Continue/Abort`). If the orchestrated path can't be located without hub UI state, leave it to the manual fallback (documented in the B1 prompt) and note the gap in `docs/open.md`.
|
||||
- Commit: `feat(worker): add continue_merge and abort_merge MCP tools`
|
||||
|
||||
---
|
||||
|
||||
## Phase B — Worker launch for the merge-helper session
|
||||
|
||||
### B1 — Prompt templates
|
||||
- Add `PromptKind.MergeHelper` + `PromptKind.MergeHelperInitial` to `ClaudeDo.Data/PromptFiles.cs` (file names `merge-helper-system.md` / `merge-helper-initial.md`, built-in `DefaultFor`, `Render` tokens for the initial brief).
|
||||
- System prompt encodes §7 behaviour (per-status algorithm, ask-on-uncertainty, summary format). Merge-state rule: **prefer MCP tools whenever they apply**; hand-merge (Edit + `git commit -- <paths>`) is an accepted fallback only for merges the MCP tools can't reach (§5.3), never a shortcut around them.
|
||||
- Initial brief renders a task table `{id,title,status,list,repo}` + scope label.
|
||||
- TDD: `PromptFiles` tests — kinds resolve, defaults non-empty, `Render` substitutes brief tokens.
|
||||
- Commit: `feat(data): add merge-helper prompt templates`
|
||||
|
||||
### B2 — `BuildForMergeHelper` launch spec
|
||||
- TDD (`Worker.Tests`): distinct-repo `--add-dir` set computed from selected tasks; correct cwd per scope (per-list repo vs first repo global); brief file written to `~/.todo-app/merge-helper-sessions/<guid>/brief.md`; allowed-tools + `--permission-mode default` + `MCP_TOOL_TIMEOUT` env correct; single-line kickoff points at the brief.
|
||||
- Implement `InteractiveLaunchSpecService.BuildForMergeHelper(IReadOnlyList<string> taskIds, MergeHelperScope scope, ct)`. Reuse the planning brief-file/kickoff pattern.
|
||||
- Commit: `feat(worker): build merge-helper interactive launch spec`
|
||||
|
||||
### B3 — Hub endpoint + client method
|
||||
- `WorkerHub.GetMergeHelperLaunchSpec(string[] taskIds, string? listId)`; `IWorkerClient.GetMergeHelperLaunchSpecAsync(...)` + `WorkerClient` impl.
|
||||
- Update hand-rolled `IWorkerClient` fakes in **both** test projects (see gotcha memory).
|
||||
- Commit: `feat(worker): expose merge-helper launch spec over the hub`
|
||||
|
||||
---
|
||||
|
||||
## Phase C — UI
|
||||
|
||||
### C1 — Selection dialog (View + VM)
|
||||
- New `MergeHelperSelectionViewModel` + `MergeHelperSelectionDialog.axaml` (compiled bindings, `TaskCompletionSource<T>` pattern). Checkbox rows (title, status badge, list/repo), grouping in global mode, default ticks per §4, select-all/none, confirm disabled when empty.
|
||||
- Candidates via existing `list_tasks`/worker client; filter client-side.
|
||||
- TDD (`Ui.Tests`): default-tick logic, empty→confirm-disabled, returns ordered selected IDs + list mapping.
|
||||
- Commit: `feat(ui): add merge-helper task selection dialog`
|
||||
|
||||
### C2 — Entry points + event plumbing
|
||||
- Per-list context-menu item **"Let Claude handle it"** in `ListsIslandView.axaml` (user-list rows) + one global entry in the footer. Bind to `LetClaudeHandleCommand` on `ListsIslandViewModel` (param = `ListNavItemViewModel` or a global sentinel).
|
||||
- VM raises `LetClaudeHandleRequested(MergeHelperScope)`; `IslandsShellViewModel` forwards to Mission Control.
|
||||
- Commit: `feat(ui): add "Let Claude handle it" entry points`
|
||||
|
||||
### C3 — Mission Control wiring
|
||||
- `MissionControlViewModel.OpenMergeHelperConPtySessionAsync(scope)`: open selection dialog → on confirm, `GetMergeHelperLaunchSpecAsync` → wrap in `TerminalLaunchDescriptor` → new `ConPtyPaneViewModel` (never deduped) → add to `ConPtySessions`/`Panes`.
|
||||
- Commit: `feat(ui): open merge-helper ConPTY tile from selection`
|
||||
|
||||
---
|
||||
|
||||
## Verify (per task + at the end)
|
||||
- Read each subagent diff; build the touched csproj `-c Release`; run the relevant test project.
|
||||
- `locales/en.json` + `de.json` parity for any new UI strings (Localization.Tests enforces it).
|
||||
- Flag visual-verification gaps (dialog layout, tile) for the user — never claim UI works without a run.
|
||||
- End-to-end ConPTY smoke (real Claude) is a manual item in `docs/open.md`.
|
||||
|
||||
## Commit docs first
|
||||
`docs(merge-helper): spec + implementation plan` (this file + the spec).
|
||||
@@ -0,0 +1,169 @@
|
||||
# Session Skills — Design
|
||||
|
||||
**Date:** 2026-07-03
|
||||
**Status:** Approved (design), implementation not started
|
||||
|
||||
## Problem
|
||||
|
||||
Mika wants to give headless task agents a specific Claude skill (e.g.
|
||||
[ponytail](https://github.com/DietrichGebert/ponytail)) **without** installing it
|
||||
globally in `~/.claude/skills/`, where it would leak into every interactive session.
|
||||
Skills should be a first-class, per-level session setting alongside `model`,
|
||||
`max_turns`, and `system_prompt` — configurable **global / per-list / per-task** — and
|
||||
sourced from a GitHub URL.
|
||||
|
||||
## Key facts that shape the design
|
||||
|
||||
- The Claude CLI discovers skills from two places: the **global** `~/.claude/skills/`
|
||||
(every session — undesirable here) and the **working directory's** `.claude/skills/`
|
||||
(plus plugins). ClaudeDo fully controls each spawned session's `WorkingDirectory`
|
||||
(`ClaudeProcess.cs:30`), so a skill dropped into the session cwd is scoped to exactly
|
||||
that headless run.
|
||||
- **Auto-commit uses `git add -A`** (`WorktreeManager.CommitIfChangedAsync` →
|
||||
`_git.AddAllAsync`, `WorktreeManager.cs:142`). Anything seeded into a worktree's
|
||||
`.claude/skills/` would be committed unless explicitly excluded → the seeder must add
|
||||
the seeded paths to the worktree's `info/exclude`.
|
||||
- A skill is not just prompt text: `SKILL.md` may reference scripts that run via Bash.
|
||||
Headless agents run with `--permission-mode auto` (effectively unattended), so a skill
|
||||
pulled from an arbitrary URL is **unattended third-party code execution**. This is why
|
||||
install is a deliberate, pinned, reviewable step — not a live per-run URL fetch.
|
||||
|
||||
## Decisions (locked)
|
||||
|
||||
1. **Install-and-pin, not live fetch.** A dedicated registry screen installs a skill
|
||||
once: clone the repo, pin to the current commit, store locally. Per-level config then
|
||||
references installed skills **by name** (checkboxes), never a URL.
|
||||
2. **Three levels, additive union.** Effective skill set = `global ∪ list ∪ task`.
|
||||
(Unlike `model`/`prompt`, which override — skills add up. Trade-off accepted: an
|
||||
inherited skill can't be switched off for a single task in the MVP.)
|
||||
3. **A repo can contribute multiple skills.** Installer detects the layout:
|
||||
- `skills/*/SKILL.md` (plugin bundle) → import **each** subskill flat. This is
|
||||
ponytail: it ships 6 skills (`ponytail`, `-help`, `-review`, `-audit`, `-debt`,
|
||||
`-gain`) under `skills/<name>/SKILL.md` plus `.claude-plugin/`, hooks, commands, an
|
||||
MCP — none of which we consume; we take only the `skills/<name>/` dirs.
|
||||
- root `SKILL.md` → single skill.
|
||||
- neither → reject.
|
||||
The CLI expects `.claude/skills/<name>/SKILL.md` **flat**, so subskills are flattened
|
||||
on install. Selection is **per individual skill name** (enable just `ponytail` +
|
||||
`ponytail-help` if you want, not all six).
|
||||
4. **Public repos only** (plain `git clone` over HTTPS, no auth) for MVP.
|
||||
|
||||
> **Correction (2026-07-03, from the smoke test):** the original "one repo = one skill
|
||||
> at root" MVP was wrong for the very target repo — ponytail is a multi-skill plugin.
|
||||
> Decision 3 above replaces it.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Storage & registry
|
||||
|
||||
- Each discovered skill is copied **flat** to `~/.todo-app/session-skills/<name>/` (its
|
||||
own self-contained dir with `SKILL.md` at the root of that dir), so the seeder just
|
||||
copies `<name>/` → cwd.
|
||||
- New DB table `session_skills`, one row **per skill** (a multi-skill repo writes N rows
|
||||
sharing `source_url` + `pinned_ref`): `name` (PK), `source_url`, `pinned_ref` (commit
|
||||
SHA), `subpath` (dir within the repo the skill came from, e.g. `skills/ponytail` or
|
||||
`.` for root), `description`, `added_at`. Repo-level ops act on all rows with the same
|
||||
`source_url` (no separate sources table — keep it flat).
|
||||
- New worker service `SessionSkillRegistry` (in a new `Skills/` area under the Worker):
|
||||
- `InstallAsync(url)` — clone to temp → **detect layout** (`skills/*/SKILL.md` bundle
|
||||
vs root `SKILL.md`) → for each discovered skill parse frontmatter (`name`,
|
||||
`description`), resolve HEAD SHA as `pinned_ref`, copy its dir flat into place, upsert
|
||||
a row. Returns the list of installed skill names. Name collision (a skill name from a
|
||||
*different* source) → error surfaced to UI; reinstalling the same source updates.
|
||||
Clone is injected (`IRepoCloner`) so tests use a local source dir — **no real network,
|
||||
no real CLI**.
|
||||
- `UpdateAsync(sourceUrl)` — re-clone, re-detect, refresh that source's skills +
|
||||
`pinned_ref`.
|
||||
- `RemoveAsync(sourceUrl)` — delete all its skill dirs + rows.
|
||||
- `ListAsync()` — registry entries for the UI (grouped by source for display).
|
||||
|
||||
### Resolution
|
||||
|
||||
`TaskRunner.ResolveConfigAsync` (`TaskRunner.cs:488`) already merges
|
||||
task → list → global for the other fields. Add:
|
||||
|
||||
```
|
||||
SkillNames = Union(task.SessionSkills, listConfig?.SessionSkills, global.SessionSkills)
|
||||
```
|
||||
|
||||
deduped, filtered to names that still exist in the registry (a removed skill is silently
|
||||
dropped — logged). Add `IReadOnlyList<string> SkillNames` to `ClaudeRunConfig`
|
||||
(`ClaudeArgsBuilder.cs:5`). **No CLI flag is emitted** — skills are seeded on disk, not
|
||||
passed as args. `ClaudeArgsBuilder.Build` is unchanged for skills.
|
||||
|
||||
Per-level storage: a nullable TEXT column `session_skills` (JSON array of names) on
|
||||
`tasks`, `list_config`, and `app_settings`.
|
||||
|
||||
### Seeding
|
||||
|
||||
New service `SessionSkillSeeder`, called by `TaskRunner` after the working dir is
|
||||
resolved and before `ClaudeProcess.RunAsync`:
|
||||
|
||||
- For each resolved skill name, copy `~/.todo-app/session-skills/<name>/` →
|
||||
`<cwd>/.claude/skills/<name>/` (overwrite → idempotent for resume/re-run).
|
||||
- If the cwd is a git worktree, append `/.claude/skills/<name>/` to the worktree's
|
||||
`info/exclude` (path via `git rev-parse --git-path info/exclude`, so it targets the
|
||||
per-worktree exclude), only if not already present. **Only the seeded subdirs are
|
||||
excluded** — never blanket-exclude `/.claude/`, in case the target project commits its
|
||||
own `.claude/`.
|
||||
- Sandbox runs (not a repo) skip the exclude step.
|
||||
- No separate cleanup: seeded dirs vanish with the worktree/sandbox.
|
||||
|
||||
### allowedTools caveat
|
||||
|
||||
`--allowedTools` is only emitted when set (`ClaudeArgsBuilder.cs:80`); normal task runs
|
||||
leave it null → all tools allowed → the `Skill` tool is available. If a future per-task
|
||||
allowedTools restriction is added, it must include `Skill`. Noted, not handled in MVP.
|
||||
|
||||
## UI
|
||||
|
||||
Mirror the existing agent-file pattern.
|
||||
|
||||
- **Registry screen ("extra mask"):** a new **Skills** tab in the Settings modal
|
||||
(`SettingsModalView.axaml`) with `SessionSkillsSettingsTabViewModel`. **Add** (URL text
|
||||
box → install a repo, which may yield several skills); lists installed skills grouped by
|
||||
source (name, description, source, short ref); **Update** / **Remove** act per source
|
||||
(repo). Status/error line like `FilesSettingsTabViewModel`.
|
||||
- **Global selector:** multi-select (checkbox list) of installed skills in the General
|
||||
settings tab → `AppSettings.SessionSkills`.
|
||||
- **List + Task selectors:** add a skills multi-select to the shared
|
||||
`AgentConfigEditor` control (`AgentConfigEditor.axaml` /
|
||||
`AgentConfigEditorViewModel.cs`), which is already reused by both List settings and the
|
||||
per-task flyout — one addition covers both levels, with the existing inheritance-badge
|
||||
pattern.
|
||||
|
||||
### Hub / client surface
|
||||
|
||||
New `WorkerHub` methods + `IWorkerClient` entries (update hand-rolled fakes in both test
|
||||
projects — see memory `iworkerclient_fakes_sync`):
|
||||
`GetSessionSkills`, `InstallSessionSkill(url)`, `UpdateSessionSkill(sourceUrl)`,
|
||||
`RemoveSessionSkill(sourceUrl)`. Extend `AppSettingsDto`, `ListConfigDto`,
|
||||
`UpdateListConfigDto`, `UpdateTaskAgentSettingsDto` with the selected skill-name lists.
|
||||
New `SessionSkillDto`.
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **Removed skill still referenced** by a level → dropped at resolve time, logged, no
|
||||
failure.
|
||||
- **Name collision on install** → reject with a clear message; offer Update instead.
|
||||
- **Repo without root `SKILL.md`** → reject at install.
|
||||
- **Target project already has `.claude/skills/`** → additive copy; exclude only our
|
||||
subdirs.
|
||||
- **Resume / re-run** reuses the worktree → re-seed overwrites, exclude append is
|
||||
idempotent.
|
||||
|
||||
## Verification (must-check, can't be unit-tested)
|
||||
|
||||
- **Does `claude -p` actually load and invoke a skill placed in cwd `.claude/skills/`?**
|
||||
**CONFIRMED 2026-07-03.** Mika ran, in a stable terminal, a `claude -p` invocation in a
|
||||
scratch cwd holding `.claude/skills/ponytail*/SKILL.md`; the model invoked the
|
||||
`ponytail-help` skill and returned its exact Lite/Full/Ultra table. Headless mode does
|
||||
surface cwd skills → the whole approach holds; no `CLAUDE_CONFIG_DIR` fallback needed.
|
||||
- Seeded skill is **not** committed by the auto-commit step (worktree run).
|
||||
- Skill does not appear in a normal interactive session (no global leak).
|
||||
|
||||
## Out of scope (MVP)
|
||||
|
||||
Private-repo auth; consuming a plugin's *non-skill* parts (hooks, commands, MCP — we take
|
||||
only `skills/<name>/`); auto-update & update notifications; per-task *disabling* of an
|
||||
inherited skill; surfacing skill invocation in the run log.
|
||||
@@ -0,0 +1,171 @@
|
||||
# ConPTY Interactive Sessions — Design
|
||||
|
||||
Date: 2026-07-23
|
||||
Status: Approved (design), implementation not started
|
||||
|
||||
## Problem
|
||||
|
||||
The current in-app interactive session path streams `stream-json` from a
|
||||
`claude` process spawned **in the Worker** and renders it as a chat log with a
|
||||
composer. It does not surface permission requests, AskUser questions, and other
|
||||
TUI-native interactions well — the rendering is a partial reimplementation of
|
||||
what the real Claude Code TUI already does. We want full fidelity for
|
||||
interactive work without rebuilding the TUI.
|
||||
|
||||
## Decision
|
||||
|
||||
**Hybrid execution model:**
|
||||
|
||||
- **Autonomous queue tasks** (`Status=Queued`, picked by the queue): unchanged.
|
||||
Headless `stream-json`, full orchestration (status flow, diff, review, merge).
|
||||
- **Interactive sessions**: an **embedded ConPTY terminal** running the real
|
||||
`claude` CLI, rendered in the **UI process**. Full TUI fidelity (permission
|
||||
prompts, questions, colors, everything the standalone CLI does). Detached from
|
||||
the review/merge/status machinery — these are a manual cockpit.
|
||||
|
||||
This is the third direction for interactive (external `wt` terminal → streaming
|
||||
chat → embedded ConPTY). The streaming interactive stack is **removed**, not run
|
||||
in parallel — accepted as discarded work in exchange for one interactive path
|
||||
and full fidelity.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Process location
|
||||
|
||||
ConPTY terminal controls render in-process and spawn their child (`claude`) as a
|
||||
child of the host process. Therefore interactive sessions move **out of the
|
||||
Worker and into the UI process**. They no longer flow over SignalR. This mirrors
|
||||
the existing `ResumeTaskInTerminal` behavior (launch real `claude`), but embedded
|
||||
instead of via external `wt.exe`.
|
||||
|
||||
### Worktree preparation (task-based sessions)
|
||||
|
||||
Interactive task sessions get the **same worktree preparation as autonomous
|
||||
runs**: session-skills seeding, agent files, MCP config, environment. The Worker
|
||||
performs the prep and returns a launch spec to the UI:
|
||||
|
||||
```
|
||||
LaunchSpec {
|
||||
cwd: string // worktree path
|
||||
exe: string // resolved claude executable / shell
|
||||
args: string[] // e.g. --resume <sessionId>
|
||||
env: Dictionary<string,string>
|
||||
}
|
||||
```
|
||||
|
||||
The command construction reuses `WindowsTerminalLauncher.BuildResumeArgs`/`Resolve`.
|
||||
Guards mirror `ResumeTaskInTerminal` for Running/Queued (rejected). Worktree
|
||||
handling (FINAL):
|
||||
- Existing Active/Kept worktree + persisted SessionId → `--resume <id>`.
|
||||
- No usable worktree but the list has a WorkingDir (git repo) → create a worktree
|
||||
**on demand** via `WorktreeManager.CreateAsync` (the same path autonomous runs
|
||||
use), then a fresh-start spec. This lets never-run tasks be opened interactively.
|
||||
- Fresh (non-resume) session → the task's prompt (title + description) is passed
|
||||
as claude's positional prompt so the session starts on the task.
|
||||
- No worktree and no WorkingDir → clear error.
|
||||
Interactive sessions are detached: ClaudeDo does NOT record their claude session
|
||||
id (ConPTY is opaque, no stream-json), so resuming a specific past interactive
|
||||
conversation is only via claude's own `--continue`/`--resume` in the worktree dir.
|
||||
|
||||
### Free / ad-hoc sessions
|
||||
|
||||
In addition to task-based sessions, the user can open an ad-hoc terminal in a
|
||||
chosen directory (no task). These also get MCP config + env set up so the
|
||||
`claudedo` tools are available, but no per-level session-skills seeding tied to a
|
||||
task.
|
||||
|
||||
### Terminal host control — RESOLVED by spike (2026-07-23)
|
||||
|
||||
**Library: `Iciclecreek.Avalonia.Terminal` 2.0.3** (namespace `Iciclecreek.Terminal`).
|
||||
It needs Avalonia >= 12.0.2; the repo is on 12.0.4 → compatible, no bump.
|
||||
`SvcSystems.UI.Terminal` (latest) needs Avalonia 12.1+ → rejected.
|
||||
|
||||
Visual pass (user, 2026-07-23): the real `claude` TUI renders correctly inside
|
||||
the embedded control — Claude Code opened and was usable.
|
||||
|
||||
**Binding approach — use the library's own `LaunchProcess()` (FINAL).**
|
||||
An initial attempt drove `Porta.Pty` ourselves (own read loop, key tunneling via
|
||||
`GenerateKeyInput`/`GenerateCharInput`, manual resize) to bypass
|
||||
`LaunchProcess()` and inject a custom `PtyOptions.Environment`. That was a
|
||||
mistake: it rendered wrong, lagged, and dropped input. The spike had proven the
|
||||
library's own `TerminalControl.LaunchProcess()` pipeline renders correctly, stays
|
||||
responsive, and handles input/resize/focus. So `PtyTerminalSession` is a thin
|
||||
wrapper (`src/ClaudeDo.Ui/Services/PtyTerminalSession.cs`):
|
||||
|
||||
- Set `control.Process = descriptor.Exe`, `control.Args = descriptor.Args`,
|
||||
`control.StartingDirectory = descriptor.Cwd`, then `await control.LaunchProcess()`.
|
||||
- Relay the control's own `ProcessExited` event and `Kill()`.
|
||||
- `Process=""` stays on the AXAML `TerminalControl` to suppress the library's
|
||||
auto-launch-on-load, so exactly one process starts (our manual launch).
|
||||
|
||||
**Environment:** `LaunchProcess()` gives no per-launch env dict, but
|
||||
`Porta.Pty.SpawnAsync` inherits the *current process* environment. So apply
|
||||
`descriptor.Env` entries via `Environment.SetEnvironmentVariable(key, value)`
|
||||
(process scope) before `LaunchProcess()`. The only var needed is
|
||||
`MCP_TOOL_TIMEOUT`; session-skills/agent-files/MCP-config are on disk / globally
|
||||
registered, independent of env. (The earlier "child gets zero env vars" claim was
|
||||
wrong — Porta.Pty seeds from the process env.)
|
||||
|
||||
**Sizing gotcha (critical):** the control derives Cols/Rows from
|
||||
`arranged-size / character-cell-size`. Without an EXPLICIT monospace font the cell
|
||||
metrics are wrong and the child TUI renders into the wrong area. Set
|
||||
`FontFamily="Cascadia Mono,Consolas,monospace"`, `FontSize`, `BufferSize`, and
|
||||
`HorizontalAlignment/VerticalAlignment=Stretch` on the `TerminalControl` (matching
|
||||
the spike) — this is what made rendering correct in the pane.
|
||||
|
||||
**Lesson:** do not hand-roll pty/input/render around this control — use its
|
||||
`LaunchProcess()` pipeline.
|
||||
|
||||
### Command Center layout
|
||||
|
||||
- `MonitorPaneView` keeps the streamed log for autonomous tasks.
|
||||
- Interactive panes host the terminal control instead of the log+composer.
|
||||
- Layout is **toggleable**: focus mode (tabs, one session large) ↔ overview mode
|
||||
(grid, several sessions at once).
|
||||
|
||||
## Removals
|
||||
|
||||
Worker:
|
||||
- `StreamingClaudeSession`, `InteractiveSessionService`
|
||||
- `WorkerHub` interactive methods: `OpenInteractiveTerminal`,
|
||||
`SendInteractiveMessage`, `RemoveQueuedInteractiveMessage`,
|
||||
`StopInteractiveSession`, `InterruptInteractiveSession`
|
||||
- Broadcast events: `InteractiveSessionStarted/Ended`, `InteractiveQueueChanged`,
|
||||
`InteractiveMessageSent`
|
||||
- `IdleSessionReaper` and `LiveSessionRegistry` **iff** unused elsewhere
|
||||
(verify during implementation — do not delete blindly).
|
||||
|
||||
UI:
|
||||
- Composer on `TaskMonitorViewModel`: `ComposerDraft`, `SubmitComposerCommand`,
|
||||
`InterruptInteractiveCommand`, `StopInteractiveCommand`, `QueuedMessages`,
|
||||
`IsInteractiveLive`.
|
||||
- The composer + queued-messages portion of `SessionTerminalView` (the log
|
||||
portion stays for autonomous panes).
|
||||
- `IWorkerClient` interactive methods.
|
||||
|
||||
## Open items (implementation time)
|
||||
|
||||
- Whether `LiveSessionRegistry` is referenced outside the interactive path.
|
||||
- Session-id availability for a never-run task (no `--resume` → start fresh).
|
||||
|
||||
Resolved: env approach (see Terminal host control — add custom vars on top of the
|
||||
inherited process env via `PtyOptions.Environment`); library + binding seam.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No screen-scraping of terminal output back into task status/diff/review.
|
||||
- No change to the autonomous queue execution path.
|
||||
|
||||
## Status (2026-07-23)
|
||||
|
||||
Implemented on main (not pushed) and visually verified by the user (rendering,
|
||||
input, responsiveness correct after switching to `LaunchProcess()` + setting a
|
||||
monospace font). Done: worker launch-spec (task + ad-hoc, on-demand worktree,
|
||||
prompt seeding), UI terminal host, Command Center hosting (grid↔tabs), streaming
|
||||
stack removed. Permission mode: left to claude's default (not forced), per user.
|
||||
|
||||
Deferred: **Avalonia 12.1 upgrade** — blocked, not adopted. 12.1's XAML source
|
||||
generator needs Roslyn 4.14 (.NET 9.0.3xx SDK); the repo pins .NET 8 in
|
||||
`global.json`, and bumping the SDK floor risks the Gitea Actions release build.
|
||||
Staying on Avalonia 12.0.x (Iciclecreek 2.0.3 works there). Revisit only with a
|
||||
deliberate SDK-floor decision.
|
||||
@@ -0,0 +1,61 @@
|
||||
# ConPTY Planning Sessions — Design (2026-07-24)
|
||||
|
||||
**Task:** `5d627df8` — interaktive Planning-Session über embedded ConPTY statt externem `wt`-Fenster.
|
||||
|
||||
## Problem
|
||||
Planning-Sessions (`StartPlanningSession`/`ResumePlanningSession`) öffnen heute ein externes
|
||||
Windows-Terminal (`WindowsTerminalLauncher` → `wt.exe`). Seit ConPTY existiert (embedded
|
||||
claude-TUI im UI-Prozess, Command Center), soll die interaktive Planning-Session denselben
|
||||
Weg nutzen: konsistente UX, keine externen Fenster.
|
||||
|
||||
## Approved decisions (Brainstorm 2026-07-24)
|
||||
- **Env-Isolation:** Prozess-global akzeptiert (Sessions werden sequenziell per Klick geöffnet;
|
||||
jedes claude-Child snapshottet Env beim Spawn). Keine Änderung an `PtyTerminalSession`.
|
||||
- **wt-Launcher:** Code bleibt; nur das Routing für Planning wird auf ConPTY umgestellt
|
||||
(kein Rip-out von `LaunchPlanningStart/ResumeAsync`).
|
||||
- Planning-Kachel im Command Center (Mission Control), wie andere ConPTY-Sessions.
|
||||
Finalize/Discard/Queue-Plan bleiben unverändert auf den bestehenden Buttons.
|
||||
|
||||
## Change map
|
||||
|
||||
### Worker
|
||||
1. `WindowsTerminalLauncher`: Planning-Start-Args in einen bare Builder
|
||||
`BuildPlanningStartArgs(PlanningSessionStartContext) -> IReadOnlyList<string>` herausziehen
|
||||
(analog `BuildResumeArgs`); `BuildPlanningStartCommand` nutzt ihn weiter (wt unverändert).
|
||||
Resume-Args `BuildPlanningResumeArgs(sessionId) = ["--permission-mode","plan","--resume",id]`
|
||||
(bisher inline in `LaunchPlanningResumeAsync`).
|
||||
2. `InteractiveLaunchSpecService`: `BuildPlanningStart(PlanningSessionStartContext) -> LaunchSpec`
|
||||
und `BuildPlanningResume(PlanningSessionResumeContext) -> LaunchSpec` — resolve claude,
|
||||
Args aus (1), Env `MAX_THINKING_TOKENS=20000` + `CLAUDEDO_PLANNING_TOKEN=<token>`
|
||||
(+ `MCP_TOOL_TIMEOUT` wie interaktiv), Cwd = ctx.WorkingDir. Nimmt den Kontext (keine
|
||||
Manager-Kopplung).
|
||||
3. Hub: `GetPlanningStartLaunchSpec(taskId) -> LaunchSpec` (ruft `_planning.StartAsync`,
|
||||
broadcastet `TaskUpdated`, baut Spec; bei Fehler discard+rethrow wie heute),
|
||||
`GetPlanningResumeLaunchSpec(taskId) -> LaunchSpec` (ruft `_planning.ResumeAsync`).
|
||||
|
||||
### UI
|
||||
4. `IWorkerClient`/`WorkerClient`: `GetPlanningStartLaunchSpecAsync`/`GetPlanningResumeLaunchSpecAsync`
|
||||
(mirror `GetInteractiveLaunchSpecAsync`).
|
||||
5. `MissionControlViewModel`: `OpenPlanningConPtySessionAsync(taskId, resume)` — dedupt nach
|
||||
TaskId, holt die Planning-Spec, baut `TerminalLaunchDescriptor` → `ConPtyPaneViewModel`
|
||||
(Titel „<task> (Planning)").
|
||||
6. `TasksIslandViewModel`: `OpenPlanningSessionAsync`/`ResumePlanningSessionAsync` (Resume-Zweig
|
||||
der `UnfinishedPlanningModal`) öffnen die Planning-ConPTY-Kachel via neuem Event
|
||||
`OpenPlanningConPtyRequested(taskId, resume)`, statt `StartPlanningSessionAsync`/wt.
|
||||
`IslandsShellViewModel` verdrahtet das Event → `OpenMissionControl()` +
|
||||
`MissionControl.OpenPlanningConPtySessionAsync`.
|
||||
|
||||
### #12 (Nebenbefund)
|
||||
MCP-Permission-Prompt trotz `--allowedTools "mcp__claudedo__*"`: im interaktiven ConPTY vom
|
||||
User bestätigbar (kein Blocker wie headless). Beim Testen prüfen, ob der Glob den Prompt in
|
||||
der aktuellen CLI unterdrückt; falls nicht, allowedTools/`--permission-mode`-Kombi nachziehen.
|
||||
|
||||
## Out of scope
|
||||
- Entfernen des wt-Codes / `ResumeTaskInTerminal`.
|
||||
- Per-Child-Env-Isolation in `PtyTerminalSession`.
|
||||
- Änderungen an Finalize/Discard/Queue-Plan-Lifecycle.
|
||||
|
||||
## Verification
|
||||
- Build Worker + App (`-c Release`), Worker.Tests + Ui.Tests grün.
|
||||
- Visual (User): Planning-Start öffnet Command-Center-Kachel mit claude-TUI im Plan-Modus;
|
||||
create_child_task erzeugt Draft-Kinder live; Resume greift die Session; kein wt-Fenster.
|
||||
@@ -0,0 +1,182 @@
|
||||
# Merge Helper ("Let Claude handle it") — Design
|
||||
|
||||
**Status:** Proposed — awaiting approval
|
||||
**Date:** 2026-07-24
|
||||
**Scope:** Feature — a per-list and global button that opens an **interactive ConPTY Claude session** pre-loaded with a set of user-selected tasks. The session (the "Merge Helper") drives each selected task to completion and merge autonomously via `mcp__claudedo__*` tools, asks the user interactively (in the ConPTY terminal) only when uncertain, resolves merge conflicts itself, and ends with a written summary of everything that changed.
|
||||
|
||||
---
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Collapse the repetitive per-task review→merge clicking into a single "Let Claude handle it" action. The user picks the tasks; an embedded Claude session babysits them — running the ones that still need running, reviewing diffs, merging the clean ones, resolving conflicts, and reporting back — while remaining fully interactive so the user can answer questions mid-run.
|
||||
|
||||
This reuses the existing ConPTY infrastructure (UI-process embedded terminal) and the globally-registered `claudedo` MCP server. The only genuinely new worker capability is **MCP-driven conflict resolution** (§5), which today exists only in the UI hub.
|
||||
|
||||
---
|
||||
|
||||
## 2. Decisions (locked with user, 2026-07-24)
|
||||
|
||||
| Question | Decision |
|
||||
|---|---|
|
||||
| Which tasks does the helper handle? | **Free choice, any status.** User hand-picks; helper acts per-status. |
|
||||
| How are tasks selected for a run? | **Checkbox dialog before launch** (candidates listed, user ticks). |
|
||||
| Merge authority / review-gate | **Auto-merge; asks interactively on uncertainty.** The app's per-task diff-gate is intentionally bypassed for helper-driven merges. |
|
||||
| Conflict handling | **Build MCP conflict tools** so the helper resolves conflicts in the working tree itself, asking only when unsure (Option B). The helper must handle **all** cases including parent/children unit merges; where the MCP path doesn't reach, **manual resolution by hand (Edit + git) is an accepted fallback** (user-confirmed 2026-07-24). |
|
||||
|
||||
---
|
||||
|
||||
## 3. UX Flow
|
||||
|
||||
1. **Entry points**
|
||||
- **Per-list:** context-menu item **"Let Claude handle it"** on each user-list row in `ListsIslandView.axaml` (alongside Settings / Worktrees / Open in Explorer).
|
||||
- **Global:** one entry (footer of the lists island) that spans *all* lists/repos.
|
||||
2. Click opens the **Merge Helper selection dialog** (§4): a checkbox list of candidate tasks, grouped by list/repo, pre-filtered to tasks worth acting on but freely overridable.
|
||||
3. User ticks tasks → **"Let Claude handle it"** confirm button.
|
||||
4. UI asks the worker for a `MergeHelperLaunchSpec`, opens a **ConPTY tile in Mission Control** running the real `claude` TUI with the merge-helper prompt.
|
||||
5. The session works through the tasks, printing progress and asking questions inline; the user answers directly in the terminal.
|
||||
6. On completion Claude prints a **summary** (merged / skipped / conflicted / follow-ups). The tile stays open for review.
|
||||
|
||||
---
|
||||
|
||||
## 4. Selection Dialog
|
||||
|
||||
New modal `MergeHelperSelectionDialog` (View + VM), built with the existing `TaskCompletionSource<T>` dialog pattern used by other modals.
|
||||
|
||||
**Contents:**
|
||||
- Title: *"Let Claude handle it"* + subtitle naming the scope ("List: <name>" or "All lists").
|
||||
- A scrollable checkbox list of **candidate tasks**. Per row: checkbox, title, status badge, list/repo name (in global mode).
|
||||
- Grouping: by list/repo in global mode; flat in per-list mode.
|
||||
- Default selection: all **actionable** tasks pre-ticked — actionable = `WaitingForReview`, `Idle`, `Queued`, `Failed` (resettable). `Running` / `WaitingForChildren` shown but unticked (helper will poll them). Terminal `Done`/`Cancelled` excluded from the list entirely.
|
||||
- Footer: **"Let Claude handle it"** (disabled when nothing ticked) + **Cancel**. A "select all / none" affordance.
|
||||
|
||||
**Candidate source:** `list_tasks` via the existing worker client (per list, or across all lists for global). No new query needed; the VM filters client-side by status.
|
||||
|
||||
**Output:** an ordered `IReadOnlyList<string>` of selected task IDs (+ their list/repo mapping), passed to the launch request.
|
||||
|
||||
---
|
||||
|
||||
## 5. New Worker Capability — MCP Conflict Resolution
|
||||
|
||||
Today (verified): `merge_task` / `review_task approve` call `TaskMergeService.MergeAsync(..., leaveConflictsInTree:false)` — on conflict they run `git merge --abort` (clean rollback, no markers) and return `mergeStatus="conflict"`; the task stays `WaitingForReview`. Continue/abort/write-resolution exist **only** on the SignalR hub (Rider merge editor). An MCP agent therefore cannot resolve conflicts. This section adds that.
|
||||
|
||||
### 5.1 Approach
|
||||
|
||||
Reuse the *exact* engine methods the UI already uses — `TaskMergeService.MergeAsync(leaveConflictsInTree:true)`, `ContinueMergeAsync`, `AbortMergeAsync` — and expose them over MCP. The helper resolves conflict markers on disk (it has filesystem access to the repo checkouts via `--add-dir`, see §6.3) and drives the merge state exclusively through MCP tools so the engine stays authoritative.
|
||||
|
||||
### 5.2 MCP surface changes (`ExternalMcpService.cs`)
|
||||
|
||||
1. **`review_task` / `merge_task` — new optional param `leaveConflictsInTree: bool = false`.**
|
||||
When `true` and the merge conflicts: leave markers in the checkout instead of aborting, and return
|
||||
`{ mergeStatus: "conflict_in_tree", conflicts: string[], repoPath: string }`
|
||||
where `repoPath` is the checkout holding the markers. Task stays `WaitingForReview`, merge is in progress. Clean-merge behaviour is unchanged, so the helper can always pass `true`.
|
||||
|
||||
2. **New tool `continue_merge(taskId)`** → `TaskMergeService.ContinueMergeAsync`.
|
||||
Stages the resolved files and commits the merge; on success the task goes to `Done` and the worktree is marked merged, returning `{ merged: true, mergeCommit }`. If markers remain, returns `{ merged: false, conflicts: string[] }`.
|
||||
|
||||
3. **New tool `abort_merge(taskId)`** → `TaskMergeService.AbortMergeAsync`.
|
||||
Aborts the in-progress merge; task stays `WaitingForReview`. Returns `{ aborted: true }`.
|
||||
|
||||
**Implementation notes (verify against `TaskMergeService.cs` during the plan):**
|
||||
- Confirm the exact signatures of `ContinueMergeAsync` / `AbortMergeAsync` and how in-progress-merge state is keyed. The hub tracks a single active conflict merge; the MCP variants must locate the merge from `taskId` (target branch + repo from the task/list), not shared hub state.
|
||||
- Emit the existing `TaskUpdated` event after continue/abort so the UI list re-buckets live.
|
||||
- Guard against a repo already mid-merge (`Blocked`) — surface it to the agent rather than clobbering.
|
||||
|
||||
### 5.3 Parent/children unit merges
|
||||
|
||||
`review_task approve` on a task **with children** drives `PlanningMergeOrchestrator` (a multi-step unit merge with its own continue/abort on the hub). The helper must handle these too. Two paths, tried in order:
|
||||
|
||||
1. **MCP (preferred):** `continue_merge` / `abort_merge` detect *which* kind of in-progress merge the task has (single-task `TaskMergeService` vs orchestrated `PlanningMergeOrchestrator`) and route to the matching engine continue/abort. This keeps the orchestrated path engine-mediated over MCP too. Implement if the orchestrator's continue/abort can be located from the task without shared hub UI state (verify in A1).
|
||||
2. **Manual fallback (accepted):** where the MCP path genuinely can't reach an in-progress merge, the helper resolves the conflict markers on disk (Read/Edit) and completes the merge by hand (`git add <paths>` + `git commit`, or `git merge --continue`). The user has explicitly accepted hand-merging as a fallback. The system prompt still mandates: **prefer the MCP tools whenever they apply**; only drop to raw git for cases the MCP tools don't cover, and honour the shared-checkout rule (`git commit -- <paths>`, never a bare commit that sweeps peers' index).
|
||||
|
||||
---
|
||||
|
||||
## 6. Launch — Worker + Wiring
|
||||
|
||||
### 6.1 Prompt templates
|
||||
|
||||
Add `PromptKind.MergeHelper` (system) and `PromptKind.MergeHelperInitial` (brief) to `ClaudeDo.Data/PromptFiles.cs`, with built-in defaults and `{{token}}` rendering, mirroring `Planning` / `PlanningInitial`.
|
||||
|
||||
- **System prompt** (`merge-helper-system.md`): defines the role and the per-status algorithm (§7), the merge/conflict rules, the "ask on uncertainty" posture, and the required final summary format.
|
||||
- **Initial brief** (`merge-helper-initial.md`): rendered with the selected tasks — a table of `{id, title, status, list, repo}` plus the scope label. Written to a session-brief file on disk; the positional prompt is a **single-line kickoff** pointing at that file via `--add-dir` (planning pattern — a multi-line positional prompt truncates at the first newline).
|
||||
|
||||
### 6.2 Session files
|
||||
|
||||
Path: `~/.todo-app/merge-helper-sessions/<sessionId>/` (a fresh GUID per run — these sessions are ephemeral and never resumed):
|
||||
- `brief.md` — rendered task list + scope + instructions.
|
||||
- No per-session MCP config: the session uses the **globally-registered `claudedo` MCP server** (same as task/ad-hoc sessions), so no token is needed.
|
||||
|
||||
Cleanup: prune session dirs older than N days on app start (best-effort; same posture as planning dirs).
|
||||
|
||||
### 6.3 Launch spec
|
||||
|
||||
New `InteractiveLaunchSpecService.BuildForMergeHelper(selectedTaskIds, scope, ct)` returning a `LaunchSpec`:
|
||||
- **Cwd:** per-list → the list's repo working dir; global → the first selected task's repo (any valid repo; the agent works cross-repo via MCP).
|
||||
- **`--add-dir`:** the session-brief dir **plus every distinct repo checkout** among the selected tasks (so the agent can read/resolve conflict markers in each repo). Computed from each task's list working dir.
|
||||
- **Args:** `--permission-mode default`, `--allowedTools mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill`, `--append-system-prompt-file <merge-helper-system.md>`, `--add-dir ...`, then the single-line kickoff prompt.
|
||||
- `Edit` is required for conflict resolution; `Bash` is allowed for **read-only** git inspection (`git status`/`diff`) — the system prompt mandates that all merge *state changes* go through MCP tools, never raw `git merge/commit`, to keep the engine authoritative and honour the user's rejection of the "raw git" option.
|
||||
- **Env:** `MCP_TOOL_TIMEOUT=200000` (as task/ad-hoc sessions set).
|
||||
|
||||
### 6.4 Hub + client + Mission Control
|
||||
|
||||
- **Hub:** `WorkerHub.GetMergeHelperLaunchSpec(string[] taskIds, string? listId)` → `_launchSpecService.BuildForMergeHelper(...)`. Sibling to `GetAdHocLaunchSpec` / `GetPlanningStartLaunchSpec`.
|
||||
- **Client:** `IWorkerClient.GetMergeHelperLaunchSpecAsync(...)` + `WorkerClient` impl.
|
||||
- **Mission Control:** `MissionControlViewModel.OpenMergeHelperConPtySessionAsync(spec)` — wraps the spec in a `TerminalLaunchDescriptor`, creates a `ConPtyPaneViewModel` (ad-hoc style, **never deduped** — each run is its own tile), adds it to `ConPtySessions`.
|
||||
- **Event plumbing:** `ListsIslandViewModel` raises `LetClaudeHandleRequested(scope)`; `IslandsShellViewModel` forwards to Mission Control, which opens the selection dialog, then (on confirm) fetches the spec and opens the tile.
|
||||
|
||||
---
|
||||
|
||||
## 7. Helper Behaviour (encoded in the system prompt)
|
||||
|
||||
For each selected task, act by status:
|
||||
- **Idle / Queued:** `run_task_now`; poll `get_task` until terminal or `WaitingForReview`.
|
||||
- **Failed:** `reset_failed_task` then run, *or* ask the user — failures often need a human call; default to asking briefly.
|
||||
- **Running / WaitingForChildren:** poll `get_task` until it surfaces for review.
|
||||
- **WaitingForReview:** `get_task_diff` (stat first, then full if needed), sanity-check the change against the task's intent, then `review_task approve` with `leaveConflictsInTree:true`.
|
||||
- **Clean →** merged, task Done.
|
||||
- **Conflict (`conflict_in_tree`) →** open the conflicted files under `repoPath` (Read/Edit), resolve the markers guided by both sides' intent, then `continue_merge`. If the resolution is non-obvious or risky, **ask the user in the terminal** before continuing. `abort_merge` if the user declines or it's unsafe.
|
||||
- **Parent with children:** clean unit merge proceeds; on conflict, resolve via the MCP tools if they reach the orchestrated merge, else hand-merge the markers and complete it (§5.3) — asking the user first when the resolution is non-obvious.
|
||||
|
||||
Cross-cutting rules (in the prompt):
|
||||
- Ask the user interactively for anything ambiguous, risky, or destructive — that is the point of the ConPTY session.
|
||||
- Never use raw `git merge/commit/reset`; drive all merge state through the MCP tools.
|
||||
- Keep a running tally; at the end print a **summary**: per task — final status, merge commit (if any), conflicts resolved, anything skipped, and suggested follow-ups.
|
||||
|
||||
---
|
||||
|
||||
## 8. Testing
|
||||
|
||||
**Automated (`ClaudeDo.Worker.Tests`, real SQLite + real git):**
|
||||
- `review_task`/`merge_task` with `leaveConflictsInTree:true`: clean merge → Done; conflicting merge → `conflict_in_tree`, markers present in the checkout, task stays `WaitingForReview`.
|
||||
- `continue_merge`: after markers resolved on disk → commits, task Done, worktree merged; with markers still present → returns remaining conflicts.
|
||||
- `abort_merge`: in-progress merge aborted, markers gone, task stays `WaitingForReview`.
|
||||
- `continue_merge`/`abort_merge` on a task with no in-progress merge → clean MCP error, no clobber.
|
||||
- `TaskUpdated` fired after continue/abort.
|
||||
- `BuildForMergeHelper`: computes distinct repo `--add-dir` set, correct cwd per scope, brief file rendered with all selected tasks, allowed-tools string correct.
|
||||
|
||||
**No real-Claude tests** (per project convention) — the end-to-end ConPTY run is a manual smoke item.
|
||||
|
||||
**Manual (add to `docs/open.md`):**
|
||||
- ConPTY tile launches with the brief; MCP tools reachable; a clean multi-task run merges all and prints a summary.
|
||||
- A seeded conflict is resolved autonomously via `continue_merge`.
|
||||
- Interactive question round-trip (helper asks, user answers in terminal).
|
||||
- Global (multi-repo) run with `--add-dir` for each repo.
|
||||
- Selection dialog: grouping, default ticks, select-all/none, per-list vs global scope.
|
||||
|
||||
---
|
||||
|
||||
## 9. Phasing
|
||||
|
||||
Delivered as one plan with three phases (see the plan doc). Phase A is independently useful and merges first.
|
||||
|
||||
- **Phase A — Worker MCP conflict tools** (§5): `leaveConflictsInTree` param + `continue_merge` + `abort_merge` + tests. No UI.
|
||||
- **Phase B — Worker launch** (§6.1–6.4 worker side): prompt templates, `BuildForMergeHelper`, hub endpoint, session-file/brief generation, client method. Contract for C locked here.
|
||||
- **Phase C — UI**: selection dialog (View+VM), per-list + global entries, event plumbing, `OpenMergeHelperConPtySessionAsync`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Out of scope (v1)
|
||||
|
||||
- Resuming a merge-helper session (`--resume`); sessions are ephemeral.
|
||||
- A non-interactive/headless merge-helper (this is deliberately a ConPTY interactive session).
|
||||
- Cross-list *batching* semantics beyond "act on each selected task independently."
|
||||
- Any change to the existing per-task Approve/diff-gate flow.
|
||||
@@ -0,0 +1,113 @@
|
||||
# Verifikations-Handoff (Stand 2026-07-24)
|
||||
|
||||
Manuelle Verifikation am laufenden System. **Mika bedient die UI, die Session protokolliert Pass/Fail** und bereitet Fixtures vor (Tasks via ClaudeDo-MCP, Git-Setups im Testrepo). Aktive Findings landen in `docs/open.md`; hier steht der Fortschritt je Abschnitt.
|
||||
|
||||
## Handoff für die nächste Session
|
||||
|
||||
**Erledigt (2026-07-24):** §1 Detail-Insel/Diff-Viewer (bis auf DiffModal-Fehler-State), §2 Worktree-Pipeline (alle 3), §3 Planning-Walkthrough (inkl. UnfinishedPlanning-Modal: dismiss/Finalize/Discard PASS, **Resume BUG**), §4 Merge-Editor (Single-Task **und** Planning-Unit-Konflikt **und** Abort), §6 Pick-up-Gating (Code), §7 AskUser (Happy-Path + Timeout + UI-Cleanup; Finding: Banner nur in Mission Control), §8 Session Skills (Install/Aktivierung/Seeding/Gegenprobe/Remove; Findings: fehlender Empty-State + abweichendes Agent-Gear-Icon), §9 Attachments (Drag&Drop-UI + MCP + ComposedPreview; Finding: intermittenter erster-Drop-Fehler), §12 RunNow (moot).
|
||||
|
||||
**Noch offen:**
|
||||
- **§10 Daily Prep/Weekly** — **Bewusst zurückgestellt (2026-07-24)**: Mika will Daily Prep + Weekly Report ohnehin überarbeiten — Verifikation lohnt erst nach dem Rework.
|
||||
- **§11 Self-Update/Autostart** — **Nicht formell gefahren, aber laut Mika „läuft bisher sehr gut" (2026-07-24)** → als OK behandelt; bei Bedarf später gezielt nachtesten.
|
||||
- ~~**Kanten:** §1 DiffModal-Fehler-State, §3 UnfinishedPlanning-Modal, §4 Abort~~ — **alle erledigt (2026-07-24)**: §4 Abort PASS, §3 Modal (Finalize/Discard PASS, Resume BUG), §1 via Code-Analyse geklärt (defensiv/unerreichbar).
|
||||
|
||||
**Vorbedingungen:** Worker + App laufen (SignalR 37821, External MCP 47822 — lt. `~/.todo-app/worker.config.json`). Testliste **`ClaudeDoTests`** (`C:\TestRepos\ClaudeDoTests`, listId `e7992fee6035448394b646d069690e05`) für zerstörungsfreie Runs.
|
||||
|
||||
**Wichtige Gotchas / Lessons (diese Session):**
|
||||
- **NIE `model:"haiku"` für schreibende Task-/Verifikations-Runs** — unter dem Default `--permission-mode auto` werden haiku-Writes denied (still no-op). Immer **sonnet**. (Memory `auto_permission_haiku_footgun`.)
|
||||
- **Merge-Preflight blockt bei dirty Ziel-Working-Tree** (getrackte uncommittete Änderung im `main`-Checkout) — und **Approve schluckt das still** (Bug, open.md). Vor Merge-Checks `git -C C:\TestRepos\ClaudeDoTests status --short` prüfen; fremde WIP nur mit Rücksprache verwerfen (Memory `shared_worktree_partial_commits`: nur pfad-scoped committen).
|
||||
- **Konflikt-Fixture-Rezept:** Task (sonnet) überschreibt eine getrackte Datei im Branch → danach dieselbe Datei auf `main` divergent ändern + `git commit -- <pfad>` → Approve konfliktet. Für Planning-Unit-Konflikt: main-Edit erst scharfschalten, wenn der Parent in WaitingForReview steht.
|
||||
- **Datenwahrheit** notfalls direkt aus der DB: `sqlite3 -readonly ~/.todo-app/todo.db "SELECT substr(id,1,8),status,planning_phase,substr(blocked_by_task_id,1,8),title FROM tasks WHERE …"`.
|
||||
|
||||
**Testrepo-/Fixture-Zustand:** Aufgeräumt (2026-07-24, §7+§8-Session-Ende) — alle `verif §7/§8`-Tasks gelöscht, deren Worktrees + Branches entfernt, `ClaudeDoTests`-`main` unangetastet bei `ecad650` (Tree clean; die §7-Runs liefen isoliert in Worktrees, main nie berührt). ponytail-Skills nach dem Remove-Test wieder deinstalliert (`session_skills` leer, `~/.todo-app/session-skills/` leer). Keine `verif`-Tasks und keine claudedotests-Worktrees übrig. Die übrigen `claudedo/*`-Branches im Testrepo stammen aus früheren Sessions — nicht anfassen. **Die nächste Session baut Fixtures frisch** (Rezepte s. Gotchas oben).
|
||||
|
||||
**Erfasste Folge-Tasks** (Liste „Claude do", Idle, brauchen Brainstorm vor Umsetzung): `f9809a93` Approve erzwingt Diff/Review vor Merge (+ blocked-Merge-Silent-Fail-Fix), `5d627df8` Planning-Session über embedded ConPTY statt wt (+ Planning-Permission-Prompt).
|
||||
|
||||
**Fix-Session:** Alle fixbaren Findings sind für eine frische Session in **`docs/fix-plan-2026-07-24.md`** aufbereitet (gruppiert nach Fixbarkeit: A mechanisch, B error-surfacing, C entscheidungsbedürftig, D investigation, E nits). Volltext je Finding bleibt in `docs/open.md`.
|
||||
|
||||
**Nacharbeit:** Erledigtes aus `docs/open.md` austragen; dieses File löschen, sobald §10 (nach Rework) + evtl. §11-Nachtest erledigt sind.
|
||||
|
||||
---
|
||||
|
||||
## 1. Detail-Insel & Diff-Viewer (reines Durchklicken)
|
||||
|
||||
- [~] Detail-Insel komplett: Output/Git/Session-Tabs, Merge-Sektion, Agent-Settings-Overrides (InheritedBadge korrekt), Prep-Panel — nach dem VM-Split (`DetailsIslandViewModel` → Sektions-VMs) alles gebunden, keine leeren Panels. — **TEILWEISE (2026-07-24, User-Sichtprüfung an `verif §1 diff matrix`):** Output/Git-Tabs, Merge-Sektion, Agent-Badges vorhanden & gebunden, keine leeren Panels. Git-Tab: `+1 −37`, „merges cleanly" korrekt. **BUG:** OUTCOME-Karte zeigt rohes JSON (s. open.md). Session-Tab fehlt — **erwartet** (nur bei Parent mit Kindern, `HasChildOutcomes`; wird in §4/§3 mit echtem Parent geprüft). Minor: TurnsText `0/max` bei terminalem Reload (open.md). Prep-Panel → §10.
|
||||
- [~] Diff-Viewer: Dateiliste, Added/Deleted/Renamed/Binary-Erkennung, Commit-Range-Diff nach einem Merge. — **Added/Deleted/Binary PASS**; **Renamed** erkannt + „R"-Badge, aber schwach dargestellt (kein alt→neu-Pfad, „+0 −0" — nit, s. open.md); „Review Combined Diff" korrekt ausgegraut (kein Planning-Parent). **Commit-Range-Diff nach Merge: PASS (2026-07-24)** — Diff des gemergten Done-Tasks rendert über `base..head` trotz entferntem Worktree.
|
||||
- [x] DiffModal-Fehler-State: Commit-Range ohne aufgezeichnete Commits → „Diff nicht mehr verfügbar" statt Crash/leer. — **GEKLÄRT via Code-Analyse (2026-07-24): der Fehler-State ist defensiv/unerreichbar.** `vm.diff.unavailable` (DiffViewerViewModel.cs:117-119) feuert nur bei `FromCommitRange && (BaseRef==null || HeadCommit==null)` oder `WorktreePath==null`. Alle Aufrufer sind gegated: `MergeSectionViewModel.OpenDiffAsync` ruft `ConfigureCommitRange` nur unter `CanDiffMergedRange` (base **und** head non-null) und `ConfigureWorktree` nur unter `hasLiveWorktree` (Pfad non-null + existiert); `WorktreesOverviewModalViewModel` übergibt `row.Path` (non-null). Damit können die Null-Checks über die UI nie wahr werden — der „Crash/leer"-Fall, den der Check absichern wollte, ist durch die CanExecute-/Modus-Gates ausgeschlossen. (Fehlende Commit-Objekte bei vorhandenem base+head → `GetCommitRangeDiffAsync` wirft → „loadFailed", nicht „unavailable".) Nicht manuell auslösbar; kein Bug.
|
||||
- [x] „children need attention"-Band auf dem Session-Tab eines Parents mit failed/blocked Kind. — **PASS (2026-07-24, in §3)**: Band + OUTCOMES-Liste mit Roadblock-Hinweis auf dem Session-Tab des Planning-Parents (`verif §3`, Roadblock-Kind).
|
||||
|
||||
## 2. Worktree-Pipeline (3 falsifizierbare Fälle)
|
||||
|
||||
- [x] Happy-Path: Task mit WorkingDir → `worktrees.state='active'`, `head_commit` gesetzt, `diff_stat` non-empty, Branch `claudedo/<id[:8]>` existiert auf Disk. — **PASS (2026-07-24)** via Task `e63eb2f5` (sonnet): Worktree active, `headCommit 8a3ae86` (ahead=1 vom Base), `diff_stat` non-empty (`review-cancel.txt`), Branch `claudedo/e63eb2f5…` auf Disk. (Mein erster Versuch mit `model=haiku` schlug fehl — das war ein haiku/auto-Footgun, keine Pipeline-Sache; s. open.md.)
|
||||
- [x] No-Changes-Run: → `status='Done'`, `head_commit IS NULL`, `diff_stat IS NULL`. — **PASS (2026-07-24)**; Präzisierung: aktueller Flow endet in `WaitingForReview` (nicht `Done`, das ist erst nach Approve) — Doc-„Done" ist veraltet. Kein neuer Commit, leerer Diff bestätigt.
|
||||
- [x] Kein Git-Repo (WorkingDir = `C:\Temp`): → `status='Failed'`, KEINE `worktrees`-Row, Git-Fehler im Log. — **PASS (2026-07-24)**; Fehler: „Worktree creation failed: working_dir is not a git repository".
|
||||
|
||||
## 3. Planning-Flow-Walkthrough
|
||||
|
||||
- [x] Draft → Finalize → Kette: Finalize queued NICHT automatisch (Kinder bleiben Idle); „Queue plan" setzt alle nicht-terminalen Kinder Queued, Kette läuft sequenziell durch (blocked-by löst sich je Vorgänger). — **PASS (2026-07-24, End-to-End mit `verif §3`)**: 3 Kinder gedraftet, Finalize → Parent WaitingForChildren+finalized, Kinder Idle+Kette (clean→conflict→roadblock), Queue plan → sequenzieller Durchlauf, alle Done. **Begleit-Findings (open.md):** „Waiting for Improvements"-Mislabel, Kind-Badge-Live-Refresh (Draft→Planned erst nach Listenwechsel), Kette nicht visualisiert, planning-aktiver Parent zeigt „Idle", Dequeue-X fehlt auf wartenden Kindern, Planning-Permission-Prompt, Planning nutzt wt statt ConPTY.
|
||||
- [x] Parent landet nach letztem terminalen Kind in WaitingForReview; Approve merged die ganze Unit (Parent-Worktree falls Active + jedes Done-Kind in Reihenfolge). — **PASS (2026-07-24)**: Parent → WaitingForReview auch mit Roadblock-Kind; Approve → Unit-Merge (child-clean clean, child-conflict → Konflikt-Editor pro Subtask → aufgelöst → Continue, child-roadblock no-op) → Parent Done, Merge gelandet.
|
||||
- [x] UnfinishedPlanning-Modal: Resume / FinalizeNow / Discard. — **GETESTET (2026-07-24, `verif §3 unfinished-planning` + `verif §3 resume-discard`)**: Modal wird via Rechtsklick→„Resume planning session" ausgelöst, zeigt Titel + „N draft task(s) waiting to be finalized" + Buttons Discard/Finalize/Resume + ×. **× (dismiss)**: No-op (State bleibt `active`). **Finalize**: Parent → `finalized`+`WaitingForChildren`, Kinder Idle+Blocked-by-Kette (= Planned) — PASS. **Discard**: Draft-Kinder gelöscht, Parent → `none`+`idle` — funktional PASS. **Resume: BUG — macht nichts** (`planning_session_id` nie erfasst → `ResumeAsync` wirft „No Claude session ID captured yet", vom UI im leeren `catch` verschluckt; s. open.md). Beide State-ändernden Aktionen reproduzieren das Kind-Rows-Live-Refresh-Finding (Finalize: Badges stale; Discard: gelöschte Rows bleiben bis Reload; open.md).
|
||||
|
||||
## 4. Merge-Editor (Rider-Style 3-Pane) mit echtem Konflikt
|
||||
|
||||
Konflikt provozieren: gleiche Datei auf main ändern, während der Task-Branch sie ändert. Beide Wege testen: **(a)** Single-Task-Approve mit Konflikt, **(b)** Planning-Unit-Merge mit Konflikt in einem Subtask (`PlanningMergeConflict` → Editor öffnet pro Subtask).
|
||||
|
||||
**Verifiziert 2026-07-24 (Single-Task-Approve mit Konflikt, 2 Dateien, `verif §4`): End-to-End PASS** — Approve→Konflikt→3-Pane-Resolver→beide Dateien auflösen→Continue→Merge→Task Done (Merge-Commit gelandet). Sauberer additiver Approve (`verif §1b`, kein Konflikt) → direkt gemergt → Done, kein Editor: **PASS (2026-07-24)**. ⚠ **Vorbedingung-Fund:** ein dirty Ziel-Working-Tree (getrackte uncommittete Änderung) blockt den Merge und Approve schluckt das **still** (s. open.md „Approve & Merge schluckt blocked"). Planning-Unit-Merge-Konflikt (Weg b): **PASS (2026-07-24, in §3)** — Approve eines Planning-Parents mit einem konfliktenden Subtask öffnet den 3-Pane-Editor pro Subtask (`PlanningMergeConflict`), Continue führt den Unit-Merge fort → Parent Done.
|
||||
|
||||
- [x] Drei Panes: MAIN read-only | Result editierbar | INCOMING read-only; Konfliktblöcke rot, aufgelöst grün, in allen Panes. — **PASS**.
|
||||
- [x] Gutter-Toggle `›`/`‹`: Seite rein/raus, Klickreihenfolge = Reihenfolge im Result; main/incoming/beide/keine möglich. — **PASS**.
|
||||
- [x] Nur Konfliktregionen im Result editierbar (Stable read-only); Edits fließen in den Block zurück. — **PASS**.
|
||||
- [~] Synchrones vertikales Scrollen; File-Switcher bei mehreren Dateien; `M conflicts · K resolved`-Readout; Conflict-Ruler (Klick springt). — **PASS**, aber **Multi-File schlecht erkennbar** (2 Konfliktdateien schwer zu sehen — UX-Nit, open.md).
|
||||
- [~] Continue erst aktiv, wenn ALLE Konflikte in ALLEN Dateien gelöst; Binär-Guard greift. — **funktional PASS** (merged erst nach Auflösung beider Dateien), aber **Button ist klickbar statt disabled** solange offen → stummer No-op (UX-Bug, open.md). Binär-Guard hier n/a.
|
||||
- [x] Abort: Tree sauber, Task bleibt WaitingForReview. — **PASS (2026-07-24, `verif §4 abort`)**: Single-Task-Approve mit divergentem main→Konflikt→3-Pane-Editor→**Abort**: Editor schließt, Task bleibt `WaitingForReview`, `main`-Tree sauber (kein `MERGE_HEAD`, kein dirty), `main`-Inhalt + HEAD unverändert.
|
||||
- [FEATURE-WUNSCH] farbliches Hervorheben der übernommenen/eingefügten Zeilen im Result-Pane (open.md).
|
||||
- Bekannte Kanten (nur gegenprüfen, nicht als Fail werten): leere Ours-Seite → null-lange Result-Region (Accept geht, Handtippen fummelig); Gutter-Y-Ausrichtung bei sehr hohen Fenstern/großem Scroll; vertikaler Drift nachfolgender Blöcke nach Konflikt mit ungleicher Zeilenzahl.
|
||||
|
||||
## 5. Embedded ConPTY / Mission Control
|
||||
|
||||
- [x] **Kritisch:** Task-basiert (Kontextmenü „Open ConPTY session"): frischer Task → Worktree wird on-demand angelegt, `claude` startet — Task-Prompt wirklich GESENDET. — **PASS (2026-07-24)**: Agent antwortete mit dem Marker `CONPTY-PROMPT-RECEIVED-4Q7`, Worktree on-demand angelegt.
|
||||
- [~] Ad-hoc: „New session"-Button → Ordnerwahl → freie Session im gewählten Verzeichnis. — **funktioniert, aber Button unsichtbar**: `Icon.Plus` ist Strich-Only → `PathIcon` rendert nichts (Bug, open.md). Über Tooltip/Klick auf die leere Header-Fläche erreichbar; Ordnerwahl + Ad-hoc-Session funktionieren.
|
||||
- [x] Grid↔Tabs-Toggle; Close killt den Prozess + entfernt die Kachel; mehrere Sessions parallel; Pane-Resize reflowt das TUI. — **PASS (2026-07-24)**: Resize/Reflow ok; Close entfernt Kachel UND killt den Prozess (verifiziert: ConPTY-PID 50964, Kind von ClaudeDo.App, nach Close tot).
|
||||
- [~] Resume: im Worktree-Dir per `claude --continue` möglich (ClaudeDo persistiert die ConPTY-Session-Id bewusst nicht). — **wie designt**: erneutes „Open ConPTY session" resumt NICHT, sondern startet frisch und **sendet den Prompt erneut** (UX-Falle, open.md). In-App-Resume gibt es nicht; nur claude-eigenes `--continue`/`--resume` im Worktree-Dir.
|
||||
|
||||
## 6. Pick up in terminal
|
||||
|
||||
- [x] Sichtbarkeit: Kontextmenü-Eintrag + Terminal-Button (ArrowOut) NUR bei WaitingForReview und Failed; bei Idle/Running/Queued/Done nicht. — **PASS (2026-07-24, Code+Test)**: `CanPickUpInTerminal => Status is WaitingForReview or Failed` (TaskRowViewModel.cs:69, DetailsIslandViewModel.cs:898), gebunden in TaskRowView.axaml:56 + TaskHeaderBar.axaml:34, Notify bei Statuswechsel, Unit-Test `CanPickUpInTerminal_OnlyForReviewOrFailed`. (Klick→Terminal + Fehler-Surfacing bleiben manueller UI/CLI-Check.)
|
||||
- [ ] Klick → neues Windows-Terminal im Worktree-Verzeichnis, `claude --resume <id>` nimmt die Session mit Kontext wieder auf.
|
||||
- [ ] Fehlerfälle surfacen sauber (Footer-Strip bzw. Fehlerdialog): laufende/gequeuete Task, keine persistierte Session-Id, kein aktiver Worktree.
|
||||
- Bekannte Kante: parked-Idle (reject-park) hat oft Session+Worktree, zeigt die Aktion aber bewusst NICHT (Idle nicht unterscheidbar). Nervt das in der Praxis → `CanPickUpInTerminal` erweitern.
|
||||
|
||||
## 7. AskUser (Frage aus laufendem Task)
|
||||
|
||||
Runtime-Toolname ist `mcp__claudedo_run__ask_user` (snake_case, nicht `AskUser`). Fixture: sonnet-Task mit Prompt „zwei sich widersprechende, irreversible Optionen — du MUSST `ask_user` fragen, nicht raten" erzwingt den Call zuverlässig.
|
||||
|
||||
- [x] Task, dessen Prompt eine Rückfrage erzwingt → Frage erscheint im Task-Monitor (Mission Control), Prozess wartet. — **PASS (2026-07-24, `verif §7 askuser 2`)**: Banner in Mission Control, Run blockiert im `ask_user`-Tool-Call. **BUG:** in der Task-Detail-Insel erscheint KEIN Banner (nur Mission Control) → s. open.md.
|
||||
- [x] Antwort inline absenden → Run läuft mit der Antwort weiter. — **PASS (2026-07-24)**: Antwort „A" inline gesendet → Agent bekam „option A", benannte `merge-playground.txt` → `renamed-by-agent.txt` um, schrieb `askuser-result.txt` = „USER CHOSE: A", Task → WaitingForReview.
|
||||
- [x] Timeout-/Cleanup-Verhalten der PendingQuestionRegistry (Frage unbeantwortet lassen): Task schlägt kontrolliert fehl, UI räumt die Frage auf (MCP_TOOL_TIMEOUT-Gotcha). — **PASS (2026-07-24)**: Backend (`verif §7 askuser`) nach exakt 3 min Fallback zurück, Agent hielt sich an „MUST NOT guess", meldete `CLAUDEDO_BLOCKED`, Repo untouched, Registry im `finally` aufgeräumt, Run endete `success` → WaitingForReview. **UI-Cleanup visuell bestätigt** (`verif §7 timeout-ui`, MC-Kachel offen/subscribt bis zum Timeout): Banner verschwindet beim Timeout automatisch (`TaskQuestionResolved` → `ClearPendingQuestion`).
|
||||
|
||||
## 8. Session Skills (E2E + UI)
|
||||
|
||||
- [x] Settings → Skills: `https://github.com/DietrichGebert/ponytail` installieren → 6 Skills erscheinen (ponytail, -help, -review, -audit, -debt, -gain), auf Commit gepinnt, Dateien unter `~/.todo-app/session-skills/<name>/`. — **PASS (2026-07-24)**: 6 Rows in `session_skills`, alle auf Commit `16f29800` gepinnt, `subpath=skills/<name>`, Dateien inkl. `SKILL.md` am erwarteten Ort. UI-Install nahm die URL an. **Nit:** Skills-Tab hat keinen Empty-State (s. open.md).
|
||||
- [x] Skill per-Task (Agent-Settings-Flyout) oder global aktivieren → Task laufen lassen → im Worktree liegt `.claude/skills/<name>/`, Agent kann ihn nutzen, `git status` im Worktree bleibt sauber (info/exclude greift). — **PASS (2026-07-24, per-Task, `verif §8 skill-activate`)**: `ponytail-help` per Flyout aktiviert (`task.SessionSkills=["ponytail-help"]`), Run → `.claude/skills/ponytail-help/SKILL.md` im Worktree, `info/exclude` bekam `/.claude/skills/ponytail-help/`, `git status` clean, Auto-Commit enthält NUR `skill-proof.txt` (Skill NICHT im Tree). Agent hat den Skill nachweislich genutzt (`skill-proof.txt` = ponytail-help-Referenzkarte verbatim). Global-Aktivierung (AppSettings.SessionSkills, General-Tab) nutzt denselben Seeding-Union-Pfad — funktional äquivalent, nicht separat gefahren.
|
||||
- [x] Gegenprobe: nicht-aktivierte/andere interaktive Session sieht den Skill NICHT (kein Leak nach `~/.claude`). — **PASS (2026-07-24)**: `~/.claude/skills/` enthält nur die vorbestehenden globalen Skills, KEIN ponytail. Seeder schreibt per Konstruktion nur nach `<workingDir>/.claude/skills` (SessionSkillSeeder), nie nach `~/.claude`.
|
||||
- [~] UI: Skills-Tab (Install-Zeile, Karten mit Update/Remove), Checkbox-Listen im General-Tab + AgentConfigEditor (Flyout-Höhe!), leerer Zustand (0 Skills — Empty-State fehlt evtl., dann entscheiden), lange Namen/URLs (Trimming). — **TEILWEISE (2026-07-24)**: Install-Zeile nimmt URL an (PASS); AgentConfigEditor-Flyout-Checkbox-Liste zeigt alle 6 Skills, Höhe ok, Trimming ok (User: „funktioniert wie erhofft"). **Empty-State fehlt** (nackte Fläche — open.md). **Agent-Settings-Gear ⚙ weicht vom Listen-Gear ab** (open.md). Skills-Tab-**Karten** (Name/Beschreibung/Pinned-Commit + Update/Remove) und **General-Tab-Checkbox-Liste** (6 Skills) sichtgeprüft (User: „sieht alles gut aus"). **Remove PASS (2026-07-24)**: ein Remove-Klick auf eine ponytail-Karte entfernte alle 6 Skills per Quell-URL (`session_skills` leer, alle Verzeichnisse unter `~/.todo-app/session-skills/` weg). **Update** nicht separat gefahren (Code-Pfad `SessionSkillRegistry.UpdateAsync`).
|
||||
|
||||
## 9. Attachments (Drag & Drop + MCP)
|
||||
|
||||
- [x] Drop aufs Detail-Pane: „Drop to attach"-Overlay, Datei erscheint in der Liste, landet unter `~/.todo-app/attachments/<taskId>/`; „Add file…"-Picker; Remove-Button. — **PASS (2026-07-24, `verif §9 attachments-ui`)**: Overlay/Highlight beim Drüberziehen, Drop-Round-Trip (`drop-me.txt` in Liste + Disk + DB, 29 B), „Add file…"-Picker (`pick-me.md`), Remove (Datei aus Liste + Disk + DB) — alle bestätigt. **Finding:** der ALLERERSTE Drop der Session schlug einmalig mit inline „An error occurred" fehl (nichts persistiert), danach fehlerfrei — intermittierend, nicht reproduzierbar (s. open.md).
|
||||
- [x] `ComposedPreview` enthält die Attachment-Pfade („## Reference files"). — **PASS (2026-07-24, Code+Test)**: TaskPromptComposer.cs:31 emittiert „## Reference files", ComposedPreview reicht Pfade durch, TaskRunner.cs:132 injiziert zur Laufzeit; TaskPromptComposerTests decken es ab.
|
||||
- [x] MCP: `add_task_attachment` / `list_task_attachments` / `remove_task_attachment`; Running-Task verweigert add/remove. — **PASS (2026-07-24)** für add/list/remove-Round-Trip inkl. Datei unter `~/.todo-app/attachments/<taskId>/` (71 B, korrekt, nach Remove weg). Running-Task-Verweigerung ist code-guarded (AttachmentMcpTools), aber ohne dauerhaft laufende Task nicht live geprüft → manueller Rest.
|
||||
|
||||
## 10. Daily Prep (Prime) & Weekly Report
|
||||
|
||||
- [ ] Prime-Trigger: Schedule feuert bzw. „Plan day" manuell → Prep-Log streamt live, `daily-prep.log` enthält letzten Run, MyDay-Auswahl respektiert `DailyPrepMaxTasks`.
|
||||
- [ ] Weekly Report: Range-Default „seit letztem Standup-Wochentag → heute", Markdown rendert, Cache pro Range.
|
||||
|
||||
## 11. Self-Update & Autostart (am Gerät)
|
||||
|
||||
- [ ] Update-Banner → Update durchführen → danach „up to date".
|
||||
- [ ] Autostart: Logoff/Logon startet den Worker (Startup-`.lnk`); Update-Pfad erhält den Autostart; Uninstall entfernt die `.lnk`.
|
||||
|
||||
## 12. Status-Bar / RunNow (Mini-Codecheck, erst messen)
|
||||
|
||||
- [x] Worker trennen/verbinden → prüfen, ob RunNow-Enable pro Task-Row sauber re-evaluiert (Connection-State lebt in `IslandsShellViewModel`). Nur fixen, wenn tatsächlich kaputt. — **PASS/moot (2026-07-24, Code)**: Es gibt kein per-Row-RunNow-Control in der UI; `RunNowAsync` (IWorkerClient/WorkerClient) hat keinen VM/View-Aufrufer (RunNow nur via MCP `run_task_now`). Die realen Detail-Pane-Aktionen (Enqueue/Dequeue/Continue/ResetAndRetry) re-evaluieren korrekt bei Connection-Change (DetailsIslandViewModel.cs:317-323). Nebenbefund: `RunNowAsync` in der UI ist toter Code.
|
||||
@@ -125,6 +125,7 @@ sealed class Program
|
||||
sc.AddTransient<Func<DiffViewerViewModel>>(sp => () => sp.GetRequiredService<DiffViewerViewModel>());
|
||||
sc.AddTransient<WorktreesOverviewModalViewModel>();
|
||||
sc.AddTransient<Func<WorktreesOverviewModalViewModel>>(sp => () => sp.GetRequiredService<WorktreesOverviewModalViewModel>());
|
||||
sc.AddTransient<MergeHelperSelectionModalViewModel>();
|
||||
sc.AddSingleton<IPrimeScheduleApi, WorkerPrimeScheduleApi>();
|
||||
sc.AddSingleton<INotesApi, WorkerNotesApi>();
|
||||
sc.AddSingleton<IOnlineLoginService, OnlineLoginService>();
|
||||
|
||||
@@ -51,6 +51,7 @@ public class ClaudeDoDbContext : DbContext
|
||||
public DbSet<PrimeScheduleEntity> PrimeSchedules => Set<PrimeScheduleEntity>();
|
||||
public DbSet<DailyNoteEntity> DailyNotes => Set<DailyNoteEntity>();
|
||||
public DbSet<WeekReportEntity> WeekReports => Set<WeekReportEntity>();
|
||||
public DbSet<SessionSkillEntity> SessionSkills => Set<SessionSkillEntity>();
|
||||
|
||||
private static readonly ValueConverter<DateTime, DateTime> UtcConverter =
|
||||
new(v => v, v => DateTime.SpecifyKind(v, DateTimeKind.Utc));
|
||||
|
||||
@@ -44,6 +44,8 @@ public class AppSettingsEntityConfiguration : IEntityTypeConfiguration<AppSettin
|
||||
builder.Property(s => s.DailyPrepMaxTasks)
|
||||
.HasColumnName("daily_prep_max_tasks").IsRequired().HasDefaultValue(5);
|
||||
|
||||
builder.Property(s => s.SessionSkills).HasColumnName("session_skills");
|
||||
|
||||
builder.HasData(new AppSettingsEntity { Id = AppSettingsEntity.SingletonId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,5 +16,6 @@ public class ListConfigEntityConfiguration : IEntityTypeConfiguration<ListConfig
|
||||
builder.Property(c => c.SystemPrompt).HasColumnName("system_prompt");
|
||||
builder.Property(c => c.AgentPath).HasColumnName("agent_path");
|
||||
builder.Property(c => c.MaxTurns).HasColumnName("max_turns");
|
||||
builder.Property(c => c.SessionSkills).HasColumnName("session_skills");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ClaudeDo.Data.Configuration;
|
||||
|
||||
public class SessionSkillEntityConfiguration : IEntityTypeConfiguration<SessionSkillEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SessionSkillEntity> builder)
|
||||
{
|
||||
builder.ToTable("session_skills");
|
||||
|
||||
builder.HasKey(s => s.Name);
|
||||
builder.Property(s => s.Name).HasColumnName("name");
|
||||
builder.Property(s => s.SourceUrl).HasColumnName("source_url").IsRequired();
|
||||
builder.Property(s => s.PinnedRef).HasColumnName("pinned_ref").IsRequired();
|
||||
builder.Property(s => s.Subpath).HasColumnName("subpath").IsRequired();
|
||||
builder.Property(s => s.Description).HasColumnName("description").IsRequired();
|
||||
builder.Property(s => s.AddedAt).HasColumnName("added_at").IsRequired();
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,7 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
|
||||
builder.Property(t => t.IsMyDay).HasColumnName("is_my_day").HasDefaultValue(false);
|
||||
builder.Property(t => t.Notes).HasColumnName("notes");
|
||||
builder.Property(t => t.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0);
|
||||
builder.Property(t => t.SessionSkills).HasColumnName("session_skills");
|
||||
|
||||
builder.Property(t => t.ParentTaskId).HasColumnName("parent_task_id");
|
||||
builder.Property(t => t.PlanningSessionId).HasColumnName("planning_session_id");
|
||||
|
||||
@@ -71,9 +71,22 @@ public sealed class GitService
|
||||
return stdout;
|
||||
}
|
||||
|
||||
public async Task<bool> HasChangesAsync(string worktreePath, CancellationToken ct = default)
|
||||
public Task<bool> HasChangesAsync(string worktreePath, CancellationToken ct = default) =>
|
||||
HasChangesAsync(worktreePath, includeUntracked: true, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Uncommitted-changes check. <paramref name="includeUntracked"/>=false ignores untracked
|
||||
/// files — use this for merge preflights on a shared target working dir, where stray
|
||||
/// untracked files (e.g. from a concurrent session) shouldn't block a merge. Auto-commit
|
||||
/// and data-loss-guard callers keep the default (true): a new file a task created, or an
|
||||
/// untracked file about to be discarded, is a real uncommitted change.
|
||||
/// </summary>
|
||||
public async Task<bool> HasChangesAsync(string worktreePath, bool includeUntracked, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, ["status", "--porcelain"], ct);
|
||||
string[] args = includeUntracked
|
||||
? ["status", "--porcelain"]
|
||||
: ["status", "--porcelain", "--untracked-files=no"];
|
||||
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, args, ct);
|
||||
if (exitCode != 0)
|
||||
throw new InvalidOperationException($"git status --porcelain failed (exit {exitCode}): {stderr}");
|
||||
return !string.IsNullOrWhiteSpace(stdout);
|
||||
@@ -330,13 +343,6 @@ public sealed class GitService
|
||||
.Count(s => s.Length > 0);
|
||||
}
|
||||
|
||||
public async Task MergeFfOnlyAsync(string repoDir, string branchName, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["merge", "--ff-only", branchName], ct);
|
||||
if (exitCode != 0)
|
||||
throw new InvalidOperationException($"Fast-forward merge of '{branchName}' failed. Manual merge required. git stderr: {stderr}");
|
||||
}
|
||||
|
||||
private static async Task<(int ExitCode, string Stdout, string Stderr)> RunGitAsync(
|
||||
string workDir, IEnumerable<string> args, CancellationToken ct, string? stdinData = null, bool trimOutput = true)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,786 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using ClaudeDo.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(ClaudeDoDbContext))]
|
||||
[Migration("20260703072917_AddSessionSkills")]
|
||||
partial class AddSessionSkills
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("CentralWorktreeRoot")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("central_worktree_root");
|
||||
|
||||
b.Property<int>("DailyPrepMaxTasks")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(5)
|
||||
.HasColumnName("daily_prep_max_tasks");
|
||||
|
||||
b.Property<string>("DefaultClaudeInstructions")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("")
|
||||
.HasColumnName("default_claude_instructions");
|
||||
|
||||
b.Property<int>("DefaultMaxTurns")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(30)
|
||||
.HasColumnName("default_max_turns");
|
||||
|
||||
b.Property<string>("DefaultModel")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("sonnet")
|
||||
.HasColumnName("default_model");
|
||||
|
||||
b.Property<string>("DefaultPermissionMode")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("bypassPermissions")
|
||||
.HasColumnName("default_permission_mode");
|
||||
|
||||
b.Property<int>("MaxParallelExecutions")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(1)
|
||||
.HasColumnName("max_parallel_executions");
|
||||
|
||||
b.Property<string>("RepoImportFolders")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("repo_import_folders");
|
||||
|
||||
b.Property<string>("ReportExcludedPaths")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("report_excluded_paths");
|
||||
|
||||
b.Property<string>("SessionSkills")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_skills");
|
||||
|
||||
b.Property<int>("StandupWeekday")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(3)
|
||||
.HasColumnName("standup_weekday");
|
||||
|
||||
b.Property<int>("WorktreeAutoCleanupDays")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(7)
|
||||
.HasColumnName("worktree_auto_cleanup_days");
|
||||
|
||||
b.Property<bool>("WorktreeAutoCleanupEnabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("worktree_auto_cleanup_enabled");
|
||||
|
||||
b.Property<string>("WorktreeStrategy")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("sibling")
|
||||
.HasColumnName("worktree_strategy");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("app_settings", (string)null);
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1,
|
||||
DailyPrepMaxTasks = 5,
|
||||
DefaultClaudeInstructions = "",
|
||||
DefaultMaxTurns = 100,
|
||||
DefaultModel = "sonnet",
|
||||
DefaultPermissionMode = "auto",
|
||||
MaxParallelExecutions = 1,
|
||||
StandupWeekday = 3,
|
||||
WorktreeAutoCleanupDays = 7,
|
||||
WorktreeAutoCleanupEnabled = false,
|
||||
WorktreeStrategy = "sibling"
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<DateOnly>("Date")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("note_date");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("sort_order");
|
||||
|
||||
b.Property<string>("Text")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Date");
|
||||
|
||||
b.ToTable("daily_notes", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
|
||||
{
|
||||
b.Property<string>("ListId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("list_id");
|
||||
|
||||
b.Property<string>("AgentPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("agent_path");
|
||||
|
||||
b.Property<int?>("MaxTurns")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("max_turns");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model");
|
||||
|
||||
b.Property<string>("SessionSkills")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_skills");
|
||||
|
||||
b.Property<string>("SystemPrompt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("system_prompt");
|
||||
|
||||
b.HasKey("ListId");
|
||||
|
||||
b.ToTable("list_config", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("DefaultCommitType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("chore")
|
||||
.HasColumnName("default_commit_type");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("sort_order");
|
||||
|
||||
b.Property<string>("WorkingDir")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("working_dir");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SortOrder")
|
||||
.HasDatabaseName("idx_lists_sort");
|
||||
|
||||
b.ToTable("lists", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<int>("Days")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(31)
|
||||
.HasColumnName("days_of_week");
|
||||
|
||||
b.Property<bool>("Enabled")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(true)
|
||||
.HasColumnName("enabled");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastRunAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("last_run_at");
|
||||
|
||||
b.Property<string>("PromptOverride")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("prompt_override");
|
||||
|
||||
b.Property<TimeSpan>("TimeOfDay")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("time_of_day");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("prime_schedules", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b =>
|
||||
{
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<DateTimeOffset>("AddedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("added_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<string>("PinnedRef")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("pinned_ref");
|
||||
|
||||
b.Property<string>("SourceUrl")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("source_url");
|
||||
|
||||
b.Property<string>("Subpath")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("subpath");
|
||||
|
||||
b.HasKey("Name");
|
||||
|
||||
b.ToTable("session_skills", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<bool>("Completed")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("completed");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<int>("OrderNum")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("order_num");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId")
|
||||
.HasDatabaseName("idx_subtasks_task_id");
|
||||
|
||||
b.ToTable("subtasks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<long>("ByteSize")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("byte_size");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("file_name");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId")
|
||||
.HasDatabaseName("idx_task_attachments_task_id");
|
||||
|
||||
b.ToTable("task_attachments", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("AgentPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("agent_path");
|
||||
|
||||
b.Property<string>("BlockedByTaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("blocked_by_task_id");
|
||||
|
||||
b.Property<string>("CommitType")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("chore")
|
||||
.HasColumnName("commit_type");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("CreatedBy")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_by");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("finished_at");
|
||||
|
||||
b.Property<bool>("IsMyDay")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_my_day");
|
||||
|
||||
b.Property<bool>("IsStarred")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_starred");
|
||||
|
||||
b.Property<string>("ListId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("list_id");
|
||||
|
||||
b.Property<string>("LogPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("log_path");
|
||||
|
||||
b.Property<int?>("MaxTurns")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("max_turns");
|
||||
|
||||
b.Property<string>("Model")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("notes");
|
||||
|
||||
b.Property<string>("ParentTaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("parent_task_id");
|
||||
|
||||
b.Property<DateTime?>("PlanningFinalizedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_finalized_at");
|
||||
|
||||
b.Property<string>("PlanningPhase")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("none")
|
||||
.HasColumnName("planning_phase");
|
||||
|
||||
b.Property<string>("PlanningSessionId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_session_id");
|
||||
|
||||
b.Property<string>("PlanningSessionToken")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("planning_session_token");
|
||||
|
||||
b.Property<string>("Result")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("result");
|
||||
|
||||
b.Property<string>("ReviewFeedback")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("review_feedback");
|
||||
|
||||
b.Property<int>("RoadblockCount")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("roadblock_count");
|
||||
|
||||
b.Property<DateTime?>("ScheduledFor")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("scheduled_for");
|
||||
|
||||
b.Property<string>("SessionSkills")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_skills");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0)
|
||||
.HasColumnName("sort_order");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("started_at");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.Property<string>("SystemPrompt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("system_prompt");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("title");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("BlockedByTaskId")
|
||||
.HasDatabaseName("idx_tasks_blocked_by");
|
||||
|
||||
b.HasIndex("ListId")
|
||||
.HasDatabaseName("idx_tasks_list_id");
|
||||
|
||||
b.HasIndex("ParentTaskId")
|
||||
.HasDatabaseName("idx_tasks_parent_task_id");
|
||||
|
||||
b.HasIndex("Status")
|
||||
.HasDatabaseName("idx_tasks_status");
|
||||
|
||||
b.HasIndex("ListId", "SortOrder")
|
||||
.HasDatabaseName("idx_tasks_list_sort");
|
||||
|
||||
b.ToTable("tasks", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("ErrorMarkdown")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("error_markdown");
|
||||
|
||||
b.Property<int?>("ExitCode")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("exit_code");
|
||||
|
||||
b.Property<DateTime?>("FinishedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("finished_at");
|
||||
|
||||
b.Property<bool>("IsRetry")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_retry");
|
||||
|
||||
b.Property<string>("LogPath")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("log_path");
|
||||
|
||||
b.Property<string>("Prompt")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("prompt");
|
||||
|
||||
b.Property<string>("ResultMarkdown")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("result_markdown");
|
||||
|
||||
b.Property<int>("RunNumber")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("run_number");
|
||||
|
||||
b.Property<string>("SessionId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_id");
|
||||
|
||||
b.Property<DateTime?>("StartedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("started_at");
|
||||
|
||||
b.Property<string>("StructuredOutputJson")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("structured_output");
|
||||
|
||||
b.Property<string>("TaskId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<int?>("TokensIn")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("tokens_in");
|
||||
|
||||
b.Property<int?>("TokensOut")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("tokens_out");
|
||||
|
||||
b.Property<int?>("TurnCount")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("turn_count");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("TaskId")
|
||||
.HasDatabaseName("idx_task_runs_task_id");
|
||||
|
||||
b.ToTable("task_runs", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<DateOnly>("EndDate")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("end_date");
|
||||
|
||||
b.Property<DateTime>("GeneratedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("generated_at");
|
||||
|
||||
b.Property<string>("Markdown")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("markdown");
|
||||
|
||||
b.Property<DateOnly>("StartDate")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("start_date");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("StartDate", "EndDate")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("week_reports", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
|
||||
{
|
||||
b.Property<string>("TaskId")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("task_id");
|
||||
|
||||
b.Property<string>("BaseCommit")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("base_commit");
|
||||
|
||||
b.Property<string>("BranchName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("branch_name");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("created_at");
|
||||
|
||||
b.Property<string>("DiffStat")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("diff_stat");
|
||||
|
||||
b.Property<string>("HeadCommit")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("head_commit");
|
||||
|
||||
b.Property<string>("Path")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("path");
|
||||
|
||||
b.Property<string>("State")
|
||||
.IsRequired()
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT")
|
||||
.HasDefaultValue("active")
|
||||
.HasColumnName("state");
|
||||
|
||||
b.HasKey("TaskId");
|
||||
|
||||
b.ToTable("worktrees", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
||||
.WithOne("Config")
|
||||
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("List");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithMany("Subtasks")
|
||||
.HasForeignKey("TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithMany()
|
||||
.HasForeignKey("TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("BlockedByTaskId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
||||
.WithMany("Tasks")
|
||||
.HasForeignKey("ListId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
|
||||
.WithMany("Children")
|
||||
.HasForeignKey("ParentTaskId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("List");
|
||||
|
||||
b.Navigation("Parent");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithMany("Runs")
|
||||
.HasForeignKey("TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
|
||||
{
|
||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||
.WithOne("Worktree")
|
||||
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Task");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
|
||||
{
|
||||
b.Navigation("Config");
|
||||
|
||||
b.Navigation("Tasks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||
{
|
||||
b.Navigation("Children");
|
||||
|
||||
b.Navigation("Runs");
|
||||
|
||||
b.Navigation("Subtasks");
|
||||
|
||||
b.Navigation("Worktree");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddSessionSkills : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "session_skills",
|
||||
table: "tasks",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "session_skills",
|
||||
table: "list_config",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "session_skills",
|
||||
table: "app_settings",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "session_skills",
|
||||
columns: table => new
|
||||
{
|
||||
name = table.Column<string>(type: "TEXT", nullable: false),
|
||||
source_url = table.Column<string>(type: "TEXT", nullable: false),
|
||||
pinned_ref = table.Column<string>(type: "TEXT", nullable: false),
|
||||
subpath = table.Column<string>(type: "TEXT", nullable: false),
|
||||
description = table.Column<string>(type: "TEXT", nullable: false),
|
||||
added_at = table.Column<DateTimeOffset>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_session_skills", x => x.name);
|
||||
});
|
||||
|
||||
migrationBuilder.UpdateData(
|
||||
table: "app_settings",
|
||||
keyColumn: "id",
|
||||
keyValue: 1,
|
||||
column: "session_skills",
|
||||
value: null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "session_skills");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "session_skills",
|
||||
table: "tasks");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "session_skills",
|
||||
table: "list_config");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "session_skills",
|
||||
table: "app_settings");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,10 @@ namespace ClaudeDo.Data.Migrations
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("report_excluded_paths");
|
||||
|
||||
b.Property<string>("SessionSkills")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_skills");
|
||||
|
||||
b.Property<int>("StandupWeekday")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
@@ -168,6 +172,10 @@ namespace ClaudeDo.Data.Migrations
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model");
|
||||
|
||||
b.Property<string>("SessionSkills")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_skills");
|
||||
|
||||
b.Property<string>("SystemPrompt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("system_prompt");
|
||||
@@ -256,6 +264,41 @@ namespace ClaudeDo.Data.Migrations
|
||||
b.ToTable("prime_schedules", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b =>
|
||||
{
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<DateTimeOffset>("AddedAt")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("added_at");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("description");
|
||||
|
||||
b.Property<string>("PinnedRef")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("pinned_ref");
|
||||
|
||||
b.Property<string>("SourceUrl")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("source_url");
|
||||
|
||||
b.Property<string>("Subpath")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("subpath");
|
||||
|
||||
b.HasKey("Name");
|
||||
|
||||
b.ToTable("session_skills", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
@@ -437,6 +480,10 @@ namespace ClaudeDo.Data.Migrations
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("scheduled_for");
|
||||
|
||||
b.Property<string>("SessionSkills")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("session_skills");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
|
||||
@@ -27,4 +27,7 @@ public sealed class AppSettingsEntity
|
||||
|
||||
// Max number of open tasks the daily prep ("Prime Claude") may place in MyDay.
|
||||
public int DailyPrepMaxTasks { get; set; } = 5;
|
||||
|
||||
// JSON array of session skill names applied by default to new tasks.
|
||||
public string? SessionSkills { get; set; }
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ public sealed class ListConfigEntity
|
||||
public string? SystemPrompt { get; set; }
|
||||
public string? AgentPath { get; set; }
|
||||
public int? MaxTurns { get; set; }
|
||||
public string? SessionSkills { get; set; }
|
||||
|
||||
// Navigation property
|
||||
public ListEntity List { get; set; } = null!;
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace ClaudeDo.Data.Models;
|
||||
|
||||
public sealed class SessionSkillEntity
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required string SourceUrl { get; set; }
|
||||
public required string PinnedRef { get; set; }
|
||||
public required string Subpath { get; set; }
|
||||
public required string Description { get; set; }
|
||||
public required DateTimeOffset AddedAt { get; set; }
|
||||
}
|
||||
@@ -45,6 +45,7 @@ public sealed class TaskEntity
|
||||
public bool IsMyDay { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
public int SortOrder { get; set; }
|
||||
public string? SessionSkills { get; set; }
|
||||
|
||||
public string? ParentTaskId { get; set; }
|
||||
public string? PlanningSessionId { get; set; }
|
||||
|
||||
@@ -2,7 +2,7 @@ using System.Text;
|
||||
|
||||
namespace ClaudeDo.Data;
|
||||
|
||||
public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport, ImprovementChild, Refine }
|
||||
public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport, ImprovementChild, Refine, MergeHelper, MergeHelperInitial }
|
||||
|
||||
public static class PromptFiles
|
||||
{
|
||||
@@ -18,6 +18,8 @@ public static class PromptFiles
|
||||
PromptKind.WeeklyReport => Path.Combine(Root, "weekly-report.md"),
|
||||
PromptKind.ImprovementChild => Path.Combine(Root, "improvement-child.md"),
|
||||
PromptKind.Refine => Path.Combine(Root, "refine.md"),
|
||||
PromptKind.MergeHelper => Path.Combine(Root, "merge-helper-system.md"),
|
||||
PromptKind.MergeHelperInitial => Path.Combine(Root, "merge-helper-initial.md"),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind))
|
||||
};
|
||||
|
||||
@@ -63,6 +65,8 @@ public static class PromptFiles
|
||||
PromptKind.WeeklyReport => WeeklyReportDefault,
|
||||
PromptKind.ImprovementChild => ImprovementChildDefault,
|
||||
PromptKind.Refine => RefineDefault,
|
||||
PromptKind.MergeHelper => MergeHelperDefault,
|
||||
PromptKind.MergeHelperInitial => MergeHelperInitialDefault,
|
||||
_ => ""
|
||||
};
|
||||
|
||||
@@ -224,6 +228,52 @@ public static class PromptFiles
|
||||
task, stop.
|
||||
""";
|
||||
|
||||
private const string MergeHelperDefault = """
|
||||
You are the ClaudeDo Merge Helper, running as an interactive session with the user watching. Ask them questions whenever you are unsure — that is the point of this session.
|
||||
|
||||
Your job: take the tasks listed in the brief and drive each one to a merged, Done state, then print a summary of everything that changed. You act through the mcp__claudedo__* tools. Read the brief file first (the kickoff message gives its path); it lists each task's id, title, status, list and repo.
|
||||
|
||||
Handle the tasks in the order listed, one at a time. For each task, act on its current status:
|
||||
- Idle or Queued: run it with run_task_now, then poll get_task until it leaves Running (it lands in WaitingForReview on success, or Failed).
|
||||
- Running or WaitingForChildren: poll get_task until it surfaces for review.
|
||||
- Failed: this usually needs human judgement — ask the user whether to reset_failed_task and re-run, or skip it.
|
||||
- WaitingForReview: review, then merge it (below).
|
||||
|
||||
Reviewing and merging a WaitingForReview task:
|
||||
1. Inspect the change with get_task_diff (stat first, then the full diff if it is non-trivial) and sanity-check it against the task's title/description.
|
||||
2. If the change looks wrong, incomplete, or risky, STOP and ask the user before merging — offer reject_rerun (with feedback) or skip.
|
||||
3. Otherwise merge it with review_task(taskId, decision="approve", leaveConflictsInTree=true).
|
||||
- Clean merge → the task is Done; move on.
|
||||
- Conflict (markers left in the working tree, repoPath returned) → resolve it (below).
|
||||
|
||||
Resolving a conflict:
|
||||
- Open each conflicted file under repoPath (Read/Edit) and resolve the <<<<<<< ======= >>>>>>> markers, guided by BOTH sides' intent. Then call continue_merge(taskId). If markers remain it tells you — fix and call again. Use abort_merge(taskId) to cancel a merge you cannot safely resolve.
|
||||
- For a task WITH children (a unit merge), pass the PARENT task id to continue_merge / abort_merge.
|
||||
- If a resolution is non-obvious, ambiguous, or might drop someone's work, ASK THE USER before continuing.
|
||||
- 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.
|
||||
|
||||
Rules:
|
||||
- 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.
|
||||
- Ask the user for anything ambiguous, risky, or destructive.
|
||||
|
||||
When every task is handled, print a SUMMARY:
|
||||
- one line per task: title — final status — merge commit (if any) — conflicts resolved (if any)
|
||||
- anything you skipped or left for the user, and why
|
||||
- suggested follow-ups, if any.
|
||||
""";
|
||||
|
||||
private const string MergeHelperInitialDefault = """
|
||||
# Merge Helper brief
|
||||
|
||||
Scope: {scope}
|
||||
|
||||
Handle the following tasks, in order. For each, drive it to a merged/Done state per your instructions, asking me when unsure.
|
||||
|
||||
{tasks}
|
||||
|
||||
When every task is handled, print the summary.
|
||||
""";
|
||||
|
||||
private const string WeeklyReportDefault = """
|
||||
You are generating a concise weekly standup report for a software developer,
|
||||
covering {start} to {end}.
|
||||
|
||||
@@ -64,6 +64,7 @@ public sealed class AppSettingsRepository
|
||||
? null : updated.ReportExcludedPaths;
|
||||
row.StandupWeekday = updated.StandupWeekday;
|
||||
row.DailyPrepMaxTasks = updated.DailyPrepMaxTasks < 1 ? 1 : updated.DailyPrepMaxTasks;
|
||||
row.SessionSkills = string.IsNullOrWhiteSpace(updated.SessionSkills) ? null : updated.SessionSkills;
|
||||
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ public sealed class ListRepository
|
||||
existing.SystemPrompt = config.SystemPrompt;
|
||||
existing.AgentPath = config.AgentPath;
|
||||
existing.MaxTurns = config.MaxTurns;
|
||||
existing.SessionSkills = config.SessionSkills;
|
||||
}
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Data.Repositories;
|
||||
|
||||
public sealed class SessionSkillRepository
|
||||
{
|
||||
private readonly ClaudeDoDbContext _context;
|
||||
|
||||
public SessionSkillRepository(ClaudeDoDbContext context) => _context = context;
|
||||
|
||||
public async Task<IReadOnlyList<SessionSkillEntity>> ListAsync(CancellationToken ct = default)
|
||||
{
|
||||
return await _context.SessionSkills.AsNoTracking()
|
||||
.OrderBy(s => s.Name)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<SessionSkillEntity?> GetAsync(string name, CancellationToken ct = default) =>
|
||||
await _context.SessionSkills.AsNoTracking().FirstOrDefaultAsync(s => s.Name == name, ct);
|
||||
|
||||
public async Task UpsertAsync(SessionSkillEntity entity, CancellationToken ct = default)
|
||||
{
|
||||
var existing = await _context.SessionSkills.FirstOrDefaultAsync(s => s.Name == entity.Name, ct);
|
||||
if (existing is null)
|
||||
{
|
||||
_context.SessionSkills.Add(entity);
|
||||
}
|
||||
else
|
||||
{
|
||||
existing.SourceUrl = entity.SourceUrl;
|
||||
existing.PinnedRef = entity.PinnedRef;
|
||||
existing.Subpath = entity.Subpath;
|
||||
existing.Description = entity.Description;
|
||||
existing.AddedAt = entity.AddedAt;
|
||||
}
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string name, CancellationToken ct = default)
|
||||
{
|
||||
await _context.SessionSkills
|
||||
.Where(s => s.Name == name)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
public async Task DeleteBySourceAsync(string sourceUrl, CancellationToken ct = default)
|
||||
{
|
||||
await _context.SessionSkills
|
||||
.Where(s => s.SourceUrl == sourceUrl)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<SessionSkillEntity>> ListBySourceAsync(string sourceUrl, CancellationToken ct = default)
|
||||
{
|
||||
return await _context.SessionSkills.AsNoTracking()
|
||||
.Where(s => s.SourceUrl == sourceUrl)
|
||||
.OrderBy(s => s.Name)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -189,6 +189,7 @@ public sealed class TaskRepository
|
||||
string? systemPrompt,
|
||||
string? agentPath,
|
||||
int? maxTurns = null,
|
||||
string? sessionSkills = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await _context.Tasks
|
||||
@@ -197,7 +198,8 @@ public sealed class TaskRepository
|
||||
.SetProperty(t => t.Model, model)
|
||||
.SetProperty(t => t.SystemPrompt, systemPrompt)
|
||||
.SetProperty(t => t.AgentPath, agentPath)
|
||||
.SetProperty(t => t.MaxTurns, maxTurns), ct);
|
||||
.SetProperty(t => t.MaxTurns, maxTurns)
|
||||
.SetProperty(t => t.SessionSkills, sessionSkills), ct);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Threading;
|
||||
using ClaudeDo.Installer.Core;
|
||||
using ClaudeDo.Releases;
|
||||
|
||||
@@ -77,10 +78,23 @@ public sealed class DownloadAndExtractStep : IInstallStep
|
||||
var appBak = appDest + ".bak";
|
||||
var workerBak = workerDest + ".bak";
|
||||
|
||||
if (Directory.Exists(appBak)) Directory.Delete(appBak, recursive: true);
|
||||
if (Directory.Exists(workerBak)) Directory.Delete(workerBak, recursive: true);
|
||||
if (Directory.Exists(appDest)) Directory.Move(appDest, appBak);
|
||||
if (Directory.Exists(workerDest)) Directory.Move(workerDest, workerBak);
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(appBak)) DeleteWithRetry(appBak);
|
||||
if (Directory.Exists(workerBak)) DeleteWithRetry(workerBak);
|
||||
if (Directory.Exists(appDest)) MoveWithRetry(appDest, appBak);
|
||||
if (Directory.Exists(workerDest)) MoveWithRetry(workerDest, workerBak);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// A just-stopped app/worker (or an Explorer/terminal window sitting in
|
||||
// the install dir) still held a handle. Surface an actionable message
|
||||
// instead of the raw "process cannot access the file" error.
|
||||
return StepResult.Fail(
|
||||
"Could not replace the existing app/worker files — they are still in use. " +
|
||||
"Make sure ClaudeDo is fully closed (app and worker) and no Explorer or " +
|
||||
$"terminal window is open inside the install folder, then run the update again. Details: {ex.Message}");
|
||||
}
|
||||
|
||||
progress.Report("Extracting...");
|
||||
Directory.CreateDirectory(ctx.InstallDirectory);
|
||||
@@ -91,17 +105,17 @@ public sealed class DownloadAndExtractStep : IInstallStep
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Roll back to previous binaries.
|
||||
if (Directory.Exists(appDest)) Directory.Delete(appDest, recursive: true);
|
||||
if (Directory.Exists(workerDest)) Directory.Delete(workerDest, recursive: true);
|
||||
if (Directory.Exists(appBak)) Directory.Move(appBak, appDest);
|
||||
if (Directory.Exists(workerBak)) Directory.Move(workerBak, workerDest);
|
||||
if (Directory.Exists(appDest)) DeleteWithRetry(appDest);
|
||||
if (Directory.Exists(workerDest)) DeleteWithRetry(workerDest);
|
||||
if (Directory.Exists(appBak)) MoveWithRetry(appBak, appDest);
|
||||
if (Directory.Exists(workerBak)) MoveWithRetry(workerBak, workerDest);
|
||||
return StepResult.Fail(
|
||||
$"Extraction failed; previous binaries have been restored: {ex.Message}.");
|
||||
}
|
||||
|
||||
// Success — drop stash.
|
||||
if (Directory.Exists(appBak)) Directory.Delete(appBak, recursive: true);
|
||||
if (Directory.Exists(workerBak)) Directory.Delete(workerBak, recursive: true);
|
||||
if (Directory.Exists(appBak)) DeleteWithRetry(appBak);
|
||||
if (Directory.Exists(workerBak)) DeleteWithRetry(workerBak);
|
||||
|
||||
ctx.InstalledVersion = release.TagName.TrimStart('v', 'V');
|
||||
return StepResult.Ok();
|
||||
@@ -111,4 +125,26 @@ public sealed class DownloadAndExtractStep : IInstallStep
|
||||
try { Directory.Delete(scratchDir, recursive: true); } catch { /* best effort */ }
|
||||
}
|
||||
}
|
||||
|
||||
private static void MoveWithRetry(string source, string dest)
|
||||
=> RetryIo(() => Directory.Move(source, dest));
|
||||
|
||||
private static void DeleteWithRetry(string dir)
|
||||
=> RetryIo(() => Directory.Delete(dir, recursive: true));
|
||||
|
||||
// WaitForExit returns before Windows releases a just-killed process's file
|
||||
// handles, so the stash Move/Delete can briefly hit a sharing violation.
|
||||
// Retry through transient IO/access errors (~5s) before letting it surface.
|
||||
private static void RetryIo(Action action)
|
||||
{
|
||||
const int attempts = 10;
|
||||
for (var i = 0; ; i++)
|
||||
{
|
||||
try { action(); return; }
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException && i < attempts - 1)
|
||||
{
|
||||
Thread.Sleep(500);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,22 +19,33 @@ public sealed class StopWorkerStep : IInstallStep
|
||||
{
|
||||
progress.Report("Stopping ClaudeDo processes (if running)...");
|
||||
var installDir = ctx.InstallDirectory;
|
||||
var killedAny = false;
|
||||
foreach (var name in ProcessNames)
|
||||
{
|
||||
foreach (var p in Process.GetProcessesByName(name))
|
||||
{
|
||||
try
|
||||
{
|
||||
var path = p.MainModule?.FileName;
|
||||
// Scope to THIS install when the module path is readable; if it
|
||||
// can't be read (access race / exiting process), fall through and
|
||||
// kill anyway — a survivor would lock the install dir during
|
||||
// extraction. Reading MainModule must not skip the Kill.
|
||||
string? path = null;
|
||||
try { path = p.MainModule?.FileName; } catch { /* unreadable — kill anyway */ }
|
||||
if (path is not null && !IsUnder(path, installDir)) continue;
|
||||
p.Kill(entireProcessTree: true);
|
||||
p.WaitForExit(10000);
|
||||
killedAny = true;
|
||||
}
|
||||
catch { /* process may have exited or be inaccessible */ }
|
||||
finally { p.Dispose(); }
|
||||
}
|
||||
}
|
||||
await Task.CompletedTask;
|
||||
|
||||
// WaitForExit returns before the OS releases the process's file handles.
|
||||
// Give it a moment so DownloadAndExtractStep's Directory.Move doesn't race
|
||||
// a still-open handle. (That step also retries, this just avoids the churn.)
|
||||
if (killedAny) await Task.Delay(1500, ct);
|
||||
return StepResult.Ok();
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"tabWorktrees": "Worktrees",
|
||||
"tabFiles": "Dateien",
|
||||
"tabPrime": "Prime Claude",
|
||||
"tabSkills": "Skills",
|
||||
"general": {
|
||||
"defaultInstructions": "Standard-Anweisungen",
|
||||
"defaultInstructionsPlaceholder": "Basis-Anweisungen, die auf jede Aufgabe angewendet werden",
|
||||
@@ -25,7 +26,9 @@
|
||||
"weekdayWednesday": "Mittwoch",
|
||||
"weekdayThursday": "Donnerstag",
|
||||
"weekdayFriday": "Freitag",
|
||||
"weekdaySaturday": "Samstag"
|
||||
"weekdaySaturday": "Samstag",
|
||||
"sessionSkills": "Session-Skills",
|
||||
"sessionSkillsHint": "Gilt für jede Aufgabe. Kombiniert sich mit Listen- und Aufgaben-Auswahl."
|
||||
},
|
||||
"worktrees": {
|
||||
"strategy": "Strategie",
|
||||
@@ -95,7 +98,18 @@
|
||||
"systemPrompt": "System-Prompt (angehängt)",
|
||||
"promptPrepended": "Wird automatisch vorangestellt:",
|
||||
"agentFile": "Agent-Datei",
|
||||
"browse": "Durchsuchen..."
|
||||
"browse": "Durchsuchen...",
|
||||
"sessionSkills": "Session-Skills",
|
||||
"sessionSkillsHint": "Kombiniert sich additiv mit globaler (und Listen-)Auswahl."
|
||||
},
|
||||
"skills": {
|
||||
"installSection": "INSTALLIEREN",
|
||||
"installUrlPlaceholder": "Git-Repo-URL (owner/repo oder vollständige URL)",
|
||||
"installButton": "Installieren",
|
||||
"installedSection": "INSTALLIERT",
|
||||
"updateButton": "Aktualisieren",
|
||||
"removeButton": "Entfernen",
|
||||
"emptyState": "Keine Skills installiert — oben eine GitHub-URL einfügen, um einen hinzuzufügen."
|
||||
}
|
||||
},
|
||||
"tasks": {
|
||||
@@ -115,7 +129,8 @@
|
||||
"ctxMarkAs": "Markieren als",
|
||||
"ctxMarkDone": "Erledigt",
|
||||
"ctxMarkCancelled": "Abgebrochen",
|
||||
"ctxRunInteractively": "Interaktiv ausführen",
|
||||
"ctxOpenConPtySession": "ConPTY-Sitzung öffnen",
|
||||
"ctxPickUpInTerminal": "Im Terminal fortsetzen",
|
||||
"ctxOpenPlanningSession": "Planungssitzung öffnen",
|
||||
"ctxResumePlanningSession": "Planungssitzung fortsetzen",
|
||||
"ctxFinalizePlanningSession": "Plan finalisieren",
|
||||
@@ -158,10 +173,13 @@
|
||||
"contextWorktrees": "Worktrees…",
|
||||
"contextOpenExplorer": "Im Explorer öffnen",
|
||||
"contextOpenTerminal": "Im Terminal öffnen",
|
||||
"contextLetClaude": "Claude machen lassen",
|
||||
"newList": "Neue Liste",
|
||||
"addReposTip": "Repos als Listen hinzufügen"
|
||||
"addReposTip": "Repos als Listen hinzufügen",
|
||||
"letClaudeAllTip": "Claude machen lassen (alle Listen)"
|
||||
},
|
||||
"details": {
|
||||
"pickUpInTerminalTip": "Diese Sitzung im Terminal fortsetzen",
|
||||
"deleteTaskTip": "Aufgabe löschen",
|
||||
"killSessionTip": "Laufende Sitzung beenden",
|
||||
"closeTip": "Schließen",
|
||||
@@ -173,7 +191,7 @@
|
||||
"mergeTargetLabel": "Merge-Ziel",
|
||||
"reviewCombinedDiff": "Kombiniertes Diff prüfen",
|
||||
"mergeAllSubtasks": "Alle Teilaufgaben mergen",
|
||||
"childOutcomesLabel": "VERBESSERUNGEN",
|
||||
"childOutcomesLabel": "TEILAUFGABEN",
|
||||
"stepsLabel": "SCHRITTE",
|
||||
"addStepPlaceholder": "Schritt hinzufügen...",
|
||||
"detailsLabel": "DETAILS",
|
||||
@@ -194,6 +212,7 @@
|
||||
"addedSummary": "✓ Hinzugefügt: {0} ({1} Datei(en))",
|
||||
"overLimitError": "Konnte {0} nicht hinzufügen: {1}",
|
||||
"invalidNameError": "Konnte {0} nicht hinzufügen: {1}",
|
||||
"dropReadError": "Abgelegte Dateien konnten nicht gelesen werden: {0}",
|
||||
"selectIdleTask": "Zuerst eine inaktive Aufgabe auswählen"
|
||||
},
|
||||
"sections": {
|
||||
@@ -231,15 +250,7 @@
|
||||
"chipDone": "FERTIG",
|
||||
"chipFailed": "FEHLGESCHLAGEN",
|
||||
"reviewContinueTip": "Dieses Feedback senden und die Aufgabe erneut ausführen",
|
||||
"reviewResetTip": "Alle Änderungen verwerfen und die Aufgabe auf Leerlauf zurücksetzen",
|
||||
"composer": {
|
||||
"placeholder": "Nachricht an die Sitzung…",
|
||||
"send": "Senden",
|
||||
"stop": "Sitzung beenden",
|
||||
"interrupt": "Aktuellen Zug unterbrechen",
|
||||
"queued": "Wartet — wird nach dem aktuellen Zug gesendet",
|
||||
"unqueue": "Aus Warteschlange entfernen"
|
||||
}
|
||||
"reviewResetTip": "Alle Änderungen verwerfen und die Aufgabe auf Leerlauf zurücksetzen"
|
||||
},
|
||||
"missionControl": {
|
||||
"openInApp": "In App öffnen",
|
||||
@@ -247,11 +258,21 @@
|
||||
"detach": "Abdocken",
|
||||
"redock": "Andocken",
|
||||
"windowTitle": "Mission Control",
|
||||
"newSession": "Neue Sitzung",
|
||||
"clearFinished": "Erledigte entfernen",
|
||||
"empty": "Keine laufenden Aufgaben",
|
||||
"settings": "Einstellungen",
|
||||
"queue": "Warteschlange",
|
||||
"blocked": "Blockiert",
|
||||
"focusMode": "Fokus",
|
||||
"overviewMode": "Übersicht",
|
||||
"closeSession": "Sitzung schließen",
|
||||
"conptyLaunchFailed": "ConPTY-Sitzung konnte nicht geöffnet werden: {0}",
|
||||
"mergeHelperTitle": "Merge-Helfer",
|
||||
"submitForReviewFailed": "Einreichen zum Review fehlgeschlagen: {0}",
|
||||
"submitForReview": "Zum Review einreichen",
|
||||
"submitForReviewTip": "Diesen Worktree committen und den Task ins Review bringen, damit er gemergt werden kann",
|
||||
"planningTitleSuffix": " (Planung)",
|
||||
"question": {
|
||||
"title": "Claude fragt nach",
|
||||
"placeholder": "Antwort eingeben…",
|
||||
@@ -347,6 +368,20 @@
|
||||
"forgetFolders": "Ordner vergessen",
|
||||
"alreadyAdded": "(bereits hinzugefügt)"
|
||||
},
|
||||
"mergeHelper": {
|
||||
"title": "CLAUDE MACHEN LASSEN",
|
||||
"windowTitle": "Claude machen lassen",
|
||||
"scopeAll": "Alle Listen",
|
||||
"scopeList": "Liste: {0}",
|
||||
"confirm": "Claude machen lassen",
|
||||
"cancel": "Abbrechen",
|
||||
"selectAll": "Alle auswählen",
|
||||
"selectNone": "Keine auswählen",
|
||||
"empty": "Keine Aufgaben zu übergeben — alles erledigt oder abgebrochen.",
|
||||
"columnTask": "AUFGABE",
|
||||
"columnStatus": "STATUS",
|
||||
"columnList": "LISTE"
|
||||
},
|
||||
"unfinishedPlanning": {
|
||||
"title": "UNVOLLENDETE PLANUNGSSITZUNG",
|
||||
"windowTitle": "Unvollendete Planungssitzung",
|
||||
@@ -488,11 +523,11 @@
|
||||
"vm": {
|
||||
"connection": { "online": "Online", "connecting": "Verbinden…", "offline": "Offline" },
|
||||
"shell": { "restartingWorker": "Worker wird neu gestartet…" },
|
||||
"agentStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "review": "Prüfung", "children": "Wartet auf Verbesserungen", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen" },
|
||||
"taskStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "waitingForReview": "Wartet auf Prüfung", "waitingForChildren": "Wartet auf Verbesserungen", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen", "parked": "Geparkt" },
|
||||
"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" },
|
||||
"planningBadge": { "active": "PLANUNG", "finalized": "GEPLANT" },
|
||||
"taskRow": { "createdPrefix": "Erstellt {0}", "stepsText": "{0}/{1} Schritte" },
|
||||
"tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "runInteractiveFailed": "Interaktiv ausführen fehlgeschlagen: {0}", "planningOpenFailed": "Planungssitzung konnte nicht geöffnet werden: {0}" },
|
||||
"tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "planningOpenFailed": "Planungssitzung konnte nicht geöffnet werden: {0}", "planningResumeFailed": "Planungssitzung konnte nicht fortgesetzt werden: {0}", "pickUpInTerminalFailed": "Im Terminal fortsetzen fehlgeschlagen: {0}", "approveFailed": "Genehmigen & Mergen fehlgeschlagen: {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)." },
|
||||
"merge": { "commitMessage": "Merge-Aufgabe: {0}", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "conflict": "Merge-Konflikt — Ziel-Branch wiederhergestellt. Manuell oder über Fortsetzen lösen, dann erneut versuchen.", "blocked": "Blockiert: {0}", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" },
|
||||
@@ -501,6 +536,7 @@
|
||||
"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}" },
|
||||
"weeklyReport": { "invalidRange": "Ungültiger Datumsbereich.", "generating": "Bericht wird erstellt…", "error": "Fehler: {0}" },
|
||||
"filesTab": { "workerOffline": "Worker offline.", "noneBundled": "Keine Standard-Agenten mitgeliefert.", "allPresent": "Alle Standard-Agenten bereits vorhanden.", "restored": "{0} Standard-Agent(en) wiederhergestellt.", "restoreFailed": "Wiederherstellung fehlgeschlagen: {0}", "openFailed": "Öffnen fehlgeschlagen: {0}" },
|
||||
"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." },
|
||||
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "Liste", "cleanupFailed": "Aufräumen fehlgeschlagen.", "removed": "{0} Worktree(s) entfernt.", "discardFailed": "Worktree konnte nicht verworfen werden.", "keepFailed": "Worktree konnte nicht behalten werden.", "cannotForceRunning": "Eine laufende Aufgabe kann nicht zwangsweise entfernt werden.", "forceRemoveFailed": "Zwangsentfernung fehlgeschlagen.", "batchProgress": "Merge {0}/{1}…", "batchDone": "{0} gemergt, {1} zu lösen." },
|
||||
"listSettings": { "untitled": "Unbenannt" },
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"tabWorktrees": "Worktrees",
|
||||
"tabFiles": "Files",
|
||||
"tabPrime": "Prime Claude",
|
||||
"tabSkills": "Skills",
|
||||
"general": {
|
||||
"defaultInstructions": "Default instructions",
|
||||
"defaultInstructionsPlaceholder": "Baseline instructions applied to every task",
|
||||
@@ -25,7 +26,9 @@
|
||||
"weekdayWednesday": "Wednesday",
|
||||
"weekdayThursday": "Thursday",
|
||||
"weekdayFriday": "Friday",
|
||||
"weekdaySaturday": "Saturday"
|
||||
"weekdaySaturday": "Saturday",
|
||||
"sessionSkills": "Session skills",
|
||||
"sessionSkillsHint": "Applied to every task. Combines with list- and task-level selections."
|
||||
},
|
||||
"worktrees": {
|
||||
"strategy": "Strategy",
|
||||
@@ -95,7 +98,18 @@
|
||||
"systemPrompt": "System prompt (appended)",
|
||||
"promptPrepended": "Prepended automatically:",
|
||||
"agentFile": "Agent file",
|
||||
"browse": "Browse..."
|
||||
"browse": "Browse...",
|
||||
"sessionSkills": "Session skills",
|
||||
"sessionSkillsHint": "Combines additively with global (and list) selections."
|
||||
},
|
||||
"skills": {
|
||||
"installSection": "INSTALL",
|
||||
"installUrlPlaceholder": "Git repo URL (owner/repo or full URL)",
|
||||
"installButton": "Install",
|
||||
"installedSection": "INSTALLED",
|
||||
"updateButton": "Update",
|
||||
"removeButton": "Remove",
|
||||
"emptyState": "No skills installed — paste a GitHub URL above to add one."
|
||||
}
|
||||
},
|
||||
"tasks": {
|
||||
@@ -115,7 +129,8 @@
|
||||
"ctxMarkAs": "Mark as",
|
||||
"ctxMarkDone": "Done",
|
||||
"ctxMarkCancelled": "Cancelled",
|
||||
"ctxRunInteractively": "Run interactively",
|
||||
"ctxOpenConPtySession": "Open ConPTY session",
|
||||
"ctxPickUpInTerminal": "Pick up in terminal",
|
||||
"ctxOpenPlanningSession": "Open planning Session",
|
||||
"ctxResumePlanningSession": "Resume planning Session",
|
||||
"ctxFinalizePlanningSession": "Finalize plan",
|
||||
@@ -158,10 +173,13 @@
|
||||
"contextWorktrees": "Worktrees…",
|
||||
"contextOpenExplorer": "Open in Explorer",
|
||||
"contextOpenTerminal": "Open in Terminal",
|
||||
"contextLetClaude": "Let Claude handle it",
|
||||
"newList": "New list",
|
||||
"addReposTip": "Add repos as lists"
|
||||
"addReposTip": "Add repos as lists",
|
||||
"letClaudeAllTip": "Let Claude handle it (all lists)"
|
||||
},
|
||||
"details": {
|
||||
"pickUpInTerminalTip": "Pick up this session in a terminal",
|
||||
"deleteTaskTip": "Delete task",
|
||||
"killSessionTip": "Kill the running session",
|
||||
"closeTip": "Close",
|
||||
@@ -173,7 +191,7 @@
|
||||
"mergeTargetLabel": "Merge target",
|
||||
"reviewCombinedDiff": "Review combined diff",
|
||||
"mergeAllSubtasks": "Merge all subtasks",
|
||||
"childOutcomesLabel": "IMPROVEMENTS",
|
||||
"childOutcomesLabel": "SUBTASKS",
|
||||
"stepsLabel": "STEPS",
|
||||
"addStepPlaceholder": "Add a step...",
|
||||
"detailsLabel": "DETAILS",
|
||||
@@ -194,6 +212,7 @@
|
||||
"addedSummary": "✓ Added {0} ({1} file(s))",
|
||||
"overLimitError": "Could not add {0}: {1}",
|
||||
"invalidNameError": "Could not add {0}: {1}",
|
||||
"dropReadError": "Couldn't read the dropped files: {0}",
|
||||
"selectIdleTask": "Select an idle task first"
|
||||
},
|
||||
"sections": {
|
||||
@@ -231,15 +250,7 @@
|
||||
"chipDone": "DONE",
|
||||
"chipFailed": "FAILED",
|
||||
"reviewContinueTip": "Send this feedback and re-run the task",
|
||||
"reviewResetTip": "Discard all changes and reset the task to Idle",
|
||||
"composer": {
|
||||
"placeholder": "Message the session…",
|
||||
"send": "Send",
|
||||
"stop": "Stop session",
|
||||
"interrupt": "Interrupt current turn",
|
||||
"queued": "Queued — sends after the current turn",
|
||||
"unqueue": "Remove from queue"
|
||||
}
|
||||
"reviewResetTip": "Discard all changes and reset the task to Idle"
|
||||
},
|
||||
"missionControl": {
|
||||
"openInApp": "Open in app",
|
||||
@@ -247,11 +258,21 @@
|
||||
"detach": "Detach",
|
||||
"redock": "Re-dock",
|
||||
"windowTitle": "Mission Control",
|
||||
"newSession": "New session",
|
||||
"clearFinished": "Clear finished",
|
||||
"empty": "No running tasks",
|
||||
"settings": "Settings",
|
||||
"queue": "Queue",
|
||||
"blocked": "Blocked",
|
||||
"focusMode": "Focus",
|
||||
"overviewMode": "Overview",
|
||||
"closeSession": "Close session",
|
||||
"conptyLaunchFailed": "Couldn't open ConPTY session: {0}",
|
||||
"mergeHelperTitle": "Merge Helper",
|
||||
"submitForReviewFailed": "Couldn't submit for review: {0}",
|
||||
"submitForReview": "Submit for review",
|
||||
"submitForReviewTip": "Commit this worktree and move the task to review so it can be merged",
|
||||
"planningTitleSuffix": " (Planning)",
|
||||
"question": {
|
||||
"title": "Claude is asking",
|
||||
"placeholder": "Type your answer…",
|
||||
@@ -347,6 +368,20 @@
|
||||
"forgetFolders": "Forget folders",
|
||||
"alreadyAdded": "(already added)"
|
||||
},
|
||||
"mergeHelper": {
|
||||
"title": "LET CLAUDE HANDLE IT",
|
||||
"windowTitle": "Let Claude handle it",
|
||||
"scopeAll": "All lists",
|
||||
"scopeList": "List: {0}",
|
||||
"confirm": "Let Claude handle it",
|
||||
"cancel": "Cancel",
|
||||
"selectAll": "Select all",
|
||||
"selectNone": "Select none",
|
||||
"empty": "No tasks to hand over — everything is done or cancelled.",
|
||||
"columnTask": "TASK",
|
||||
"columnStatus": "STATUS",
|
||||
"columnList": "LIST"
|
||||
},
|
||||
"unfinishedPlanning": {
|
||||
"title": "UNFINISHED PLANNING SESSION",
|
||||
"windowTitle": "Unfinished planning session",
|
||||
@@ -488,11 +523,11 @@
|
||||
"vm": {
|
||||
"connection": { "online": "Online", "connecting": "Connecting…", "offline": "Offline" },
|
||||
"shell": { "restartingWorker": "Restarting worker…" },
|
||||
"agentStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "review": "Review", "children": "Waiting for Improvements", "done": "Done", "failed": "Failed", "cancelled": "Cancelled" },
|
||||
"taskStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "waitingForReview": "Waiting for Review", "waitingForChildren": "Waiting for Improvements", "done": "Done", "failed": "Failed", "cancelled": "Cancelled", "parked": "Parked" },
|
||||
"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" },
|
||||
"planningBadge": { "active": "PLANNING", "finalized": "PLANNED" },
|
||||
"taskRow": { "createdPrefix": "Created {0}", "stepsText": "{0}/{1} steps" },
|
||||
"tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "runInteractiveFailed": "Run interactively failed: {0}", "planningOpenFailed": "Couldn't open planning session: {0}" },
|
||||
"tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "planningOpenFailed": "Couldn't open planning session: {0}", "planningResumeFailed": "Couldn't resume planning session: {0}", "pickUpInTerminalFailed": "Pick up in terminal failed: {0}", "approveFailed": "Approve & merge failed: {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)." },
|
||||
"merge": { "commitMessage": "Merge task: {0}", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "conflict": "Merge conflict — target branch restored. Resolve manually or via Continue, then retry.", "blocked": "Blocked: {0}", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" },
|
||||
@@ -501,6 +536,7 @@
|
||||
"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}" },
|
||||
"weeklyReport": { "invalidRange": "Invalid date range.", "generating": "Generating report…", "error": "Error: {0}" },
|
||||
"filesTab": { "workerOffline": "Worker offline.", "noneBundled": "No default agents bundled.", "allPresent": "All default agents already present.", "restored": "Restored {0} default agent(s).", "restoreFailed": "Restore failed: {0}", "openFailed": "Open failed: {0}" },
|
||||
"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)." },
|
||||
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "list", "cleanupFailed": "Cleanup failed.", "removed": "Removed {0} worktree(s).", "discardFailed": "Failed to discard worktree.", "keepFailed": "Failed to keep worktree.", "cannotForceRunning": "Cannot force-remove a running task.", "forceRemoveFailed": "Force remove failed.", "batchProgress": "Merging {0}/{1}…", "batchDone": "Merged {0}, {1} need resolution." },
|
||||
"listSettings": { "untitled": "Untitled" },
|
||||
|
||||
@@ -35,8 +35,8 @@ Design/ — Tokens.axaml (design tokens; merged before styles) + IslandStyle
|
||||
|
||||
- **IslandsShellViewModel** — root coordinator; owns the three island VMs and the `WorkerClient`, wires cross-island events (selection, notes/prep mode, conflict resolution), owns connection state, the update banner, the inline worker-log strip (clickable → Log Visualizer overlay via `OpenLogVisualizerCommand`; `FlashFooterError` surfaces UI-action failures + the worker's Serilog Warn/Error there), responsive-layout flags (`ShowLists`/`ShowDetails` by window width), `PrimeStatus` flash, and the modal openers (About, RepoImport, WeeklyReport, WorktreesOverview, WorkerConnection help, LogVisualizer) plus `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`.
|
||||
- **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 (approve, reject-rerun, reject-park, cancel); planning session lifecycle (open/resume/discard/finalize, `QueuePlanningSubtasksAsync`); `RunInteractivelyAsync`, `RefineTask`; MyDay extras (`IsMyDayList`, `ClearDayCommand`, `ShowPrepLogCommand`) and the pinned Notes pseudo-row (`ShowNotesRow`, `OpenNotesCommand`). Raises `NotesRequested`/`PrepRequested` events consumed by the shell.
|
||||
- **DetailsIslandViewModel** — the detail pane for a bound `TaskRowViewModel`. Owns live-log streaming (`Log` via `StreamLineFormatter`), debounced title/description editing, subtasks, session-outcome/roadblock split (splits `Result` at the roadblock marker into two cards), the three-tab work console (`output`/`git`/`session`), child surfacing (`ChildOutcomes` rows plus `ChildrenNeedingAttention`/`HasChildrenNeedingAttention` — children that failed, were cancelled, await review, or reported roadblocks — drive an attention band on the Session tab, which is only visible when `HasChildOutcomes`), and the modes: `IsNotesMode` (hosts `NotesEditorViewModel`), `IsPrepMode`, computed `IsTaskDetailVisible = !IsNotesMode && !IsPrepMode`. Three concerns are extracted into section VMs exposed as properties: **AgentConfigEditorViewModel** (scope=Task; per-task Model/MaxTurns/AgentPath overrides with `InheritedBadge` + `InheritanceResolver`, additive SystemPrompt, debounced auto-save; exposed as `AgentSettings`), **MergeSectionViewModel** (merge-target selection, mergeability indicator via `MergePreviewPresenter` over `PreviewMergeAsync`, `OpenDiffAsync` and `ReviewCombinedDiffCommand` — both build a `DiffViewerViewModel` and call `ShowDiffViewer`), **PrepPanelViewModel** (daily-prep panel: `PrepLog`, `PlanDayCommand` → `RunDailyPrepNowAsync`, persisted last run via `GetLastPrepLogAsync`). Attachments: `Attachments` (`ObservableCollection<AttachmentRowViewModel>`), `IsDragOver`, `DropStatus`, `CanAcceptDrop`, `AddFilesAsync`, `RemoveAttachmentCommand`; loads on task change; `ComposedPreview` includes attachment paths. Writes directly via `new AttachmentStore()` + `new TaskAttachmentRepository(ctx)`. Helper rows (`ChildOutcomeRowViewModel`, `SubtaskRowViewModel`, `LogLineViewModel`, `AttachmentRowViewModel`) live in the same file.
|
||||
- **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 (approve, reject-rerun, reject-park, cancel); planning session lifecycle (open/resume/discard/finalize, `QueuePlanningSubtasksAsync`); `RefineTask`, `OpenConPtySessionRequested` (embedded ConPTY terminal), `PickUpInTerminalAsync`; MyDay extras (`IsMyDayList`, `ClearDayCommand`, `ShowPrepLogCommand`) and the pinned Notes pseudo-row (`ShowNotesRow`, `OpenNotesCommand`). Raises `NotesRequested`/`PrepRequested` events consumed by the shell.
|
||||
- **DetailsIslandViewModel** — the detail pane for a bound `TaskRowViewModel`. Owns live-log streaming (`Log` via `StreamLineFormatter`), debounced title/description editing, subtasks, session-outcome/roadblock split (splits `Result` at the roadblock marker into two cards), the three-tab work console (`output`/`git`/`session`), child surfacing (`ChildOutcomes` rows plus `ChildrenNeedingAttention`/`HasChildrenNeedingAttention` — children that failed, were cancelled, await review, or reported roadblocks — drive an attention band on the Session tab, which is only visible when `HasChildOutcomes`), and the modes: `IsNotesMode` (hosts `NotesEditorViewModel`), `IsPrepMode`, computed `IsTaskDetailVisible = !IsNotesMode && !IsPrepMode`. Three concerns are extracted into section VMs exposed as properties: **AgentConfigEditorViewModel** (scope=Task; per-task Model/MaxTurns/AgentPath overrides with `InheritedBadge` + `InheritanceResolver`, additive SystemPrompt, debounced auto-save; exposed as `AgentSettings`), **MergeSectionViewModel** (merge-target selection, mergeability indicator via `MergePreviewPresenter` over `PreviewMergeAsync`, `OpenDiffAsync` and `ReviewCombinedDiffCommand` — both build a `DiffViewerViewModel`, call `ShowDiffViewer`, and fire the `DiffViewed` callback; `HasReviewableDiff` reports whether anything is inspectable, feeding the review gate), **PrepPanelViewModel** (daily-prep panel: `PrepLog`, `PlanDayCommand` → `RunDailyPrepNowAsync`, persisted last run via `GetLastPrepLogAsync`). Attachments: `Attachments` (`ObservableCollection<AttachmentRowViewModel>`), `IsDragOver`, `DropStatus`, `CanAcceptDrop`, `AddFilesAsync`, `RemoveAttachmentCommand`; loads on task change; `ComposedPreview` includes attachment paths. Writes directly via `new AttachmentStore()` + `new TaskAttachmentRepository(ctx)`. Helper rows (`ChildOutcomeRowViewModel`, `SubtaskRowViewModel`, `LogLineViewModel`, `AttachmentRowViewModel`) live in the same file.
|
||||
- **TaskRowViewModel** / **ListNavItemViewModel** — lightweight display VMs (task row: status, planning phase, parent/blocked links, roadblock count, computed `IsDraft`/`IsPlanned`/`IsChild`/`IsPlanningParent`/`CanRefine`; list row: kind Smart/Virtual/User, count, icon/dot keys, drop hints).
|
||||
- **NotesEditorViewModel** — day navigator + bullet CRUD for daily notes via `INotesApi`.
|
||||
- **Modal VMs** — `SettingsModalViewModel` (four tabs: General, Worktrees, Files prompt-paths, Prime Claude incl. `DailyPrepMaxTasks` + prime-schedule rows), `ListSettingsModalViewModel` (name, working dir, commit type, delete list; hosts shared `AgentConfigEditorViewModel` as `Agent` property (scope=List) — save delegates to `Agent.SaveAsync()`), `RepoImportModalViewModel` (bulk-create lists from git repos found under chosen parents; already-wired repos disabled), `WeeklyReportModalViewModel` (range pickers default "since last standup weekday → today", cached per range, markdown via MarkdownView), `MergeModalViewModel` (single-task merge form, called from the diff modal), `WorktreesOverviewModalViewModel` (global/per-list worktree rows, batch merge + state ops), `UnfinishedPlanningModalViewModel` (Resume/FinalizeNow/Discard for a draft planning session), `WorkerConnectionModalViewModel` (offline help), `AboutModalViewModel`, `LogVisualizerViewModel` (worker logs, last 30 min, all levels + a warn/error-only filter; loads via `GetRecentLogsAsync`).
|
||||
@@ -45,7 +45,7 @@ Design/ — Tokens.axaml (design tokens; merged before styles) + IslandStyle
|
||||
|
||||
## Services
|
||||
|
||||
- **WorkerClient** / **IWorkerClient** — SignalR client connecting to `http://127.0.0.1:47821/hub`, auto-reconnect with exponential backoff. The surface tracks `WorkerHub` (see `src/ClaudeDo.Worker/CLAUDE.md` for the canonical method/event list); groups: task execution (RunNow/Cancel/Continue/Reset/SetTaskStatus), review (`ApproveReviewAsync(taskId, targetBranch) -> MergeResultDto`, reject-to-queue/idle, cancel review, `PreviewMergeAsync -> MergePreviewDto`), planning sessions (start/resume/discard/finalize, queue subtasks, pending draft count, interactive terminal, refine), planning aggregate/integration-branch diffs, unit-merge continue/abort, single-task conflict resolving (start/get-conflict-documents/write-resolution/continue/abort), worktrees (overview, set state, force remove, cleanup, reset all), agents, app settings, lists/config, weekly report, daily notes, daily prep (`RunDailyPrepNowAsync`, `ClearMyDayAsync`, `GetLastPrepLogAsync`), prime schedules, recent worker logs (`GetRecentLogsAsync`). Events mirror `HubBroadcaster` (task/worktree/list/run updates, prep events, planning-merge events, refine events, worker log). Lifecycle (`StartAsync`/`StopAsync`) and a few admin methods live only on the concrete `WorkerClient`.
|
||||
- **WorkerClient** / **IWorkerClient** — SignalR client connecting to `http://127.0.0.1:47821/hub`, auto-reconnect with exponential backoff. The surface tracks `WorkerHub` (see `src/ClaudeDo.Worker/CLAUDE.md` for the canonical method/event list); groups: task execution (RunNow/Cancel/Continue/Reset/SetTaskStatus), review (`ApproveReviewAsync(taskId, targetBranch) -> MergeResultDto`, reject-to-queue/idle, cancel review, `PreviewMergeAsync -> MergePreviewDto`), planning sessions (start/resume/discard/finalize, queue subtasks, pending draft count, refine), pick-up-in-terminal + embedded ConPTY launch specs (`GetInteractiveLaunchSpecAsync`/`GetAdHocLaunchSpecAsync`), planning aggregate/integration-branch diffs, unit-merge continue/abort, single-task conflict resolving (start/get-conflict-documents/write-resolution/continue/abort), worktrees (overview, set state, force remove, cleanup, reset all), agents, app settings, lists/config, weekly report, daily notes, daily prep (`RunDailyPrepNowAsync`, `ClearMyDayAsync`, `GetLastPrepLogAsync`), prime schedules, recent worker logs (`GetRecentLogsAsync`). Events mirror `HubBroadcaster` (task/worktree/list/run updates, prep events, planning-merge events, refine events, worker log). Lifecycle (`StartAsync`/`StopAsync`) and a few admin methods live only on the concrete `WorkerClient`.
|
||||
- **INotesApi** / **WorkerNotesApi** — daily-note CRUD (`ListAsync(day)`, `AddAsync`, `UpdateAsync`, `DeleteAsync`); UI DTO `DailyNoteDto(Id, Date, Text, SortOrder)`.
|
||||
- **IPrimeScheduleApi** — prime-schedule CRUD (`ListAsync`, `UpsertAsync`, `DeleteAsync`).
|
||||
- **UpdateCheckService** — polls releases, exposes `LastCheckStatus`/`LatestVersion`/`CheckNowAsync` (feeds the shell's update banner).
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
|
||||
<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" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -57,8 +57,8 @@
|
||||
<!-- Icon.Search -->
|
||||
<StreamGeometry x:Key="Icon.Search">M11 4a7 7 0 1 0 0 14A7 7 0 0 0 11 4z M20 20l-3.5-3.5</StreamGeometry>
|
||||
|
||||
<!-- Icon.Plus -->
|
||||
<StreamGeometry x:Key="Icon.Plus">M12 5v14M5 12h14</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>
|
||||
@@ -1162,6 +1162,13 @@
|
||||
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
|
||||
<Setter Property="TextElement.Foreground" Value="{StaticResource TextBrush}" />
|
||||
</Style>
|
||||
<!-- Disabled: drop the accent so a gated action (e.g. Approve before the diff is
|
||||
opened) reads clearly as unavailable instead of looking clickable. -->
|
||||
<Style Selector="Button.accent:disabled /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
|
||||
<Setter Property="TextElement.Foreground" Value="{StaticResource TextMuteBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- DAY TOGGLE -->
|
||||
|
||||
@@ -22,6 +22,9 @@ public interface IDialogService
|
||||
Task ShowListSettingsAsync(ListSettingsModalViewModel vm);
|
||||
Task ShowRepoImportAsync(RepoImportModalViewModel vm);
|
||||
Task ShowWorktreesOverviewAsync(WorktreesOverviewModalViewModel vm);
|
||||
|
||||
/// <summary>Merge-helper task picker. Returns the ordered selected task ids, or null on cancel.</summary>
|
||||
Task<IReadOnlyList<string>?> ShowMergeHelperSelectionAsync(MergeHelperSelectionModalViewModel vm);
|
||||
Task ShowWorkerConnectionAsync(WorkerConnectionModalViewModel vm);
|
||||
Task ShowConflictResolverAsync(ConflictResolverViewModel vm);
|
||||
Task ShowLogVisualizerAsync(LogVisualizerViewModel vm);
|
||||
|
||||
@@ -25,11 +25,6 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
/// <summary>A pending question was answered, timed out, or the run ended: (taskId, questionId).</summary>
|
||||
event Action<string, string>? TaskQuestionResolvedEvent;
|
||||
|
||||
event Action<string>? InteractiveSessionStartedEvent;
|
||||
event Action<string>? InteractiveSessionEndedEvent;
|
||||
event Action<string, IReadOnlyList<string>>? InteractiveQueueChangedEvent;
|
||||
event Action<string, string>? InteractiveMessageSentEvent;
|
||||
|
||||
event Action? PrepStartedEvent;
|
||||
event Action<string>? PrepLineEvent;
|
||||
event Action<bool>? PrepFinishedEvent;
|
||||
@@ -51,10 +46,6 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
Task ContinueTaskAsync(string taskId, string followUpPrompt);
|
||||
/// <summary>Answer a question a running task raised via AskUser.</summary>
|
||||
Task AnswerTaskQuestionAsync(string taskId, string questionId, string answer);
|
||||
Task SendInteractiveMessageAsync(string taskId, string text);
|
||||
Task RemoveQueuedInteractiveMessageAsync(string taskId, string text);
|
||||
Task StopInteractiveSessionAsync(string taskId);
|
||||
Task InterruptInteractiveSessionAsync(string taskId);
|
||||
/// <summary>The question a running task is currently blocked on, if any (for re-attach).</summary>
|
||||
Task<PendingQuestionDto?> GetPendingQuestionAsync(string taskId);
|
||||
Task ResetTaskAsync(string taskId);
|
||||
@@ -64,6 +55,10 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
Task<SeedResultDto?> RestoreDefaultAgentsAsync();
|
||||
Task<ListConfigDto?> GetListConfigAsync(string listId);
|
||||
Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto);
|
||||
Task<List<SessionSkillDto>> GetSessionSkillsAsync();
|
||||
Task<List<string>> InstallSessionSkillAsync(string url);
|
||||
Task UpdateSessionSkillAsync(string sourceUrl);
|
||||
Task RemoveSessionSkillAsync(string sourceUrl);
|
||||
Task SetTaskStatusAsync(string taskId, TaskStatus status);
|
||||
Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch);
|
||||
Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch);
|
||||
@@ -79,7 +74,25 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
Task<MergeResultDto> ContinueConflictMergeAsync(string taskId);
|
||||
Task AbortConflictMergeAsync(string taskId);
|
||||
Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default);
|
||||
Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default);
|
||||
// Picks up a task's Claude session in a real terminal window (--resume).
|
||||
Task ResumeTaskInTerminalAsync(string taskId, CancellationToken ct = default);
|
||||
/// <summary>Commits an interactively-worked task's worktree and moves it to WaitingForReview
|
||||
/// (the only path that flips a hand-driven ConPTY session into the review/merge pipeline).</summary>
|
||||
Task SubmitTaskForReviewAsync(string taskId, CancellationToken ct = default);
|
||||
/// <summary>Launch spec for an embedded ConPTY terminal to open an interactive session
|
||||
/// 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);
|
||||
/// <summary>Launch spec for an embedded ConPTY "merge helper" session that drives the given
|
||||
/// tasks to a merged/Done state. listId scopes the session to that list; null = all lists.</summary>
|
||||
Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string? listId, CancellationToken ct = default);
|
||||
/// <summary>Starts a planning session and returns the launch spec for an embedded ConPTY
|
||||
/// planning terminal (replaces StartPlanningSessionAsync's external wt window).</summary>
|
||||
Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default);
|
||||
/// <summary>Resumes a planning session and returns its embedded-ConPTY launch spec.</summary>
|
||||
Task<LaunchSpec> GetPlanningResumeLaunchSpecAsync(string taskId, CancellationToken ct = default);
|
||||
Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default);
|
||||
Task<DiscardPlanningOutcome> DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false, CancellationToken ct = default);
|
||||
Task FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default);
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Iciclecreek.Terminal;
|
||||
|
||||
namespace ClaudeDo.Ui.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Thin wrapper around <see cref="TerminalControl.LaunchProcess()"/> — the library owns the
|
||||
/// Porta.Pty spawn, keyboard input, rendering, resize, and focus end to end (see
|
||||
/// <c>spikes/ConPtyTerminal/MainWindow.axaml.cs</c>, which proved this renders correctly and
|
||||
/// stays responsive). We only:
|
||||
/// 1) apply <see cref="TerminalLaunchDescriptor.Env"/> onto the current process environment
|
||||
/// before launching — Porta.Pty inherits the calling process's environment and there is no
|
||||
/// per-launch env seam on <see cref="TerminalControl"/>/<see cref="TerminalControl.LaunchProcess()"/> —
|
||||
/// 2) relay the control's own <see cref="TerminalControl.ProcessExited"/> event and
|
||||
/// <see cref="TerminalControl.Kill()"/> method.
|
||||
/// </summary>
|
||||
public sealed class PtyTerminalSession : IDisposable
|
||||
{
|
||||
private TerminalControl? _control;
|
||||
private bool _disposed;
|
||||
|
||||
public bool IsRunning { get; private set; }
|
||||
public int? ExitCode { get; private set; }
|
||||
|
||||
/// <summary>Raised when the child process exits, on the UI thread, with its exit code.</summary>
|
||||
public event EventHandler<int>? ProcessExited;
|
||||
|
||||
/// <summary>
|
||||
/// Applies <paramref name="descriptor"/>'s env vars to the current process, then drives
|
||||
/// <paramref name="control"/> to launch it via <see cref="TerminalControl.LaunchProcess()"/>.
|
||||
/// </summary>
|
||||
public async Task StartAsync(TerminalLaunchDescriptor descriptor, TerminalControl control, CancellationToken ct = default)
|
||||
{
|
||||
if (_control is not null) throw new InvalidOperationException("Session already started.");
|
||||
_control = control;
|
||||
control.ProcessExited += OnControlProcessExited;
|
||||
|
||||
foreach (var (key, value) in descriptor.Env)
|
||||
Environment.SetEnvironmentVariable(key, value);
|
||||
|
||||
control.Process = descriptor.Exe;
|
||||
control.Args = new List<string>(descriptor.Args);
|
||||
control.StartingDirectory = descriptor.Cwd;
|
||||
|
||||
await control.LaunchProcess();
|
||||
IsRunning = true;
|
||||
}
|
||||
|
||||
private void OnControlProcessExited(object? sender, ProcessExitedEventArgs e)
|
||||
{
|
||||
IsRunning = false;
|
||||
ExitCode = e.ExitCode;
|
||||
ProcessExited?.Invoke(this, e.ExitCode);
|
||||
}
|
||||
|
||||
public void Kill()
|
||||
{
|
||||
try { _control?.Kill(); }
|
||||
catch (Exception) { /* already exited */ }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
if (_control is not null)
|
||||
_control.ProcessExited -= OnControlProcessExited;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ClaudeDo.Ui.Services;
|
||||
|
||||
/// <summary>Plain launch spec for an embedded ConPTY terminal session — no worker/SignalR coupling.</summary>
|
||||
public sealed record TerminalLaunchDescriptor(
|
||||
string Cwd,
|
||||
string Exe,
|
||||
IReadOnlyList<string> Args,
|
||||
IReadOnlyDictionary<string, string> Env);
|
||||
@@ -49,10 +49,6 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
public event Action<string>? TaskUpdatedEvent;
|
||||
public event Action<string, string, string>? TaskQuestionAskedEvent;
|
||||
public event Action<string, string>? TaskQuestionResolvedEvent;
|
||||
public event Action<string>? InteractiveSessionStartedEvent;
|
||||
public event Action<string>? InteractiveSessionEndedEvent;
|
||||
public event Action<string, IReadOnlyList<string>>? InteractiveQueueChangedEvent;
|
||||
public event Action<string, string>? InteractiveMessageSentEvent;
|
||||
public event Action? ConnectionRestoredEvent;
|
||||
public event Action<string>? WorktreeUpdatedEvent;
|
||||
public event Action<string>? ListUpdatedEvent;
|
||||
@@ -152,26 +148,6 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
Dispatcher.UIThread.Post(() => TaskQuestionResolvedEvent?.Invoke(taskId, questionId));
|
||||
});
|
||||
|
||||
_hub.On<string>("InteractiveSessionStarted", taskId =>
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => InteractiveSessionStartedEvent?.Invoke(taskId));
|
||||
});
|
||||
|
||||
_hub.On<string>("InteractiveSessionEnded", taskId =>
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => InteractiveSessionEndedEvent?.Invoke(taskId));
|
||||
});
|
||||
|
||||
_hub.On<string, IReadOnlyList<string>>("InteractiveQueueChanged", (taskId, pending) =>
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => InteractiveQueueChangedEvent?.Invoke(taskId, pending));
|
||||
});
|
||||
|
||||
_hub.On<string, string>("InteractiveMessageSent", (taskId, text) =>
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => InteractiveMessageSentEvent?.Invoke(taskId, text));
|
||||
});
|
||||
|
||||
_hub.On<string>("WorktreeUpdated", taskId =>
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => WorktreeUpdatedEvent?.Invoke(taskId));
|
||||
@@ -303,30 +279,6 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
catch { /* offline or already resolved — the UI clears optimistically */ }
|
||||
}
|
||||
|
||||
public async Task SendInteractiveMessageAsync(string taskId, string text)
|
||||
{
|
||||
try { await _hub.InvokeAsync("SendInteractiveMessage", taskId, text); }
|
||||
catch { /* offline or session already ended */ }
|
||||
}
|
||||
|
||||
public async Task RemoveQueuedInteractiveMessageAsync(string taskId, string text)
|
||||
{
|
||||
try { await _hub.InvokeAsync("RemoveQueuedInteractiveMessage", taskId, text); }
|
||||
catch { /* offline or session already ended */ }
|
||||
}
|
||||
|
||||
public async Task StopInteractiveSessionAsync(string taskId)
|
||||
{
|
||||
try { await _hub.InvokeAsync("StopInteractiveSession", taskId); }
|
||||
catch { /* offline */ }
|
||||
}
|
||||
|
||||
public async Task InterruptInteractiveSessionAsync(string taskId)
|
||||
{
|
||||
try { await _hub.InvokeAsync("InterruptInteractiveSession", taskId); }
|
||||
catch { /* offline */ }
|
||||
}
|
||||
|
||||
public Task<PendingQuestionDto?> GetPendingQuestionAsync(string taskId)
|
||||
=> TryInvokeAsync<PendingQuestionDto>("GetPendingQuestion", taskId);
|
||||
|
||||
@@ -481,6 +433,18 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
await _hub.InvokeAsync("UpdateTaskAgentSettings", dto);
|
||||
}
|
||||
|
||||
public async Task<List<SessionSkillDto>> GetSessionSkillsAsync()
|
||||
=> await TryInvokeAsync<List<SessionSkillDto>>("GetSessionSkills") ?? [];
|
||||
|
||||
public Task<List<string>> InstallSessionSkillAsync(string url)
|
||||
=> _hub.InvokeAsync<List<string>>("InstallSessionSkill", url);
|
||||
|
||||
public Task UpdateSessionSkillAsync(string sourceUrl)
|
||||
=> _hub.InvokeAsync("UpdateSessionSkill", sourceUrl);
|
||||
|
||||
public Task RemoveSessionSkillAsync(string sourceUrl)
|
||||
=> _hub.InvokeAsync("RemoveSessionSkill", sourceUrl);
|
||||
|
||||
public async Task SetTaskStatusAsync(string taskId, ClaudeDo.Data.Models.TaskStatus status)
|
||||
{
|
||||
await _hub.InvokeAsync("SetTaskStatus", taskId, status.ToString());
|
||||
@@ -546,8 +510,26 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
public async Task<PlanningSessionResumeInfo> ResumePlanningSessionAsync(string taskId, CancellationToken ct = default)
|
||||
=> await _hub.InvokeAsync<PlanningSessionResumeInfo>("ResumePlanningSessionAsync", taskId, ct);
|
||||
|
||||
public async Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default)
|
||||
=> await _hub.InvokeAsync("OpenInteractiveTerminalAsync", taskId, ct);
|
||||
public async Task ResumeTaskInTerminalAsync(string taskId, CancellationToken ct = default)
|
||||
=> await _hub.InvokeAsync("ResumeTaskInTerminal", taskId, ct);
|
||||
|
||||
public async Task SubmitTaskForReviewAsync(string taskId, CancellationToken ct = default)
|
||||
=> await _hub.InvokeAsync("SubmitTaskForReview", taskId, ct);
|
||||
|
||||
public async Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
||||
=> await _hub.InvokeAsync<LaunchSpec>("GetInteractiveLaunchSpec", taskId, ct);
|
||||
|
||||
public async Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
|
||||
=> await _hub.InvokeAsync<LaunchSpec>("GetAdHocLaunchSpec", directory, ct);
|
||||
|
||||
public async Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string? listId, CancellationToken ct = default)
|
||||
=> await _hub.InvokeAsync<LaunchSpec>("GetMergeHelperLaunchSpec", taskIds, listId, ct);
|
||||
|
||||
public async Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
||||
=> await _hub.InvokeAsync<LaunchSpec>("GetPlanningStartLaunchSpec", taskId, ct);
|
||||
|
||||
public async Task<LaunchSpec> GetPlanningResumeLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
||||
=> await _hub.InvokeAsync<LaunchSpec>("GetPlanningResumeLaunchSpec", taskId, ct);
|
||||
|
||||
public async Task<DiscardPlanningOutcome> DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false, CancellationToken ct = default)
|
||||
=> await _hub.InvokeAsync<DiscardPlanningOutcome>("DiscardPlanningSessionAsync", taskId, dequeueQueuedChildren, ct);
|
||||
@@ -624,7 +606,15 @@ public sealed record AppSettingsDto(
|
||||
int WorktreeAutoCleanupDays,
|
||||
string? ReportExcludedPaths,
|
||||
int StandupWeekday,
|
||||
int DailyPrepMaxTasks);
|
||||
int DailyPrepMaxTasks,
|
||||
List<string>? SessionSkills = null);
|
||||
|
||||
public sealed record SessionSkillDto(
|
||||
string Name,
|
||||
string Description,
|
||||
string SourceUrl,
|
||||
string PinnedRef,
|
||||
DateTimeOffset AddedAt);
|
||||
|
||||
public sealed record WorktreeCleanupDto(int Removed);
|
||||
public sealed record WorktreeResetDto(int Removed, int TasksAffected, bool Blocked, int RunningTasks);
|
||||
@@ -635,9 +625,9 @@ public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDoc
|
||||
public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList<MergeSegmentDto> Segments);
|
||||
public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs);
|
||||
public sealed record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType);
|
||||
public sealed record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
|
||||
public sealed record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
|
||||
public sealed record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
|
||||
public sealed record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
|
||||
public sealed record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
|
||||
public sealed record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
|
||||
public sealed record SeedResultDto(int Copied, int Skipped);
|
||||
|
||||
public sealed record WorktreeOverviewDto(
|
||||
@@ -654,6 +644,12 @@ public sealed record WorktreeOverviewDto(
|
||||
DateTime CreatedAt,
|
||||
bool PathExistsOnDisk);
|
||||
|
||||
public sealed record LaunchSpec(
|
||||
string Cwd,
|
||||
string Exe,
|
||||
IReadOnlyList<string> Args,
|
||||
IReadOnlyDictionary<string, string> Env);
|
||||
|
||||
public sealed record ForceRemoveResultDto(bool Removed, string? Reason);
|
||||
public sealed record PendingQuestionDto(string TaskId, string QuestionId, string Question);
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Specialized;
|
||||
using System.ComponentModel;
|
||||
using System.Text.Json;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using ClaudeDo.Data.Models;
|
||||
@@ -57,6 +60,7 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
|
||||
|
||||
public ObservableCollection<string> ModelOptions { get; } = new(ModelRegistry.Aliases);
|
||||
public ObservableCollection<AgentInfo> Agents { get; } = new();
|
||||
public ObservableCollection<SelectableSkillViewModel> SessionSkills { get; } = new();
|
||||
|
||||
public AgentConfigEditorViewModel(IWorkerClient worker, AgentConfigScope scope)
|
||||
{
|
||||
@@ -67,9 +71,31 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
|
||||
// short-lived modal recreated with the current language on each open.
|
||||
if (scope == AgentConfigScope.Task)
|
||||
Loc.LanguageChanged += _langChangedHandler;
|
||||
|
||||
SessionSkills.CollectionChanged += OnSessionSkillsCollectionChanged;
|
||||
}
|
||||
|
||||
public void Dispose() => Loc.LanguageChanged -= _langChangedHandler;
|
||||
private void OnSessionSkillsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
if (e.NewItems is not null)
|
||||
foreach (SelectableSkillViewModel item in e.NewItems)
|
||||
item.PropertyChanged += OnSkillItemPropertyChanged;
|
||||
if (e.OldItems is not null)
|
||||
foreach (SelectableSkillViewModel item in e.OldItems)
|
||||
item.PropertyChanged -= OnSkillItemPropertyChanged;
|
||||
}
|
||||
|
||||
private void OnSkillItemPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(SelectableSkillViewModel.IsSelected)) QueueSave();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Loc.LanguageChanged -= _langChangedHandler;
|
||||
SessionSkills.CollectionChanged -= OnSessionSkillsCollectionChanged;
|
||||
foreach (var s in SessionSkills) s.PropertyChanged -= OnSkillItemPropertyChanged;
|
||||
}
|
||||
|
||||
partial void OnModelChanged(string? value) { RecomputeModelBadge(); QueueSave(); }
|
||||
|
||||
@@ -154,11 +180,18 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
|
||||
var sp = string.IsNullOrWhiteSpace(SystemPrompt) ? null : SystemPrompt;
|
||||
var ap = SelectedAgent is null || string.IsNullOrWhiteSpace(SelectedAgent.Path) ? null : SelectedAgent.Path;
|
||||
var turns = MaxTurns is decimal d ? (int?)d : null;
|
||||
var skills = SelectedSessionSkillNames();
|
||||
|
||||
if (_scope == AgentConfigScope.Task)
|
||||
await _worker.UpdateTaskAgentSettingsAsync(new UpdateTaskAgentSettingsDto(TargetId, model, sp, ap, turns));
|
||||
await _worker.UpdateTaskAgentSettingsAsync(new UpdateTaskAgentSettingsDto(TargetId, model, sp, ap, turns, skills));
|
||||
else
|
||||
await _worker.UpdateListConfigAsync(new UpdateListConfigDto(TargetId, model, sp, ap, turns));
|
||||
await _worker.UpdateListConfigAsync(new UpdateListConfigDto(TargetId, model, sp, ap, turns, skills));
|
||||
}
|
||||
|
||||
private List<string>? SelectedSessionSkillNames()
|
||||
{
|
||||
var names = SessionSkills.Where(s => s.IsSelected).Select(s => s.Name).ToList();
|
||||
return names.Count == 0 ? null : names;
|
||||
}
|
||||
|
||||
public async System.Threading.Tasks.Task LoadForListAsync(string listId, CancellationToken ct = default)
|
||||
@@ -172,6 +205,7 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
|
||||
|
||||
var cfg = await _worker.GetListConfigAsync(listId);
|
||||
ApplyConfig(cfg?.Model, cfg?.MaxTurns, cfg?.SystemPrompt, cfg?.AgentPath);
|
||||
await ReloadSessionSkillsAsync(cfg?.SessionSkills);
|
||||
|
||||
_listModel = null; _listMaxTurns = null; _listAgentName = null;
|
||||
EffectiveSystemPromptHint = "";
|
||||
@@ -189,6 +223,7 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
|
||||
TargetId = entity.Id;
|
||||
await ReloadAgentsAsync("(inherited)");
|
||||
ApplyConfig(entity.Model, entity.MaxTurns, entity.SystemPrompt, entity.AgentPath);
|
||||
await ReloadSessionSkillsAsync(ParseSessionSkills(entity.SessionSkills));
|
||||
|
||||
var listCfg = await _worker.GetListConfigAsync(entity.ListId);
|
||||
await LoadGlobalDefaultsAsync();
|
||||
@@ -214,12 +249,30 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
|
||||
MaxTurns = null;
|
||||
SystemPrompt = "";
|
||||
SelectedAgent = null;
|
||||
foreach (var s in SessionSkills) s.IsSelected = false;
|
||||
}
|
||||
finally { _suppressSave = false; }
|
||||
EffectiveSystemPromptHint = "";
|
||||
TargetId = null;
|
||||
}
|
||||
|
||||
private static List<string>? ParseSessionSkills(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return null;
|
||||
try { return JsonSerializer.Deserialize<List<string>>(json); }
|
||||
catch (JsonException) { return null; }
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task ReloadSessionSkillsAsync(IReadOnlyCollection<string>? selected)
|
||||
{
|
||||
var installed = await _worker.GetSessionSkillsAsync();
|
||||
var selectedSet = selected is null ? new HashSet<string>() : new HashSet<string>(selected);
|
||||
foreach (var s in SessionSkills) s.PropertyChanged -= OnSkillItemPropertyChanged;
|
||||
SessionSkills.Clear();
|
||||
foreach (var s in installed)
|
||||
SessionSkills.Add(new SelectableSkillViewModel(s.Name, s.Description, selectedSet.Contains(s.Name)));
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task ReloadAgentsAsync(string placeholderName)
|
||||
{
|
||||
Agents.Clear();
|
||||
@@ -255,5 +308,6 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
|
||||
MaxTurns = null;
|
||||
SystemPrompt = "";
|
||||
SelectedAgent = Agents.Count > 0 ? Agents[0] : null;
|
||||
foreach (var s in SessionSkills) s.IsSelected = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// One installed session skill shown as a checkbox row. Reused by the global (General tab),
|
||||
/// list, and task selectors — selection is additive-union across all three levels, so this
|
||||
/// is deliberately just a name + checked flag with no inheritance/override state.
|
||||
/// </summary>
|
||||
public sealed partial class SelectableSkillViewModel : ViewModelBase
|
||||
{
|
||||
public string Name { get; }
|
||||
public string Description { get; }
|
||||
|
||||
[ObservableProperty] private bool _isSelected;
|
||||
|
||||
public SelectableSkillViewModel(string name, string description = "", bool isSelected = false)
|
||||
{
|
||||
Name = name;
|
||||
Description = description;
|
||||
_isSelected = isSelected;
|
||||
}
|
||||
}
|
||||
@@ -103,9 +103,19 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
|
||||
}
|
||||
}
|
||||
|
||||
public string ContinueHint => HasBinaryFiles
|
||||
? "Binary conflicts must be resolved externally — abort and resolve in your editor."
|
||||
: "";
|
||||
public string ContinueHint
|
||||
{
|
||||
get
|
||||
{
|
||||
if (HasBinaryFiles)
|
||||
return "Binary conflicts must be resolved externally — abort and resolve in your editor.";
|
||||
var openFiles = Files.Count(f => !f.AllResolved);
|
||||
if (openFiles == 0) return "";
|
||||
var openConflicts = _flat.Count(x => !x.Block.IsResolved);
|
||||
return $"{openConflicts} {(openConflicts == 1 ? "conflict" : "conflicts")} in " +
|
||||
$"{openFiles} {(openFiles == 1 ? "file" : "files")} still unresolved.";
|
||||
}
|
||||
}
|
||||
|
||||
private bool InRange => CurrentIndex >= 0 && CurrentIndex < _flat.Count;
|
||||
|
||||
@@ -188,6 +198,7 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
|
||||
OnPropertyChanged(nameof(HasBinaryFiles));
|
||||
OnPropertyChanged(nameof(HasMultipleFiles));
|
||||
OnPropertyChanged(nameof(FilesSummary));
|
||||
OnPropertyChanged(nameof(ContinueHint));
|
||||
RecomputeCanContinue();
|
||||
if (_flat.Count > 0)
|
||||
MoveTo(0); // also sets ActiveFile via MoveTo
|
||||
@@ -211,6 +222,7 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
|
||||
OnPropertyChanged(nameof(PositionText));
|
||||
OnPropertyChanged(nameof(ActiveResultText));
|
||||
OnPropertyChanged(nameof(FilesSummary));
|
||||
OnPropertyChanged(nameof(ContinueHint));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using Iciclecreek.Terminal;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Hosts a <see cref="PtyTerminalSession"/> for an embedded ConPTY terminal. The view attaches
|
||||
/// its <see cref="TerminalControl"/> once loaded via <see cref="AttachControl"/>; <see cref="Start"/>
|
||||
/// can be called before or after attach — whichever happens second triggers the launch.
|
||||
/// </summary>
|
||||
public sealed partial class InteractiveTerminalViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
private readonly PtyTerminalSession _session = new();
|
||||
private TerminalControl? _control;
|
||||
private TerminalLaunchDescriptor? _pendingDescriptor;
|
||||
|
||||
[ObservableProperty] private bool _isRunning;
|
||||
[ObservableProperty] private bool _hasExited;
|
||||
[ObservableProperty] private int? _exitCode;
|
||||
[ObservableProperty] private string? _startError;
|
||||
|
||||
public InteractiveTerminalViewModel()
|
||||
{
|
||||
_session.ProcessExited += OnSessionProcessExited;
|
||||
}
|
||||
|
||||
/// <summary>Called by the view once its <see cref="TerminalControl"/> has loaded (template applied).</summary>
|
||||
public void AttachControl(TerminalControl control)
|
||||
{
|
||||
_control = control;
|
||||
if (_pendingDescriptor is { } descriptor)
|
||||
{
|
||||
_pendingDescriptor = null;
|
||||
_ = StartCoreAsync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
public void Start(TerminalLaunchDescriptor descriptor)
|
||||
{
|
||||
if (_control is null)
|
||||
{
|
||||
_pendingDescriptor = descriptor;
|
||||
return;
|
||||
}
|
||||
_ = StartCoreAsync(descriptor);
|
||||
}
|
||||
|
||||
private async Task StartCoreAsync(TerminalLaunchDescriptor descriptor)
|
||||
{
|
||||
if (_control is null) return;
|
||||
try
|
||||
{
|
||||
StartError = null;
|
||||
await _session.StartAsync(descriptor, _control);
|
||||
IsRunning = true;
|
||||
HasExited = false;
|
||||
ExitCode = null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
IsRunning = false;
|
||||
HasExited = true;
|
||||
StartError = ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSessionProcessExited(object? sender, int exitCode)
|
||||
{
|
||||
IsRunning = false;
|
||||
HasExited = true;
|
||||
ExitCode = exitCode;
|
||||
}
|
||||
|
||||
public void Kill() => _session.Kill();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_session.ProcessExited -= OnSessionProcessExited;
|
||||
_session.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -296,6 +296,15 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
|
||||
AgentSettings = new AgentConfigEditorViewModel(worker, AgentConfigScope.Task);
|
||||
Merge = new MergeSectionViewModel(worker, services);
|
||||
Merge.DiffViewed = () => ReviewDiffViewed = true;
|
||||
Merge.PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(MergeSectionViewModel.HasReviewableDiff))
|
||||
{
|
||||
ApproveReviewCommand.NotifyCanExecuteChanged();
|
||||
OnPropertyChanged(nameof(ShowReviewDiffHint));
|
||||
}
|
||||
};
|
||||
Prep = new PrepPanelViewModel(worker);
|
||||
|
||||
Notes = new NotesEditorViewModel(_notesApi);
|
||||
@@ -399,6 +408,13 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
DequeueCommand.NotifyCanExecuteChanged();
|
||||
ResetAndRetryCommand.NotifyCanExecuteChanged();
|
||||
ContinueCommand.NotifyCanExecuteChanged();
|
||||
// A state change means a new run/review cycle: the diff must be
|
||||
// re-inspected before merge can be approved again.
|
||||
ReviewDiffViewed = false;
|
||||
ApproveReviewCommand.NotifyCanExecuteChanged();
|
||||
OnPropertyChanged(nameof(ShowReviewDiffHint));
|
||||
OnPropertyChanged(nameof(CanSubmitForReview));
|
||||
SubmitForReviewCommand.NotifyCanExecuteChanged();
|
||||
AgentSettings.IsRunning = IsRunning;
|
||||
NotifySessionSections();
|
||||
OnPropertyChanged(nameof(CanAcceptDrop));
|
||||
@@ -536,6 +552,10 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
var latestRun = await runRepo.GetLatestByTaskIdAsync(row.Id, ct);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
LatestRunSessionId = latestRun?.SessionId;
|
||||
// Restore turn/token counts from the last run so a reloaded terminal task
|
||||
// shows its real turns instead of "0/max".
|
||||
Turns = latestRun?.TurnCount ?? 0;
|
||||
Tokens = (latestRun?.TokensIn ?? 0) + (latestRun?.TokensOut ?? 0);
|
||||
Monitor.ApplyOutcome(entity.Result, latestRun?.ErrorMarkdown);
|
||||
|
||||
Monitor.SetTaskId(row.Id);
|
||||
@@ -740,6 +760,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
Merge.SyncWorktree(WorktreePath, WorktreeBaseCommit, WorktreeHeadCommit,
|
||||
WorktreeStateLabel, _listWorkingDir);
|
||||
NotifySessionSections();
|
||||
OnPropertyChanged(nameof(CanSubmitForReview));
|
||||
SubmitForReviewCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
partial void OnWorktreeHeadCommitChanged(string? value) =>
|
||||
@@ -748,9 +770,11 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
|
||||
partial void OnTaskChanged(TaskRowViewModel? value)
|
||||
{
|
||||
ReviewDiffViewed = false;
|
||||
Merge.SyncTaskContext(Task?.Id, Task?.Title, Task?.IsPlanningParent == true);
|
||||
NotifySessionSections();
|
||||
OnPropertyChanged(nameof(CanAcceptDrop));
|
||||
OnPropertyChanged(nameof(CanPickUpInTerminal));
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
@@ -892,6 +916,24 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
catch { /* offline */ }
|
||||
}
|
||||
|
||||
// Pick up in a terminal only where a session + worktree reliably still exist (parked
|
||||
// for review or failed mid-run); the worker reports a clear error otherwise.
|
||||
public bool CanPickUpInTerminal => Task is not null
|
||||
&& Task.Status is ClaudeDo.Data.Models.TaskStatus.WaitingForReview
|
||||
or ClaudeDo.Data.Models.TaskStatus.Failed;
|
||||
|
||||
[RelayCommand]
|
||||
private async System.Threading.Tasks.Task PickUpInTerminalAsync()
|
||||
{
|
||||
if (Task is null) return;
|
||||
ClaudeDo.Ui.Services.ForegroundHelper.AllowAny();
|
||||
try { await _worker.ResumeTaskInTerminalAsync(Task.Id); }
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
if (ShowErrorAsync != null) await ShowErrorAsync(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanEnqueue))]
|
||||
private async System.Threading.Tasks.Task EnqueueAsync()
|
||||
{
|
||||
@@ -958,7 +1000,20 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
private bool CanResetAndRetry() =>
|
||||
Task != null && _worker.IsConnected && ShowResetAndRetry;
|
||||
|
||||
[RelayCommand]
|
||||
// Set once the user opens the diff/combined-diff for the current review. Reset on
|
||||
// task switch and on every state change (a new run means a new diff to read), so
|
||||
// Approve & Merge stays blocked until the pending changes have been inspected.
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(ApproveReviewCommand))]
|
||||
[NotifyPropertyChangedFor(nameof(ShowReviewDiffHint))]
|
||||
private bool _reviewDiffViewed;
|
||||
|
||||
// True while a review is pending, there is a diff to inspect, and it has not been
|
||||
// opened yet — drives the "open the diff first" hint next to Approve & Merge.
|
||||
public bool ShowReviewDiffHint =>
|
||||
IsWaitingForReview && Merge.HasReviewableDiff && !ReviewDiffViewed;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanApproveReview))]
|
||||
private async System.Threading.Tasks.Task ApproveReviewAsync()
|
||||
{
|
||||
if (Task is null || !_worker.IsConnected) return;
|
||||
@@ -976,6 +1031,30 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
// Force the diff to have been opened before a merge can happen — but only when
|
||||
// there is actually something to inspect (a childless sandbox run with no worktree
|
||||
// has no diff, so it approves straight through).
|
||||
private bool CanApproveReview() =>
|
||||
Task != null && _worker.IsConnected && IsWaitingForReview
|
||||
&& (!Merge.HasReviewableDiff || ReviewDiffViewed);
|
||||
|
||||
// An interactive (ConPTY) session leaves its worktree changed but never flips the task
|
||||
// status. Offer "Submit for review" for an Idle/Failed task that still has a worktree, so
|
||||
// its hand-driven work can enter the normal review/merge flow (commits first, server-side).
|
||||
public bool CanSubmitForReview =>
|
||||
Task != null && _worker.IsConnected && (IsIdle || IsFailed) && !string.IsNullOrEmpty(WorktreePath);
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanSubmitForReview))]
|
||||
private async System.Threading.Tasks.Task SubmitForReviewAsync()
|
||||
{
|
||||
if (Task is null || !_worker.IsConnected) return;
|
||||
try { await _worker.SubmitTaskForReviewAsync(Task.Id); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (ShowErrorAsync != null) await ShowErrorAsync(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(HasReviewFeedback))]
|
||||
private async System.Threading.Tasks.Task RejectReviewAsync()
|
||||
{
|
||||
@@ -1080,7 +1159,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failures.Add($"{fileName}: {ex.Message}");
|
||||
// Keep the exception type in the message so an intermittent failure is analyzable.
|
||||
failures.Add($"{fileName}: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@ namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||
|
||||
public enum ListKind { Smart, Virtual, User }
|
||||
|
||||
/// <summary>Confirmed merge-helper run: the scope list (null = all lists) and the ordered selected task ids.</summary>
|
||||
public sealed record MergeHelperRequest(string? ListId, IReadOnlyList<string> TaskIds);
|
||||
|
||||
public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
@@ -80,6 +83,35 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
finally { _worktreesOverviewOpen = false; }
|
||||
}
|
||||
|
||||
/// <summary>Raised after the merge-helper selection dialog is confirmed; the shell opens the ConPTY tile.</summary>
|
||||
public event Action<MergeHelperRequest>? LetClaudeHandleRequested;
|
||||
|
||||
[RelayCommand]
|
||||
private async Task LetClaudeHandleListAsync(ListNavItemViewModel? row)
|
||||
{
|
||||
if (row is null || Dialogs is null || _services is null) return;
|
||||
if (row.Kind != ListKind.User || string.IsNullOrWhiteSpace(row.WorkingDir)) return;
|
||||
var rawId = row.Id.StartsWith("user:", StringComparison.Ordinal) ? row.Id["user:".Length..] : row.Id;
|
||||
var vm = _services.GetRequiredService<MergeHelperSelectionModalViewModel>();
|
||||
vm.Configure(rawId, row.Name);
|
||||
await vm.LoadAsync();
|
||||
var ids = await Dialogs.ShowMergeHelperSelectionAsync(vm);
|
||||
if (ids is { Count: > 0 })
|
||||
LetClaudeHandleRequested?.Invoke(new MergeHelperRequest(rawId, ids));
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task LetClaudeHandleAllAsync()
|
||||
{
|
||||
if (Dialogs is null || _services is null) return;
|
||||
var vm = _services.GetRequiredService<MergeHelperSelectionModalViewModel>();
|
||||
vm.Configure(null, null);
|
||||
await vm.LoadAsync();
|
||||
var ids = await Dialogs.ShowMergeHelperSelectionAsync(vm);
|
||||
if (ids is { Count: > 0 })
|
||||
LetClaudeHandleRequested?.Invoke(new MergeHelperRequest(null, ids));
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenInExplorer(ListNavItemViewModel? row)
|
||||
{
|
||||
|
||||
@@ -48,6 +48,15 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
|
||||
public Func<DiffViewerViewModel, System.Threading.Tasks.Task>? ShowDiffViewer { get; set; }
|
||||
public Func<MergeModalViewModel, System.Threading.Tasks.Task>? ShowMergeModal { get; set; }
|
||||
|
||||
// Raised when the user opens a diff/combined-diff for the current task, so the
|
||||
// review gate can record that the changes were inspected before merging.
|
||||
public Action? DiffViewed { get; set; }
|
||||
|
||||
// True when there is something to inspect before merging (a live worktree diff,
|
||||
// a merged commit range, or a planning/children combined diff). When false there
|
||||
// is nothing to read, so the review gate must not block approve.
|
||||
public bool HasReviewableDiff => CanOpenDiff() || CanReviewDiff();
|
||||
|
||||
public MergeSectionViewModel(IWorkerClient worker, IServiceProvider services)
|
||||
{
|
||||
_worker = worker;
|
||||
@@ -69,6 +78,7 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
|
||||
_worktreeStateLabel = worktreeState;
|
||||
_listWorkingDir = listWorkDir;
|
||||
OnPropertyChanged(nameof(ShowMergeSection));
|
||||
OnPropertyChanged(nameof(HasReviewableDiff));
|
||||
OpenDiffCommand.NotifyCanExecuteChanged();
|
||||
OpenWorktreeCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
@@ -79,6 +89,7 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
|
||||
TaskTitle = taskTitle;
|
||||
_isPlanningParent = isPlanningParent;
|
||||
OnPropertyChanged(nameof(ShowMergeSection));
|
||||
OnPropertyChanged(nameof(HasReviewableDiff));
|
||||
}
|
||||
|
||||
internal void SyncChildOutcomes(bool hasChildOutcomes, int subtaskCount)
|
||||
@@ -86,6 +97,7 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
|
||||
_hasChildOutcomes = hasChildOutcomes;
|
||||
_subtaskCount = subtaskCount;
|
||||
OnPropertyChanged(nameof(ShowMergeSection));
|
||||
OnPropertyChanged(nameof(HasReviewableDiff));
|
||||
ReviewCombinedDiffCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
@@ -128,6 +140,7 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
|
||||
var vm = _services.GetRequiredService<DiffViewerViewModel>();
|
||||
vm.ConfigurePlanning(TaskId, SelectedMergeTarget ?? "main");
|
||||
await vm.LoadAsync();
|
||||
DiffViewed?.Invoke();
|
||||
await ShowDiffViewer(vm);
|
||||
}
|
||||
|
||||
@@ -157,6 +170,7 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
|
||||
else return;
|
||||
|
||||
await vm.LoadAsync();
|
||||
DiffViewed?.Invoke();
|
||||
await ShowDiffViewer(vm);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using ClaudeDo.Data;
|
||||
@@ -7,11 +8,12 @@ using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Ui.Helpers;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.MissionControl;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||
|
||||
public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
|
||||
public sealed partial class TaskMonitorViewModel : ViewModelBase, IMissionControlPane, IDisposable
|
||||
{
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly IWorkerClient _worker;
|
||||
@@ -70,9 +72,6 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
|
||||
|
||||
private const string RoadblockMarker = "Roadblocks reported during the run:";
|
||||
|
||||
public ObservableCollection<QueuedMessageViewModel> QueuedMessages { get; } = new();
|
||||
public bool HasQueuedMessages => QueuedMessages.Count > 0;
|
||||
|
||||
// Captured handler delegates for disposal
|
||||
private readonly Action<string, string> _onTaskMessage;
|
||||
private readonly Action<string, string, DateTime> _onTaskStarted;
|
||||
@@ -80,19 +79,6 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
|
||||
private readonly Action<string> _onTaskUpdated;
|
||||
private readonly Action<string, string, string> _onTaskQuestionAsked;
|
||||
private readonly Action<string, string> _onTaskQuestionResolved;
|
||||
private readonly Action<string> _onInteractiveStarted;
|
||||
private readonly Action<string> _onInteractiveEnded;
|
||||
private readonly Action<string, IReadOnlyList<string>> _onInteractiveQueueChanged;
|
||||
private readonly Action<string, string> _onInteractiveMessageSent;
|
||||
|
||||
// Interactive composer — active while the worker is in an interactive session.
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(SubmitComposerCommand))]
|
||||
private bool _isInteractiveLive;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(SubmitComposerCommand))]
|
||||
private string _composerDraft = string.Empty;
|
||||
|
||||
// A question the running task raised via AskUser and is blocking on, plus the answer
|
||||
// the user is typing. Ephemeral (in-memory + live events) — the task is still Running.
|
||||
@@ -160,46 +146,6 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
|
||||
ClearPendingQuestion();
|
||||
};
|
||||
_worker.TaskQuestionResolvedEvent += _onTaskQuestionResolved;
|
||||
|
||||
_onInteractiveStarted = taskId =>
|
||||
{
|
||||
if (taskId == _subscribedTaskId) { IsInteractiveLive = true; AgentState = "running"; }
|
||||
};
|
||||
_worker.InteractiveSessionStartedEvent += _onInteractiveStarted;
|
||||
|
||||
_onInteractiveEnded = taskId =>
|
||||
{
|
||||
if (taskId != _subscribedTaskId) return;
|
||||
IsInteractiveLive = false;
|
||||
AgentState = "done";
|
||||
QueuedMessages.Clear();
|
||||
OnPropertyChanged(nameof(HasQueuedMessages));
|
||||
};
|
||||
_worker.InteractiveSessionEndedEvent += _onInteractiveEnded;
|
||||
|
||||
_onInteractiveQueueChanged = (taskId, pending) =>
|
||||
{
|
||||
if (taskId != _subscribedTaskId) return;
|
||||
QueuedMessages.Clear();
|
||||
foreach (var m in pending)
|
||||
{
|
||||
var text = m;
|
||||
QueuedMessages.Add(new QueuedMessageViewModel
|
||||
{
|
||||
Text = text,
|
||||
RemoveCommand = new CommunityToolkit.Mvvm.Input.RelayCommand(() => _ = RemoveQueuedAsync(text)),
|
||||
});
|
||||
}
|
||||
OnPropertyChanged(nameof(HasQueuedMessages));
|
||||
};
|
||||
_worker.InteractiveQueueChangedEvent += _onInteractiveQueueChanged;
|
||||
|
||||
_onInteractiveMessageSent = (taskId, text) =>
|
||||
{
|
||||
if (taskId == _subscribedTaskId)
|
||||
Log.Add(new LogLineViewModel { Kind = LogKind.User, Text = text });
|
||||
};
|
||||
_worker.InteractiveMessageSentEvent += _onInteractiveMessageSent;
|
||||
}
|
||||
|
||||
// Surface a pending question (used by live event + re-attach hydration).
|
||||
@@ -209,45 +155,6 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
|
||||
PendingQuestion = question;
|
||||
}
|
||||
|
||||
// Used by Mission Control when it creates the monitor after the started event already fired.
|
||||
public void SetInteractiveLive(bool live)
|
||||
{
|
||||
IsInteractiveLive = live;
|
||||
if (live) AgentState = "running";
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanSubmitComposer))]
|
||||
private async System.Threading.Tasks.Task SubmitComposer()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_subscribedTaskId)) return;
|
||||
var text = ComposerDraft;
|
||||
if (string.IsNullOrWhiteSpace(text)) return;
|
||||
ComposerDraft = string.Empty;
|
||||
await _worker.SendInteractiveMessageAsync(_subscribedTaskId, text);
|
||||
}
|
||||
|
||||
private bool CanSubmitComposer() => IsInteractiveLive && !string.IsNullOrWhiteSpace(ComposerDraft);
|
||||
|
||||
[RelayCommand]
|
||||
private async System.Threading.Tasks.Task StopInteractive()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_subscribedTaskId) && IsInteractiveLive)
|
||||
await _worker.StopInteractiveSessionAsync(_subscribedTaskId);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async System.Threading.Tasks.Task InterruptInteractive()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_subscribedTaskId) && IsInteractiveLive)
|
||||
await _worker.InterruptInteractiveSessionAsync(_subscribedTaskId);
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task RemoveQueuedAsync(string text)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_subscribedTaskId))
|
||||
await _worker.RemoveQueuedInteractiveMessageAsync(_subscribedTaskId, text);
|
||||
}
|
||||
|
||||
private void ClearPendingQuestion()
|
||||
{
|
||||
PendingQuestionId = null;
|
||||
@@ -296,10 +203,6 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
|
||||
SessionOutcome = null;
|
||||
Roadblocks = null;
|
||||
ClearPendingQuestion();
|
||||
IsInteractiveLive = false;
|
||||
ComposerDraft = string.Empty;
|
||||
QueuedMessages.Clear();
|
||||
OnPropertyChanged(nameof(HasQueuedMessages));
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
@@ -356,16 +259,38 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
|
||||
var idx = result.IndexOf(RoadblockMarker, StringComparison.Ordinal);
|
||||
if (idx < 0)
|
||||
{
|
||||
SessionOutcome = result;
|
||||
SessionOutcome = UnwrapSummary(result);
|
||||
Roadblocks = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var summary = result[..idx].TrimEnd().TrimEnd('⚠').TrimEnd();
|
||||
SessionOutcome = string.IsNullOrWhiteSpace(summary) ? null : summary;
|
||||
SessionOutcome = string.IsNullOrWhiteSpace(summary) ? null : UnwrapSummary(summary);
|
||||
Roadblocks = result[(idx + RoadblockMarker.Length)..].Trim();
|
||||
}
|
||||
|
||||
/// <summary>Safety net for older rows persisted as raw structured-output JSON: if the outcome
|
||||
/// is a JSON object with a string <c>summary</c>, show that; otherwise keep the text as-is.</summary>
|
||||
private static string? UnwrapSummary(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text)) return text;
|
||||
var trimmed = text.TrimStart();
|
||||
if (trimmed.Length == 0 || trimmed[0] != '{') return text;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(trimmed);
|
||||
if (doc.RootElement.ValueKind == JsonValueKind.Object &&
|
||||
doc.RootElement.TryGetProperty("summary", out var s) &&
|
||||
s.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var summary = s.GetString();
|
||||
if (!string.IsNullOrWhiteSpace(summary)) return summary;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return text;
|
||||
}
|
||||
|
||||
public async System.Threading.Tasks.Task ReplayLogFileAsync(string? logPath, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(logPath)) return;
|
||||
@@ -523,15 +448,5 @@ public sealed partial class TaskMonitorViewModel : ViewModelBase, IDisposable
|
||||
_worker.TaskUpdatedEvent -= _onTaskUpdated;
|
||||
_worker.TaskQuestionAskedEvent -= _onTaskQuestionAsked;
|
||||
_worker.TaskQuestionResolvedEvent -= _onTaskQuestionResolved;
|
||||
_worker.InteractiveSessionStartedEvent -= _onInteractiveStarted;
|
||||
_worker.InteractiveSessionEndedEvent -= _onInteractiveEnded;
|
||||
_worker.InteractiveQueueChangedEvent -= _onInteractiveQueueChanged;
|
||||
_worker.InteractiveMessageSentEvent -= _onInteractiveMessageSent;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class QueuedMessageViewModel
|
||||
{
|
||||
public required string Text { get; init; }
|
||||
public required System.Windows.Input.ICommand RemoveCommand { get; init; }
|
||||
}
|
||||
|
||||
@@ -63,6 +63,11 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
&& !IsChild;
|
||||
public bool CanResumeOrDiscardPlanning => PlanningPhase == PlanningPhase.Active;
|
||||
|
||||
// Pick up in a terminal only where a session + worktree reliably still exist: a task
|
||||
// parked for review, or one that failed mid-run. The worker validates and reports a
|
||||
// clear error if there's no resumable session/worktree.
|
||||
public bool CanPickUpInTerminal => Status is TaskStatus.WaitingForReview or TaskStatus.Failed;
|
||||
|
||||
public string? PlanningBadge => PlanningPhase switch
|
||||
{
|
||||
PlanningPhase.Active => Loc.T("vm.planningBadge.active"),
|
||||
@@ -123,6 +128,11 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
_ => Status.ToString(),
|
||||
};
|
||||
|
||||
// A planning parent that is still Idle (Active planning, or Finalized-but-not-yet-queued)
|
||||
// already shows its state via the PLANNING/PLANNED badge next to the title. The lifecycle
|
||||
// chip would read "Idle" (or "Parked"), which is misleading, so hide it in that case.
|
||||
public bool ShowStatusChip => !(PlanningPhase != PlanningPhase.None && Status == TaskStatus.Idle);
|
||||
|
||||
public string StatusChipClass => (Status, IsBlocked: !string.IsNullOrEmpty(BlockedByTaskId)) switch
|
||||
{
|
||||
(TaskStatus.Running, _) => "running",
|
||||
@@ -139,8 +149,10 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
{
|
||||
OnPropertyChanged(nameof(StatusChipClass));
|
||||
OnPropertyChanged(nameof(StatusLabel));
|
||||
OnPropertyChanged(nameof(ShowStatusChip));
|
||||
OnPropertyChanged(nameof(IsRunning));
|
||||
OnPropertyChanged(nameof(IsWaitingForReview));
|
||||
OnPropertyChanged(nameof(CanPickUpInTerminal));
|
||||
OnPropertyChanged(nameof(IsParked));
|
||||
OnPropertyChanged(nameof(IsQueued));
|
||||
OnPropertyChanged(nameof(IsWaiting));
|
||||
@@ -183,6 +195,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
{
|
||||
OnPropertyChanged(nameof(IsPlanningParent));
|
||||
OnPropertyChanged(nameof(PlanningBadge));
|
||||
OnPropertyChanged(nameof(ShowStatusChip));
|
||||
OnPropertyChanged(nameof(IsPlanActive));
|
||||
OnPropertyChanged(nameof(IsPlanFinalized));
|
||||
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
||||
|
||||
@@ -151,6 +151,18 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
.Include(t => t.Worktree)
|
||||
.FirstOrDefaultAsync(t => t.Id == taskId);
|
||||
|
||||
// A parent transition (finalize/discard) broadcasts only the parent's id, but it
|
||||
// changes its children's derived state — finalize flips them Draft→Planned, discard
|
||||
// deletes them. The delta path below only touches the parent row and never recomputes
|
||||
// the child-derived flags (ParentFinalized, HasPlanningChildren) nor drops deleted
|
||||
// children, so reconcile the whole list when the updated task is (or owns) a subtree.
|
||||
if (entity is not null &&
|
||||
(entity.PlanningPhase != PlanningPhase.None || Items.Any(r => r.ParentTaskId == entity.Id)))
|
||||
{
|
||||
LoadForList(list);
|
||||
return;
|
||||
}
|
||||
|
||||
var existing = Items.FirstOrDefault(r => r.Id == taskId);
|
||||
|
||||
if (entity is null)
|
||||
@@ -732,7 +744,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
if (row is null || !row.IsWaitingForReview || _worker is null) return;
|
||||
try { await _worker.ApproveReviewAsync(row.Id, ""); }
|
||||
catch { /* offline; broadcast reconciles on return */ }
|
||||
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.approveFailed", ex.Message)); }
|
||||
}
|
||||
|
||||
public async Task RejectReviewToQueueAsync(TaskRowViewModel row, string feedback)
|
||||
@@ -801,22 +813,38 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
private void OpenListSettings() => OpenListSettingsRequested?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
[RelayCommand]
|
||||
private async Task OpenPlanningSessionAsync(TaskRowViewModel? row)
|
||||
private void OpenPlanningSession(TaskRowViewModel? row)
|
||||
{
|
||||
if (row is null) return;
|
||||
if (row.Status != TaskStatus.Idle || row.PlanningPhase != PlanningPhase.None) return;
|
||||
ForegroundHelper.AllowAny();
|
||||
try { await _worker!.StartPlanningSessionAsync(row.Id); }
|
||||
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.planningOpenFailed", ex.Message)); }
|
||||
// Planning now runs as an embedded ConPTY pane in the Command Center (not an external wt
|
||||
// window). The shell owns Mission Control, so raise an event; the actual StartAsync happens
|
||||
// server-side inside GetPlanningStartLaunchSpec when the pane opens.
|
||||
OpenPlanningConPtyRequested?.Invoke(row.Id, false);
|
||||
}
|
||||
|
||||
// Opens the task in an embedded ConPTY terminal pane in the Command Center. The shell owns
|
||||
// the Mission Control view model, so this just raises an event for it to act on.
|
||||
public event Action<string>? OpenConPtySessionRequested;
|
||||
|
||||
// Opens (resume=false) or resumes (resume=true) a planning session as an embedded ConPTY
|
||||
// pane in the Command Center.
|
||||
public event Action<string, bool>? OpenPlanningConPtyRequested;
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenConPtySession(TaskRowViewModel? row)
|
||||
{
|
||||
if (row is null) return;
|
||||
OpenConPtySessionRequested?.Invoke(row.Id);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RunInteractivelyAsync(TaskRowViewModel? row)
|
||||
private async Task PickUpInTerminalAsync(TaskRowViewModel? row)
|
||||
{
|
||||
if (row is null || _worker is null) return;
|
||||
ForegroundHelper.AllowAny();
|
||||
try { await _worker.OpenInteractiveTerminalAsync(row.Id); }
|
||||
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.runInteractiveFailed", ex.Message)); }
|
||||
try { await _worker.ResumeTaskInTerminalAsync(row.Id); }
|
||||
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.pickUpInTerminalFailed", ex.Message)); }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
@@ -842,8 +870,9 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
switch (choice)
|
||||
{
|
||||
case UnfinishedPlanningModalResult.Resume:
|
||||
ForegroundHelper.AllowAny();
|
||||
await _worker.ResumePlanningSessionAsync(row.Id);
|
||||
// Resume as an embedded ConPTY pane (server-side ResumeAsync runs inside
|
||||
// GetPlanningResumeLaunchSpec when the pane opens).
|
||||
OpenPlanningConPtyRequested?.Invoke(row.Id, true);
|
||||
break;
|
||||
case UnfinishedPlanningModalResult.FinalizeNow:
|
||||
await _worker.FinalizePlanningSessionAsync(row.Id, queueAgentTasks: false);
|
||||
@@ -856,7 +885,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.planningResumeFailed", ex.Message)); }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
|
||||
@@ -97,7 +97,6 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
|
||||
[ObservableProperty] private bool _isUpdateBannerVisible;
|
||||
[ObservableProperty] private string? _updateBannerLatestVersion;
|
||||
[ObservableProperty] private string? _inlineUpdateStatus;
|
||||
private bool _bannerDismissedThisSession;
|
||||
|
||||
[ObservableProperty]
|
||||
@@ -215,6 +214,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
MissionControl.OpenInApp = id => _ = RevealTaskAsync(id);
|
||||
MissionControl.ShowDetached = (monitor, reDock) => Dialogs?.ShowDetachedMonitor(monitor, reDock);
|
||||
MissionControl.OpenSettingsRequested = () => Lists.OpenSettingsCommand.Execute(null);
|
||||
MissionControl.ErrorReported += FlashFooterError;
|
||||
_updateCheck = updateCheck;
|
||||
_installerLocator = installerLocator;
|
||||
_workerLocator = workerLocator;
|
||||
@@ -228,6 +228,21 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
Tasks.NotesRequested += () => Details.ShowNotes();
|
||||
Tasks.PrepRequested += () => Details.ShowPrep();
|
||||
Tasks.ErrorReported += FlashFooterError;
|
||||
Tasks.OpenConPtySessionRequested += taskId =>
|
||||
{
|
||||
OpenMissionControl();
|
||||
_ = MissionControl.OpenConPtySessionAsync(taskId);
|
||||
};
|
||||
Tasks.OpenPlanningConPtyRequested += (taskId, resume) =>
|
||||
{
|
||||
OpenMissionControl();
|
||||
_ = MissionControl.OpenPlanningConPtySessionAsync(taskId, resume);
|
||||
};
|
||||
Lists.LetClaudeHandleRequested += req =>
|
||||
{
|
||||
OpenMissionControl();
|
||||
_ = MissionControl.OpenMergeHelperConPtySessionAsync(req.ListId, req.TaskIds);
|
||||
};
|
||||
Tasks.TasksChanged += (_, _) => _ = Lists.RefreshCountsAsync();
|
||||
Tasks.OpenListSettingsRequested += (_, _) =>
|
||||
{
|
||||
@@ -299,25 +314,15 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
if (_bannerDismissedThisSession) { IsUpdateBannerVisible = false; break; }
|
||||
UpdateBannerLatestVersion = _updateCheck.LatestVersion;
|
||||
IsUpdateBannerVisible = true;
|
||||
InlineUpdateStatus = null;
|
||||
break;
|
||||
case UpdateCheckStatus.UpToDate:
|
||||
IsUpdateBannerVisible = false;
|
||||
ShowInlineStatus($"You're up to date (v{_updateCheck.CurrentVersion})");
|
||||
break;
|
||||
case UpdateCheckStatus.CheckFailed:
|
||||
ShowInlineStatus("Could not check for updates");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private async void ShowInlineStatus(string text)
|
||||
{
|
||||
InlineUpdateStatus = text;
|
||||
await Task.Delay(3000);
|
||||
if (InlineUpdateStatus == text) InlineUpdateStatus = null;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenMissionControl()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using ClaudeDo.Ui.Services;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.MissionControl;
|
||||
|
||||
/// <summary>
|
||||
/// Command Center pane hosting an embedded ConPTY terminal for either one task's interactive
|
||||
/// Claude session, or an ad-hoc/free session in a user-chosen directory (no task, <see cref="TaskId"/>
|
||||
/// is null — ad-hoc panes are never deduped, unlike task-based ones). Distinct from the streamed-log
|
||||
/// <see cref="ClaudeDo.Ui.ViewModels.Islands.TaskMonitorViewModel"/> pane; the two coexist until
|
||||
/// the streaming interactive stack is removed.
|
||||
/// </summary>
|
||||
public sealed partial class ConPtyPaneViewModel : ViewModelBase, IMissionControlPane, IDisposable
|
||||
{
|
||||
public string? TaskId { get; }
|
||||
|
||||
// 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;
|
||||
|
||||
[ObservableProperty] private string _displayTitle;
|
||||
|
||||
public InteractiveTerminalViewModel Terminal { get; } = new();
|
||||
|
||||
/// <summary>Raised when the terminal failed to start — the host surfaces this via the footer error strip.</summary>
|
||||
public event Action<string>? ErrorReported;
|
||||
|
||||
/// <summary>Set by the host (Mission Control) to remove this pane from its collection.</summary>
|
||||
public Action<ConPtyPaneViewModel>? CloseRequested { get; set; }
|
||||
|
||||
/// <summary>Raised when the user submits this task's hand-driven work for review; the host
|
||||
/// commits the worktree and moves the task to WaitingForReview.</summary>
|
||||
public event Action<string>? SubmitForReviewRequested;
|
||||
|
||||
/// <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.</summary>
|
||||
public ConPtyPaneViewModel(string? taskId, string displayTitle, TerminalLaunchDescriptor descriptor)
|
||||
{
|
||||
TaskId = taskId;
|
||||
_displayTitle = displayTitle;
|
||||
Terminal.PropertyChanged += OnTerminalPropertyChanged;
|
||||
Terminal.Start(descriptor);
|
||||
}
|
||||
|
||||
/// <summary>Ad-hoc pane — no task, no dedup.</summary>
|
||||
public static ConPtyPaneViewModel CreateAdHoc(string displayTitle, TerminalLaunchDescriptor descriptor)
|
||||
=> new(null, displayTitle, descriptor);
|
||||
|
||||
private void OnTerminalPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(InteractiveTerminalViewModel.StartError) && Terminal.StartError is { Length: > 0 } error)
|
||||
ErrorReported?.Invoke(error);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Close() => CloseRequested?.Invoke(this);
|
||||
|
||||
private bool CanSubmitForReview() => IsTaskBased;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanSubmitForReview))]
|
||||
private void SubmitForReview()
|
||||
{
|
||||
if (TaskId is { } id) SubmitForReviewRequested?.Invoke(id);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Terminal.PropertyChanged -= OnTerminalPropertyChanged;
|
||||
Terminal.Kill();
|
||||
Terminal.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ClaudeDo.Ui.ViewModels.MissionControl;
|
||||
|
||||
/// <summary>
|
||||
/// Common contract for anything hosted as a pane in the Command Center — implemented by both
|
||||
/// the streamed-log <see cref="ClaudeDo.Ui.ViewModels.Islands.TaskMonitorViewModel"/> and the
|
||||
/// embedded ConPTY <see cref="ConPtyPaneViewModel"/> — so the layout toggle (grid/tabs) can
|
||||
/// bind one heterogeneous pane collection.
|
||||
/// </summary>
|
||||
public interface IMissionControlPane
|
||||
{
|
||||
string DisplayTitle { get; }
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Specialized;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
using ClaudeDo.Ui.ViewModels.MissionControl;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels;
|
||||
@@ -19,12 +22,31 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
private readonly Action<string, string, string, DateTime> _onTaskFinished;
|
||||
private readonly Action<string> _onTaskUpdated;
|
||||
private readonly Action _onConnectionRestored;
|
||||
private readonly Action<string> _onInteractiveStarted;
|
||||
|
||||
public ObservableCollection<TaskMonitorViewModel> Monitors { get; } = new();
|
||||
|
||||
// Embedded ConPTY sessions (task-based only) — a manual cockpit detached from the
|
||||
// review/merge/status machinery. Mirrored into Panes alongside the streamed-log Monitors.
|
||||
public ObservableCollection<ConPtyPaneViewModel> ConPtySessions { get; } = new();
|
||||
|
||||
// Unified view of Monitors ++ ConPtySessions (in that order) so the layout toggle can
|
||||
// present one heterogeneous collection as either a grid or tabs.
|
||||
public ObservableCollection<IMissionControlPane> Panes { get; } = new();
|
||||
|
||||
[ObservableProperty] private int _columnCount = 1;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(LayoutToggleLabel))]
|
||||
private bool _isFocusMode;
|
||||
|
||||
[ObservableProperty] private IMissionControlPane? _focusedPane;
|
||||
|
||||
public string LayoutToggleLabel => Loc.T(IsFocusMode ? "missionControl.overviewMode" : "missionControl.focusMode");
|
||||
|
||||
/// <summary>Surfaces a Command Center failure (e.g. a ConPTY launch spec fetch) — the shell
|
||||
/// wires this into the footer error strip, same as the island view models' ErrorReported.</summary>
|
||||
public event Action<string>? ErrorReported;
|
||||
|
||||
private Action<string>? _openInApp;
|
||||
public Action<string>? OpenInApp
|
||||
{
|
||||
@@ -44,6 +66,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
public Action? OpenSettingsRequested { get; set; }
|
||||
|
||||
public bool HasMonitors => Monitors.Count > 0;
|
||||
public bool HasPanes => Panes.Count > 0;
|
||||
|
||||
// Read-only view of the worker queue (tasks waiting to run), shown as a side strip.
|
||||
public ObservableCollection<QueuedTaskViewModel> Queued { get; } = new();
|
||||
@@ -55,6 +78,8 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
_worker = worker;
|
||||
|
||||
Monitors.CollectionChanged += OnMonitorsChanged;
|
||||
ConPtySessions.CollectionChanged += OnConPtySessionsChanged;
|
||||
Panes.CollectionChanged += OnPanesChanged;
|
||||
|
||||
_onTaskStarted = (slot, taskId, startedAt) => { EnsureMonitor(taskId); _ = RefreshQueueAsync(); };
|
||||
_worker.TaskStartedEvent += _onTaskStarted;
|
||||
@@ -68,14 +93,6 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
_onConnectionRestored = () => { SeedActive(); _ = RefreshQueueAsync(); };
|
||||
_worker.ConnectionRestoredEvent += _onConnectionRestored;
|
||||
|
||||
_onInteractiveStarted = taskId =>
|
||||
{
|
||||
EnsureMonitor(taskId);
|
||||
var m = Monitors.FirstOrDefault(x => x.SubscribedTaskId == taskId);
|
||||
m?.SetInteractiveLive(true);
|
||||
};
|
||||
_worker.InteractiveSessionStartedEvent += _onInteractiveStarted;
|
||||
|
||||
SeedActive();
|
||||
_ = RefreshQueueAsync();
|
||||
}
|
||||
@@ -192,6 +209,9 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
[RelayCommand]
|
||||
private void OpenSettings() => OpenSettingsRequested?.Invoke();
|
||||
|
||||
[RelayCommand]
|
||||
private void ToggleLayout() => IsFocusMode = !IsFocusMode;
|
||||
|
||||
public void MoveMonitor(TaskMonitorViewModel dragged, TaskMonitorViewModel target)
|
||||
{
|
||||
if (ReferenceEquals(dragged, target)) return;
|
||||
@@ -201,15 +221,221 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
Monitors.Move(from, to);
|
||||
}
|
||||
|
||||
// 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)
|
||||
{
|
||||
if (string.IsNullOrEmpty(taskId)) return;
|
||||
if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is { } existing)
|
||||
{
|
||||
FocusedPane = existing;
|
||||
return;
|
||||
}
|
||||
|
||||
var title = taskId;
|
||||
try
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
var entity = await ctx.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId);
|
||||
if (entity?.Title is { Length: > 0 } t) title = t;
|
||||
}
|
||||
catch { /* best-effort title lookup */ }
|
||||
|
||||
try
|
||||
{
|
||||
var spec = await _worker.GetInteractiveLaunchSpecAsync(taskId);
|
||||
var descriptor = new TerminalLaunchDescriptor(spec.Cwd, spec.Exe, spec.Args, spec.Env);
|
||||
var pane = new ConPtyPaneViewModel(taskId, title, descriptor);
|
||||
pane.ErrorReported += OnConPtyPaneError;
|
||||
pane.CloseRequested += CloseConPtySession;
|
||||
pane.SubmitForReviewRequested += OnPaneSubmitForReview;
|
||||
ConPtySessions.Add(pane);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorReported?.Invoke(Loc.T("missionControl.conptyLaunchFailed", ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
// Starts (or resumes) a planning session and hosts it as an embedded ConPTY Command Center
|
||||
// pane — the ConPTY replacement for the old external wt planning window. Deduped by TaskId
|
||||
// like OpenConPtySessionAsync.
|
||||
public async System.Threading.Tasks.Task OpenPlanningConPtySessionAsync(string taskId, bool resume)
|
||||
{
|
||||
if (string.IsNullOrEmpty(taskId)) return;
|
||||
if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is { } existing)
|
||||
{
|
||||
FocusedPane = existing;
|
||||
return;
|
||||
}
|
||||
|
||||
var title = taskId;
|
||||
try
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
var entity = await ctx.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId);
|
||||
if (entity?.Title is { Length: > 0 } t) title = t;
|
||||
}
|
||||
catch { /* best-effort title lookup */ }
|
||||
title += Loc.T("missionControl.planningTitleSuffix");
|
||||
|
||||
try
|
||||
{
|
||||
var spec = resume
|
||||
? await _worker.GetPlanningResumeLaunchSpecAsync(taskId)
|
||||
: await _worker.GetPlanningStartLaunchSpecAsync(taskId);
|
||||
var descriptor = new TerminalLaunchDescriptor(spec.Cwd, spec.Exe, spec.Args, spec.Env);
|
||||
var pane = new ConPtyPaneViewModel(taskId, title, descriptor);
|
||||
pane.ErrorReported += OnConPtyPaneError;
|
||||
pane.CloseRequested += CloseConPtySession;
|
||||
pane.SubmitForReviewRequested += OnPaneSubmitForReview;
|
||||
ConPtySessions.Add(pane);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorReported?.Invoke(Loc.T("missionControl.conptyLaunchFailed", ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
// Ad-hoc (task-less) ConPTY session in a user-chosen directory. Never deduped — every call
|
||||
// opens a fresh pane, unlike the task-based OpenConPtySessionAsync above.
|
||||
public async System.Threading.Tasks.Task OpenAdHocConPtySessionAsync(string directory)
|
||||
{
|
||||
if (string.IsNullOrEmpty(directory)) return;
|
||||
|
||||
var title = Path.GetFileName(directory.TrimEnd('\\', '/'));
|
||||
if (string.IsNullOrEmpty(title)) title = directory;
|
||||
|
||||
try
|
||||
{
|
||||
var spec = await _worker.GetAdHocLaunchSpecAsync(directory);
|
||||
var descriptor = new TerminalLaunchDescriptor(spec.Cwd, spec.Exe, spec.Args, spec.Env);
|
||||
var pane = ConPtyPaneViewModel.CreateAdHoc(title, descriptor);
|
||||
pane.ErrorReported += OnConPtyPaneError;
|
||||
pane.CloseRequested += CloseConPtySession;
|
||||
pane.SubmitForReviewRequested += OnPaneSubmitForReview;
|
||||
ConPtySessions.Add(pane);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorReported?.Invoke(Loc.T("missionControl.conptyLaunchFailed", ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
// Merge-helper session over a hand-picked set of tasks ("Let Claude handle it").
|
||||
// Ad-hoc style: no owning task, never deduped — every run opens a fresh pane.
|
||||
public async System.Threading.Tasks.Task OpenMergeHelperConPtySessionAsync(string? listId, IReadOnlyList<string> taskIds)
|
||||
{
|
||||
if (taskIds is not { Count: > 0 }) return;
|
||||
|
||||
var title = Loc.T("missionControl.mergeHelperTitle");
|
||||
if (listId is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
var list = await ctx.Lists.AsNoTracking().FirstOrDefaultAsync(l => l.Id == listId);
|
||||
if (list?.Name is { Length: > 0 } name) title = $"{title} — {name}";
|
||||
}
|
||||
catch { /* best-effort title lookup */ }
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var spec = await _worker.GetMergeHelperLaunchSpecAsync(taskIds, listId);
|
||||
var descriptor = new TerminalLaunchDescriptor(spec.Cwd, spec.Exe, spec.Args, spec.Env);
|
||||
var pane = ConPtyPaneViewModel.CreateAdHoc(title, descriptor);
|
||||
pane.ErrorReported += OnConPtyPaneError;
|
||||
pane.CloseRequested += CloseConPtySession;
|
||||
pane.SubmitForReviewRequested += OnPaneSubmitForReview;
|
||||
ConPtySessions.Add(pane);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorReported?.Invoke(Loc.T("missionControl.conptyLaunchFailed", ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
private void OnConPtyPaneError(string message) => ErrorReported?.Invoke(message);
|
||||
|
||||
// Submit a task's hand-driven ConPTY work for review, then close the pane (the interactive
|
||||
// session is finished). The worker commits the worktree and moves the task to WaitingForReview.
|
||||
private async void OnPaneSubmitForReview(string taskId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _worker.SubmitTaskForReviewAsync(taskId);
|
||||
if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is { } pane)
|
||||
CloseConPtySession(pane);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorReported?.Invoke(Loc.T("missionControl.submitForReviewFailed", ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
private void CloseConPtySession(ConPtyPaneViewModel pane)
|
||||
{
|
||||
if (!ConPtySessions.Contains(pane)) return;
|
||||
pane.ErrorReported -= OnConPtyPaneError;
|
||||
pane.CloseRequested -= CloseConPtySession;
|
||||
ConPtySessions.Remove(pane);
|
||||
pane.Dispose();
|
||||
}
|
||||
|
||||
// Mirrors Monitors' add/remove/move into the front (Monitors-prefix) section of Panes.
|
||||
private void OnMonitorsChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
ColumnCount = Monitors.Count switch
|
||||
switch (e.Action)
|
||||
{
|
||||
case NotifyCollectionChangedAction.Add:
|
||||
Panes.Insert(e.NewStartingIndex, (TaskMonitorViewModel)e.NewItems![0]!);
|
||||
break;
|
||||
case NotifyCollectionChangedAction.Remove:
|
||||
Panes.RemoveAt(e.OldStartingIndex);
|
||||
break;
|
||||
case NotifyCollectionChangedAction.Move:
|
||||
Panes.Move(e.OldStartingIndex, e.NewStartingIndex);
|
||||
break;
|
||||
default: // Reset (Dispose's Monitors.Clear())
|
||||
foreach (var p in Panes.OfType<TaskMonitorViewModel>().ToList())
|
||||
Panes.Remove(p);
|
||||
break;
|
||||
}
|
||||
OnPropertyChanged(nameof(HasMonitors));
|
||||
}
|
||||
|
||||
// Mirrors ConPtySessions' add/remove into the tail (ConPtySessions-suffix) section of Panes.
|
||||
private void OnConPtySessionsChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
switch (e.Action)
|
||||
{
|
||||
case NotifyCollectionChangedAction.Add:
|
||||
Panes.Insert(Monitors.Count + e.NewStartingIndex, (ConPtyPaneViewModel)e.NewItems![0]!);
|
||||
break;
|
||||
case NotifyCollectionChangedAction.Remove:
|
||||
Panes.RemoveAt(Monitors.Count + e.OldStartingIndex);
|
||||
break;
|
||||
default: // Reset
|
||||
foreach (var p in Panes.OfType<ConPtyPaneViewModel>().ToList())
|
||||
Panes.Remove(p);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPanesChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
ColumnCount = Panes.Count switch
|
||||
{
|
||||
<= 1 => 1,
|
||||
<= 4 => 2,
|
||||
_ => 3,
|
||||
};
|
||||
OnPropertyChanged(nameof(HasMonitors));
|
||||
OnPropertyChanged(nameof(HasPanes));
|
||||
if (e.Action == NotifyCollectionChangedAction.Add && e.NewItems?[0] is IMissionControlPane added)
|
||||
FocusedPane = added;
|
||||
else if (e.Action == NotifyCollectionChangedAction.Remove && ReferenceEquals(FocusedPane, e.OldItems?[0]))
|
||||
FocusedPane = Panes.LastOrDefault();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
@@ -218,10 +444,19 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
_worker.TaskFinishedEvent -= _onTaskFinished;
|
||||
_worker.TaskUpdatedEvent -= _onTaskUpdated;
|
||||
_worker.ConnectionRestoredEvent -= _onConnectionRestored;
|
||||
_worker.InteractiveSessionStartedEvent -= _onInteractiveStarted;
|
||||
Monitors.CollectionChanged -= OnMonitorsChanged;
|
||||
ConPtySessions.CollectionChanged -= OnConPtySessionsChanged;
|
||||
Panes.CollectionChanged -= OnPanesChanged;
|
||||
foreach (var m in Monitors) m.Dispose();
|
||||
Monitors.Clear();
|
||||
foreach (var c in ConPtySessions.ToList())
|
||||
{
|
||||
c.ErrorReported -= OnConPtyPaneError;
|
||||
c.CloseRequested -= CloseConPtySession;
|
||||
c.Dispose();
|
||||
}
|
||||
ConPtySessions.Clear();
|
||||
Panes.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,14 @@ public sealed class DiffFileViewModel
|
||||
|
||||
/// A text file that produced no diff hunks (e.g. a newly added empty file).
|
||||
public bool IsEmptyContent => !IsBinary && Lines.Count == 0;
|
||||
|
||||
/// A rename with no content change — showing "+0 −0" for it is misleading.
|
||||
public bool IsPureRename => Status == DiffFileStatus.Renamed && Additions == 0 && Deletions == 0;
|
||||
|
||||
/// Header path label: for a rename, spell out old → new; otherwise just the path.
|
||||
public string HeaderPath => Status == DiffFileStatus.Renamed && !string.IsNullOrEmpty(OldPath)
|
||||
? $"{OldPath} → {Path}"
|
||||
: Path;
|
||||
}
|
||||
|
||||
/// One row in the planning subtask list (left pane in Planning mode).
|
||||
@@ -72,7 +80,7 @@ public sealed partial class DiffTreeNodeViewModel : ViewModelBase
|
||||
[ObservableProperty] private bool _isExpanded = true;
|
||||
|
||||
public string? StatusCode => File?.StatusCode;
|
||||
public bool ShowStats => File is { IsBinary: false };
|
||||
public bool ShowStats => File is { IsBinary: false, IsPureRename: false };
|
||||
public int Additions => File?.Additions ?? 0;
|
||||
public int Deletions => File?.Deletions ?? 0;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Modals;
|
||||
|
||||
public sealed partial class MergeHelperTaskRowViewModel : ViewModelBase
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Title { get; init; }
|
||||
public required string StatusText { get; init; }
|
||||
public required string ListName { get; init; }
|
||||
|
||||
[ObservableProperty] private bool _isSelected;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// "Let Claude handle it" task picker: lists the non-terminal tasks of a list (or all lists),
|
||||
/// pre-ticks the actionable ones, and resolves <see cref="Result"/> with the ordered selected
|
||||
/// task ids (null on cancel).
|
||||
/// </summary>
|
||||
public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
|
||||
{
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private string? _listId;
|
||||
|
||||
public ObservableCollection<MergeHelperTaskRowViewModel> Tasks { get; } = new();
|
||||
|
||||
[ObservableProperty] private string _scopeLabel = "";
|
||||
[ObservableProperty] private bool _isGlobal;
|
||||
|
||||
public bool HasTasks => Tasks.Count > 0;
|
||||
public bool CanConfirm => Tasks.Any(t => t.IsSelected);
|
||||
|
||||
public TaskCompletionSource<IReadOnlyList<string>?> Result { get; } = new();
|
||||
public Action? CloseAction { get; set; }
|
||||
|
||||
public MergeHelperSelectionModalViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory)
|
||||
=> _dbFactory = dbFactory;
|
||||
|
||||
public void Configure(string? listId, string? listName)
|
||||
{
|
||||
_listId = listId;
|
||||
IsGlobal = listId is null;
|
||||
ScopeLabel = listId is null
|
||||
? Loc.T("modals.mergeHelper.scopeAll")
|
||||
: Loc.T("modals.mergeHelper.scopeList", listName ?? "");
|
||||
}
|
||||
|
||||
public async Task LoadAsync(CancellationToken ct = default)
|
||||
{
|
||||
foreach (var row in Tasks) row.PropertyChanged -= OnRowChanged;
|
||||
Tasks.Clear();
|
||||
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
var query = ctx.Tasks.AsNoTracking()
|
||||
.Where(t => t.Status != TaskStatus.Done && t.Status != TaskStatus.Cancelled);
|
||||
if (_listId is not null)
|
||||
query = query.Where(t => t.ListId == _listId);
|
||||
|
||||
var candidates = await query
|
||||
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
||||
.Select(t => new { t.Id, t.Title, t.Status, ListName = t.List.Name })
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var c in candidates)
|
||||
{
|
||||
var row = new MergeHelperTaskRowViewModel
|
||||
{
|
||||
Id = c.Id,
|
||||
Title = c.Title,
|
||||
StatusText = c.Status.ToString(),
|
||||
ListName = c.ListName,
|
||||
IsSelected = IsTickedByDefault(c.Status),
|
||||
};
|
||||
row.PropertyChanged += OnRowChanged;
|
||||
Tasks.Add(row);
|
||||
}
|
||||
OnPropertyChanged(nameof(HasTasks));
|
||||
OnPropertyChanged(nameof(CanConfirm));
|
||||
}
|
||||
|
||||
// Actionable statuses are pre-ticked; Running/WaitingForChildren are listed but unticked
|
||||
// (the helper would only poll them). Done/Cancelled never make it into the list.
|
||||
internal static bool IsTickedByDefault(TaskStatus status) => status
|
||||
is TaskStatus.Idle or TaskStatus.Queued or TaskStatus.WaitingForReview or TaskStatus.Failed;
|
||||
|
||||
private void OnRowChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(MergeHelperTaskRowViewModel.IsSelected))
|
||||
OnPropertyChanged(nameof(CanConfirm));
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void SelectAll()
|
||||
{
|
||||
foreach (var t in Tasks) t.IsSelected = true;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void SelectNone()
|
||||
{
|
||||
foreach (var t in Tasks) t.IsSelected = false;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Confirm()
|
||||
{
|
||||
Result.TrySetResult(Tasks.Where(t => t.IsSelected).Select(t => t.Id).ToList());
|
||||
CloseAction?.Invoke();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Cancel()
|
||||
{
|
||||
Result.TrySetResult(null);
|
||||
CloseAction?.Invoke();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Localization;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.Agent;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Modals.Settings;
|
||||
@@ -22,6 +25,8 @@ public sealed partial class GeneralSettingsTabViewModel : ViewModelBase
|
||||
public IReadOnlyList<string> Models { get; } = ModelRegistry.Aliases;
|
||||
public IReadOnlyList<string> PermissionModes { get; } = PermissionModeRegistry.Modes;
|
||||
|
||||
public ObservableCollection<SelectableSkillViewModel> SessionSkills { get; } = new();
|
||||
|
||||
public GeneralSettingsTabViewModel() { }
|
||||
|
||||
public GeneralSettingsTabViewModel(ILocalizer localizer, Action<string> persist)
|
||||
@@ -51,4 +56,20 @@ public sealed partial class GeneralSettingsTabViewModel : ViewModelBase
|
||||
return "Max parallel executions must be between 1 and 20.";
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Loads the installed-skills checkbox list, reflecting the given selection.</summary>
|
||||
public async Task LoadSessionSkillsAsync(IWorkerClient worker, IReadOnlyCollection<string>? selected)
|
||||
{
|
||||
var installed = await worker.GetSessionSkillsAsync();
|
||||
var selectedSet = selected is null ? new HashSet<string>() : new HashSet<string>(selected);
|
||||
SessionSkills.Clear();
|
||||
foreach (var s in installed)
|
||||
SessionSkills.Add(new SelectableSkillViewModel(s.Name, s.Description, selectedSet.Contains(s.Name)));
|
||||
}
|
||||
|
||||
public List<string>? SelectedSessionSkillNames()
|
||||
{
|
||||
var names = SessionSkills.Where(s => s.IsSelected).Select(s => s.Name).ToList();
|
||||
return names.Count == 0 ? null : names;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Modals.Settings;
|
||||
|
||||
public sealed partial class SessionSkillsSettingsTabViewModel : ViewModelBase
|
||||
{
|
||||
private readonly IWorkerClient _worker;
|
||||
|
||||
[ObservableProperty] private string _installUrl = "";
|
||||
[ObservableProperty] private string _statusMessage = "";
|
||||
[ObservableProperty] private bool _isBusy;
|
||||
[ObservableProperty] private bool _isEmpty = true;
|
||||
|
||||
public ObservableCollection<SessionSkillDto> Skills { get; } = new();
|
||||
|
||||
public SessionSkillsSettingsTabViewModel(IWorkerClient worker) => _worker = worker;
|
||||
|
||||
public async Task LoadAsync()
|
||||
{
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
var skills = await _worker.GetSessionSkillsAsync();
|
||||
Skills.Clear();
|
||||
foreach (var s in skills) Skills.Add(s);
|
||||
IsEmpty = Skills.Count == 0;
|
||||
}
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task InstallAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(InstallUrl)) return;
|
||||
IsBusy = true; StatusMessage = "";
|
||||
try
|
||||
{
|
||||
var installed = await _worker.InstallSessionSkillAsync(InstallUrl.Trim());
|
||||
StatusMessage = Loc.T("vm.sessionSkillsTab.installed", string.Join(", ", installed));
|
||||
InstallUrl = "";
|
||||
await LoadAsync();
|
||||
}
|
||||
catch (Exception ex) { StatusMessage = Loc.T("vm.sessionSkillsTab.installFailed", ex.Message); }
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task UpdateAsync(string? sourceUrl)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sourceUrl)) return;
|
||||
IsBusy = true; StatusMessage = "";
|
||||
try
|
||||
{
|
||||
await _worker.UpdateSessionSkillAsync(sourceUrl);
|
||||
StatusMessage = Loc.T("vm.sessionSkillsTab.updated");
|
||||
await LoadAsync();
|
||||
}
|
||||
catch (Exception ex) { StatusMessage = Loc.T("vm.sessionSkillsTab.updateFailed", ex.Message); }
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RemoveAsync(string? sourceUrl)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sourceUrl)) return;
|
||||
IsBusy = true; StatusMessage = "";
|
||||
try
|
||||
{
|
||||
await _worker.RemoveSessionSkillAsync(sourceUrl);
|
||||
StatusMessage = Loc.T("vm.sessionSkillsTab.removed");
|
||||
await LoadAsync();
|
||||
}
|
||||
catch (Exception ex) { StatusMessage = Loc.T("vm.sessionSkillsTab.removeFailed", ex.Message); }
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
||||
public FilesSettingsTabViewModel Files { get; }
|
||||
public PrimeClaudeTabViewModel Prime { get; }
|
||||
public OnlineInboxSettingsViewModel OnlineInbox { get; }
|
||||
public SessionSkillsSettingsTabViewModel SessionSkills { get; }
|
||||
|
||||
[ObservableProperty] private string _validationError = "";
|
||||
[ObservableProperty] private bool _isBusy;
|
||||
@@ -39,6 +40,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
||||
Files = new FilesSettingsTabViewModel(worker);
|
||||
Prime = prime;
|
||||
OnlineInbox = new OnlineInboxSettingsViewModel(worker, onlineLoginService);
|
||||
SessionSkills = new SessionSkillsSettingsTabViewModel(worker);
|
||||
}
|
||||
|
||||
public async Task LoadAsync()
|
||||
@@ -69,6 +71,8 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
||||
|
||||
await Prime.LoadAsync();
|
||||
await OnlineInbox.LoadAsync();
|
||||
await SessionSkills.LoadAsync();
|
||||
await General.LoadSessionSkillsAsync(_worker, dto?.SessionSkills);
|
||||
}
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
@@ -97,7 +101,8 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
||||
General.ReportExcludedPaths
|
||||
.Split('\n').Select(l => l.Trim().TrimEnd('\r')).Where(l => l.Length > 0).ToList()),
|
||||
General.StandupWeekday,
|
||||
Prime.DailyPrepMaxTasks);
|
||||
Prime.DailyPrepMaxTasks,
|
||||
General.SelectedSessionSkillNames());
|
||||
await _worker.UpdateAppSettingsAsync(dto);
|
||||
await Prime.SaveAsync();
|
||||
await OnlineInbox.SaveAsync();
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Text;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Modals;
|
||||
|
||||
/// Shared unified-diff parser used by both the per-task diff viewer and the
|
||||
@@ -16,10 +18,13 @@ public static class UnifiedDiffParser
|
||||
{
|
||||
if (line.StartsWith("diff --git ", StringComparison.Ordinal))
|
||||
{
|
||||
// e.g. "diff --git a/src/Foo.cs b/src/Foo.cs"
|
||||
var parts = line.Split(' ');
|
||||
var path = parts.Length >= 4 ? parts[3][2..] : line;
|
||||
current = new DiffFileViewModel { Path = path };
|
||||
// e.g. "diff --git a/src/Foo.cs b/src/Foo.cs". Paths may each be
|
||||
// git-quoted (C-style, octal-escaped) when they contain non-ASCII
|
||||
// bytes, and unquoted paths may themselves contain spaces, so the
|
||||
// two paths can't be split on a plain ' ' - this is only a best
|
||||
// effort default; "--- "/"+++ "/rename lines below correct it.
|
||||
var (_, newPath) = SplitGitHeaderPaths(line["diff --git ".Length..]);
|
||||
current = new DiffFileViewModel { Path = newPath };
|
||||
files.Add(current);
|
||||
oldLine = 0; newLine = 0;
|
||||
continue;
|
||||
@@ -41,13 +46,13 @@ public static class UnifiedDiffParser
|
||||
if (line.StartsWith("rename from ", StringComparison.Ordinal))
|
||||
{
|
||||
current.Status = DiffFileStatus.Renamed;
|
||||
current.OldPath = line["rename from ".Length..];
|
||||
current.OldPath = UnquotePath(line["rename from ".Length..]);
|
||||
continue;
|
||||
}
|
||||
if (line.StartsWith("rename to ", StringComparison.Ordinal))
|
||||
{
|
||||
current.Status = DiffFileStatus.Renamed;
|
||||
current.Path = line["rename to ".Length..];
|
||||
current.Path = UnquotePath(line["rename to ".Length..]);
|
||||
continue;
|
||||
}
|
||||
if (line.StartsWith("Binary files", StringComparison.Ordinal) ||
|
||||
@@ -64,10 +69,27 @@ public static class UnifiedDiffParser
|
||||
continue;
|
||||
}
|
||||
|
||||
// "--- a/..." / "+++ b/..." each carry a single unambiguous path (unlike
|
||||
// the two-paths-on-one-line "diff --git" header above), so use them to
|
||||
// correct the file's identity - git also appends a disambiguating tab
|
||||
// after unquoted paths that contain whitespace, which must be trimmed.
|
||||
if (line.StartsWith("--- ", StringComparison.Ordinal))
|
||||
{
|
||||
var oldPath = UnquotePath(line[4..].TrimEnd('\t'));
|
||||
if (oldPath != "/dev/null" && current.Status == DiffFileStatus.Deleted)
|
||||
current.Path = StripPrefix(oldPath, "a/");
|
||||
continue;
|
||||
}
|
||||
if (line.StartsWith("+++ ", StringComparison.Ordinal))
|
||||
{
|
||||
var newPath = UnquotePath(line[4..].TrimEnd('\t'));
|
||||
if (newPath != "/dev/null" && current.Status != DiffFileStatus.Renamed)
|
||||
current.Path = StripPrefix(newPath, "b/");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip remaining diff metadata lines
|
||||
if (line.StartsWith("--- ", StringComparison.Ordinal) ||
|
||||
line.StartsWith("+++ ", StringComparison.Ordinal) ||
|
||||
line.StartsWith("index ", StringComparison.Ordinal) ||
|
||||
if (line.StartsWith("index ", StringComparison.Ordinal) ||
|
||||
line.StartsWith("old mode", StringComparison.Ordinal) ||
|
||||
line.StartsWith("new mode", StringComparison.Ordinal) ||
|
||||
line.StartsWith("similarity index", StringComparison.Ordinal) ||
|
||||
@@ -140,4 +162,111 @@ public static class UnifiedDiffParser
|
||||
newStart = n;
|
||||
}
|
||||
}
|
||||
|
||||
/// Splits a "diff --git" header's remainder ("a/<old> b/<new>", where
|
||||
/// either side may be individually git-quoted) into its two paths.
|
||||
private static (string OldPath, string NewPath) SplitGitHeaderPaths(string content)
|
||||
{
|
||||
string oldToken, newToken;
|
||||
|
||||
if (content.Length > 0 && content[0] == '"')
|
||||
{
|
||||
var close = FindClosingQuote(content, 0);
|
||||
oldToken = content[..(close + 1)];
|
||||
newToken = content[(close + 1)..].TrimStart(' ');
|
||||
}
|
||||
else
|
||||
{
|
||||
var quoteAt = content.IndexOf('"');
|
||||
if (quoteAt >= 0)
|
||||
{
|
||||
// Mixed quoting: an unquoted path never contains a literal quote
|
||||
// (git would have quoted it), so the quote marks the other side.
|
||||
oldToken = content[..quoteAt].TrimEnd(' ');
|
||||
newToken = content[quoteAt..];
|
||||
}
|
||||
else
|
||||
{
|
||||
var splitAt = content.IndexOf(" b/", StringComparison.Ordinal);
|
||||
oldToken = splitAt < 0 ? content : content[..splitAt];
|
||||
newToken = splitAt < 0 ? content : content[(splitAt + 1)..];
|
||||
}
|
||||
}
|
||||
|
||||
return (StripPrefix(UnquotePath(oldToken), "a/"), StripPrefix(UnquotePath(newToken), "b/"));
|
||||
}
|
||||
|
||||
private static int FindClosingQuote(string s, int start)
|
||||
{
|
||||
var i = start + 1;
|
||||
while (i < s.Length)
|
||||
{
|
||||
if (s[i] == '\\') { i += 2; continue; }
|
||||
if (s[i] == '"') return i;
|
||||
i++;
|
||||
}
|
||||
return s.Length - 1;
|
||||
}
|
||||
|
||||
private static string StripPrefix(string path, string prefix) =>
|
||||
path.StartsWith(prefix, StringComparison.Ordinal) ? path[prefix.Length..] : path;
|
||||
|
||||
/// Reverses git's C-style path quoting: a quoted token is wrapped in double
|
||||
/// quotes with '\\', '"' and non-ASCII bytes escaped as \nnn octal sequences
|
||||
/// (used whenever core.quotePath would otherwise consider the path "unusual").
|
||||
private static string UnquotePath(string token)
|
||||
{
|
||||
if (token.Length < 2 || token[0] != '"' || token[^1] != '"')
|
||||
return token;
|
||||
|
||||
var bytes = new List<byte>();
|
||||
var end = token.Length - 1;
|
||||
var i = 1;
|
||||
while (i < end)
|
||||
{
|
||||
var c = token[i];
|
||||
if (c != '\\')
|
||||
{
|
||||
bytes.Add((byte)c);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
i++;
|
||||
if (i >= end) break;
|
||||
switch (token[i])
|
||||
{
|
||||
case '"': bytes.Add((byte)'"'); i++; break;
|
||||
case '\\': bytes.Add((byte)'\\'); i++; break;
|
||||
case 'a': bytes.Add(0x07); i++; break;
|
||||
case 'b': bytes.Add(0x08); i++; break;
|
||||
case 'f': bytes.Add(0x0C); i++; break;
|
||||
case 'n': bytes.Add((byte)'\n'); i++; break;
|
||||
case 'r': bytes.Add((byte)'\r'); i++; break;
|
||||
case 't': bytes.Add((byte)'\t'); i++; break;
|
||||
case 'v': bytes.Add(0x0B); i++; break;
|
||||
default:
|
||||
if (token[i] is >= '0' and <= '7')
|
||||
{
|
||||
var value = 0;
|
||||
var digits = 0;
|
||||
while (digits < 3 && i < end && token[i] is >= '0' and <= '7')
|
||||
{
|
||||
value = value * 8 + (token[i] - '0');
|
||||
i++;
|
||||
digits++;
|
||||
}
|
||||
bytes.Add((byte)value);
|
||||
}
|
||||
else
|
||||
{
|
||||
bytes.Add((byte)token[i]);
|
||||
i++;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return Encoding.UTF8.GetString(bytes.ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
<TextBlock Grid.Column="0" Classes="meta" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource BloodBrush}"
|
||||
Text="{Binding ContinueHint}"
|
||||
IsVisible="{Binding HasBinaryFiles}"/>
|
||||
IsVisible="{Binding ContinueHint, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="btn accent" Content="{loc:Tr conflictResolver.continue}"
|
||||
Command="{Binding ContinueCommand}" IsEnabled="{Binding CanContinue}"/>
|
||||
|
||||
@@ -81,5 +81,20 @@
|
||||
IsVisible="{Binding SelectedAgent.Path, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Session skills (additive union with list/global; no inheritance badge) -->
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Classes="field-label" Text="{loc:Tr settings.agentEditor.sessionSkills}"/>
|
||||
<TextBlock Classes="meta" Opacity="0.6" TextWrapping="Wrap"
|
||||
Text="{loc:Tr settings.agentEditor.sessionSkillsHint}"/>
|
||||
<ItemsControl ItemsSource="{Binding SessionSkills}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SelectableSkillViewModel">
|
||||
<CheckBox IsChecked="{Binding IsSelected, Mode=TwoWay}" Content="{Binding Name}"
|
||||
ToolTip.Tip="{Binding Description}"/>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels"
|
||||
xmlns:term="using:Iciclecreek.Terminal"
|
||||
x:Class="ClaudeDo.Ui.Views.InteractiveTerminalView"
|
||||
x:DataType="vm:InteractiveTerminalViewModel"
|
||||
x:Name="Root">
|
||||
<!--
|
||||
Process="" suppresses TerminalView.OnLoaded's built-in auto-launch (it otherwise spawns its
|
||||
own cmd.exe/bash the moment this control loads). PtyTerminalSession sets Process/Args/
|
||||
StartingDirectory to our real target and calls TerminalControl.LaunchProcess() exactly once,
|
||||
once the view model's descriptor is known (see AttachControl/Start).
|
||||
-->
|
||||
<!--
|
||||
Font + BufferSize match the working spike. The control derives Cols/Rows from
|
||||
(arranged size / character cell size), so an explicit monospace font is what keeps that
|
||||
calculation correct; without it the terminal miscomputes its size and the child TUI renders
|
||||
into the wrong area. Stretch so the pane's full size reaches the control's Arrange pass.
|
||||
-->
|
||||
<term:TerminalControl x:Name="TerminalHost" Process=""
|
||||
FontFamily="Cascadia Mono,Consolas,monospace"
|
||||
FontSize="14"
|
||||
BufferSize="2000"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch" />
|
||||
</UserControl>
|
||||
@@ -0,0 +1,28 @@
|
||||
using System;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using ClaudeDo.Ui.ViewModels;
|
||||
|
||||
namespace ClaudeDo.Ui.Views;
|
||||
|
||||
public partial class InteractiveTerminalView : UserControl
|
||||
{
|
||||
public InteractiveTerminalView()
|
||||
{
|
||||
InitializeComponent();
|
||||
TerminalHost.Loaded += OnTerminalHostLoaded;
|
||||
DataContextChanged += OnDataContextChanged;
|
||||
}
|
||||
|
||||
private void OnDataContextChanged(object? sender, EventArgs e)
|
||||
{
|
||||
if (DataContext is InteractiveTerminalViewModel vm && TerminalHost.IsLoaded)
|
||||
vm.AttachControl(TerminalHost);
|
||||
}
|
||||
|
||||
private void OnTerminalHostLoaded(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is InteractiveTerminalViewModel vm)
|
||||
vm.AttachControl(TerminalHost);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
x:Class="ClaudeDo.Ui.Views.Islands.Detail.TaskHeaderBar"
|
||||
x:DataType="vm:DetailsIslandViewModel">
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto,Auto">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto,Auto">
|
||||
|
||||
<!-- Column 0: id badge + editable title -->
|
||||
<StackPanel Grid.Column="0" Spacing="0">
|
||||
@@ -27,8 +27,18 @@
|
||||
Padding="0"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Column 1: trash button (not running) -->
|
||||
<!-- Column 1: pick up in terminal (review / failed) -->
|
||||
<Button Grid.Column="1" Classes="icon-btn"
|
||||
Command="{Binding PickUpInTerminalCommand}"
|
||||
ToolTip.Tip="{loc:Tr details.pickUpInTerminalTip}"
|
||||
IsVisible="{Binding CanPickUpInTerminal}"
|
||||
VerticalAlignment="Top"
|
||||
Margin="6,0,0,0">
|
||||
<PathIcon Data="{StaticResource Icon.ArrowOut}" Width="14" Height="14"/>
|
||||
</Button>
|
||||
|
||||
<!-- Column 2: trash button (not running) -->
|
||||
<Button Grid.Column="2" Classes="icon-btn"
|
||||
Command="{Binding DeleteTaskCommand}"
|
||||
ToolTip.Tip="{loc:Tr details.deleteTaskTip}"
|
||||
IsVisible="{Binding !IsRunning}"
|
||||
@@ -38,8 +48,8 @@
|
||||
Foreground="{DynamicResource BloodBrush}"/>
|
||||
</Button>
|
||||
|
||||
<!-- Column 1: skull button (running) -->
|
||||
<Button Grid.Column="1" Classes="icon-btn"
|
||||
<!-- Column 2: skull button (running) -->
|
||||
<Button Grid.Column="2" Classes="icon-btn"
|
||||
Command="{Binding StopCommand}"
|
||||
ToolTip.Tip="{loc:Tr details.killSessionTip}"
|
||||
IsVisible="{Binding IsRunning}"
|
||||
@@ -49,13 +59,13 @@
|
||||
Foreground="{DynamicResource BloodBrush}"/>
|
||||
</Button>
|
||||
|
||||
<!-- Column 2: gear button with agent settings flyout -->
|
||||
<Button Grid.Column="2" Classes="icon-btn"
|
||||
<!-- Column 3: gear button with agent settings flyout -->
|
||||
<Button Grid.Column="3" Classes="icon-btn"
|
||||
ToolTip.Tip="{loc:Tr details.agentSettingsTip}"
|
||||
IsEnabled="{Binding AgentSettings.IsEnabled}"
|
||||
VerticalAlignment="Top"
|
||||
Margin="6,0,0,0">
|
||||
<TextBlock Text="⚙" FontSize="{StaticResource FontSizeTaskTitle}"/>
|
||||
<PathIcon Data="{StaticResource Icon.Settings}" Width="15" Height="15"/>
|
||||
<Button.Flyout>
|
||||
<Flyout Placement="BottomEdgeAlignedRight" ShowMode="Standard">
|
||||
<StackPanel Width="340" Spacing="10" Margin="4">
|
||||
|
||||
@@ -263,83 +263,6 @@
|
||||
Command="{Binding RejectReviewCommand}" />
|
||||
</Grid>
|
||||
|
||||
<!-- Interactive composer + queued strip — chat with a live in-app session -->
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Vertical">
|
||||
<!-- Queued messages strip -->
|
||||
<Border IsVisible="{Binding Monitor.HasQueuedMessages}"
|
||||
BorderBrush="{DynamicResource LineBrush}"
|
||||
BorderThickness="0,1,0,0"
|
||||
Padding="12,4">
|
||||
<StackPanel Spacing="2">
|
||||
<TextBlock Classes="meta"
|
||||
Text="{loc:Tr session.composer.queued}"
|
||||
Foreground="{DynamicResource TextMuteBrush}" />
|
||||
<ItemsControl ItemsSource="{Binding Monitor.QueuedMessages}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:QueuedMessageViewModel">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" Margin="0,1">
|
||||
<TextBlock Grid.Column="0"
|
||||
Text="⧗"
|
||||
Foreground="{DynamicResource TextMuteBrush}"
|
||||
FontFamily="{StaticResource MonoFont}"
|
||||
FontSize="{StaticResource FontSizeMono}"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,6,0" />
|
||||
<TextBlock Grid.Column="1"
|
||||
Text="{Binding Text}"
|
||||
Foreground="{DynamicResource TextDimBrush}"
|
||||
FontFamily="{StaticResource MonoFont}"
|
||||
FontSize="{StaticResource FontSizeMono}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
VerticalAlignment="Center" />
|
||||
<Button Grid.Column="2"
|
||||
Classes="title-ctrl"
|
||||
Command="{Binding RemoveCommand}"
|
||||
ToolTip.Tip="{loc:Tr session.composer.unqueue}"
|
||||
Margin="4,0,0,0">
|
||||
<PathIcon Data="{StaticResource Icon.WinClose}" Width="8" Height="8"/>
|
||||
</Button>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<!-- Composer input row -->
|
||||
<Grid IsVisible="{Binding Monitor.IsInteractiveLive}"
|
||||
ColumnDefinitions="Auto,*,Auto,Auto"
|
||||
Margin="12,2,12,8">
|
||||
<TextBlock Grid.Column="0" Text="❯"
|
||||
FontFamily="{StaticResource MonoFont}"
|
||||
FontSize="{StaticResource FontSizeMono}"
|
||||
Foreground="{DynamicResource AccentBrush}"
|
||||
VerticalAlignment="Center" Margin="0,0,8,0" />
|
||||
<TextBox Grid.Column="1"
|
||||
Classes="review-prompt"
|
||||
Text="{Binding Monitor.ComposerDraft, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
|
||||
AcceptsReturn="False"
|
||||
TextWrapping="Wrap"
|
||||
MaxHeight="160"
|
||||
PlaceholderText="{loc:Tr session.composer.placeholder}"
|
||||
VerticalContentAlignment="Center"
|
||||
FontFamily="{StaticResource MonoFont}"
|
||||
FontSize="{StaticResource FontSizeMono}">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding Monitor.SubmitComposerCommand}" />
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
<Button Grid.Column="2" Classes="prompt-action"
|
||||
VerticalAlignment="Center" Margin="12,0,0,0"
|
||||
Command="{Binding Monitor.InterruptInteractiveCommand}"
|
||||
ToolTip.Tip="{loc:Tr session.composer.interrupt}">
|
||||
<PathIcon Data="{StaticResource Icon.Stop}" Width="10" Height="10"/>
|
||||
</Button>
|
||||
<Button Grid.Column="3" Classes="prompt-action accent" Content="[Send]"
|
||||
VerticalAlignment="Center" Margin="4,0,0,0"
|
||||
Command="{Binding Monitor.SubmitComposerCommand}" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
<ScrollViewer Name="LogScroll"
|
||||
VerticalScrollBarVisibility="Visible"
|
||||
AllowAutoHide="False"
|
||||
@@ -425,6 +348,16 @@
|
||||
<Border Height="1" Background="{DynamicResource LineBrush}"
|
||||
IsVisible="{Binding Merge.ShowMergeSection}" />
|
||||
|
||||
<!-- Gate: you must open the diff before Approve & Merge unlocks. -->
|
||||
<StackPanel Orientation="Horizontal" Spacing="6"
|
||||
IsVisible="{Binding ShowReviewDiffHint}">
|
||||
<PathIcon Data="{StaticResource Icon.ArrowOut}" Width="11" Height="11"
|
||||
Foreground="{DynamicResource AmberBrush}" VerticalAlignment="Center" />
|
||||
<TextBlock Classes="meta" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource AmberBrush}"
|
||||
Text="Open the diff to enable merge" />
|
||||
</StackPanel>
|
||||
|
||||
<WrapPanel Orientation="Horizontal">
|
||||
<Button Classes="btn accent" Content="Approve & Merge" Margin="0,0,8,8"
|
||||
Command="{Binding ApproveReviewCommand}" />
|
||||
@@ -439,6 +372,17 @@
|
||||
ToolTip.Tip="{loc:Tr session.reviewResetTip}"
|
||||
Command="{Binding ResetReviewCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Submit an interactive (ConPTY) session for review: an Idle/Failed task that
|
||||
still has a worktree. Commits the worktree, then moves it to WaitingForReview. -->
|
||||
<StackPanel Spacing="8" IsVisible="{Binding CanSubmitForReview}">
|
||||
<Border Height="1" Background="{DynamicResource LineBrush}" />
|
||||
<TextBlock Classes="meta" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextMuteBrush}"
|
||||
Text="Worked on this by hand? Submit the worktree for review to merge it." />
|
||||
<Button Classes="btn accent" Content="Submit for review" HorizontalAlignment="Left"
|
||||
Command="{Binding SubmitForReviewCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
|
||||
@@ -80,6 +80,37 @@
|
||||
Foreground="{DynamicResource TextDimBrush}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Question prompt (AskUser): answer the running task's question inline, mirroring
|
||||
the Mission Control banner. State is shared via the same TaskMonitorViewModel. -->
|
||||
<Border DockPanel.Dock="Top"
|
||||
IsVisible="{Binding Monitor.HasPendingQuestion}"
|
||||
Margin="0,0,0,10" Padding="12,8"
|
||||
Background="{DynamicResource AccentSoftBrush}"
|
||||
BorderBrush="{DynamicResource AccentBrush}"
|
||||
BorderThickness="1" CornerRadius="10">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="meta"
|
||||
Text="{loc:Tr missionControl.question.title}"
|
||||
Foreground="{DynamicResource AccentBrush}" FontWeight="SemiBold"/>
|
||||
<TextBlock Text="{Binding Monitor.PendingQuestion}" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextBrush}"/>
|
||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="6">
|
||||
<TextBox Grid.Column="0"
|
||||
Text="{Binding Monitor.AnswerDraft, UpdateSourceTrigger=PropertyChanged}"
|
||||
PlaceholderText="{loc:Tr missionControl.question.placeholder}"
|
||||
AcceptsReturn="False">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding Monitor.SubmitAnswerCommand}"/>
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
<Button Grid.Column="1"
|
||||
Content="{loc:Tr missionControl.question.send}"
|
||||
Command="{Binding Monitor.SubmitAnswerCommand}"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<detail:WorkConsole/>
|
||||
</DockPanel>
|
||||
|
||||
|
||||
@@ -81,14 +81,28 @@ public partial class DetailsIslandView : UserControl
|
||||
if (items is null) return;
|
||||
|
||||
var files = new List<(string FileName, System.IO.Stream Content)>();
|
||||
foreach (var item in items)
|
||||
try
|
||||
{
|
||||
if (item is IStorageFile sf)
|
||||
foreach (var item in items)
|
||||
{
|
||||
var stream = await sf.OpenReadAsync();
|
||||
files.Add((sf.Name, stream));
|
||||
if (item is IStorageFile sf)
|
||||
{
|
||||
var stream = await sf.OpenReadAsync();
|
||||
files.Add((sf.Name, stream));
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Opening the dropped stream can fail (shell IO, file lock) before AddFilesAsync
|
||||
// ever runs; surface it with the exception type so the next occurrence is analyzable
|
||||
// instead of vanishing as an unobserved async-void fault.
|
||||
_vm.DropStatus = string.Format(
|
||||
ClaudeDo.Ui.Localization.Loc.T("details.attachments.dropReadError"),
|
||||
$"{ex.GetType().Name}: {ex.Message}");
|
||||
foreach (var (_, s) in files) await s.DisposeAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
if (files.Count == 0) return;
|
||||
|
||||
|
||||
@@ -137,6 +137,11 @@
|
||||
IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
|
||||
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenInTerminalCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
<Separator IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
<MenuItem Header="{loc:Tr lists.contextLetClaude}"
|
||||
IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
|
||||
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).LetClaudeHandleListCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
</ContextMenu>
|
||||
</Border.ContextMenu>
|
||||
<Grid ColumnDefinitions="20,*,Auto">
|
||||
@@ -176,8 +181,8 @@
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!-- New list + import row -->
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="0,4,0,0">
|
||||
<!-- New list + import + merge-helper row -->
|
||||
<Grid ColumnDefinitions="*,Auto,Auto" Margin="0,4,0,0">
|
||||
<Button Grid.Column="0" Classes="new-list-btn"
|
||||
Command="{Binding CreateListCommand}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
@@ -198,6 +203,13 @@
|
||||
Width="14" Height="14"
|
||||
Foreground="{DynamicResource TextMuteBrush}"/>
|
||||
</Button>
|
||||
<Button Grid.Column="2" Classes="icon-btn" Margin="6,0,0,0"
|
||||
Command="{Binding LetClaudeHandleAllCommand}"
|
||||
ToolTip.Tip="{loc:Tr lists.letClaudeAllTip}">
|
||||
<PathIcon Data="{StaticResource Icon.Broom}"
|
||||
Width="14" Height="14"
|
||||
Foreground="{DynamicResource TextMuteBrush}"/>
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
@@ -50,79 +50,6 @@
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
<!-- ── Queued strip + Composer bar ── -->
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Vertical">
|
||||
<!-- Queued messages strip -->
|
||||
<Border IsVisible="{Binding #Root.HasQueuedMessages}"
|
||||
Background="{DynamicResource Surface2Brush}"
|
||||
BorderBrush="{DynamicResource LineBrush}"
|
||||
BorderThickness="0,0,0,1"
|
||||
Padding="8,4">
|
||||
<StackPanel Spacing="2">
|
||||
<TextBlock Classes="meta"
|
||||
Text="{loc:Tr session.composer.queued}"
|
||||
Foreground="{DynamicResource TextMuteBrush}" />
|
||||
<ItemsControl ItemsSource="{Binding #Root.QueuedMessages}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:QueuedMessageViewModel">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" Margin="0,1">
|
||||
<TextBlock Grid.Column="0"
|
||||
Text="⧗"
|
||||
Foreground="{DynamicResource TextMuteBrush}"
|
||||
FontFamily="{StaticResource MonoFont}"
|
||||
FontSize="{StaticResource FontSizeMono}"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,6,0" />
|
||||
<TextBlock Grid.Column="1"
|
||||
Text="{Binding Text}"
|
||||
Foreground="{DynamicResource TextDimBrush}"
|
||||
FontFamily="{StaticResource MonoFont}"
|
||||
FontSize="{StaticResource FontSizeMono}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
VerticalAlignment="Center" />
|
||||
<Button Grid.Column="2"
|
||||
Classes="title-ctrl"
|
||||
Command="{Binding RemoveCommand}"
|
||||
ToolTip.Tip="{loc:Tr session.composer.unqueue}"
|
||||
Margin="4,0,0,0">
|
||||
<PathIcon Data="{StaticResource Icon.WinClose}" Width="8" Height="8"/>
|
||||
</Button>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<!-- Composer input row -->
|
||||
<Border IsVisible="{Binding #Root.IsComposerVisible}"
|
||||
Background="{DynamicResource Surface2Brush}"
|
||||
BorderBrush="{DynamicResource LineBrush}"
|
||||
BorderThickness="0,1,0,0"
|
||||
Padding="6,5">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto">
|
||||
<TextBox Grid.Column="0"
|
||||
Text="{Binding #Root.ComposerText, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
|
||||
PlaceholderText="{Binding #Root.ComposerPlaceholder}"
|
||||
AcceptsReturn="False">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding #Root.SubmitCommand}"/>
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
<Button Grid.Column="1"
|
||||
Margin="6,0,0,0"
|
||||
Classes="title-ctrl"
|
||||
Command="{Binding #Root.InterruptCommand}"
|
||||
ToolTip.Tip="{loc:Tr session.composer.interrupt}">
|
||||
<PathIcon Data="{StaticResource Icon.Stop}" Width="10" Height="10"/>
|
||||
</Button>
|
||||
<Button Grid.Column="2"
|
||||
Margin="6,0,0,0"
|
||||
Content="{loc:Tr session.composer.send}"
|
||||
Command="{Binding #Root.SubmitCommand}"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
<!-- ── Log output ── -->
|
||||
<ScrollViewer Name="LogScroll"
|
||||
VerticalScrollBarVisibility="Visible"
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Specialized;
|
||||
using System.Windows.Input;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Data;
|
||||
|
||||
namespace ClaudeDo.Ui.Views.Islands;
|
||||
|
||||
@@ -19,33 +17,12 @@ public partial class SessionTerminalView : UserControl
|
||||
AvaloniaProperty.Register<SessionTerminalView, bool>(nameof(IsDone));
|
||||
public static readonly StyledProperty<bool> IsFailedProperty =
|
||||
AvaloniaProperty.Register<SessionTerminalView, bool>(nameof(IsFailed));
|
||||
public static readonly StyledProperty<bool> IsComposerVisibleProperty =
|
||||
AvaloniaProperty.Register<SessionTerminalView, bool>(nameof(IsComposerVisible), defaultValue: false);
|
||||
public static readonly StyledProperty<string?> ComposerTextProperty =
|
||||
AvaloniaProperty.Register<SessionTerminalView, string?>(nameof(ComposerText), defaultBindingMode: BindingMode.TwoWay);
|
||||
public static readonly StyledProperty<ICommand?> SubmitCommandProperty =
|
||||
AvaloniaProperty.Register<SessionTerminalView, ICommand?>(nameof(SubmitCommand));
|
||||
public static readonly StyledProperty<ICommand?> InterruptCommandProperty =
|
||||
AvaloniaProperty.Register<SessionTerminalView, ICommand?>(nameof(InterruptCommand));
|
||||
public static readonly StyledProperty<string?> ComposerPlaceholderProperty =
|
||||
AvaloniaProperty.Register<SessionTerminalView, string?>(nameof(ComposerPlaceholder));
|
||||
public static readonly StyledProperty<System.Collections.IEnumerable?> QueuedMessagesProperty =
|
||||
AvaloniaProperty.Register<SessionTerminalView, System.Collections.IEnumerable?>(nameof(QueuedMessages));
|
||||
public static readonly StyledProperty<bool> HasQueuedMessagesProperty =
|
||||
AvaloniaProperty.Register<SessionTerminalView, bool>(nameof(HasQueuedMessages), defaultValue: false);
|
||||
|
||||
public IEnumerable? Entries { get => GetValue(EntriesProperty); set => SetValue(EntriesProperty, value); }
|
||||
public string? Label { get => GetValue(LabelProperty); set => SetValue(LabelProperty, value); }
|
||||
public bool IsRunning { get => GetValue(IsRunningProperty); set => SetValue(IsRunningProperty, value); }
|
||||
public bool IsDone { get => GetValue(IsDoneProperty); set => SetValue(IsDoneProperty, value); }
|
||||
public bool IsFailed { get => GetValue(IsFailedProperty); set => SetValue(IsFailedProperty, value); }
|
||||
public bool IsComposerVisible { get => GetValue(IsComposerVisibleProperty); set => SetValue(IsComposerVisibleProperty, value); }
|
||||
public string? ComposerText { get => GetValue(ComposerTextProperty); set => SetValue(ComposerTextProperty, value); }
|
||||
public ICommand? SubmitCommand { get => GetValue(SubmitCommandProperty); set => SetValue(SubmitCommandProperty, value); }
|
||||
public ICommand? InterruptCommand { get => GetValue(InterruptCommandProperty); set => SetValue(InterruptCommandProperty, value); }
|
||||
public string? ComposerPlaceholder { get => GetValue(ComposerPlaceholderProperty); set => SetValue(ComposerPlaceholderProperty, value); }
|
||||
public System.Collections.IEnumerable? QueuedMessages { get => GetValue(QueuedMessagesProperty); set => SetValue(QueuedMessagesProperty, value); }
|
||||
public bool HasQueuedMessages { get => GetValue(HasQueuedMessagesProperty); set => SetValue(HasQueuedMessagesProperty, value); }
|
||||
|
||||
private INotifyCollectionChanged? _subscribedCollection;
|
||||
|
||||
|
||||
@@ -49,8 +49,11 @@
|
||||
<MenuItem Header="{loc:Tr tasks.ctxMarkCancelled}" Tag="Cancelled" Click="OnSetStatusClick"/>
|
||||
</MenuItem>
|
||||
<Separator/>
|
||||
<MenuItem Header="{loc:Tr tasks.ctxRunInteractively}"
|
||||
Click="OnRunInteractivelyClick"/>
|
||||
<MenuItem Header="{loc:Tr tasks.ctxOpenConPtySession}"
|
||||
Click="OnOpenConPtySessionClick"/>
|
||||
<MenuItem Header="{loc:Tr tasks.ctxPickUpInTerminal}"
|
||||
Click="OnPickUpInTerminalClick"
|
||||
IsVisible="{Binding CanPickUpInTerminal}"/>
|
||||
<MenuItem Header="{loc:Tr tasks.ctxOpenPlanningSession}"
|
||||
Click="OnOpenPlanningSessionClick"
|
||||
IsVisible="{Binding CanOpenPlanningSession}"/>
|
||||
@@ -153,6 +156,7 @@
|
||||
|
||||
<!-- Status chip -->
|
||||
<Border Classes="chip"
|
||||
IsVisible="{Binding ShowStatusChip}"
|
||||
Classes.parked="{Binding IsParked}"
|
||||
Classes.running="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Running}"
|
||||
Classes.review="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=WaitingForReview}"
|
||||
|
||||
@@ -55,16 +55,22 @@ public partial class TaskRowView : UserControl
|
||||
await vm.RemoveFromMyDayCommand.ExecuteAsync(row);
|
||||
}
|
||||
|
||||
private async void OnOpenPlanningSessionClick(object? sender, RoutedEventArgs e)
|
||||
private void OnOpenPlanningSessionClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
|
||||
await vm.OpenPlanningSessionCommand.ExecuteAsync(row);
|
||||
vm.OpenPlanningSessionCommand.Execute(row);
|
||||
}
|
||||
|
||||
private async void OnRunInteractivelyClick(object? sender, RoutedEventArgs e)
|
||||
private void OnOpenConPtySessionClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
|
||||
await vm.RunInteractivelyCommand.ExecuteAsync(row);
|
||||
vm.OpenConPtySessionCommand.Execute(row);
|
||||
}
|
||||
|
||||
private async void OnPickUpInTerminalClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
|
||||
await vm.PickUpInTerminalCommand.ExecuteAsync(row);
|
||||
}
|
||||
|
||||
private async void OnResumePlanningSessionClick(object? sender, RoutedEventArgs e)
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<KeyBinding Gesture="Shift+OemQuestion" Command="{Binding FocusSearchCommand}"/>
|
||||
<KeyBinding Gesture="Ctrl+N" Command="{Binding FocusAddTaskCommand}"/>
|
||||
</Window.KeyBindings>
|
||||
<Grid x:Name="RootGrid" RowDefinitions="36,Auto,*,22">
|
||||
<Grid x:Name="RootGrid" RowDefinitions="36,*,22">
|
||||
<!-- Custom title bar -->
|
||||
<Border Grid.Row="0"
|
||||
Background="{DynamicResource DeepBrush}"
|
||||
@@ -107,45 +107,8 @@
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Update banner -->
|
||||
<Border Grid.Row="1"
|
||||
Background="{DynamicResource DeepBrush}"
|
||||
BorderBrush="{DynamicResource LineBrush}"
|
||||
BorderThickness="0,0,0,1"
|
||||
Padding="14,6"
|
||||
IsVisible="{Binding IsUpdateBannerVisible}">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto">
|
||||
<TextBlock Grid.Column="0"
|
||||
Classes="body"
|
||||
VerticalAlignment="Center">
|
||||
<Run Text="{loc:Tr shell.update.available}"/>
|
||||
<Run Text="{Binding UpdateCheck.CurrentVersion}"/>
|
||||
<Run Text=" → v"/>
|
||||
<Run Text="{Binding UpdateBannerLatestVersion}"/>
|
||||
</TextBlock>
|
||||
<Button Grid.Column="1"
|
||||
Classes="btn"
|
||||
Margin="0,0,8,0"
|
||||
Content="{loc:Tr shell.update.updateNow}"
|
||||
Command="{Binding UpdateNowCommand}"/>
|
||||
<Button Grid.Column="2"
|
||||
Classes="btn"
|
||||
Content="{loc:Tr shell.update.dismiss}"
|
||||
Command="{Binding DismissBannerCommand}"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Inline update status (appears at right of banner row when no banner) -->
|
||||
<TextBlock Grid.Row="1"
|
||||
Classes="meta"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,14,0"
|
||||
Text="{Binding InlineUpdateStatus}"
|
||||
IsVisible="{Binding InlineUpdateStatus, Converter={x:Static ObjectConverters.IsNotNull}}"/>
|
||||
|
||||
<!-- Background gradient layer -->
|
||||
<Border Grid.Row="2">
|
||||
<Border Grid.Row="1">
|
||||
<Border.Background>
|
||||
<RadialGradientBrush Center="50%,50%" GradientOrigin="50%,50%" RadiusX="70%" RadiusY="70%">
|
||||
<GradientStop Offset="0" Color="{StaticResource DeepColor}" />
|
||||
@@ -155,7 +118,7 @@
|
||||
</Border>
|
||||
|
||||
<!-- Three islands (user-resizable) -->
|
||||
<Grid Grid.Row="2" Margin="7">
|
||||
<Grid Grid.Row="1" Margin="7">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="260" MinWidth="200"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
@@ -196,7 +159,7 @@
|
||||
</Grid>
|
||||
|
||||
<!-- Footer: connection status -->
|
||||
<Border Grid.Row="3"
|
||||
<Border Grid.Row="2"
|
||||
Background="{DynamicResource DeepBrush}"
|
||||
BorderBrush="{DynamicResource LineBrush}"
|
||||
BorderThickness="0,1,0,0">
|
||||
@@ -255,5 +218,35 @@
|
||||
<Panel/>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
<!-- Update popup card — bottom-right corner, only when update available -->
|
||||
<Border Grid.Row="0" Grid.RowSpan="3"
|
||||
VerticalAlignment="Bottom" HorizontalAlignment="Right"
|
||||
Margin="0,0,16,30"
|
||||
ZIndex="100"
|
||||
IsVisible="{Binding IsUpdateBannerVisible}"
|
||||
Background="{DynamicResource DeepBrush}"
|
||||
BorderBrush="{DynamicResource LineBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="6"
|
||||
Padding="14,10">
|
||||
<StackPanel Spacing="8" Width="260">
|
||||
<TextBlock Classes="body">
|
||||
<Run Text="{loc:Tr shell.update.available}"/>
|
||||
<Run Text=" v"/>
|
||||
<Run Text="{Binding UpdateCheck.CurrentVersion}"/>
|
||||
<Run Text=" → v"/>
|
||||
<Run Text="{Binding UpdateBannerLatestVersion}"/>
|
||||
</TextBlock>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right">
|
||||
<Button Classes="btn"
|
||||
Content="{loc:Tr shell.update.dismiss}"
|
||||
Command="{Binding DismissBannerCommand}"/>
|
||||
<Button Classes="btn"
|
||||
Content="{loc:Tr shell.update.updateNow}"
|
||||
Command="{Binding UpdateNowCommand}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</Grid>
|
||||
</Window>
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels.MissionControl"
|
||||
xmlns:views="using:ClaudeDo.Ui.Views"
|
||||
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
||||
xmlns:conv="using:Avalonia.Data.Converters"
|
||||
x:DataType="vm:ConPtyPaneViewModel"
|
||||
x:Class="ClaudeDo.Ui.Views.MissionControl.ConPtyPaneView">
|
||||
<Border Classes="monitor-pane" BorderThickness="1" CornerRadius="10" ClipToBounds="True">
|
||||
<DockPanel LastChildFill="True">
|
||||
|
||||
<!-- Header: title + close -->
|
||||
<Border DockPanel.Dock="Top"
|
||||
Background="{DynamicResource Surface2Brush}"
|
||||
BorderBrush="{DynamicResource LineBrush}"
|
||||
BorderThickness="0,0,0,1" Padding="8,3">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="meta" Text="{Binding DisplayTitle}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
ToolTip.Tip="{Binding DisplayTitle}"
|
||||
Foreground="{DynamicResource TextDimBrush}"
|
||||
VerticalAlignment="Center" Margin="4,0,0,0" />
|
||||
<Button Grid.Column="1" Classes="btn" Margin="0,0,6,0"
|
||||
IsVisible="{Binding IsTaskBased}"
|
||||
Content="{loc:Tr missionControl.submitForReview}"
|
||||
ToolTip.Tip="{loc:Tr missionControl.submitForReviewTip}"
|
||||
Command="{Binding SubmitForReviewCommand}" />
|
||||
<Button Grid.Column="2" Classes="title-ctrl"
|
||||
Command="{Binding CloseCommand}"
|
||||
ToolTip.Tip="{loc:Tr missionControl.closeSession}">
|
||||
<PathIcon Data="{StaticResource Icon.WinClose}" Width="12" Height="12"/>
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Start-failure banner -->
|
||||
<Border DockPanel.Dock="Top"
|
||||
IsVisible="{Binding Terminal.StartError, Converter={x:Static conv:ObjectConverters.IsNotNull}}"
|
||||
Background="{DynamicResource ErrorTintBrush}"
|
||||
BorderBrush="{DynamicResource BloodBrush}"
|
||||
BorderThickness="0,0,0,1" Padding="12,6">
|
||||
<TextBlock Classes="meta" Text="{Binding Terminal.StartError}"
|
||||
Foreground="{DynamicResource BloodBrush}"
|
||||
TextWrapping="Wrap" />
|
||||
</Border>
|
||||
|
||||
<!-- Embedded ConPTY terminal -->
|
||||
<views:InteractiveTerminalView DataContext="{Binding Terminal}" />
|
||||
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,8 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace ClaudeDo.Ui.Views.MissionControl;
|
||||
|
||||
public partial class ConPtyPaneView : UserControl
|
||||
{
|
||||
public ConPtyPaneView() => InitializeComponent();
|
||||
}
|
||||
@@ -2,10 +2,21 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels"
|
||||
xmlns:vmi="using:ClaudeDo.Ui.ViewModels.Islands"
|
||||
xmlns:vmm="using:ClaudeDo.Ui.ViewModels.MissionControl"
|
||||
xmlns:mc="using:ClaudeDo.Ui.Views.MissionControl"
|
||||
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
||||
x:DataType="vm:MissionControlViewModel"
|
||||
x:Class="ClaudeDo.Ui.Views.MissionControl.MissionControlView">
|
||||
<UserControl.DataTemplates>
|
||||
<!-- Polymorphic pane templates: both the grid ItemsControl and the focus-mode TabControl
|
||||
resolve per-item content through these (no explicit ItemTemplate on either). -->
|
||||
<DataTemplate DataType="vmi:TaskMonitorViewModel">
|
||||
<mc:MonitorPaneView Margin="6" />
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="vmm:ConPtyPaneViewModel">
|
||||
<mc:ConPtyPaneView Margin="6" />
|
||||
</DataTemplate>
|
||||
</UserControl.DataTemplates>
|
||||
<DockPanel LastChildFill="True" Background="{DynamicResource VoidBrush}"
|
||||
DragDrop.AllowDrop="True">
|
||||
|
||||
@@ -21,6 +32,18 @@
|
||||
LetterSpacing="1.4" VerticalAlignment="Center" />
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8"
|
||||
VerticalAlignment="Center">
|
||||
<Button Classes="icon-btn"
|
||||
Click="OnNewSessionClicked"
|
||||
ToolTip.Tip="{loc:Tr missionControl.newSession}">
|
||||
<PathIcon Data="{StaticResource Icon.Plus}" Width="15" Height="15"
|
||||
Foreground="{DynamicResource TextMuteBrush}"/>
|
||||
</Button>
|
||||
<Button Classes="icon-btn"
|
||||
Command="{Binding ToggleLayoutCommand}"
|
||||
ToolTip.Tip="{Binding LayoutToggleLabel}">
|
||||
<PathIcon Data="{StaticResource Icon.Grid}" Width="15" Height="15"
|
||||
Foreground="{DynamicResource TextMuteBrush}"/>
|
||||
</Button>
|
||||
<Button Classes="icon-btn"
|
||||
Command="{Binding OpenSettingsCommand}"
|
||||
ToolTip.Tip="{loc:Tr missionControl.settings}">
|
||||
@@ -72,22 +95,29 @@
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Grid / empty state -->
|
||||
<!-- Grid / tabs / empty state -->
|
||||
<Panel Margin="6">
|
||||
<ItemsControl ItemsSource="{Binding Monitors}" IsVisible="{Binding HasMonitors}">
|
||||
<!-- Overview mode: the existing UniformGrid, one tile per pane -->
|
||||
<ItemsControl ItemsSource="{Binding Panes}" IsVisible="{Binding !IsFocusMode}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<UniformGrid x:CompileBindings="False" Columns="{Binding ColumnCount}" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vmi:TaskMonitorViewModel">
|
||||
<mc:MonitorPaneView Margin="6" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<TextBlock IsVisible="{Binding !HasMonitors}"
|
||||
<!-- Focus mode: one pane large, switched via tabs -->
|
||||
<TabControl ItemsSource="{Binding Panes}"
|
||||
SelectedItem="{Binding FocusedPane}"
|
||||
IsVisible="{Binding IsFocusMode}">
|
||||
<TabControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vmm:IMissionControlPane">
|
||||
<TextBlock Text="{Binding DisplayTitle}" TextTrimming="CharacterEllipsis" MaxWidth="160" />
|
||||
</DataTemplate>
|
||||
</TabControl.ItemTemplate>
|
||||
</TabControl>
|
||||
|
||||
<TextBlock IsVisible="{Binding !HasPanes}"
|
||||
Text="{loc:Tr missionControl.empty}"
|
||||
Foreground="{DynamicResource TextMuteBrush}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center" />
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Linq;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Avalonia.VisualTree;
|
||||
using ClaudeDo.Ui.ViewModels;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
@@ -21,6 +22,23 @@ public partial class MissionControlView : UserControl
|
||||
AddHandler(DragDrop.DropEvent, OnPaneDrop);
|
||||
}
|
||||
|
||||
// Ad-hoc ConPTY session: the view owns the folder picker, the VM only takes the chosen path.
|
||||
private async void OnNewSessionClicked(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not MissionControlViewModel vm) return;
|
||||
var topLevel = TopLevel.GetTopLevel(this);
|
||||
if (topLevel is null) return;
|
||||
|
||||
var folders = await topLevel.StorageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions
|
||||
{
|
||||
Title = "Choose a directory",
|
||||
AllowMultiple = false,
|
||||
});
|
||||
if (folders.Count == 0) return;
|
||||
|
||||
await vm.OpenAdHocConPtySessionAsync(folders[0].Path.LocalPath);
|
||||
}
|
||||
|
||||
private void OnPaneDragOver(object? sender, DragEventArgs e)
|
||||
{
|
||||
var dt = e.DataTransfer;
|
||||
|
||||
@@ -107,14 +107,7 @@
|
||||
Label="{Binding DisplayTitle}"
|
||||
IsRunning="{Binding IsRunning}"
|
||||
IsDone="{Binding IsDone}"
|
||||
IsFailed="{Binding IsFailed}"
|
||||
IsComposerVisible="{Binding IsInteractiveLive}"
|
||||
ComposerText="{Binding ComposerDraft, Mode=TwoWay}"
|
||||
SubmitCommand="{Binding SubmitComposerCommand}"
|
||||
InterruptCommand="{Binding InterruptInteractiveCommand}"
|
||||
ComposerPlaceholder="{loc:Tr session.composer.placeholder}"
|
||||
QueuedMessages="{Binding QueuedMessages}"
|
||||
HasQueuedMessages="{Binding HasQueuedMessages}" />
|
||||
IsFailed="{Binding IsFailed}" />
|
||||
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
@@ -131,7 +131,7 @@
|
||||
FontFamily="{DynamicResource MonoFont}" FontSize="{StaticResource FontSizeEyebrow}"
|
||||
Foreground="{DynamicResource TextBrush}"/>
|
||||
</Border>
|
||||
<TextBlock Grid.Column="1" Classes="path-mono" Text="{Binding SelectedFile.Path}"
|
||||
<TextBlock Grid.Column="1" Classes="path-mono" Text="{Binding SelectedFile.HeaderPath}"
|
||||
VerticalAlignment="Center" TextTrimming="PrefixCharacterEllipsis"/>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
|
||||
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
|
||||
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
||||
x:Class="ClaudeDo.Ui.Views.Modals.MergeHelperSelectionModal"
|
||||
x:DataType="vm:MergeHelperSelectionModalViewModel"
|
||||
Title="{loc:Tr modals.mergeHelper.windowTitle}"
|
||||
Width="560" Height="480" MinWidth="420" MinHeight="320"
|
||||
CanResize="True"
|
||||
WindowDecorations="BorderOnly"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
ExtendClientAreaTitleBarHeightHint="-1"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Background="{DynamicResource SurfaceBrush}">
|
||||
<Window.KeyBindings>
|
||||
<KeyBinding Gesture="Escape" Command="{Binding CancelCommand}"/>
|
||||
</Window.KeyBindings>
|
||||
|
||||
<ctl:ModalShell Title="{loc:Tr modals.mergeHelper.title}" CloseCommand="{Binding CancelCommand}">
|
||||
<ctl:ModalShell.Footer>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" HorizontalAlignment="Right"
|
||||
VerticalAlignment="Center">
|
||||
<Button Classes="btn" Content="{loc:Tr modals.mergeHelper.cancel}" Command="{Binding CancelCommand}" MinWidth="90"/>
|
||||
<Button Content="{loc:Tr modals.mergeHelper.confirm}" Command="{Binding ConfirmCommand}"
|
||||
IsEnabled="{Binding CanConfirm}" MinWidth="140" Classes="primary"/>
|
||||
</StackPanel>
|
||||
</ctl:ModalShell.Footer>
|
||||
|
||||
<!-- Body: scope + select-all/none toolbar, headers, checkbox rows -->
|
||||
<DockPanel>
|
||||
<Grid DockPanel.Dock="Top" ColumnDefinitions="*,Auto,Auto" Margin="20,12,20,6">
|
||||
<TextBlock Grid.Column="0" Classes="meta" Text="{Binding ScopeLabel}" VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="1" Classes="btn" Content="{loc:Tr modals.mergeHelper.selectAll}"
|
||||
Command="{Binding SelectAllCommand}" IsEnabled="{Binding HasTasks}"/>
|
||||
<Button Grid.Column="2" Classes="btn" Content="{loc:Tr modals.mergeHelper.selectNone}"
|
||||
Command="{Binding SelectNoneCommand}" IsEnabled="{Binding HasTasks}" Margin="8,0,0,0"/>
|
||||
</Grid>
|
||||
|
||||
<!-- Column headers -->
|
||||
<Grid DockPanel.Dock="Top" ColumnDefinitions="32,*,120,120" Margin="20,0,20,4"
|
||||
IsVisible="{Binding HasTasks}">
|
||||
<TextBlock Grid.Column="1" Classes="eyebrow" Text="{loc:Tr modals.mergeHelper.columnTask}"/>
|
||||
<TextBlock Grid.Column="2" Classes="eyebrow" Text="{loc:Tr modals.mergeHelper.columnStatus}"/>
|
||||
<TextBlock Grid.Column="3" Classes="eyebrow" Text="{loc:Tr modals.mergeHelper.columnList}"
|
||||
IsVisible="{Binding IsGlobal}"/>
|
||||
</Grid>
|
||||
|
||||
<ScrollViewer Padding="20,2,20,8">
|
||||
<StackPanel>
|
||||
<TextBlock Classes="meta" Margin="0,8"
|
||||
Text="{loc:Tr modals.mergeHelper.empty}"
|
||||
IsVisible="{Binding !HasTasks}"/>
|
||||
<ItemsControl ItemsSource="{Binding Tasks}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="vm:MergeHelperTaskRowViewModel">
|
||||
<Grid ColumnDefinitions="32,*,120,120" Margin="0,1">
|
||||
<CheckBox Grid.Column="0" MinWidth="0"
|
||||
IsChecked="{Binding IsSelected, Mode=TwoWay}"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock Classes="body" Grid.Column="1" Text="{Binding Title}"
|
||||
VerticalAlignment="Center" Margin="4,0,0,0"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
<Border Grid.Column="2" Classes="chip"
|
||||
HorizontalAlignment="Left" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding StatusText}"/>
|
||||
</Border>
|
||||
<TextBlock Classes="meta" Grid.Column="3" Text="{Binding ListName}"
|
||||
VerticalAlignment="Center" Margin="8,0,0,0"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
IsVisible="{Binding $parent[Window].((vm:MergeHelperSelectionModalViewModel)DataContext).IsGlobal}"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</DockPanel>
|
||||
|
||||
</ctl:ModalShell>
|
||||
</Window>
|
||||
@@ -0,0 +1,11 @@
|
||||
using Avalonia.Controls;
|
||||
|
||||
namespace ClaudeDo.Ui.Views.Modals;
|
||||
|
||||
public partial class MergeHelperSelectionModal : Window
|
||||
{
|
||||
public MergeHelperSelectionModal()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
|
||||
xmlns:settings="using:ClaudeDo.Ui.ViewModels.Modals.Settings"
|
||||
xmlns:agent="using:ClaudeDo.Ui.ViewModels.Agent"
|
||||
xmlns:services="using:ClaudeDo.Ui.Services"
|
||||
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
|
||||
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
||||
xmlns:locm="using:ClaudeDo.Localization"
|
||||
@@ -108,6 +110,19 @@
|
||||
<ComboBoxItem Content="{loc:Tr settings.general.weekdaySaturday}"/>
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Classes="field-label" Text="{loc:Tr settings.general.sessionSkills}"/>
|
||||
<TextBlock Classes="meta" Opacity="0.6" TextWrapping="Wrap"
|
||||
Text="{loc:Tr settings.general.sessionSkillsHint}"/>
|
||||
<ItemsControl ItemsSource="{Binding General.SessionSkills}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="agent:SelectableSkillViewModel">
|
||||
<CheckBox IsChecked="{Binding IsSelected, Mode=TwoWay}" Content="{Binding Name}"
|
||||
ToolTip.Tip="{Binding Description}"/>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
@@ -354,6 +369,61 @@
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
<TabItem Header="{loc:Tr settings.tabSkills}">
|
||||
<ScrollViewer>
|
||||
<StackPanel Spacing="12" Margin="0,8,0,0">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="section-label" Text="{loc:Tr settings.skills.installSection}"/>
|
||||
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="8">
|
||||
<TextBox Grid.Column="0" Text="{Binding SessionSkills.InstallUrl, Mode=TwoWay}"
|
||||
PlaceholderText="{loc:Tr settings.skills.installUrlPlaceholder}"/>
|
||||
<Button Grid.Column="1" Classes="btn" Content="{loc:Tr settings.skills.installButton}"
|
||||
Command="{Binding SessionSkills.InstallCommand}"
|
||||
IsEnabled="{Binding !SessionSkills.IsBusy}"/>
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
|
||||
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="0,1,0,0" Margin="0,2,0,0"/>
|
||||
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="section-label" Text="{loc:Tr settings.skills.installedSection}"/>
|
||||
<ItemsControl ItemsSource="{Binding SessionSkills.Skills}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="services:SessionSkillDto">
|
||||
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="1"
|
||||
CornerRadius="6" Padding="10,8" Margin="0,0,0,8"
|
||||
Background="{DynamicResource DeepBrush}">
|
||||
<StackPanel Spacing="4">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="title" Text="{Binding Name}"/>
|
||||
<Button Grid.Column="1" Classes="btn" Content="{loc:Tr settings.skills.updateButton}"
|
||||
Margin="0,0,6,0"
|
||||
Command="{Binding $parent[ItemsControl].((vm:SettingsModalViewModel)DataContext).SessionSkills.UpdateCommand}"
|
||||
CommandParameter="{Binding SourceUrl}"/>
|
||||
<Button Grid.Column="2" Classes="btn danger" Content="{loc:Tr settings.skills.removeButton}"
|
||||
Command="{Binding $parent[ItemsControl].((vm:SettingsModalViewModel)DataContext).SessionSkills.RemoveCommand}"
|
||||
CommandParameter="{Binding SourceUrl}"/>
|
||||
</Grid>
|
||||
<TextBlock Classes="meta" Text="{Binding Description}" TextWrapping="Wrap"
|
||||
IsVisible="{Binding Description, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
<TextBlock Classes="meta" Opacity="0.6" Text="{Binding PinnedRef}"/>
|
||||
<TextBlock Classes="path-mono" Text="{Binding SourceUrl}" TextTrimming="PrefixCharacterEllipsis"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<TextBlock Classes="meta" Opacity="0.6" TextWrapping="Wrap"
|
||||
Text="{loc:Tr settings.skills.emptyState}"
|
||||
IsVisible="{Binding SessionSkills.IsEmpty}"/>
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Classes="meta" Text="{Binding SessionSkills.StatusMessage}"
|
||||
IsVisible="{Binding SessionSkills.StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
|
||||
</TabControl>
|
||||
</DockPanel>
|
||||
|
||||
|
||||
@@ -81,6 +81,15 @@ public sealed class WindowDialogService : IDialogService
|
||||
await dlg.ShowDialog(_owner);
|
||||
}
|
||||
|
||||
public async Task<System.Collections.Generic.IReadOnlyList<string>?> ShowMergeHelperSelectionAsync(MergeHelperSelectionModalViewModel vm)
|
||||
{
|
||||
var dlg = new MergeHelperSelectionModal { DataContext = vm };
|
||||
vm.CloseAction = () => dlg.Close();
|
||||
dlg.Closed += (_, _) => vm.Result.TrySetResult(null); // native close counts as cancel
|
||||
await dlg.ShowDialog(_owner);
|
||||
return await vm.Result.Task;
|
||||
}
|
||||
|
||||
public async Task ShowWorktreesOverviewAsync(WorktreesOverviewModalViewModel vm)
|
||||
{
|
||||
var dlg = new WorktreesOverviewModalView { DataContext = vm };
|
||||
|
||||
@@ -7,12 +7,12 @@ ASP.NET Core hosted service that executes tasks via Claude CLI in isolated envir
|
||||
```
|
||||
Worker/
|
||||
State/ — TaskStateService + TransitionResult (sole owner of Status/PlanningPhase/BlockedBy writes)
|
||||
Queue/ — IQueueWaker, IQueuePicker, QueueService (BackgroundService), OverrideSlotService
|
||||
Queue/ — IQueueWaker, IQueuePicker, QueueService (BackgroundService), OverrideSlotService, RunCancellationRegistry (taskId → running-run CTS; lets TaskStateService.CancelAsync kill the process of a cancelled task/child without a DI cycle)
|
||||
Lifecycle/ — StaleTaskRecovery, TaskResetService, TaskMergeService, ClaudeCliPreflight, OrphanRecovery, PlanningLineageRecovery, AttachmentOrphanRecovery (startup sweep: deletes any `attachments/<taskId>/` dirs whose task no longer exists)
|
||||
Worktrees/ — WorktreeMaintenanceService
|
||||
Agents/ — AgentFileService, DefaultAgentSeeder
|
||||
Runner/ — TaskRunner + Claude CLI integration; TaskRunMcpService/TaskRunMcpContext/TaskRunTokenRegistry (in-task MCP wired during execution)
|
||||
Planning/ — PlanningSessionManager, PlanningChainCoordinator, PlanningMcpService, PlanningMergeOrchestrator, PlanningAggregator, PlanningSessionContext/PlanningTokenAuth/PlanningMcpContextAccessor, WindowsTerminalLauncher (ITerminalLauncher) — wt launcher for planning + interactive sessions
|
||||
Planning/ — PlanningSessionManager, PlanningChainCoordinator, PlanningMcpService, PlanningMergeOrchestrator, PlanningAggregator, PlanningSessionContext/PlanningTokenAuth/PlanningMcpContextAccessor, WindowsTerminalLauncher (ITerminalLauncher) — wt launcher for planning sessions + pick-up-in-terminal
|
||||
Refine/ — RefineRunner + RefinePrompt (hub `RefineTask`; broadcasts RefineStarted/RefineFinished)
|
||||
External/ — ExternalMcpService + sibling tool classes
|
||||
Config/ — WorkerConfig
|
||||
@@ -20,7 +20,7 @@ Worker/
|
||||
Logging/ — LogRingBuffer (30-min in-memory log window) + BroadcastLogSink (Serilog sink → footer + overlay)
|
||||
Report/ — ClaudeHistoryReader, WeekReportPromptBuilder, WeekReportService; interfaces in Report/Interfaces/
|
||||
Prime/ — daily-prep ("Prime Claude"): PrimeScheduler (BackgroundService), PrimeRunner (runs the daily prep), DailyPrepPrompt (fixed prompt + CLI args + LogPath() helper), NextDueCalculator, PrimeScheduleSignal; interfaces in Prime/Interfaces/ (IPrimeRunner, IPrimeClock, IPrimeScheduleSignal, IPrimeBroadcaster)
|
||||
Online/ — optional Online Inbox sync: OnlineInboxConfig (config record), Dtos (RemoteList/RemoteTask/MirrorTask), IOnlineInboxApi, OnlineInboxApiClient (typed HttpClient, bearer auth, HTTPS guard), OnlineTokenStore (DPAPI refresh-token store, Windows-only), StaticTokenAuthProvider (default/test IOnlineAuthProvider), ZitadelAuthProvider (stub — TODO(online-inbox) Phase 2), OnlineSyncService (BackgroundService: reconcile loop), OnlineBacklog (Idle-backlog filter/query); interface in Online/Interfaces/ (IOnlineAuthProvider)
|
||||
Online/ — optional Online Inbox sync: OnlineInboxConfig (config record), Dtos (RemoteList/RemoteTask/MirrorTask), IOnlineInboxApi, OnlineInboxApiClient (typed HttpClient, bearer auth, HTTPS guard), OnlineTokenStore (DPAPI refresh-token store, Windows-only), StaticTokenAuthProvider (default/test IOnlineAuthProvider), ZitadelAuthProvider (OIDC discovery + refresh-token flow), OnlineSyncService (BackgroundService: reconcile loop), OnlineBacklog (Idle-backlog filter/query); interface in Online/Interfaces/ (IOnlineAuthProvider)
|
||||
```
|
||||
|
||||
Interfaces (e.g. `IQueueWaker`, `IPrimeClock`, `ITaskStateService`) live in an `Interfaces/` subfolder within their area; the namespace stays the area namespace.
|
||||
@@ -32,13 +32,13 @@ Interfaces (e.g. `IQueueWaker`, `IPrimeClock`, `ITaskStateService`) live in an `
|
||||
- **IQueueWaker / IQueuePicker / QueueService** — waker is a singleton `SemaphoreSlim`; picker performs the atomic `Queued → Running` claim filtered by `BlockedByTaskId IS NULL` and schedule; QueueService is a thin `BackgroundService` that loops on the waker and dispatches via `TaskRunner`.
|
||||
- **OverrideSlotService** — owns `RunNow` / `ContinueTask`; goes through `TaskStateService.StartRunningAsync` (caller-driven, serialized by slot lock).
|
||||
- **StaleTaskRecovery** — startup-only service; calls `TaskStateService.RecoverStaleRunningAsync` to flip orphaned `Running` rows to `Failed`.
|
||||
- **External/*** — always-on MCP tools for general Claude sessions, scoped to *starting* and *observing* sessions (no worktree/merge, multi-turn, planning, or app-settings writes). Auth via optional `X-ClaudeDo-Key` header. Registered explicitly in `Program.cs`'s external app via `.WithTools<T>()`. Organized by concern:
|
||||
- `ExternalMcpService` — task CRUD + execution: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `AddSubtask`, `UpdateTask`, `UpdateTaskStatus` (`Idle` / `Queued`), `GetTaskStatusValues`, `ReviewTask` (`approve` / `reject_rerun` / `reject_park` / `cancel` for a WaitingForReview task), `RunTaskNow`, `ContinueTask`, `CancelTask`, `DeleteTask`; worktree/git: `GetTaskWorktree`, `GetTaskDiff`, `MergeTask`, `ListWorktrees`, `CleanupTaskWorktree`
|
||||
- **External/*** — always-on MCP tools for general Claude sessions, scoped to *starting* and *observing* sessions (no worktree/merge, multi-turn, planning, or app-settings writes). Auth via optional `X-ClaudeDo-Key` header. Registered explicitly in `Program.cs`'s external app via `.WithTools<T>()`. Every optional/filter parameter across these tools must carry a C# default value (e.g. `string? status = null`) — the MCP schema only marks a parameter optional when it has one; nullability alone doesn't do it (`ExternalMcpToolSchemaTests` guards this by reflection). `ExternalMcpExceptionFilter.Wrap` is registered as a call-tool filter so `InvalidOperationException`/`ArgumentException` messages survive as `McpException` — otherwise the SDK's own catch-all replaces any non-`McpException` with a generic "An error occurred invoking 'X'." Organized by concern:
|
||||
- `ExternalMcpService` — task CRUD + execution: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `AddSubtask`, `UpdateTask`, `UpdateTaskStatus` (`Idle` / `Queued`), `GetTaskStatusValues`, `ReviewTask` (`approve` / `reject_rerun` / `reject_park` / `cancel` for a WaitingForReview task; approve is review+merge exactly like the hub's `ApproveReview` — unit merge for parents, worktree merge into optional `targetBranch` for childless tasks, conflicts reported in the `ReviewTaskResult`), `RunTaskNow`, `ContinueTask`, `CancelTask`, `DeleteTask`; worktree/git: `GetTaskWorktree`, `GetTaskDiff`, `MergeTask`, `ListWorktrees`, `CleanupTaskWorktree`
|
||||
- `BatchMcpTools` — best-effort batch variants that loop the `ExternalMcpService` single-entity methods (sequential — the scoped DbContext is not thread-safe; merge/review stay single-task): `BatchGetTasks`, `BatchAddTasks`, `BatchUpdateTaskStatus`, `BatchCancelTasks`, `BatchDeleteTasks`, `BatchSetMyDay`, `BatchCleanupTaskWorktrees`. Every tool returns a per-item result array ({ id/index, ok, error?, … }) — a failing item never aborts the rest — and rejects batches over 100 items.
|
||||
- `ListMcpTools` — `CreateList`, `UpdateList`, `DeleteList`
|
||||
- `ConfigMcpTools` — `GetListConfig`, `SetListConfig`, `GetTaskConfig`, `SetTaskConfig`
|
||||
- `RunHistoryMcpTools` — `ListRuns`, `GetRun`, `GetTaskLog` (latest run's log, tail-capped at 256 KB)
|
||||
- `AgentMcpTools` — `ListAgents` (class lives in `LifecycleMcpTools.cs`)
|
||||
- `AgentMcpTools` — `ListAgents`
|
||||
- `LifecycleMcpTools` — `ResetFailedTask`
|
||||
- `AppSettingsMcpTools` — `GetAppSettings` (read-only)
|
||||
- `AttachmentMcpTools` — `AddTaskAttachment(taskId, fileName, textContent?|base64Content?)`, `ListTaskAttachments`, `RemoveTaskAttachment`. Re-attaching the same fileName overwrites; add/remove refuse on a Running task.
|
||||
@@ -154,7 +154,8 @@ Each CLI invocation is recorded in the `task_runs` table via `TaskRunRepository`
|
||||
- Execution: `Ping`, `GetActive`, `RunNow`, `CancelTask`, `WakeQueue`, `ContinueTask`, `ResetTask`, `SetTaskStatus`, `RefineTask`
|
||||
- Review/merge: `ApproveReview(taskId, targetBranch) -> MergeResultDto` (childless task: merges its worktree then Done, conflict stays WaitingForReview; task with children: drives `PlanningMergeOrchestrator` to merge the whole unit), `ContinuePlanningMerge` / `AbortPlanningMerge` (resolve a unit-merge conflict), `PreviewMerge(taskId, targetBranch) -> MergePreviewDto` (non-destructive mergeability check), `RejectReviewToQueue`, `RejectReviewToIdle`, `CancelReview`, `MergeTask`, `GetMergeTargets`
|
||||
- Single-task conflict resolver (Layer C): `StartConflictMerge`, `GetMergeConflictDocuments` (segments), `WriteConflictResolution`, `ContinueConflictMerge`, `AbortConflictMerge` (service-level `TaskMergeService.ContinueMergeAsync`/`AbortMergeAsync` keep their names)
|
||||
- Planning sessions: `StartPlanningSession`, `ResumePlanningSession`, `DiscardPlanningSession`, `FinalizePlanningSession`, `QueuePlanningSubtasks`, `GetPendingDraftCount`, `OpenInteractiveTerminal`, `GetPlanningAggregate` (per-subtask diffs), `BuildPlanningIntegrationBranch` (combined diff)
|
||||
- Planning sessions: `StartPlanningSession`, `ResumePlanningSession`, `DiscardPlanningSession`, `FinalizePlanningSession`, `QueuePlanningSubtasks`, `GetPendingDraftCount`, `GetPlanningAggregate` (per-subtask diffs), `BuildPlanningIntegrationBranch` (combined diff)
|
||||
- Interactive sessions (embedded ConPTY, UI process): `ResumeTaskInTerminal` (pick-up-in-terminal), `GetInteractiveLaunchSpec`, `GetAdHocLaunchSpec`
|
||||
- Worktrees: `CleanupFinishedWorktrees`, `ResetAllWorktrees`, `GetWorktreesOverview`, `SetWorktreeState`, `ForceRemoveWorktree`
|
||||
- Agents/settings/lists: `GetAgents`, `RefreshAgents`, `RestoreDefaultAgents`, `GetAppSettings`, `UpdateAppSettings`, `UpdateList`, `UpdateListConfig`, `GetListConfig`, `UpdateTaskAgentSettings`
|
||||
- Reports/notes/prep: `GetWeekReport`, `GenerateWeekReport`, `GetDailyNotes`, `AddDailyNote`, `UpdateDailyNote`, `DeleteDailyNote`, `RunDailyPrepNow`, `ClearMyDay`, `GetLastPrepLog`, `ListPrimeSchedules`, `UpsertPrimeSchedule`, `DeletePrimeSchedule`
|
||||
@@ -176,7 +177,7 @@ Loaded from `~/.todo-app/worker.config.json`:
|
||||
- `enabled` (bool, default false) — when false the entire `Online/` stack is not registered
|
||||
- `api_base_url` (string) — must be HTTPS or loopback; validated at startup when enabled
|
||||
- `poll_interval_seconds` (int, default 60)
|
||||
- `zitadel.authority`, `zitadel.client_id`, `zitadel.scopes` (Phase 2; not used until ZitadelAuthProvider is wired)
|
||||
- `zitadel.authority`, `zitadel.client_id`, `zitadel.scopes` — used by `ZitadelAuthProvider` (OIDC discovery + refresh-token flow)
|
||||
- The refresh token is NOT in this file — stored encrypted via DPAPI at `~/.todo-app/online-inbox.token`
|
||||
|
||||
Per-list config (`list_config` in DB) provides defaults for `model`, `system_prompt`, `agent_path`; tasks can override each individually. Task-generating MCP tools (`AddTask`, planning `CreateChildTask`, `SuggestImprovement`) accept an optional `model` (alias-validated via `ModelRegistry.NormalizeAlias` — `haiku`/`sonnet`/`opus`, blank = inherit) so Claude assigns the cheapest capable model at creation time; the planning/system/improvement prompts instruct it to do so (`ModelRegistry.ByCostAscending` = the cost order).
|
||||
|
||||
@@ -41,10 +41,6 @@ public sealed class WorkerConfig
|
||||
[JsonPropertyName("external_mcp_api_key")]
|
||||
public string? ExternalMcpApiKey { get; set; }
|
||||
|
||||
/// <summary>Interactive/streaming sessions idle longer than this are stopped by IdleSessionReaper. 0 disables reaping.</summary>
|
||||
[JsonPropertyName("interactive_idle_timeout_minutes")]
|
||||
public int InteractiveIdleTimeoutMinutes { get; set; } = 30;
|
||||
|
||||
[JsonPropertyName("online_inbox")]
|
||||
public OnlineInboxConfig OnlineInbox { get; set; } = new();
|
||||
|
||||
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
using System.ComponentModel;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Worker.Agents;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace ClaudeDo.Worker.External;
|
||||
|
||||
[McpServerToolType]
|
||||
public sealed class AgentMcpTools
|
||||
{
|
||||
private readonly AgentFileService _agents;
|
||||
|
||||
public AgentMcpTools(AgentFileService agents) => _agents = agents;
|
||||
|
||||
[McpServerTool, Description("List available agent definition files (name, description, path) for use as a task's agent path.")]
|
||||
public async Task<IReadOnlyList<AgentInfo>> ListAgents(CancellationToken cancellationToken)
|
||||
=> await _agents.ScanAsync(cancellationToken);
|
||||
}
|
||||
+5
-3
@@ -31,7 +31,8 @@ public sealed class ConfigMcpTools
|
||||
|
||||
[McpServerTool, Description("Set a list's default model/system prompt/agent path/max turns. Passing all four as null clears the list config.")]
|
||||
public async Task SetListConfig(
|
||||
string listId, string? model, string? systemPrompt, string? agentPath, int? maxTurns, CancellationToken cancellationToken)
|
||||
string listId, string? model = null, string? systemPrompt = null, string? agentPath = null,
|
||||
int? maxTurns = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_ = await _lists.GetByIdAsync(listId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"List {listId} not found.");
|
||||
@@ -53,12 +54,13 @@ public sealed class ConfigMcpTools
|
||||
|
||||
[McpServerTool, Description("Set per-task config overrides (model/system prompt/agent path/max turns). Pass null for any field to clear that override.")]
|
||||
public async Task SetTaskConfig(
|
||||
string taskId, string? model, string? systemPrompt, string? agentPath, int? maxTurns, CancellationToken cancellationToken)
|
||||
string taskId, string? model = null, string? systemPrompt = null, string? agentPath = null,
|
||||
int? maxTurns = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_ = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
|
||||
await _tasks.UpdateAgentSettingsAsync(taskId, model.NullIfBlank(), systemPrompt.NullIfBlank(), agentPath.NullIfBlank(), maxTurns, cancellationToken);
|
||||
await _tasks.UpdateAgentSettingsAsync(taskId, model.NullIfBlank(), systemPrompt.NullIfBlank(), agentPath.NullIfBlank(), maxTurns, ct: cancellationToken);
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using ModelContextProtocol;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace ClaudeDo.Worker.External;
|
||||
|
||||
/// <summary>
|
||||
/// The MCP SDK's own call-tool catch-all only preserves ex.Message for <see cref="McpException"/> —
|
||||
/// any other exception type is replaced with a generic "An error occurred invoking 'X'." with no detail.
|
||||
/// This filter translates the expected validation exceptions thrown across the External/ tool classes
|
||||
/// (task/list not found, bad status, unknown model, etc.) into McpException so callers see why a call failed.
|
||||
/// </summary>
|
||||
public static class ExternalMcpExceptionFilter
|
||||
{
|
||||
public static McpRequestHandler<CallToolRequestParams, CallToolResult> Wrap(
|
||||
McpRequestHandler<CallToolRequestParams, CallToolResult> next) =>
|
||||
async (request, cancellationToken) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
return await next(request, cancellationToken);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
throw new McpException(ex.Message, ex);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
throw new McpException(ex.Message, ex);
|
||||
}
|
||||
};
|
||||
}
|
||||
+212
-32
@@ -6,6 +6,7 @@ using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Lifecycle;
|
||||
using ClaudeDo.Worker.Planning;
|
||||
using ClaudeDo.Worker.Queue;
|
||||
using ClaudeDo.Worker.State;
|
||||
using ClaudeDo.Worker.Worktrees;
|
||||
@@ -18,6 +19,7 @@ namespace ClaudeDo.Worker.External;
|
||||
public sealed record TaskListDto(string Id, string Name, string? WorkingDir);
|
||||
public sealed record DeleteTaskResult(bool Deleted, string Id);
|
||||
public sealed record CancelTaskResult(bool Cancelled, string Id);
|
||||
public sealed record ReviewTaskResult(TaskDto Task, string? MergeStatus, IReadOnlyList<string> MergeConflicts, string? MergeMessage, string? RepoPath = null);
|
||||
public sealed record StatusValueDto(string Status, string Meaning);
|
||||
|
||||
public sealed record TaskDto(
|
||||
@@ -42,7 +44,12 @@ public sealed record TaskDiffDto(
|
||||
string Content, IReadOnlyList<string> Files, bool Truncated, int TotalBytes);
|
||||
|
||||
public sealed record MergeTaskResultDto(
|
||||
bool Merged, string? MergeCommit, IReadOnlyList<string> Conflicts);
|
||||
bool Merged, string? MergeCommit, IReadOnlyList<string> Conflicts,
|
||||
bool ConflictsInTree = false, string? RepoPath = null);
|
||||
|
||||
public sealed record MergeContinuationResultDto(
|
||||
bool Merged, string TaskStatus, IReadOnlyList<string> Conflicts,
|
||||
string? RepoPath, string? Message);
|
||||
|
||||
public sealed record WorktreeListItemDto(
|
||||
string? TaskId, string Path, string Branch,
|
||||
@@ -72,6 +79,7 @@ public sealed class ExternalMcpService
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly WorktreeMaintenanceService _maintenance;
|
||||
private readonly TaskMergeService _merge;
|
||||
private readonly PlanningMergeOrchestrator _planningMerge;
|
||||
|
||||
public ExternalMcpService(
|
||||
TaskRepository tasks,
|
||||
@@ -82,7 +90,8 @@ public sealed class ExternalMcpService
|
||||
GitService git,
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
WorktreeMaintenanceService maintenance,
|
||||
TaskMergeService merge)
|
||||
TaskMergeService merge,
|
||||
PlanningMergeOrchestrator planningMerge)
|
||||
{
|
||||
_tasks = tasks;
|
||||
_lists = lists;
|
||||
@@ -93,6 +102,7 @@ public sealed class ExternalMcpService
|
||||
_dbFactory = dbFactory;
|
||||
_maintenance = maintenance;
|
||||
_merge = merge;
|
||||
_planningMerge = planningMerge;
|
||||
}
|
||||
|
||||
[McpServerTool, Description("List all task lists available in ClaudeDo.")]
|
||||
@@ -107,9 +117,9 @@ public sealed class ExternalMcpService
|
||||
"Valid status values: Idle, Queued, Running, WaitingForReview, WaitingForChildren, Done, Failed, Cancelled.")]
|
||||
public async Task<IReadOnlyList<TaskDto>> ListTasks(
|
||||
string listId,
|
||||
string? createdBy,
|
||||
string? status,
|
||||
CancellationToken cancellationToken)
|
||||
string? createdBy = null,
|
||||
string? status = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
TaskStatus? statusFilter = null;
|
||||
if (!string.IsNullOrWhiteSpace(status))
|
||||
@@ -193,10 +203,10 @@ public sealed class ExternalMcpService
|
||||
[McpServerTool, Description("Update an existing task's title, description, and/or commit type. Pass null to leave a field unchanged. Refuses if the task is currently Running.")]
|
||||
public async Task<TaskDto> UpdateTask(
|
||||
string taskId,
|
||||
string? title,
|
||||
string? description,
|
||||
string? commitType,
|
||||
CancellationToken cancellationToken)
|
||||
string? title = null,
|
||||
string? description = null,
|
||||
string? commitType = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
@@ -219,8 +229,8 @@ public sealed class ExternalMcpService
|
||||
public async Task<TaskDto> AddSubtask(
|
||||
string taskId,
|
||||
string title,
|
||||
int? orderNum,
|
||||
CancellationToken cancellationToken)
|
||||
int? orderNum = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
throw new InvalidOperationException("title is required.");
|
||||
@@ -292,34 +302,99 @@ public sealed class ExternalMcpService
|
||||
|
||||
[McpServerTool, Description(
|
||||
"Review a task that is WaitingForReview. " +
|
||||
"decision='approve' → Done. " +
|
||||
"decision='approve' → review+merge, exactly like the UI's Approve: a childless task merges its worktree into " +
|
||||
"targetBranch (default: the repo's current branch) then goes Done; a task with children drives the unit merge " +
|
||||
"(parent worktree if active + each Done child in order); a task without an active worktree approves straight to Done. " +
|
||||
"mergeStatus 'conflict' means the merge stopped on conflicts (files listed) — by default the merge is cleanly " +
|
||||
"aborted and you resolve in the ClaudeDo UI; pass leaveConflictsInTree=true to instead leave the conflict " +
|
||||
"markers in the working tree (repoPath in the result) so you can resolve them and call continue_merge, " +
|
||||
"or abort_merge to cancel. " +
|
||||
"decision='reject_rerun' → Queued and re-runs, resuming the agent's session with your feedback as the next turn (feedback is required). " +
|
||||
"decision='reject_park' → Idle for manual editing (feedback ignored). " +
|
||||
"decision='cancel' → Cancelled. " +
|
||||
"Fails if the task is not currently WaitingForReview (except cancel, which also works while Running/Queued).")]
|
||||
public async Task<TaskDto> ReviewTask(
|
||||
public async Task<ReviewTaskResult> ReviewTask(
|
||||
string taskId,
|
||||
string decision,
|
||||
string? feedback,
|
||||
CancellationToken cancellationToken)
|
||||
string? feedback = null,
|
||||
string? targetBranch = null,
|
||||
bool leaveConflictsInTree = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_ = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
|
||||
TransitionResult result = decision.Trim().ToLowerInvariant() switch
|
||||
string? mergeStatus = null;
|
||||
IReadOnlyList<string> mergeConflicts = Array.Empty<string>();
|
||||
string? mergeMessage = null;
|
||||
string? repoPath = null;
|
||||
|
||||
if (decision.Trim().ToLowerInvariant() == "approve")
|
||||
{
|
||||
"approve" => await _state.ApproveReviewAsync(taskId, cancellationToken),
|
||||
"reject_rerun" => await _state.RejectToQueueAsync(taskId, feedback ?? "", cancellationToken),
|
||||
"reject_park" => await _state.RejectToIdleAsync(taskId, cancellationToken),
|
||||
"cancel" => await _state.CancelAsync(taskId, DateTime.UtcNow, cancellationToken),
|
||||
_ => throw new InvalidOperationException(
|
||||
$"Unknown decision '{decision}'. Use approve, reject_rerun, reject_park, or cancel."),
|
||||
};
|
||||
// Approve is the single review+merge action — mirror the hub's ApproveReview
|
||||
// routing instead of only flipping the status (which left branches unmerged).
|
||||
bool hasChildren;
|
||||
await using (var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken))
|
||||
hasChildren = await ctx.Tasks.AnyAsync(t => t.ParentTaskId == taskId, cancellationToken);
|
||||
|
||||
if (!result.Ok)
|
||||
throw new InvalidOperationException(result.Reason ?? "Review action failed.");
|
||||
if (hasChildren)
|
||||
{
|
||||
await _planningMerge.StartAsync(taskId, targetBranch ?? "", cancellationToken);
|
||||
var parentDone = (await _tasks.GetByIdAsync(taskId, cancellationToken))!.Status == TaskStatus.Done;
|
||||
mergeStatus = parentDone ? TaskMergeService.StatusMerged : TaskMergeService.StatusConflict;
|
||||
if (!parentDone)
|
||||
{
|
||||
var list = await _lists.GetByIdAsync(task.ListId, cancellationToken);
|
||||
repoPath = list?.WorkingDir;
|
||||
mergeMessage = "unit merge paused on a conflict — markers left in the working tree; " +
|
||||
"resolve them then call continue_merge with the parent task id, or abort_merge to cancel";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var r = await _merge.ApproveAndMergeAsync(taskId, targetBranch ?? "", leaveConflictsInTree, cancellationToken);
|
||||
if (r.Status == TaskMergeService.StatusBlocked)
|
||||
throw new InvalidOperationException(r.ErrorMessage ?? "approve failed");
|
||||
mergeStatus = r.Status;
|
||||
mergeConflicts = r.ConflictFiles;
|
||||
if (r.Status == TaskMergeService.StatusConflict)
|
||||
{
|
||||
if (leaveConflictsInTree)
|
||||
{
|
||||
var list = await _lists.GetByIdAsync(task.ListId, cancellationToken);
|
||||
repoPath = list?.WorkingDir;
|
||||
mergeMessage = "merge conflict — markers left in the working tree; " +
|
||||
"resolve them then call continue_merge, or abort_merge to cancel";
|
||||
}
|
||||
else
|
||||
{
|
||||
mergeMessage = "merge conflict — the task stays WaitingForReview; resolve it in the ClaudeDo UI";
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
mergeMessage = r.ErrorMessage;
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
TransitionResult result = decision.Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"reject_rerun" => await _state.RejectToQueueAsync(taskId, feedback ?? "", cancellationToken),
|
||||
"reject_park" => await _state.RejectToIdleAsync(taskId, cancellationToken),
|
||||
"cancel" => await _state.CancelAsync(taskId, DateTime.UtcNow, cancellationToken),
|
||||
_ => throw new InvalidOperationException(
|
||||
$"Unknown decision '{decision}'. Use approve, reject_rerun, reject_park, or cancel."),
|
||||
};
|
||||
|
||||
return ToDto((await _tasks.GetByIdAsync(taskId, cancellationToken))!);
|
||||
if (!result.Ok)
|
||||
throw new InvalidOperationException(result.Reason ?? "Review action failed.");
|
||||
}
|
||||
|
||||
return new ReviewTaskResult(
|
||||
ToDto((await _tasks.GetByIdAsync(taskId, cancellationToken))!),
|
||||
mergeStatus, mergeConflicts, mergeMessage, repoPath);
|
||||
}
|
||||
|
||||
[McpServerTool, Description("Immediately run a task in the override execution slot (bypasses the agent queue).")]
|
||||
@@ -357,6 +432,8 @@ public sealed class ExternalMcpService
|
||||
throw new InvalidOperationException("Cannot delete a running task. Cancel it first.");
|
||||
|
||||
await _tasks.DeleteAsync(taskId, cancellationToken);
|
||||
if (task.ParentTaskId is not null)
|
||||
await _state.TryAdvanceParentAsync(task.ParentTaskId);
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
return new DeleteTaskResult(true, taskId);
|
||||
}
|
||||
@@ -437,13 +514,17 @@ public sealed class ExternalMcpService
|
||||
"dryRun=true: validates preconditions only, does not perform the merge; merged=false in the result means 'not actually merged'. " +
|
||||
"allowWaitingForReview=true: also allows merging a task in WaitingForReview (default false, which only allows Done). " +
|
||||
"On success: merged=true, mergeCommit contains the new merge commit SHA. " +
|
||||
"On conflict: the merge is cleanly aborted (no half-merged state left); merged=false and conflicts lists the affected files.")]
|
||||
"On conflict: by default the merge is cleanly aborted (no half-merged state left); merged=false and conflicts lists the affected files. " +
|
||||
"leaveConflictsInTree=true: on conflict the merge is NOT aborted — conflict markers are left in the working " +
|
||||
"tree at repoPath (conflictsInTree=true in the result) so you can resolve them there and call continue_merge, " +
|
||||
"or abort_merge to cancel.")]
|
||||
public async Task<MergeTaskResultDto> MergeTask(
|
||||
string taskId,
|
||||
string targetBranch = "main",
|
||||
bool noFf = true,
|
||||
bool dryRun = false,
|
||||
bool allowWaitingForReview = false,
|
||||
bool leaveConflictsInTree = false,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
@@ -470,7 +551,7 @@ public sealed class ExternalMcpService
|
||||
|
||||
var commitMessage = $"Merge task branch for: {task.Title}";
|
||||
var result = await _merge.MergeAsync(
|
||||
taskId, targetBranch, removeWorktree: false, commitMessage, cancellationToken);
|
||||
taskId, targetBranch, removeWorktree: false, commitMessage, leaveConflictsInTree, cancellationToken);
|
||||
|
||||
if (result.Status == TaskMergeService.StatusMerged)
|
||||
{
|
||||
@@ -485,11 +566,110 @@ public sealed class ExternalMcpService
|
||||
}
|
||||
|
||||
if (result.Status == TaskMergeService.StatusConflict)
|
||||
return new MergeTaskResultDto(false, null, result.ConflictFiles);
|
||||
return leaveConflictsInTree
|
||||
? new MergeTaskResultDto(false, null, result.ConflictFiles,
|
||||
ConflictsInTree: true, RepoPath: list?.WorkingDir)
|
||||
: new MergeTaskResultDto(false, null, result.ConflictFiles);
|
||||
|
||||
throw new InvalidOperationException(result.ErrorMessage ?? $"Merge blocked: {result.Status}");
|
||||
}
|
||||
|
||||
[McpServerTool, Description(
|
||||
"Finish an in-progress conflicted merge after the conflict markers in the working tree (repoPath from " +
|
||||
"merge_task/review_task) have been resolved. Handles both a single task's merge and a parent/children unit " +
|
||||
"merge — pass the PARENT task id to continue a unit merge. On success merged=true and the task reaches its " +
|
||||
"post-merge status (Done when approving). If conflict markers are still present, merged=false and conflicts " +
|
||||
"lists the affected files — resolve them and call continue_merge again. " +
|
||||
"Throws if there is no in-progress merge for the task; use abort_merge to cancel a paused merge instead.")]
|
||||
public async Task<MergeContinuationResultDto> ContinueMerge(string taskId, CancellationToken cancellationToken)
|
||||
{
|
||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
var list = await _lists.GetByIdAsync(task.ListId, cancellationToken);
|
||||
var workingDir = list?.WorkingDir;
|
||||
|
||||
bool merged;
|
||||
IReadOnlyList<string> conflicts = Array.Empty<string>();
|
||||
string? repoPath = null;
|
||||
string? message = null;
|
||||
|
||||
if (_planningMerge.HasActiveMerge(taskId))
|
||||
{
|
||||
await _planningMerge.ContinueAsync(taskId, cancellationToken);
|
||||
var parent = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
|
||||
if (parent.Status == TaskStatus.Done)
|
||||
{
|
||||
merged = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
var remaining = !string.IsNullOrWhiteSpace(workingDir)
|
||||
? await _git.ListConflictedFilesAsync(workingDir, cancellationToken)
|
||||
: new List<string>();
|
||||
merged = false;
|
||||
if (remaining.Count > 0)
|
||||
{
|
||||
conflicts = remaining;
|
||||
repoPath = workingDir;
|
||||
message = "conflicts remain — resolve and call continue_merge again";
|
||||
}
|
||||
else
|
||||
{
|
||||
message = "unit merge did not complete — the orchestrator aborted or was blocked; " +
|
||||
"check the parent task and approve again to restart the merge";
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var r = await _merge.ContinueMergeAsync(taskId, cancellationToken);
|
||||
if (r.Status == TaskMergeService.StatusMerged)
|
||||
{
|
||||
merged = true;
|
||||
}
|
||||
else if (r.Status == TaskMergeService.StatusConflict)
|
||||
{
|
||||
merged = false;
|
||||
conflicts = r.ConflictFiles;
|
||||
repoPath = workingDir;
|
||||
message = r.ErrorMessage;
|
||||
}
|
||||
else
|
||||
{
|
||||
throw new InvalidOperationException(r.ErrorMessage ?? "continue failed");
|
||||
}
|
||||
}
|
||||
|
||||
var reloaded = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
return new MergeContinuationResultDto(merged, reloaded.Status.ToString(), conflicts, repoPath, message);
|
||||
}
|
||||
|
||||
[McpServerTool, Description(
|
||||
"Abort an in-progress conflicted merge, discarding the conflict markers and restoring a clean working tree. " +
|
||||
"Handles both a single task's merge and a parent/children unit merge — pass the PARENT task id to abort a " +
|
||||
"unit merge. The task keeps its pre-merge status (e.g. WaitingForReview). " +
|
||||
"Throws if there is no in-progress merge for the task.")]
|
||||
public async Task<TaskDto> AbortMerge(string taskId, CancellationToken cancellationToken)
|
||||
{
|
||||
_ = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
|
||||
if (_planningMerge.HasActiveMerge(taskId))
|
||||
{
|
||||
await _planningMerge.AbortAsync(taskId, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
var r = await _merge.AbortMergeAsync(taskId, cancellationToken);
|
||||
if (r.Status == TaskMergeService.StatusBlocked)
|
||||
throw new InvalidOperationException(r.ErrorMessage ?? "abort failed");
|
||||
}
|
||||
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
return ToDto((await _tasks.GetByIdAsync(taskId, cancellationToken))!);
|
||||
}
|
||||
|
||||
[McpServerTool, Description(
|
||||
"List all ClaudeDo-tracked worktrees. " +
|
||||
"Each entry: taskId, path, branch, headCommit (empty if path missing on disk), " +
|
||||
@@ -617,8 +797,8 @@ public sealed class ExternalMcpService
|
||||
public async Task<TaskDto> SetMyDay(
|
||||
string taskId,
|
||||
bool isMyDay,
|
||||
int? sortOrder,
|
||||
CancellationToken cancellationToken)
|
||||
int? sortOrder = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
|
||||
|
||||
|
||||
@@ -1,25 +1,11 @@
|
||||
using System.ComponentModel;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Agents;
|
||||
using ClaudeDo.Worker.Lifecycle;
|
||||
using ModelContextProtocol.Server;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Worker.External;
|
||||
|
||||
[McpServerToolType]
|
||||
public sealed class AgentMcpTools
|
||||
{
|
||||
private readonly AgentFileService _agents;
|
||||
|
||||
public AgentMcpTools(AgentFileService agents) => _agents = agents;
|
||||
|
||||
[McpServerTool, Description("List available agent definition files (name, description, path) for use as a task's agent path.")]
|
||||
public async Task<IReadOnlyList<AgentInfo>> ListAgents(CancellationToken cancellationToken)
|
||||
=> await _agents.ScanAsync(cancellationToken);
|
||||
}
|
||||
|
||||
[McpServerToolType]
|
||||
public sealed class LifecycleMcpTools
|
||||
{
|
||||
|
||||
+3
-2
@@ -22,7 +22,7 @@ public sealed class ListMcpTools
|
||||
|
||||
[McpServerTool, Description("Create a new task list. workingDir sets the git repo tasks run against; commitType defaults to 'chore'.")]
|
||||
public async Task<ListSummaryDto> CreateList(
|
||||
string name, string? workingDir, string? commitType, CancellationToken cancellationToken)
|
||||
string name, string? workingDir = null, string? commitType = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
throw new InvalidOperationException("name is required.");
|
||||
@@ -42,7 +42,8 @@ public sealed class ListMcpTools
|
||||
|
||||
[McpServerTool, Description("Rename a list and/or change its working dir and default commit type. Pass null to leave a field unchanged.")]
|
||||
public async Task<ListSummaryDto> UpdateList(
|
||||
string listId, string? name, string? workingDir, string? commitType, CancellationToken cancellationToken)
|
||||
string listId, string? name = null, string? workingDir = null, string? commitType = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await _lists.GetByIdAsync(listId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"List {listId} not found.");
|
||||
|
||||
@@ -77,16 +77,4 @@ public sealed class HubBroadcaster : IPrimeBroadcaster, IRefineBroadcaster
|
||||
Task IRefineBroadcaster.RefineStartedAsync(string taskId) => RefineStarted(taskId);
|
||||
Task IRefineBroadcaster.RefineFinishedAsync(string taskId, bool success, string? error) =>
|
||||
RefineFinished(taskId, success, error);
|
||||
|
||||
public Task InteractiveSessionStarted(string taskId) =>
|
||||
_hub.Clients.All.SendAsync("InteractiveSessionStarted", taskId);
|
||||
|
||||
public Task InteractiveSessionEnded(string taskId) =>
|
||||
_hub.Clients.All.SendAsync("InteractiveSessionEnded", taskId);
|
||||
|
||||
public Task InteractiveQueueChanged(string taskId, IReadOnlyList<string> pending) =>
|
||||
_hub.Clients.All.SendAsync("InteractiveQueueChanged", taskId, pending);
|
||||
|
||||
public Task InteractiveMessageSent(string taskId, string text) =>
|
||||
_hub.Clients.All.SendAsync("InteractiveMessageSent", taskId, text);
|
||||
}
|
||||
|
||||
@@ -15,8 +15,10 @@ using ClaudeDo.Worker.Queue;
|
||||
using ClaudeDo.Worker.Refine;
|
||||
using ClaudeDo.Worker.Report;
|
||||
using ClaudeDo.Worker.Report.Interfaces;
|
||||
using ClaudeDo.Worker.Skills;
|
||||
using ClaudeDo.Worker.State;
|
||||
using ClaudeDo.Worker.Worktrees;
|
||||
using System.Text.Json;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -37,7 +39,15 @@ public record AppSettingsDto(
|
||||
int WorktreeAutoCleanupDays,
|
||||
string? ReportExcludedPaths,
|
||||
int StandupWeekday,
|
||||
int DailyPrepMaxTasks);
|
||||
int DailyPrepMaxTasks,
|
||||
List<string>? SessionSkills = null);
|
||||
|
||||
public record SessionSkillDto(
|
||||
string Name,
|
||||
string Description,
|
||||
string SourceUrl,
|
||||
string PinnedRef,
|
||||
DateTimeOffset AddedAt);
|
||||
|
||||
public record WorktreeCleanupDto(int Removed);
|
||||
public record WorktreeResetDto(int Removed, int TasksAffected, bool Blocked, int RunningTasks);
|
||||
@@ -65,9 +75,9 @@ public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDoc
|
||||
public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList<MergeSegmentDto> Segments);
|
||||
public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs);
|
||||
public record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType);
|
||||
public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
|
||||
public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
|
||||
public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
|
||||
public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
|
||||
public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
|
||||
public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
|
||||
public record SeedResultDto(int Copied, int Skipped);
|
||||
|
||||
public record OnlineInboxStateDto(
|
||||
@@ -117,8 +127,10 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
private readonly OnlineInboxConfig _onlineInboxConfig;
|
||||
private readonly OnlineTokenStore _onlineTokenStore;
|
||||
private readonly Runner.PendingQuestionRegistry _pendingQuestions;
|
||||
private readonly InteractiveSessionService _interactive;
|
||||
private readonly LogRingBuffer? _logBuffer;
|
||||
private readonly ISessionSkillRegistry _skillRegistry;
|
||||
private readonly IInteractiveLaunchSpecService? _interactiveLaunchSpec;
|
||||
private readonly WorktreeManager? _worktreeManager;
|
||||
|
||||
public WorkerHub(
|
||||
QueueService queue,
|
||||
@@ -144,8 +156,10 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
OnlineInboxConfig onlineInboxConfig,
|
||||
OnlineTokenStore onlineTokenStore,
|
||||
Runner.PendingQuestionRegistry pendingQuestions,
|
||||
InteractiveSessionService interactive,
|
||||
LogRingBuffer? logBuffer = null)
|
||||
ISessionSkillRegistry skillRegistry,
|
||||
LogRingBuffer? logBuffer = null,
|
||||
IInteractiveLaunchSpecService? interactiveLaunchSpec = null,
|
||||
WorktreeManager? worktreeManager = null)
|
||||
{
|
||||
_queue = queue;
|
||||
_waker = waker;
|
||||
@@ -170,8 +184,23 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
_onlineInboxConfig = onlineInboxConfig;
|
||||
_onlineTokenStore = onlineTokenStore;
|
||||
_pendingQuestions = pendingQuestions;
|
||||
_interactive = interactive;
|
||||
_skillRegistry = skillRegistry;
|
||||
_logBuffer = logBuffer;
|
||||
_interactiveLaunchSpec = interactiveLaunchSpec;
|
||||
_worktreeManager = worktreeManager;
|
||||
}
|
||||
|
||||
// Persistence boundary for the session_skills JSON-array columns (task/list/global).
|
||||
// A null/empty selection persists as null so "inherit / none" stays clean, and the
|
||||
// shape matches TaskRunner.UnionSkillNames's expected JSON string array.
|
||||
private static string? SkillsToJson(List<string>? names) =>
|
||||
names is null or { Count: 0 } ? null : JsonSerializer.Serialize(names);
|
||||
|
||||
private static List<string>? SkillsFromJson(string? json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json)) return null;
|
||||
try { return JsonSerializer.Deserialize<List<string>>(json); }
|
||||
catch (JsonException) { return null; }
|
||||
}
|
||||
|
||||
/// <summary>Deliver the user's answer to a question a running task raised via AskUser.
|
||||
@@ -288,7 +317,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
row.WorktreeAutoCleanupDays,
|
||||
row.ReportExcludedPaths,
|
||||
row.StandupWeekday,
|
||||
row.DailyPrepMaxTasks);
|
||||
row.DailyPrepMaxTasks,
|
||||
SkillsFromJson(row.SessionSkills));
|
||||
}
|
||||
|
||||
public async Task UpdateAppSettings(AppSettingsDto dto)
|
||||
@@ -310,9 +340,28 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
ReportExcludedPaths = dto.ReportExcludedPaths,
|
||||
StandupWeekday = dto.StandupWeekday is >= 0 and <= 6 ? dto.StandupWeekday : (int)DayOfWeek.Wednesday,
|
||||
DailyPrepMaxTasks = dto.DailyPrepMaxTasks,
|
||||
SessionSkills = SkillsToJson(dto.SessionSkills),
|
||||
});
|
||||
}
|
||||
|
||||
public async Task<List<SessionSkillDto>> GetSessionSkills()
|
||||
{
|
||||
var rows = await _skillRegistry.ListAsync(Context.ConnectionAborted);
|
||||
return rows.Select(r => new SessionSkillDto(r.Name, r.Description, r.SourceUrl, r.PinnedRef, r.AddedAt)).ToList();
|
||||
}
|
||||
|
||||
public Task<List<string>> InstallSessionSkill(string url) => HubGuard(async () =>
|
||||
{
|
||||
var installed = await _skillRegistry.InstallAsync(url, Context.ConnectionAborted);
|
||||
return installed.ToList();
|
||||
});
|
||||
|
||||
public Task UpdateSessionSkill(string sourceUrl) => HubGuard(
|
||||
() => _skillRegistry.UpdateAsync(sourceUrl, Context.ConnectionAborted));
|
||||
|
||||
public Task RemoveSessionSkill(string sourceUrl) => HubGuard(
|
||||
() => _skillRegistry.RemoveAsync(sourceUrl, Context.ConnectionAborted));
|
||||
|
||||
public async Task<WorktreeCleanupDto> CleanupFinishedWorktrees(string? listId = null)
|
||||
{
|
||||
var result = await _wtMaintenance.CleanupFinishedAsync(listId, Context.ConnectionAborted);
|
||||
@@ -456,8 +505,9 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
var model = dto.Model.NullIfBlank();
|
||||
var systemPrompt = dto.SystemPrompt.NullIfBlank();
|
||||
var agentPath = dto.AgentPath.NullIfBlank();
|
||||
var sessionSkills = SkillsToJson(dto.SessionSkills);
|
||||
|
||||
if (model is null && systemPrompt is null && agentPath is null && dto.MaxTurns is null)
|
||||
if (model is null && systemPrompt is null && agentPath is null && dto.MaxTurns is null && sessionSkills is null)
|
||||
{
|
||||
await repo.DeleteConfigAsync(dto.ListId);
|
||||
}
|
||||
@@ -470,6 +520,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
SystemPrompt = systemPrompt,
|
||||
AgentPath = agentPath,
|
||||
MaxTurns = dto.MaxTurns,
|
||||
SessionSkills = sessionSkills,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -482,7 +533,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
var repo = new ListRepository(ctx);
|
||||
var config = await repo.GetConfigAsync(listId);
|
||||
if (config is null) return null;
|
||||
return new ListConfigDto(config.Model, config.SystemPrompt, config.AgentPath, config.MaxTurns);
|
||||
return new ListConfigDto(config.Model, config.SystemPrompt, config.AgentPath, config.MaxTurns, SkillsFromJson(config.SessionSkills));
|
||||
}
|
||||
|
||||
public async Task SetTaskStatus(string taskId, string status)
|
||||
@@ -543,7 +594,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
dto.Model.NullIfBlank(),
|
||||
dto.SystemPrompt.NullIfBlank(),
|
||||
dto.AgentPath.NullIfBlank(),
|
||||
dto.MaxTurns);
|
||||
dto.MaxTurns,
|
||||
SkillsToJson(dto.SessionSkills));
|
||||
|
||||
await _broadcaster.TaskUpdated(dto.TaskId);
|
||||
}
|
||||
@@ -572,20 +624,133 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
return ctx;
|
||||
}
|
||||
|
||||
public Task OpenInteractiveTerminalAsync(string taskId) =>
|
||||
_interactive.StartAsync(taskId, Context.ConnectionAborted);
|
||||
// Picks up a task's Claude session in a real terminal window (--resume) so the user can
|
||||
// drive it by hand. Only for tasks the worker isn't actively running, with a persisted
|
||||
// session id and a live worktree.
|
||||
public Task ResumeTaskInTerminal(string taskId) => HubGuard(async () =>
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
|
||||
public Task SendInteractiveMessage(string taskId, string text) =>
|
||||
_interactive.SendAsync(taskId, text, Context.ConnectionAborted);
|
||||
var task = await new TaskRepository(ctx).GetByIdAsync(taskId, Context.ConnectionAborted)
|
||||
?? throw new KeyNotFoundException();
|
||||
if (task.Status is TaskStatus.Running or TaskStatus.Queued)
|
||||
throw new InvalidOperationException("Can't pick up a running or queued task — interrupt it first.");
|
||||
|
||||
public Task StopInteractiveSession(string taskId) =>
|
||||
_interactive.StopAsync(taskId, Context.ConnectionAborted);
|
||||
var run = await new TaskRunRepository(ctx).GetLatestByTaskIdAsync(taskId, Context.ConnectionAborted);
|
||||
if (run?.SessionId is not { Length: > 0 } sessionId)
|
||||
throw new InvalidOperationException("This task has no resumable Claude session yet.");
|
||||
|
||||
public Task InterruptInteractiveSession(string taskId) =>
|
||||
_interactive.InterruptAsync(taskId, Context.ConnectionAborted);
|
||||
var worktree = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, Context.ConnectionAborted);
|
||||
if (worktree is null || worktree.State is not (WorktreeState.Active or WorktreeState.Kept))
|
||||
throw new InvalidOperationException("This task has no active worktree to resume in.");
|
||||
if (!Directory.Exists(worktree.Path))
|
||||
throw new InvalidOperationException("The task's worktree directory no longer exists.");
|
||||
|
||||
public Task RemoveQueuedInteractiveMessage(string taskId, string text) =>
|
||||
_interactive.RemoveQueuedAsync(taskId, text, Context.ConnectionAborted);
|
||||
try
|
||||
{
|
||||
await _launcher.LaunchResumeAsync(worktree.Path, sessionId, Context.ConnectionAborted);
|
||||
}
|
||||
catch (TerminalLaunchException ex)
|
||||
{
|
||||
throw new InvalidOperationException(ex.Message);
|
||||
}
|
||||
});
|
||||
|
||||
// Builds the launch spec an embedded ConPTY terminal (UI process) needs to open an
|
||||
// interactive Claude session in a task's worktree -- same worktree prep as an
|
||||
// autonomous run (session-skills seeding, run env vars), --resume if the task has a
|
||||
// persisted session or a fresh-start spec otherwise. Guards mirror ResumeTaskInTerminal.
|
||||
public Task<LaunchSpec> GetInteractiveLaunchSpec(string taskId) => HubGuard(() =>
|
||||
{
|
||||
if (_interactiveLaunchSpec is null)
|
||||
throw new InvalidOperationException("Interactive launch spec service is not configured.");
|
||||
return _interactiveLaunchSpec.BuildForTaskAsync(taskId, Context.ConnectionAborted);
|
||||
});
|
||||
|
||||
// Builds the launch spec for an ad-hoc interactive session in an arbitrary directory --
|
||||
// no task, no worktree, no session-skills seeding.
|
||||
public Task<LaunchSpec> GetAdHocLaunchSpec(string directory) => HubGuard(() =>
|
||||
{
|
||||
if (_interactiveLaunchSpec is null)
|
||||
throw new InvalidOperationException("Interactive launch spec service is not configured.");
|
||||
return _interactiveLaunchSpec.BuildForDirectoryAsync(directory, Context.ConnectionAborted);
|
||||
});
|
||||
|
||||
// Builds the launch spec for an embedded ConPTY "merge helper" session that drives the given
|
||||
// tasks to a merged/Done state via the mcp__claudedo__* tools. listId scopes the brief label
|
||||
// and cwd to that list; null means all lists.
|
||||
public Task<LaunchSpec> GetMergeHelperLaunchSpec(string[] taskIds, string? listId) => HubGuard(() =>
|
||||
{
|
||||
if (_interactiveLaunchSpec is null)
|
||||
throw new InvalidOperationException("Interactive launch spec service is not configured.");
|
||||
return _interactiveLaunchSpec.BuildForMergeHelperAsync(taskIds, listId, Context.ConnectionAborted);
|
||||
});
|
||||
|
||||
// Starts a planning session (worktree + prompt files + token, task -> Planning) and returns
|
||||
// the launch spec for an embedded ConPTY planning terminal -- the ConPTY replacement for
|
||||
// StartPlanningSessionAsync's external wt window. On any spec-build failure the just-started
|
||||
// session is discarded (no children exist yet), mirroring StartPlanningSessionAsync.
|
||||
public Task<LaunchSpec> GetPlanningStartLaunchSpec(string taskId) => HubGuard(async () =>
|
||||
{
|
||||
if (_interactiveLaunchSpec is null)
|
||||
throw new InvalidOperationException("Interactive launch spec service is not configured.");
|
||||
var ctx = await _planning.StartAsync(taskId, Context.ConnectionAborted);
|
||||
try
|
||||
{
|
||||
var spec = _interactiveLaunchSpec.BuildPlanningStart(ctx);
|
||||
await Clients.All.SendAsync("TaskUpdated", taskId);
|
||||
return spec;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await _planning.DiscardAsync(taskId, dequeueQueuedChildren: true, Context.ConnectionAborted);
|
||||
throw;
|
||||
}
|
||||
});
|
||||
|
||||
// Resumes a planning session and returns the ConPTY launch spec (--permission-mode plan
|
||||
// --resume). ConPTY replacement for ResumePlanningSessionAsync's external wt window.
|
||||
public Task<LaunchSpec> GetPlanningResumeLaunchSpec(string taskId) => HubGuard(async () =>
|
||||
{
|
||||
if (_interactiveLaunchSpec is null)
|
||||
throw new InvalidOperationException("Interactive launch spec service is not configured.");
|
||||
var ctx = await _planning.ResumeAsync(taskId, Context.ConnectionAborted);
|
||||
return _interactiveLaunchSpec.BuildPlanningResume(ctx);
|
||||
});
|
||||
|
||||
// Submits an interactively-worked task for review: commits whatever the ConPTY session left
|
||||
// in the worktree (so there is a diff to merge), then transitions Idle/Failed -> WaitingForReview.
|
||||
// The normal Approve flow then merges it. This is the only path that flips a hand-driven session
|
||||
// into the review pipeline — a ConPTY session never touches task status on its own.
|
||||
public Task SubmitTaskForReview(string taskId) => HubGuard(async () =>
|
||||
{
|
||||
if (_worktreeManager is null)
|
||||
throw new InvalidOperationException("Worktree manager is not configured.");
|
||||
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
var task = await new TaskRepository(ctx).GetByIdAsync(taskId, Context.ConnectionAborted)
|
||||
?? throw new KeyNotFoundException();
|
||||
if (task.Status is TaskStatus.Running or TaskStatus.Queued)
|
||||
throw new InvalidOperationException("Can't submit a running or queued task — interrupt it first.");
|
||||
if (task.Status is TaskStatus.WaitingForReview or TaskStatus.WaitingForChildren)
|
||||
throw new InvalidOperationException("Task is already awaiting review.");
|
||||
|
||||
var worktree = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, Context.ConnectionAborted);
|
||||
if (worktree is null || worktree.State is not (WorktreeState.Active or WorktreeState.Kept))
|
||||
throw new InvalidOperationException("This task has no active worktree to submit.");
|
||||
if (!Directory.Exists(worktree.Path))
|
||||
throw new InvalidOperationException("The task's worktree directory no longer exists.");
|
||||
|
||||
var list = await new ListRepository(ctx).GetByIdAsync(task.ListId, Context.ConnectionAborted)
|
||||
?? throw new InvalidOperationException("Task list not found.");
|
||||
|
||||
var wtCtx = new WorktreeContext(worktree.Path, worktree.BranchName, worktree.BaseCommit);
|
||||
await _worktreeManager.CommitIfChangedAsync(wtCtx, task, list, Context.ConnectionAborted);
|
||||
|
||||
var result = await _state.SubmitInteractiveForReviewAsync(taskId, DateTime.UtcNow, Context.ConnectionAborted);
|
||||
if (!result.Ok)
|
||||
throw new InvalidOperationException(result.Reason ?? "Could not submit for review.");
|
||||
});
|
||||
|
||||
public async Task<DiscardPlanningOutcome> DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false)
|
||||
{
|
||||
|
||||
@@ -115,7 +115,7 @@ public sealed class TaskMergeService
|
||||
return Blocked("working directory is not a git repository");
|
||||
if (await _git.IsMidMergeAsync(list.WorkingDir, ct))
|
||||
return Blocked("target working directory is mid-merge");
|
||||
if (await _git.HasChangesAsync(list.WorkingDir, ct))
|
||||
if (await _git.HasChangesAsync(list.WorkingDir, includeUntracked: false, ct))
|
||||
return Blocked("target working tree has uncommitted changes");
|
||||
|
||||
var currentBranch = await _git.GetCurrentBranchAsync(list.WorkingDir, ct);
|
||||
@@ -204,6 +204,26 @@ public sealed class TaskMergeService
|
||||
if (!await _git.IsMidMergeAsync(list.WorkingDir, ct))
|
||||
return Blocked("repo is not mid-merge");
|
||||
|
||||
// Validate BEFORE staging: `git add` marks a conflicted path resolved regardless of
|
||||
// its content, so an unresolved file with markers still in it would otherwise get
|
||||
// staged (and committed) as-is. Check text content for markers first; binary files
|
||||
// can't carry markers, so they're left to the post-stage index check below.
|
||||
var unresolved = await _git.ListConflictedFilesAsync(list.WorkingDir, ct);
|
||||
var stillConflicted = new List<string>();
|
||||
foreach (var path in unresolved)
|
||||
{
|
||||
var full = Path.Combine(list.WorkingDir, path.Replace('/', Path.DirectorySeparatorChar));
|
||||
string text;
|
||||
try { text = await File.ReadAllTextAsync(full, ct); }
|
||||
catch { continue; }
|
||||
|
||||
if (!LooksBinary(text) && ConflictMarkerParser.HasConflicts(text))
|
||||
stillConflicted.Add(path);
|
||||
}
|
||||
|
||||
if (stillConflicted.Count > 0)
|
||||
return new MergeResult(StatusConflict, stillConflicted, "conflicts not fully resolved");
|
||||
|
||||
await _git.AddAllAsync(list.WorkingDir, ct);
|
||||
|
||||
var remaining = await _git.ListConflictedFilesAsync(list.WorkingDir, ct);
|
||||
@@ -322,7 +342,11 @@ public sealed class TaskMergeService
|
||||
return new MergePreviewResult(PreviewClean, Array.Empty<string>(), count);
|
||||
}
|
||||
|
||||
public async Task<MergeResult> ApproveAndMergeAsync(string taskId, string targetBranch, CancellationToken ct)
|
||||
public Task<MergeResult> ApproveAndMergeAsync(string taskId, string targetBranch, CancellationToken ct)
|
||||
=> ApproveAndMergeAsync(taskId, targetBranch, leaveConflictsInTree: false, ct);
|
||||
|
||||
public async Task<MergeResult> ApproveAndMergeAsync(
|
||||
string taskId, string targetBranch, bool leaveConflictsInTree, CancellationToken ct)
|
||||
{
|
||||
var (task, list, wt) = await LoadMergeContextAsync(taskId, ct);
|
||||
|
||||
@@ -347,7 +371,7 @@ public sealed class TaskMergeService
|
||||
// MergeAsync transitions the task WaitingForReview -> Done on a successful merge.
|
||||
// Remove the worktree on approve (matching the unit-merge path) so merged
|
||||
// worktrees don't pile up; the merge commit on the target branch is the record.
|
||||
return await MergeAsync(taskId, target, removeWorktree: true, $"Merge {wt.BranchName}", ct);
|
||||
return await MergeAsync(taskId, target, removeWorktree: true, $"Merge {wt.BranchName}", leaveConflictsInTree, ct);
|
||||
}
|
||||
|
||||
private static MergeResult Blocked(string reason) =>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user