Compare commits
172
Commits
bae8921201
...
v2.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
711374e858 | ||
|
|
faf6104645 | ||
|
|
3eea2b7c96 | ||
|
|
afe7218b7c | ||
|
|
fd1e38fb7f | ||
|
|
e7fa373a74 | ||
|
|
7c9ff18ced | ||
|
|
84034e8395 | ||
|
|
8e1732a3a0 | ||
|
|
786eb2877f | ||
|
|
bdda98eccd | ||
|
|
9c292e5080 | ||
|
|
1fe72a1fe2 | ||
|
|
140b8e1551 | ||
|
|
9effddeb2c | ||
|
|
30e87e698e | ||
|
|
d8a043fae7 | ||
|
|
10342bc562 | ||
|
|
917301d61c | ||
|
|
c7f8280106 | ||
|
|
bec26b2232 | ||
|
|
05aec8ebfa | ||
|
|
946d26cc4b | ||
|
|
3b629c218f | ||
|
|
9eb54a0d2f | ||
|
|
1c94fbdb14 | ||
|
|
7f4dc8b973 | ||
|
|
f6ecfc995f | ||
|
|
f63be285a2 | ||
|
|
e2fad88f37 | ||
|
|
fbcffce79c | ||
|
|
5f6e7480f2 | ||
|
|
4e2798b400 | ||
|
|
b1bd91292f | ||
|
|
283310a3fd | ||
|
|
15a3e65508 | ||
|
|
5a21d673c1 | ||
|
|
42da840066 | ||
|
|
aa7a49f634 | ||
|
|
7b6a8f0852 | ||
|
|
d00899b655 | ||
|
|
66907d24c9 | ||
|
|
38defee3d8 | ||
|
|
d80a57836c | ||
|
|
178fd25b55 | ||
|
|
df84fc3f2c | ||
|
|
ea16da2756 | ||
|
|
f86b78593e | ||
|
|
19340fd9de | ||
|
|
0a119f1450 | ||
|
|
167d2fec6a | ||
|
|
4022bd7197 | ||
|
|
c4f74a7aea | ||
|
|
08a4f97a78 | ||
|
|
eb0ddb56d3 | ||
|
|
60eb671e8f | ||
|
|
134b9fb598 | ||
|
|
9301bbc81a | ||
|
|
637886f33a | ||
|
|
3cb4802f38 | ||
|
|
8716dd8e3a | ||
|
|
d8ff8cc110 | ||
|
|
f7e946e472 | ||
|
|
6a0c0f59a5 | ||
|
|
5be4b5c5fb | ||
|
|
3f9f047955 | ||
|
|
5231ad6b86 | ||
|
|
d598a539bc | ||
|
|
1fb2e34f85 | ||
|
|
b3e099ca01 | ||
|
|
0993eb0e75 |
@@ -145,18 +145,19 @@ jobs:
|
||||
ZIP_NAME="ClaudeDo-${VERSION}-win-x64.zip"
|
||||
( cd bundle && zip -r -q "../assets/${ZIP_NAME}" app worker )
|
||||
|
||||
# 2) Installer single-file exe (renamed)
|
||||
# 2) Installer single-file exe — STABLE name (no version) so the download URL
|
||||
# (…/releases/latest/download/ClaudeDo.Installer.exe) stays permanent.
|
||||
INSTALLER_EXE=$(ls out/installer/*.exe | head -n 1)
|
||||
if [ -z "$INSTALLER_EXE" ]; then
|
||||
echo "::error::No .exe produced by installer publish" >&2
|
||||
exit 1
|
||||
fi
|
||||
cp "$INSTALLER_EXE" "assets/ClaudeDo.Installer-${VERSION}.exe"
|
||||
cp "$INSTALLER_EXE" "assets/ClaudeDo.Installer.exe"
|
||||
|
||||
# 3) Checksums (sha256, relative filenames)
|
||||
( cd assets && sha256sum \
|
||||
"ClaudeDo-${VERSION}-win-x64.zip" \
|
||||
"ClaudeDo.Installer-${VERSION}.exe" \
|
||||
"ClaudeDo.Installer.exe" \
|
||||
> checksums.txt )
|
||||
|
||||
echo "--- assets ---"
|
||||
@@ -200,7 +201,7 @@ jobs:
|
||||
cd "$WORK/src/assets"
|
||||
for f in \
|
||||
"ClaudeDo-${VERSION}-win-x64.zip" \
|
||||
"ClaudeDo.Installer-${VERSION}.exe" \
|
||||
"ClaudeDo.Installer.exe" \
|
||||
"checksums.txt"
|
||||
do
|
||||
echo "Uploading: $f"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
# Local dev worktrees (created by using-git-worktrees skill)
|
||||
.worktrees/
|
||||
.claude/worktrees/
|
||||
|
||||
# Brainstorming visual companion artifacts
|
||||
.superpowers/
|
||||
|
||||
+125
@@ -1,5 +1,130 @@
|
||||
# Changelog
|
||||
|
||||
## 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
-37
@@ -1,56 +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`.
|
||||
## 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,104 @@
|
||||
# Feature unification — phased plan
|
||||
|
||||
Date: 2026-06-19
|
||||
Design: `docs/superpowers/specs/2026-06-19-feature-unification-design.md`
|
||||
|
||||
Six slices, sequenced cheapest/lowest-risk first. Each ends green
|
||||
(`dotnet build -c Release` + the touched test project) and is independently
|
||||
committable. Phases 0–1 are detailed here; 2–5 are scoped, and each gets its own
|
||||
`docs/superpowers/plans/2026-06-19-unify-<slice>.md` when picked up (per the
|
||||
2026-06-05 layer-A/B/C convention). Build per-csproj (`-c Release`) — `.slnx` needs
|
||||
.NET 9 and a running Worker locks `Debug`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Groundwork (Bucket C). No UX change.
|
||||
|
||||
**0a. Delete the dead hunks conflict API (C1).**
|
||||
- Remove `TaskMergeService.GetConflictsAsync` + the `MergeConflicts`/`ConflictFileContent` records it returns (`src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs:250`) if unused elsewhere.
|
||||
- Remove `WorkerHub.GetMergeConflicts` (`src/ClaudeDo.Worker/Hub/WorkerHub.cs:378`) + `MergeConflictsDto`/`ConflictFileDto`/`ConflictHunkDto` if unused.
|
||||
- Remove `WorkerClient`'s `"GetMergeConflicts"` invoke (`src/ClaudeDo.Ui/Services/WorkerClient.cs:276`) + the `IWorkerClient` member + every fake override (`tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`, `TasksIslandViewModelPlanningTests.cs`, others — grep `GetMergeConflicts`).
|
||||
- Delete `TaskMergeServiceTests.cs:672` `GetConflictsAsync_AfterConflictMerge_ReturnsOursAndTheirs`.
|
||||
- Verify with grep first: `GetConflictsAsync` and `GetMergeConflicts` have **no** callers outside this chain + tests.
|
||||
- Acceptance: Worker + Ui build; Worker.Tests + Ui.Tests green; `GetMergeConflictDocuments` path untouched.
|
||||
|
||||
**0b. Single task-creation path (C2).**
|
||||
- Identify the path MCP `ExternalMcpService.AddTask` uses; expose a thin creation method (repository or a small `TaskCreationService`) that applies the same defaults (ListId, SortOrder, CreatedAt).
|
||||
- Re-point `TasksIslandViewModel.AddAsync` at it instead of `db.Tasks.Add` direct EF.
|
||||
- Acceptance: quick-add still works; one creation path; Ui.Tests + Worker.Tests green.
|
||||
|
||||
**0c. Prune stale worktrees (C3).**
|
||||
- `git worktree list`; remove the orphaned `.claude/worktrees/*` entries (confirm each is unwanted with Mika before `git worktree remove`).
|
||||
- Acceptance: only intended worktrees remain; no tracked files change.
|
||||
|
||||
> C4 (naming alignment) intentionally NOT in this phase — see design.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — DialogService (B3–B5). Low–medium.
|
||||
|
||||
**Goal:** one `IDialogService` replaces the scattered `Show*` Func seams and the
|
||||
duplicate open-commands.
|
||||
|
||||
- New `IDialogService` (Ui/Services) with typed methods: `OpenListSettings(ListNavItemViewModel)`, `OpenRepoImport()`, `OpenWorktreesOverview(string? listId)`, `OpenWeeklyReport()`, `OpenAbout()`, `OpenWorkerConnectionHelp()`. Implementation owns the factories + `ModalShell`/TCS wiring currently in `MainWindow.axaml.cs` + `IslandsShellViewModel.cs:59-71`.
|
||||
- Inject it into `ListsIslandViewModel`, `TasksIslandViewModel`, `IslandsShellViewModel`. Collapse the three List-Settings doors (Lists context menu, Tasks header, shell bridge `IslandsShellViewModel.cs:190-194`) to one `dialogs.OpenListSettings(row)` call; same for Repo Import (2→1) and Worktrees Overview (2→1, keep the `listId?` param for global-vs-per-list).
|
||||
- Keep `ModalShell`/TCS dialog pattern; this only centralizes *opening*.
|
||||
- Update fakes/ctors per the IWorkerClient-fakes hazard (ctor changes ripple to Ui.Tests).
|
||||
- Acceptance: every dialog opens via one method; no duplicate open-commands; Ui.Tests green; visual gap flagged (open each dialog from each former door).
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — MergeCoordinator (B1). Medium.
|
||||
|
||||
**Goal:** delete the five `RequestConflictResolution` seams; one coordinator.
|
||||
|
||||
- New `IMergeCoordinator` (Ui) `MergeAsync(taskId, targetBranch)` = the body of `IslandsShellViewModel.RequestConflictResolutionAsync` (`:49`) plus the "open MergeModal → on conflict open resolver" flow currently split across `MergeModalViewModel:108` and `DiffModalViewModel:103`.
|
||||
- Remove the `Func<string,string,Task>? RequestConflictResolution` from `WorktreesOverviewModalViewModel:83`, `DiffModalViewModel:75`, `MergeModalViewModel:33`, `MergeSectionViewModel:51`, and the `DetailsIslandViewModel:347` delegate; inject the coordinator instead.
|
||||
- Re-point doors: review Approve, Diff Merge button, WorktreesOverview single + batch (`:331`), Details merge section.
|
||||
- Update seam tests (`WorktreesOverviewBatchMergeTests.cs:145`, `DetailsIslandConflictSeamTests.cs:84`) to assert via the coordinator.
|
||||
- Acceptance: one merge entry API; resolver still opens for single-task AND planning conflict; Ui.Tests green; visual gap flagged (force a conflict from Approve and from the Diff Merge button).
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — WorktreeActions (A3). Medium.
|
||||
|
||||
**Goal:** one per-task worktree-actions VM reused by overview rows + Details.
|
||||
|
||||
- New `WorktreeActionsViewModel(taskId)` with Merge/Diff/Discard/Keep/ForceRemove over `IWorkerClient` (uses the Phase-2 coordinator for Merge, the Phase-5 viewer for Diff — until then, current calls).
|
||||
- `WorktreesOverviewModalViewModel` rows compose one each; `MergeSectionViewModel` hosts one for the active task. Remove the duplicated commands.
|
||||
- Acceptance: both surfaces drive the same VM; Ui.Tests green; visual gap flagged.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — AgentConfigEditor (A2). Medium.
|
||||
|
||||
**Goal:** one config editor for Global | List | Task scope.
|
||||
|
||||
- New `AgentConfigEditorViewModel(scope)` over `InheritanceResolver` exposing Model/SystemPrompt/AgentPath/MaxTurns + reset commands + `InheritedBadge` state; persists via the scope's hub method (`UpdateListConfig` / `UpdateTaskAgentSettings` / app settings).
|
||||
- Embed in `SettingsModalViewModel`, `ListSettingsModalViewModel`, and the Details `AgentSettingsSectionViewModel` host; delete the duplicated field/reset logic.
|
||||
- Acceptance: identical editor in all three scopes; Localization parity; Ui.Tests green; visual gap flagged.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — DiffViewer (A1 + B2). High; last.
|
||||
|
||||
**Goal:** one diff component replaces DiffModal + WorktreeModal + PlanningDiff.
|
||||
|
||||
- New `DiffViewerViewModel` with `DiffSource` enum/abstraction (`DirtyWorktree | BranchVsBase | CommitRange | PlanningAggregate | IntegrationBranch`) and an optional file-tree pane (port `WorktreeModal`'s tree + Avalonia-12 selection workaround); reuse `UnifiedDiffParser` + `DiffLinesView`; keep PlanningDiff's combined-mode toggle as a source switch.
|
||||
- Re-point all B2 doors to open it with the right source. Remove the three old VMs/views.
|
||||
- Update `DiffModalViewModelTests`, `PlanningDiffViewModelTests`.
|
||||
- Acceptance: every diff door opens the one viewer; whole-unified AND file-tree layouts work; Ui.Tests green; visual gap flagged (worktree-dirty, post-merge commit-range, planning per-subtask + integration).
|
||||
|
||||
---
|
||||
|
||||
## Sequencing rationale
|
||||
|
||||
0 (delete/no-UX) → 1 (isolated, unblocks nothing but cheap) → 2 (coordinator; 3 & 5
|
||||
lean on it for Merge/Diff) → 3 → 4 (independent) → 5 (biggest, most UX-sensitive,
|
||||
benefits from 2's coordinator). Stop after any phase and the app is shippable.
|
||||
|
||||
## Per-phase commits
|
||||
|
||||
Conventional Commits, one per phase (or per sub-step in Phase 0): e.g.
|
||||
`refactor(merge): single MergeCoordinator replaces 5 conflict seams`. Stage by path
|
||||
(never `git add -A` — concurrent sessions). Commit the spec + this plan first.
|
||||
@@ -0,0 +1,131 @@
|
||||
# Phase 4 — AgentConfigEditor (A2)
|
||||
|
||||
Date: 2026-06-23 (picked up after reordering Phase 3 ↔ 4)
|
||||
Umbrella: `docs/superpowers/plans/2026-06-19-feature-unification-plan.md`
|
||||
Design: `docs/superpowers/specs/2026-06-19-feature-unification-design.md` (A2)
|
||||
|
||||
## Reordering note
|
||||
|
||||
Phase 3 (WorktreeActions) was deferred. Its premise — overview rows and the Details
|
||||
merge section each owning duplicate worktree commands — only half-holds: Details has
|
||||
no Discard/Keep/ForceRemove, and the two Diff doors open different VMs (`WorktreeModal`
|
||||
vs `DiffModal`) that only Phase 5 unifies. So Phase 3's clean form depends on Phase 5
|
||||
(Diff) and a fuller MergeCoordinator (Merge); doing it now would build throwaway
|
||||
per-surface delegates. **Phase 3 is folded into Phase 5.** Phase 4 (independent, clean
|
||||
dedup) runs now.
|
||||
|
||||
## Scope decision: List + Task only (global left as-is)
|
||||
|
||||
The design names three scopes (Global | List | Task). Verified against the tree on
|
||||
2026-06-23, only **List and Task genuinely duplicate**:
|
||||
|
||||
- **List** (`ListSettingsModalViewModel`, "AGENT" section): Model / MaxTurns /
|
||||
SystemPrompt / AgentFile, each with `InheritedBadge` + `↺` reset; 2-tier
|
||||
(list→global) badges computed with inline logic (does **not** use the existing
|
||||
`InheritanceResolver.ResolveList` — which is currently dead code); explicit Save.
|
||||
- **Task** (`AgentSettingsSectionViewModel`, TaskHeaderBar gear flyout): same four
|
||||
fields; 3-tier (task→list→global) badges via `InheritanceResolver.Resolve`;
|
||||
`EffectiveMaxTurns` + `EffectiveSystemPromptHint`; `IsRunning` gate; debounced
|
||||
auto-save.
|
||||
|
||||
**Global** (`GeneralSettingsTabViewModel`, Settings → General) is the root: no
|
||||
inheritance, no badges, no agent file, no reset — three plain controls (model combo,
|
||||
max-turns numeric, instructions textbox) plus a global-only PermissionMode, interleaved
|
||||
with unrelated settings (Language, parallelism, report paths, standup weekday) and
|
||||
saved batched into one `AppSettingsDto` via the modal Save. Embedding the shared editor
|
||||
there buys ~3 plain fields at the cost of a degenerate no-badges/no-agent/no-reset mode
|
||||
plus surgery on the settings save path and a relayout of the most settings-dense view.
|
||||
**Not worth it — global stays as-is.** (Confirmed with Mika 2026-06-23.)
|
||||
|
||||
The real maintenance hazard is the **VM logic** (two copies of badge/reset/inheritance
|
||||
that already drifted), and the **view** (3 of 4 field blocks are pixel-identical). Both
|
||||
collapse cleanly for List+Task.
|
||||
|
||||
## Target
|
||||
|
||||
One `AgentConfigEditorViewModel` + one `AgentConfigEditor` UserControl, instantiated
|
||||
per surface with a scope. The two host VMs keep only their non-agent concerns and host
|
||||
the editor as a child.
|
||||
|
||||
### `ViewModels/Agent/AgentConfigEditorViewModel.cs` (new)
|
||||
|
||||
- `enum AgentConfigScope { List, Task }`
|
||||
- ctor `(IWorkerClient worker, AgentConfigScope scope)`
|
||||
- Unified bindable surface (single names both views bind to):
|
||||
`Model` (string?), `MaxTurns` (decimal?), `SystemPrompt` (string),
|
||||
`SelectedAgent` (AgentInfo?); `ModelOptions`, `Agents`;
|
||||
`ModelBadge`/`TurnsBadge`/`AgentBadge`, `ModelInheritedHint`/`TurnsInheritedHint`,
|
||||
`EffectiveSystemPromptHint`; `EffectiveMaxTurns` (int), `IsRunning`/`IsEnabled`.
|
||||
- Reset commands: `ResetModel`, `ResetTurns`, `ResetAgent`, `ResetAll`.
|
||||
- Badges via `InheritanceResolver`: scope==Task → `Resolve(own, list, global)`;
|
||||
scope==List → `ResolveList(own, global)` (adopts the dead method). One `BadgeFor`
|
||||
helper covers both (List scope never yields the `List` source).
|
||||
- Load: `LoadForListAsync(listId)` and `LoadForTaskAsync(TaskEntity entity)` — both
|
||||
pull agents + app-settings (global defaults); Task also pulls the list tier +
|
||||
`EffectiveSystemPromptHint`. Localizer-change re-badges (port the `Loc.LanguageChanged`
|
||||
handler + `IDisposable`).
|
||||
- Save: `SaveAsync()` is scope-aware — List builds `UpdateListConfigDto` →
|
||||
`UpdateListConfigAsync`; Task builds `UpdateTaskAgentSettingsDto` →
|
||||
`UpdateTaskAgentSettingsAsync`. Task scope also auto-saves debounced (300ms) on field
|
||||
changes; List does not (the modal Save button calls `SaveAsync`). `SaveAsync` is
|
||||
directly callable (tests bypass the debounce).
|
||||
- Task-only `Clear()` + `TaskId`.
|
||||
|
||||
### `Views/Controls/AgentConfigEditor.axaml` (+ .axaml.cs) (new)
|
||||
|
||||
- `x:DataType` = `AgentConfigEditorViewModel`; host sets `DataContext="{Binding Agent}"`.
|
||||
- The four field blocks (model/turns/systemprompt/agent) with `InheritedBadge` + `↺`
|
||||
reset, lifted verbatim from the existing two views (they already match). Agent combo
|
||||
shows Name + Description (both scopes; harmless for task). `EffectiveSystemPromptHint`
|
||||
line gated on non-empty (hides for List).
|
||||
- `StyledProperty<bool> ShowAgentBrowse` (default false). True → render the Browse
|
||||
button + path line; the browse file-picker code-behind lives here (moved from
|
||||
`ListSettingsModalView`).
|
||||
- Shared localization namespace `settings.agentEditor.*` (model/maxTurns/systemPrompt/
|
||||
agentFile/promptPrepended). Reset tooltip reuses `settings.inherit.resetToInherited`.
|
||||
|
||||
### Re-point hosts
|
||||
|
||||
- `ListSettingsModalViewModel`: drop the agent fields/badges/resets/option-lists; add
|
||||
`public AgentConfigEditorViewModel Agent { get; }` (scope=List). `LoadAsync` →
|
||||
`Agent.LoadForListAsync(listId)`. `SaveAsync` keeps `UpdateListAsync` (name/dir) and
|
||||
adds `await Agent.SaveAsync()`. Keep working-dir browse (`BrowseClicked`).
|
||||
- `ListSettingsModalView.axaml`: replace the AGENT section body with
|
||||
`<ctl:AgentConfigEditor DataContext="{Binding Agent}" ShowAgentBrowse="True"/>`; the
|
||||
section-header "Reset agent settings" button binds `Agent.ResetAllCommand`. Remove the
|
||||
agent browse code-behind (moved into the control).
|
||||
- `DetailsIslandViewModel`: `AgentSettings` becomes `AgentConfigEditorViewModel`
|
||||
(scope=Task). Preserve the call sites: ctor, `EffectiveMaxTurns`→`TurnsText`
|
||||
PropertyChanged hook, `IsRunning` push, `Dispose`, `Clear`, `TaskId`,
|
||||
`LoadForTaskAsync(entity, ct)`.
|
||||
- `TaskHeaderBar.axaml`: replace the flyout field blocks with
|
||||
`<ctl:AgentConfigEditor DataContext="{Binding AgentSettings}"/>` (ShowAgentBrowse=false).
|
||||
Keep the gear button + heading.
|
||||
- Delete `AgentSettingsSectionViewModel.cs`.
|
||||
|
||||
## Tests
|
||||
|
||||
- New `tests/ClaudeDo.Ui.Tests/ViewModels/AgentConfigEditorViewModelTests.cs`:
|
||||
- List scope: badges resolve override-vs-global; resets clear; `SaveAsync` builds the
|
||||
right `UpdateListConfigDto` (via `StubWorkerClient`).
|
||||
- Task scope: badges resolve override/list/global; `EffectiveMaxTurns`/
|
||||
`EffectiveSystemPromptHint` from list tier; resets clear; `SaveAsync` builds the right
|
||||
`UpdateTaskAgentSettingsDto`.
|
||||
- `InheritanceResolverTests` unchanged (resolver untouched).
|
||||
- Existing DetailsIsland* tests must stay green (they construct the VM but don't name the
|
||||
moved members).
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `dotnet build -c Release` clean for Ui (+ App).
|
||||
- `Ui.Tests` + `Localization.Tests` green.
|
||||
- One editor VM + one control drive both List and Task; duplicated field/badge/reset
|
||||
logic deleted; `ResolveList` now has a real caller.
|
||||
- Visual gap flagged: open List Settings → Agent, and a task's gear flyout — verify
|
||||
badges, ↺ resets, reset-all, agent browse (list only), system-prompt hint (task), and
|
||||
that list Save persists + task auto-saves.
|
||||
|
||||
## Commit
|
||||
|
||||
`refactor(agent-config): single AgentConfigEditor for list + task scopes`. Stage by
|
||||
path. Commit this plan with it.
|
||||
@@ -0,0 +1,111 @@
|
||||
# Phase 5 — DiffViewer (A1 + B2)
|
||||
|
||||
Date: 2026-06-23
|
||||
Umbrella: `docs/superpowers/plans/2026-06-19-feature-unification-plan.md`
|
||||
Design: `docs/superpowers/specs/2026-06-19-feature-unification-design.md` (A1, B2)
|
||||
|
||||
## Goal
|
||||
|
||||
One diff component replaces the three parallel read-only diff windows:
|
||||
`DiffModalViewModel`/View, `WorktreeModalViewModel`/View, `PlanningDiffViewModel`/View.
|
||||
**Merge editor (`ConflictResolverViewModel`) is untouched** — per the design's hard
|
||||
decision; the viewer only *opens* it on conflict via the existing Merge flow.
|
||||
|
||||
All three are already master-detail: **left nav pane + right `DiffLinesView`**. They
|
||||
differ only in left-pane content, chrome, and data source — so they collapse into one
|
||||
shell with a source mode.
|
||||
|
||||
## Decisions (Mika, 2026-06-23)
|
||||
|
||||
- **File nav = file-tree** (folder-grouped), not a flat list. Port `WorktreeModal`'s tree
|
||||
+ the Avalonia-12 `TreeView.SelectionChanged` workaround. Carry per-file status + +adds/
|
||||
−dels into the tree rows (from the parsed `DiffFileViewModel`).
|
||||
- Planning keeps its **subtask-list + combined-mode toggle**; the branch source keeps its
|
||||
**Merge** button.
|
||||
|
||||
## Target
|
||||
|
||||
### Shared types → `ViewModels/Modals/DiffModels.cs` (new, same namespace)
|
||||
|
||||
Move out of the to-be-deleted VMs so `UnifiedDiffParser`/`DiffLinesView` keep compiling:
|
||||
`DiffLineKind`, `DiffFileStatus`, `DiffLineViewModel`, `DiffFileViewModel` (from
|
||||
`DiffModalViewModel.cs`), `SubtaskDiffRow` (from `PlanningDiffViewModel.cs`). Add new
|
||||
`DiffTreeNodeViewModel` (dir/file node; file leaves hold their `DiffFileViewModel`).
|
||||
|
||||
### `DiffViewerViewModel` (`ViewModels/Modals/DiffViewerViewModel.cs`, new)
|
||||
|
||||
ctor `(GitService git, IWorkerClient worker)`. A `DiffViewerMode { Files, Planning }`.
|
||||
|
||||
- **File sources** (replaces DiffModal + WorktreeModal): config props `WorktreePath`,
|
||||
`BaseRef`, `HeadCommit`, `FromCommitRange`, `TaskId`, `TaskTitle` + `ShowMergeModal`/
|
||||
`ResolveMergeVm` delegates. `LoadAsync` pulls the whole diff via GitService
|
||||
(`GetCommitRangeDiffAsync` | `GetBranchDiffAsync` | `GetDiffAsync`), parses with
|
||||
`UnifiedDiffParser.Parse`, builds `FileTree`. `SelectedNode` (leaf) → `SelectedFile`
|
||||
(header + binary/empty placeholders + `Lines`). Commit-range null-guard → "no longer
|
||||
available" (preserve DiffModal behavior). `MergeCommand` (CanMerge = TaskId +
|
||||
delegates) opens the MergeModal, closes on merged/routed (verbatim from DiffModal).
|
||||
- **Planning source** (replaces PlanningDiff): config `PlanningTaskId`, `TargetBranch`.
|
||||
`LoadAsync` pulls `GetPlanningAggregateAsync` → `Subtasks`; `SelectedSubtask` →
|
||||
`DisplayedDiff`; `IsCombinedMode` toggle → `BuildPlanningIntegrationBranchAsync`
|
||||
(success → combined diff; conflict → `CombinedWarning` with subtask + file count;
|
||||
null → hub-error warning). `DisplayedDiff` → flattened `DiffLines` (right pane).
|
||||
- Shared: `StatusMessage`, `CloseAction`, `CloseCommand`.
|
||||
|
||||
### `DiffViewerView` (`Views/Modals/DiffViewerView.axaml` + `.cs`, new)
|
||||
|
||||
`ModalShell`-based window. Left pane: `TreeView` (Files mode) or subtask `ListBox`
|
||||
(Planning mode), toggled by mode. Right pane: the DiffModal file pane (header + binary/
|
||||
empty/no-changes placeholders + `DiffLinesView Lines="SelectedFile.Lines"`) in Files mode,
|
||||
or `DiffLinesView Lines="DiffLines"` in Planning mode. Toolbar: combined toggle + warning
|
||||
+ loading (Planning). Footer: Merge button (Files mode, CanMerge). Code-behind: `CloseAction`,
|
||||
the `TreeView.SelectionChanged` → `SelectedNode` workaround, dir-row tap-to-expand.
|
||||
|
||||
### Re-point the 3 doors → one viewer
|
||||
|
||||
- **`MergeSectionViewModel`**: `OpenDiffAsync` builds a Files-mode `DiffViewerViewModel`
|
||||
(+ ShowMergeModal/ResolveMergeVm) and calls a single `ShowDiffViewer` delegate;
|
||||
`ReviewCombinedDiffAsync` builds a Planning-mode one and calls the *same* delegate.
|
||||
Replaces `ShowDiffModal` + `ShowPlanningDiffModal` with one `Func<DiffViewerViewModel,Task>
|
||||
ShowDiffViewer`; keeps `ShowMergeModal`. (Resolve the VM via `_services`.)
|
||||
- **`DetailsIslandView.axaml.cs`**: replace the two `ShowDiffModal`/`ShowPlanningDiffModal`
|
||||
wirings (→ `DiffModalView`/`PlanningDiffView`) with one `ShowDiffViewer` (→ `DiffViewerView`).
|
||||
Keep `ShowMergeModal`.
|
||||
- **`WorktreesOverviewModalViewModel`**: `ShowDiff` builds a Files-mode viewer (worktree path
|
||||
+ base). Change `_diffVmFactory` from `Func<WorktreeModalViewModel>` to
|
||||
`Func<DiffViewerViewModel>`; `ShowDiffAction` stays `Action<DiffViewerViewModel>`.
|
||||
- **`WindowDialogService.cs`**: `ShowDiffAction` → `new DiffViewerView` + `LoadAsync` + show.
|
||||
- **`Program.cs`**: register `DiffViewerViewModel` (transient) + `Func<DiffViewerViewModel>`;
|
||||
drop the `WorktreeModalViewModel` registration.
|
||||
|
||||
### Delete
|
||||
|
||||
`DiffModalViewModel.cs`, `WorktreeModalViewModel.cs`, `PlanningDiffViewModel.cs`,
|
||||
`DiffModalView.axaml(.cs)`, `WorktreeModalView.axaml(.cs)`, `PlanningDiffView.axaml(.cs)`.
|
||||
|
||||
### Localization
|
||||
|
||||
Reuse existing keys in the merged view (`modals.diff.*` for the file pane, `planning.diff.*`
|
||||
for the planning toolbar). Prune clearly-orphaned `modals.worktree.*` if trivial; keep en/de
|
||||
parity.
|
||||
|
||||
## Tests
|
||||
|
||||
Replace `DiffModalViewModelTests` + `PlanningDiffViewModelTests` with
|
||||
`DiffViewerViewModelTests` preserving the behaviors: commit-range null-guard → unavailable;
|
||||
planning init populates + selects first; subtask select → DisplayedDiff; combined toggle
|
||||
success/conflict/null. `WorktreesOverviewBatchMergeTests` compiles unchanged (`() => null!`
|
||||
satisfies the new Func type). `UnifiedDiffParserTests` unchanged.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `dotnet build -c Release` clean (App); `Ui.Tests` + `Localization.Tests` green.
|
||||
- One viewer reached from all 3 doors; old VMs/views deleted; merge editor untouched.
|
||||
- Visual gap flagged: Details "Open Diff" (dirty + post-merge commit-range), Worktrees-
|
||||
Overview "Show Diff" (tree), Details "Review Combined Diff" (subtasks + combined toggle),
|
||||
and the Merge button still opens the merge form / resolver on conflict.
|
||||
|
||||
## Commit
|
||||
|
||||
`refactor(diff): single DiffViewer replaces DiffModal + WorktreeModal + PlanningDiff`.
|
||||
Stage by path (exclude concurrent peers' files). Then Phase 3 (WorktreeActions) follows as
|
||||
its own slice, reusing this viewer.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Plan — Worker log → footer + Log Visualizer overlay
|
||||
|
||||
Design: `docs/superpowers/specs/2026-06-23-worker-log-footer-overlay-design.md`. Build on `main`, TDD, commit per task (Conventional Commits, explicit paths — shared worktree). Build `-c Release`.
|
||||
|
||||
## Task 1 — `LogRingBuffer` (Worker) + tests
|
||||
- `src/ClaudeDo.Worker/Logging/WorkerLogRecord.cs` — `record WorkerLogRecord(string Message, WorkerLogLevel Level, DateTime TimestampUtc)`.
|
||||
- `src/ClaudeDo.Worker/Logging/LogRingBuffer.cs` — thread-safe, `TimeSpan window` + int cap; `Append(record)`, `Snapshot()`. Uses an injected clock func (`Func<DateTime>`) for testability (default `() => DateTime.UtcNow`).
|
||||
- Tests: age eviction, cap eviction, snapshot order. **No `DateTime.UtcNow` in tests — drive the clock.**
|
||||
|
||||
## Task 2 — `BroadcastLogSink` (Worker) + tests
|
||||
- `src/ClaudeDo.Worker/Logging/BroadcastLogSink.cs : ILogEventSink` — level map, render (+exception first line), append-all-levels, broadcast Warn/Err via deferred `HubBroadcaster` (`Attach`), dedupe window (const 120s), loop-guard (skip SignalR `SourceContext` for broadcast; swallow broadcast exceptions). Inject clock func.
|
||||
- Broadcaster is an abstraction the test can fake: depend on a tiny `Func<string,WorkerLogLevel,DateTime,Task>?` set by `Attach`, OR on `HubBroadcaster` directly (it's a sealed class — prefer a delegate to keep the test pure). Use a delegate.
|
||||
- Tests: all levels buffered; only Warn/Err invoke the broadcast delegate; dedupe suppresses 2nd identical within window but still buffers; exception rendering; SignalR-source event buffered but not broadcast.
|
||||
|
||||
## Task 3 — wire into `Program.cs` + `WorkerHub.GetRecentLogs`
|
||||
- `Program.cs`: create `LogRingBuffer` + `BroadcastLogSink` locals before build; `.WriteTo.Sink(broadcastSink)`; `AddSingleton(logBuffer)`; after build `broadcastSink.Attach((m,l,t) => broadcaster.WorkerLog(m,l,t))` using resolved `HubBroadcaster`.
|
||||
- `WorkerHub`: inject `LogRingBuffer`; `public IReadOnlyList<WorkerLogRecordDto> GetRecentLogs()` → snapshot mapped to DTO. Add `WorkerLogRecordDto` (Hub or shared). Update `WorkerHub` ctor → check hub-construction call sites/tests.
|
||||
- Build Worker `-c Release`; run Worker.Tests (filtered to new + hub).
|
||||
|
||||
## Task 4 — `IWorkerClient.GetRecentLogsAsync` + WorkerClient + fakes
|
||||
- `IWorkerClient` + `WorkerClient` impl (`_hub.InvokeAsync<List<WorkerLogEntry>>("GetRecentLogs", ct)`).
|
||||
- Update fakes: `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`, Worker.Tests UiVm fake(s) → return `Array.Empty<WorkerLogEntry>()`.
|
||||
- Build Ui + Worker.Tests.
|
||||
|
||||
## Task 5 — `LogVisualizerViewModel` + View + dialog wiring + tests
|
||||
- VM (Modals/), View (Modals/, ModalShell), `IDialogService.ShowLogVisualizerAsync` + `WindowDialogService` impl.
|
||||
- `IslandsShellViewModel.OpenLogVisualizerCommand` (resolves VM, loads, shows). Make footer worker-log line a clickable Button → command.
|
||||
- Localization `vm.logVisualizer` en+de.
|
||||
- Tests: VM load/populate/filter. Build App `-c Release`; Ui.Tests + Localization.Tests.
|
||||
|
||||
## Task 6 — verify + docs
|
||||
- Full relevant test pass. Update `src/ClaudeDo.Ui/CLAUDE.md` (overlay VM/view, footer click) + `src/ClaudeDo.Worker/CLAUDE.md` (Logging/ folder, sink, GetRecentLogs, WorkerLog now carries Serilog Warn/Err). Note visual-verification gap (overlay render) for the user.
|
||||
@@ -0,0 +1,56 @@
|
||||
# Plan — Interactive "Answer Claude's Questions"
|
||||
|
||||
Spec: `docs/superpowers/specs/2026-06-25-interactive-ask-user-design.md`
|
||||
|
||||
Implement on the shared main tree. Commit explicit paths per task (never `git add -A`).
|
||||
Build with `-c Release` (running Worker locks Debug). No real-Claude tests.
|
||||
|
||||
## Task 1 — PendingQuestionRegistry (worker, new file)
|
||||
- `src/ClaudeDo.Worker/Runner/PendingQuestionRegistry.cs`: singleton; `record PendingQuestion(TaskId, QuestionId, Question)`.
|
||||
- `(string QuestionId, Task<string> Answer) Register(taskId, question)` — overwrites any stale entry, `RunContinuationsAsynchronously`.
|
||||
- `bool TryAnswer(taskId, questionId, answer)`; `PendingQuestion? Get(taskId)`; `void Remove(taskId, questionId)`.
|
||||
- Test: `tests/ClaudeDo.Worker.Tests/Runner/PendingQuestionRegistryTests.cs` — register→answer resolves the task; wrong questionId no-ops; Get reflects state; second Register overwrites.
|
||||
|
||||
## Task 2 — AskUser MCP tool (worker)
|
||||
- `TaskRunMcpService.cs`: inject `PendingQuestionRegistry`; add
|
||||
`[McpServerTool] async Task<string> AskUser(string question, CancellationToken ct)`:
|
||||
- caller id from `_ctx.Current.CallerTaskId`; register; broadcast `TaskQuestionAsked`.
|
||||
- await answer via `Task<string>.WaitAsync` with a 3-min linked-CTS; on timeout return the fallback string; on request-cancel rethrow.
|
||||
- `finally`: `Remove` + broadcast `TaskQuestionResolved`.
|
||||
- `[Description]`: when to use (only when a wrong guess is costly/irreversible; otherwise proceed).
|
||||
- Test: `tests/ClaudeDo.Worker.Tests/Runner/AskUserToolTests.cs` — answer path returns the answer; timeout path returns fallback (inject a short timeout or a seam) with a fake broadcaster + stub context accessor.
|
||||
|
||||
## Task 3 — Wire MCP for all runs + timeout env (worker)
|
||||
- `TaskRunner.RunAsync`: move MCP-identity setup out of the `standalone` gate so every run gets `claudedo_run`; `AllowedTools` = `mcp__claudedo_run__AskUser` always, append `,mcp__claudedo_run__SuggestImprovement` when standalone. Keep token cleanup in `finally`.
|
||||
- `ClaudeProcess.cs`: `psi.Environment["MCP_TOOL_TIMEOUT"] = "200000";`.
|
||||
- System prompt file (PromptKind.System default): add one guidance line about `AskUser`.
|
||||
|
||||
## Task 4 — Hub + Broadcaster (worker)
|
||||
- `HubBroadcaster.cs`: `TaskQuestionAsked(taskId, questionId, question)`, `TaskQuestionResolved(taskId, questionId)`.
|
||||
- `WorkerHub.cs`: inject registry; `bool AnswerTaskQuestion(taskId, questionId, answer)`; `PendingQuestionDto? GetPendingQuestion(taskId)`; `record PendingQuestionDto(...)`.
|
||||
- `Program.cs`: register `PendingQuestionRegistry` as singleton.
|
||||
|
||||
## Task 5 — UI client (IWorkerClient/WorkerClient + fakes)
|
||||
- `IWorkerClient`: `Task AnswerTaskQuestionAsync(taskId, questionId, answer)`, `Task<PendingQuestionDto?> GetPendingQuestionAsync(taskId)`, events `Action<string,string,string>? TaskQuestionAskedEvent`, `Action<string,string>? TaskQuestionResolvedEvent`; UI DTO record.
|
||||
- `WorkerClient`: implement invokes + `On<...>` handlers raising the events.
|
||||
- Update hand-rolled `IWorkerClient` fakes in Ui.Tests (and Worker.Tests if present).
|
||||
|
||||
## Task 6 — TaskMonitorViewModel (hot file)
|
||||
- Subscribe both events (filter by `_subscribedTaskId`); dispose handlers.
|
||||
- Props: `PendingQuestionId`, `PendingQuestion`, `HasPendingQuestion`, `AnswerDraft`, `IsWaitingForInput`.
|
||||
- `SubmitAnswerCommand` (CanExecute: non-empty draft + HasPendingQuestion) → `AnswerTaskQuestionAsync`; clear draft.
|
||||
- Clear pending on `TaskFinished` for this task and in `Reset()`.
|
||||
- Test: `TaskMonitorViewModelTests` — asked event surfaces question; submit invokes client + clears; resolved/finished clears.
|
||||
|
||||
## Task 7 — Hydrate on attach (MissionControlViewModel)
|
||||
- In `HydrateAsync`, after `ApplyState`, call `GetPendingQuestionAsync(taskId)`; if present, set the monitor's pending question (re-attach case).
|
||||
|
||||
## Task 8 — View banner (hot file, additive)
|
||||
- `MonitorPaneView.axaml`: a `Border DockPanel.Dock="Top"` above `SessionTerminalView`, `IsVisible="{Binding HasPendingQuestion}"`, showing the question text, a `TextBox` bound to `AnswerDraft` (Enter submits), and a Send `Button` → `SubmitAnswerCommand`. Mirror the roadblock-banner styling.
|
||||
|
||||
## Task 9 — Localization
|
||||
- `en.json` + `de.json`: `missionControl.question.title`, `.placeholder`, `.send`. Keep parity (Localization.Tests).
|
||||
|
||||
## Task 10 — Build + test + verify
|
||||
- `dotnet build` App + Worker `-c Release`; run Worker.Tests, Ui.Tests, Localization.Tests.
|
||||
- Self-review diffs. Flag the two manual verification gaps to Mika. Do not push.
|
||||
@@ -0,0 +1,98 @@
|
||||
# Plan — Mission Control (multi-task live monitoring)
|
||||
|
||||
Spec: `docs/superpowers/specs/2026-06-25-mission-control-design.md`
|
||||
|
||||
Execution: subagent-driven, **sonnet** model, TDD where a test is meaningful, build + test before
|
||||
each commit, one Conventional Commit per task. Stage files explicitly by path (never `git add -A`).
|
||||
**No duplication** — every task reuses the assets named in the spec's reuse map.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Extract the reusable monitor core (no behavior change)
|
||||
|
||||
### Task 1.1 — Move `LogLineViewModel` + `LogKind` to their own file
|
||||
- Cut `LogKind` enum and `LogLineViewModel` from `DetailsIslandViewModel.cs` into
|
||||
`ViewModels/Islands/LogLineViewModel.cs` (same namespace). No logic change.
|
||||
- Build `ClaudeDo.App`; run Ui.Tests. Commit: `refactor(ui): split LogLineViewModel into own file`.
|
||||
|
||||
### Task 1.2 — Create `TaskMonitorViewModel` owning the streaming/status/outcome core
|
||||
- New `ViewModels/Islands/TaskMonitorViewModel.cs`. Move from `DetailsIslandViewModel`:
|
||||
`Log`, `_subscribedTaskId`, `_formatter`, `_claudeBuf`, `OnTaskMessage`, `AppendStdoutLine`,
|
||||
`FlushClaudeBuffer`, `ReplayLogFileAsync`, `ExpandUserPath`; `AgentState` + all `Is*` flags +
|
||||
`OnAgentStateChanged`; `StatusToStateKey` / `FinishedStatusToStateKey`; `SessionOutcome` /
|
||||
`Roadblocks` + `ApplyOutcome` + `RoadblockMarker`; the worker `TaskMessage/Started/Finished/Updated`
|
||||
subscriptions for the streaming concern; `Title`/`TaskIdBadge`/`Model`/`TurnsText`/`TokensFormatted`/
|
||||
diff text/elapsed; `BlockingReason` (+visible flag) from `BlockedByTaskId`/review/children/roadblocks.
|
||||
- Ctor takes `IDbContextFactory<ClaudeDoDbContext>`, `IWorkerClient`. `Attach(taskId)` /
|
||||
`AttachAsync(entity)` to (re)bind + replay; `IDisposable` unsubscribes (mirror existing Dispose).
|
||||
- Unit test (Ui.Tests): feed `[stdout]`/`[claude]`/`[tool]` lines via the worker fake → `Log`
|
||||
accumulates correctly; `TaskFinished` flips `AgentState`; `ApplyOutcome` splits the roadblock marker.
|
||||
Reuse the existing IWorkerClient fake (see `iworkerclient_fakes_sync`).
|
||||
- Build + test. Commit: `feat(ui): extract TaskMonitorViewModel streaming core`.
|
||||
|
||||
### Task 1.3 — `DetailsIslandViewModel` delegates to `Monitor`
|
||||
- Add `public TaskMonitorViewModel Monitor { get; }`; construct it; route `Bind`/`BindAsync` to
|
||||
`Monitor.Attach`. Remove the moved members; keep subtasks/attachments/editing/merge/review/child
|
||||
outcomes/notes/prep intact. Dispose `Monitor`.
|
||||
- Repoint `WorkConsole.axaml` Output-tab bindings (`Log`, `IsRunning/IsDone/IsFailed`,
|
||||
`SessionOutcome`, `TurnsText`, `DiffAddText`/`DiffDelText`, `Model`) to `Monitor.*`. Leave
|
||||
review/merge/session bindings unchanged.
|
||||
- Build + test. **Manual visual pass: Details pane behaves exactly as before** (flag for Mika).
|
||||
Commit: `refactor(ui): route DetailsIsland streaming through Monitor`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Mission Control window
|
||||
|
||||
### Task 2.1 — `MissionControlViewModel`
|
||||
- New `ViewModels/MissionControlViewModel.cs`: `ObservableCollection<TaskMonitorViewModel> Monitors`
|
||||
keyed by id; seed from `GetActive()`; add on `TaskStarted`, flip-state-and-keep on `TaskFinished`;
|
||||
`ClearFinished` command; `ColumnCount`/layout signal from `Monitors.Count`; least-active collapse.
|
||||
`IDisposable` disposes all monitors. Inject `IDbContextFactory`, `IWorkerClient`, `IServiceProvider`.
|
||||
- Register `AddSingleton<MissionControlViewModel>` in `App/Program.cs`.
|
||||
- Unit test: simulate two `TaskStarted` → two monitors; `TaskFinished` keeps the pane; `ColumnCount`
|
||||
matches count. Commit: `feat(ui): add MissionControlViewModel`.
|
||||
|
||||
### Task 2.2 — `RevealTaskAsync` navigation on the shell
|
||||
- Add `IslandsShellViewModel.RevealTaskAsync(taskId)` (resolve list → select → await load → select row).
|
||||
- Wire `TaskMonitorViewModel.OpenInApp` to it (via an `Action<string>?` set by the shell, like the
|
||||
existing `CloseDetail`/`DeleteFromList` hooks — no new DI cycle).
|
||||
- Unit test for the select-by-id path. Commit: `feat(ui): reveal a task by id from anywhere`.
|
||||
|
||||
### Task 2.3 — `MonitorPaneView` (reuses `SessionTerminalView`)
|
||||
- New `Views/MissionControl/MonitorPaneView.axaml(.cs)`: header (title/chip/tok/turn/elapsed),
|
||||
blocking banner (`live-chip`/`terminal`/error-tint classes from IslandStyles — reuse), body =
|
||||
`<SessionTerminalView Entries="{Binding Log}" ... />`, footer (Open in app / Detach / Cancel).
|
||||
`x:DataType=TaskMonitorViewModel`. No new console control. Add `missionControl.*` en+de keys.
|
||||
- Build + Localization.Tests. Commit: `feat(ui): add MonitorPaneView`.
|
||||
|
||||
### Task 2.4 — `MissionControlView` grid + `MissionControlWindow`
|
||||
- `MissionControlView.axaml`: `ItemsControl`/`UniformGrid` of `MonitorPaneView` driven by `ColumnCount`,
|
||||
horizontal scroll fallback, header with `ClearFinished` (+ optional QuickAdd, deferrable).
|
||||
- `MissionControlWindow.axaml(.cs)`: hosts the view; lazy-create + hide-on-close.
|
||||
- Build. Commit: `feat(ui): add MissionControl window + grid`.
|
||||
|
||||
### Task 2.5 — Launch button + lifetime
|
||||
- Title-bar toggle button in `MainWindow.axaml` → shell command that shows/focuses the window
|
||||
(created lazily, owns the singleton VM).
|
||||
- Set `desktop.ShutdownMode = OnMainWindowClose` in `App.OnFrameworkInitializationCompleted`.
|
||||
- Build. **Manual visual pass** (flag for Mika): open with 2+ running tasks; main window still adds
|
||||
tasks; blocking banner; Open-in-app. Commit: `feat(ui): open Mission Control from the title bar`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — Per-pane detach (lowest priority)
|
||||
|
||||
### Task 3.1 — `TaskMonitorWindow` + detach/re-dock
|
||||
- `Views/MissionControl/TaskMonitorWindow.axaml(.cs)` hosting `MonitorPaneView`; `Detach` removes the
|
||||
monitor from the grid and shows it in the window (optional always-on-top); close re-docks.
|
||||
- Build. Manual visual pass. Commit: `feat(ui): detach a monitor into its own window`.
|
||||
|
||||
---
|
||||
|
||||
## Cross-cutting checklist (every task)
|
||||
- Stage by explicit path; sonnet subagents; reuse per the spec's map — no new console/streaming/insert path.
|
||||
- en.json + de.json parity for any new string (Localization.Tests).
|
||||
- If `IWorkerClient`/ctor signatures change, update the hand-rolled fakes in **both** test projects.
|
||||
- Build `ClaudeDo.App` (`-c Release` if Worker is running) before marking a task done.
|
||||
- Never push without asking.
|
||||
@@ -0,0 +1,101 @@
|
||||
# Plan — In-App Interactive Sessions
|
||||
|
||||
Spec: `docs/superpowers/specs/2026-06-26-in-app-interactive-sessions-design.md`
|
||||
|
||||
Implement on the shared main tree. Commit explicit paths per task (never `git add -A`).
|
||||
Build with `-c Release` (running Worker locks Debug). No real-Claude tests — fake the
|
||||
process stream. Sonnet subagents. Autonomous `TaskRunner`/`ClaudeProcess` path stays untouched.
|
||||
|
||||
## Task 1 — StreamingClaudeSession (worker, new file)
|
||||
- `Runner/StreamingClaudeSession.cs`: persistent `claude` process. Ctor takes resolved args,
|
||||
working dir, seeded first prompt, a line callback, `WorkerConfig`. Reuse the
|
||||
`ProcessStartInfo` shape + `MCP_TOOL_TIMEOUT="200000"` from `ClaudeProcess`.
|
||||
- Keeps stdin open; sends the first prompt as a user-message JSON line (escape via
|
||||
`JsonSerializer`).
|
||||
- stdout/stderr read tasks → line callback; parse `result` events to track `IsTurnInFlight`.
|
||||
- `SendUserMessageAsync(text, ct)` — enqueue/write a user-message JSON line; if
|
||||
`IsTurnInFlight`, also `InterruptAsync`.
|
||||
- `InterruptAsync(ct)` — write the control-protocol interrupt line; best-effort (swallow +
|
||||
log on failure → queue fallback applies).
|
||||
- `StopAsync` / `DisposeAsync` — close stdin, kill the tree, await exit.
|
||||
- Injectable stream seam so a fake can drive it without a real `claude` binary.
|
||||
- Test: `StreamingClaudeSessionTests` (fake stream) — first message emitted; `result` flips
|
||||
`IsTurnInFlight` off; a sent message produces a second turn; mid-turn send calls interrupt
|
||||
then delivers; interrupt throw → delivered at natural turn end; stop kills.
|
||||
|
||||
## Task 2 — LiveSessionRegistry (worker, new file)
|
||||
- `Runner/LiveSessionRegistry.cs`: singleton; `Register(taskId, StreamingClaudeSession)`,
|
||||
`bool TryGet(taskId, out session)`, `Unregister(taskId)`, `Task StopAsync(taskId)`.
|
||||
- Test: register→get; unregister; second register stops+replaces; missing get returns false.
|
||||
|
||||
## Task 3 — InteractiveSessionService (worker, new file)
|
||||
- `Planning/InteractiveSessionService.cs`: inject `IDbContextFactory`, `WorkerConfig`,
|
||||
`ClaudeArgsBuilder` (or build args inline), `HubBroadcaster`, `LiveSessionRegistry`.
|
||||
- `StartAsync(taskId, ct)`: resolve list working dir + seeded prompt (reuse the body of
|
||||
`PlanningSessionManager.OpenInteractiveAsync` + `BuildInteractivePrompt`); build interactive
|
||||
args (`--model PlanningAlias --permission-mode auto` + streaming flags); spawn the session
|
||||
with a callback that does `HubBroadcaster.TaskMessage(taskId, "[stdout] " + line)`;
|
||||
register; broadcast `InteractiveSessionStarted`. Reject if one is already live for the task.
|
||||
- `SendAsync(taskId, text, ct)` → registry `TryGet` → `SendUserMessageAsync`.
|
||||
- `StopAsync(taskId, ct)` → registry stop + `InteractiveSessionEnded`.
|
||||
- Move `OpenInteractiveAsync`/`BuildInteractivePrompt` out of `PlanningSessionManager` if it
|
||||
reads cleaner (or call into it). Remove the `InteractiveLaunchContext` terminal coupling.
|
||||
- Test: `InteractiveSessionServiceTests` (fake session factory + fake broadcaster) — start
|
||||
resolves dir, seeds prompt, registers, broadcasts started; missing working dir throws;
|
||||
send routes; stop broadcasts ended.
|
||||
|
||||
## Task 4 — Remove terminal interactive path (worker)
|
||||
- `Planning/Interfaces/ITerminalLauncher.cs` + `WindowsTerminalLauncher.cs`: delete
|
||||
`LaunchInteractiveAsync`; remove `InteractiveLaunchContext` from `PlanningSessionContext.cs`.
|
||||
Keep planning start/resume launches.
|
||||
- Fix any references; ensure the planning launcher tests still build.
|
||||
|
||||
## Task 5 — Hub + Broadcaster + DI (worker)
|
||||
- `Hub/WorkerHub.cs`: re-point `OpenInteractiveTerminalAsync` to
|
||||
`InteractiveSessionService.StartAsync` (drop `_launcher.LaunchInteractiveAsync`); add
|
||||
`Task SendInteractiveMessage(taskId, text)`, `Task StopInteractiveSession(taskId)`
|
||||
(+ optional `InterruptInteractiveSession`).
|
||||
- `Hub/HubBroadcaster.cs`: `InteractiveSessionStarted(taskId)`, `InteractiveSessionEnded(taskId)`.
|
||||
- `Program.cs`: register `LiveSessionRegistry` + `InteractiveSessionService` singletons.
|
||||
- Test: `WorkerHub` send routes to a fake service; start invokes the service.
|
||||
|
||||
## Task 6 — UI client + fakes (ui)
|
||||
- `Services/Interfaces/IWorkerClient.cs` + `WorkerClient.cs`: `SendInteractiveMessageAsync(
|
||||
taskId, text)`, `StopInteractiveSessionAsync(taskId)` (+ optional interrupt); events
|
||||
`Action<string>? InteractiveSessionStartedEvent`, `InteractiveSessionEndedEvent` with
|
||||
`On<...>` handlers. `OpenInteractiveTerminalAsync` keeps name/signature.
|
||||
- Update hand-rolled `IWorkerClient` fakes in **both** Ui.Tests and Worker.Tests.
|
||||
|
||||
## Task 7 — StreamLineFormatter user bubble (ui)
|
||||
- Render `type:"user"` NDJSON events as `LogKind.User` (add the kind if missing).
|
||||
- Test: a `user` event yields a `LogKind.User` `LogLineViewModel` with the text.
|
||||
|
||||
## Task 8 — Shared composer state on the session VMs (ui, hot files)
|
||||
- Add to `TaskMonitorViewModel` and `DetailsIslandViewModel` (factor a shared helper —
|
||||
`InteractiveComposer` — to avoid duplication): `ComposerDraft`, `IsInteractiveLive`
|
||||
(toggled by `InteractiveSessionStarted/Ended` for the subscribed task),
|
||||
`SubmitComposerCommand` (CanExecute: non-empty draft && (`HasPendingQuestion` ||
|
||||
`IsInteractiveLive`)). Route: pending question → existing `AnswerTaskQuestionAsync`; else →
|
||||
`SendInteractiveMessageAsync`. Clear draft on submit; clear `IsInteractiveLive` on ended.
|
||||
- `MissionControlViewModel`: `EnsureMonitor(taskId)` on `InteractiveSessionStarted`.
|
||||
- Test: composer enabled while interactive-live; submit routes (chat vs answer) + clears;
|
||||
ended clears live state.
|
||||
|
||||
## Task 9 — SessionTerminalView composer (ui)
|
||||
- `Views/Islands/SessionTerminalView.axaml(.cs)`: optional composer docked bottom (styled
|
||||
props `IsComposerVisible`, `ComposerText`, `SubmitCommand`, `ComposerPlaceholder`); TextBox
|
||||
(Enter submits) + Send button. Reuse existing tokens (no inline values).
|
||||
- Bind it in `MonitorPaneView.axaml` and `DetailsIslandView.axaml` to each VM's composer
|
||||
state. Fold the existing AskUser banner into the composer's "answering" state if it reads
|
||||
cleaner; otherwise leave the banner and add the composer below.
|
||||
|
||||
## Task 10 — Localization
|
||||
- `en.json` + `de.json`: `interactive.composer.placeholder`, `.send`, `.stop`, plus any
|
||||
"session ended" notice. Keep parity (Localization.Tests).
|
||||
|
||||
## Task 11 — Build + test + verify
|
||||
- Build App + Worker `-c Release`; run Worker.Tests, Ui.Tests, Localization.Tests.
|
||||
- Self-review diffs. **Manual smoke (real CLI) — flag to Mika:** (a) Run interactively opens
|
||||
an in-app chat (no terminal) and streams; (b) sending a message mid-turn interrupts +
|
||||
redirects; (c) stop kills the process; (d) session shows in both task detail and Mission
|
||||
Control. Do not push.
|
||||
@@ -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,91 @@
|
||||
# Feature unification — one component per feature
|
||||
|
||||
Date: 2026-06-19
|
||||
|
||||
## Goal
|
||||
|
||||
ClaudeDo grew organically; several features now exist as parallel implementations
|
||||
or are reachable through many hand-wired entry points. This design maps the
|
||||
duplication and defines a target where **each feature is one component**, reached
|
||||
through one path, with dead code removed.
|
||||
|
||||
## Method
|
||||
|
||||
Mapped via five parallel exploration agents (merge/conflict, review→merge,
|
||||
diff+worktree, task create/edit, UI entry-point inventory), then verified the
|
||||
load-bearing claims by grep/read before writing this. Every file:line below was
|
||||
confirmed against the working tree on 2026-06-19.
|
||||
|
||||
## Key finding: it is NOT three merge engines
|
||||
|
||||
There is **one** merge engine (`TaskMergeService`), wrapped **once** for multi-child
|
||||
units (`PlanningMergeOrchestrator`), with **one** conflict resolver (the Rider
|
||||
3-pane). `Worker/CLAUDE.md` already records "there is no separate 'Merge all' entry —
|
||||
approve is the single review+merge action." What *looks* like 2–3 merge features is
|
||||
**entry-point sprawl** in the UI plus **one dead hunks-API** left over from the
|
||||
Layer-C rework. So unification is mostly UI plumbing + deletion, not re-architecting
|
||||
the engine.
|
||||
|
||||
## Findings — three buckets
|
||||
|
||||
### Bucket A — genuine duplication (parallel implementations of one job)
|
||||
|
||||
| # | Feature | Duplicated components | Shared already |
|
||||
|---|---|---|---|
|
||||
| A1 | Diff viewing | `DiffModalViewModel` (worktree + commit-range), `WorktreeModalViewModel` (file-tree + per-file), `PlanningDiffViewModel` (per-subtask + integration) | `UnifiedDiffParser`, `DiffLinesView` (good) |
|
||||
| A2 | Agent-config editing | `ListSettingsModalViewModel` (list scope), `AgentSettingsSectionViewModel` (task scope); global lives in `SettingsModalViewModel` | `InheritanceResolver`, `InheritedBadge` (good) |
|
||||
| A3 | Worktree actions | `WorktreesOverviewModalViewModel` per-row cmds (Merge/Discard/Keep/ForceRemove/ShowDiff/Jump) vs `MergeSectionViewModel` (Merge/OpenDiff) | same `IWorkerClient` calls |
|
||||
| A4 | Merge display | `AgentStripView` re-displays `MergeSectionViewModel` state | — |
|
||||
|
||||
### Bucket B — entry-point sprawl (one backend, many hand-wired doors)
|
||||
|
||||
| # | Feature | Doors | Evidence |
|
||||
|---|---|---|---|
|
||||
| B1 | Conflict-resolution seam | 5 copies of `Func<string,string,Task>? RequestConflictResolution` | `WorktreesOverviewModalViewModel.cs:83`, `DiffModalViewModel.cs:75`, `MergeModalViewModel.cs:33`, `MergeSectionViewModel.cs:51`, `DetailsIslandViewModel.cs:347` (delegates). Threaded through `MainWindow.axaml.cs:81`, `IslandsShellViewModel.cs:49/202`, `DiffModalViewModel.cs:103`, `MergeSectionViewModel.cs:159` |
|
||||
| B2 | Diff (open) | 3–4 | MergeSection "Open Diff", TaskHeaderBar "Review Merged Diff", WorktreesOverview "Show Diff", Planning "Review Combined" |
|
||||
| B3 | List Settings dialog | 3 | Lists context menu, Tasks header button, shell bridge `IslandsShellViewModel.cs:190-194` |
|
||||
| B4 | Worktrees Overview | 2–3 | Repos menu (global), Lists context menu (per-list) |
|
||||
| B5 | Repo Import | 2 | Repos menu, Lists footer button |
|
||||
|
||||
The conflict-resolution *target* is already single-point (`IslandsShellViewModel.RequestConflictResolutionAsync`, line 49). What is duplicated is the **seam plumbing**: five VMs each own the Func and it is threaded by hand.
|
||||
|
||||
### Bucket C — dead / leftover
|
||||
|
||||
| # | Item | Evidence |
|
||||
|---|---|---|
|
||||
| C1 | Dead hunks conflict API | `TaskMergeService.GetConflictsAsync` (`Lifecycle/TaskMergeService.cs:250`) ← `WorkerHub.GetMergeConflicts` (`Hub/WorkerHub.cs:378`) ← `WorkerClient` `"GetMergeConflicts"` (`Services/WorkerClient.cs:276`) ← `IWorkerClient`. Live resolver uses `GetMergeConflictDocuments` (`WorkerHub.cs:389`). Only `TaskMergeServiceTests.cs:672` still references the old one. |
|
||||
| C2 | Two task-creation paths | UI quick-add `TasksIslandViewModel.AddAsync` writes EF directly (`db.Tasks.Add`); MCP `ExternalMcpService.AddTask` is the service path. They can drift. |
|
||||
| C3 | Stale worktrees | `.claude/worktrees/feat+planning-sessions-ui/…` carries old copies of `DiffModalViewModel`/`ListSettingsModalViewModel`/`WorktreeModalViewModel`; layer-c resolver leftovers. Worktree hygiene, not main code. |
|
||||
| C4 | Naming drift (deferred) | Hub `StartConflictMerge`/`ContinueConflictMerge`/`AbortConflictMerge` (`WorkerHub.cs:367/405/414`) vs service `MergeAsync`/`ContinueMergeAsync`/`AbortMergeAsync`. **Documented as intentional** at `Worker/CLAUDE.md:153`. |
|
||||
|
||||
## Targets — one component per feature
|
||||
|
||||
1. **MergeCoordinator (B1).** Replace the five `RequestConflictResolution` Func seams with one injected coordinator exposing `MergeAsync(taskId, targetBranch)` that owns the "merge → on-conflict open resolver" sequence. Every door (review Approve, Diff Merge button, WorktreesOverview single + batch, Details merge section) calls it. The single resolution point (`IslandsShellViewModel.RequestConflictResolutionAsync`) becomes the coordinator's body.
|
||||
2. **DiffViewer (A1 + B2).** One `DiffViewerViewModel` + view with a `DiffSource` abstraction (`DirtyWorktree | BranchVsBase | CommitRange | PlanningAggregate | IntegrationBranch`) and an optional file-tree pane. Replaces `DiffModal` + `WorktreeModal` + `PlanningDiff` shells; keeps `UnifiedDiffParser`/`DiffLinesView`. All B2 doors open it with a different source.
|
||||
3. **WorktreeActions (A3).** One `WorktreeActionsViewModel` for a single task's worktree (merge/diff/discard/keep/force-remove), reused by both the overview rows and the Details merge section instead of each owning copies.
|
||||
4. **AgentConfigEditor (A2).** One editor component parameterized by scope (`Global | List | Task`) over `InheritanceResolver`, embedded in Settings, List Settings, and the Details panel. Collapses the duplicated property set + reset commands + badges.
|
||||
5. **DialogService (B3–B5).** Consolidate the per-modal `Show*` Func seams (`IslandsShellViewModel.cs:59-71`) into one `IDialogService` with typed open methods (`OpenListSettings(list)`, `OpenRepoImport()`, `OpenWorktreesOverview(listId?)`…). Menu, context menu, and footer all call the same method; duplicate command definitions across `ListsIsland`/shell collapse to one.
|
||||
6. **Single task-creation path (C2).** Route UI quick-add through the same creation path MCP `AddTask` uses (repository/service), so both honor the same invariants.
|
||||
|
||||
Plus **C1** (delete dead hunks API + its test) and **C3** (prune stale worktrees) as groundwork. **C4** naming alignment is **deferred** — it is documented-intentional and would churn the hub + `WorkerClient` + every `IWorkerClient` fake (see the "fakes to sync" hazard) for cosmetic gain.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Phased, each phase ships green.** Six independently buildable/committable slices; cheapest and lowest-risk first (see the plan). No big-bang.
|
||||
- **One plan file per slice.** Matching the 2026-06-05 layer-A/B/C convention, each slice gets its own `docs/superpowers/plans/2026-06-19-unify-<slice>.md` authored when it is picked up. This umbrella plan sequences them and details Phase 0–1.
|
||||
- **DiffViewer (A1) is last.** Highest effort and most UX-sensitive (file-tree vs whole-unified are different layouts); deferring it lets the cheaper wins land first and de-risks the big one.
|
||||
- **Keep the merge engine and the resolver seam contract.** `TaskMergeService`, `PlanningMergeOrchestrator`, `ConflictResolverViewModel` ctor/`OpenAsync`/`OpenForPlanningAsync`/`CloseRequested` are unchanged — unification is above them.
|
||||
- **Naming alignment deferred, not done** (rationale above).
|
||||
|
||||
## Out of scope / deferred
|
||||
|
||||
- Hub/service merge-method renaming (C4).
|
||||
- Subtask deletion in the UI (a missing feature surfaced during mapping, not a duplicate).
|
||||
- Any DB migration, worker engine change, or push.
|
||||
|
||||
## Acceptance (per phase)
|
||||
|
||||
Each phase: `dotnet build -c Release` clean for touched projects; the relevant test
|
||||
project green; locales in parity (Localization.Tests) where keys change; the feature
|
||||
reachable through its single new path with the old doors removed or delegating. UI
|
||||
phases (2–5) flag a visual-verification gap for Mika to confirm in the running app.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Worker log → footer auto-route + Log Visualizer overlay
|
||||
|
||||
**Date:** 2026-06-23
|
||||
**Status:** approved (design forks resolved with user)
|
||||
|
||||
## Goal
|
||||
|
||||
1. Auto-route **all Worker WARN/ERROR** Serilog events to the footer status strip (today only ~10 hand-curated business events reach it).
|
||||
2. Make the footer log line **clickable** → opens a **Log Visualizer overlay** showing the **last 30 min** of logs at **all levels**, color-coded.
|
||||
3. **Dedupe/rate-limit** the footer so repeating warnings (e.g. the current 60s OIDC-discovery failure) don't strobe.
|
||||
|
||||
## Decisions (locked)
|
||||
|
||||
- **Overlay source:** Worker-side **in-memory ring buffer** (30-min window, all levels), fetched via a hub call. No log-file parsing.
|
||||
- **Levels:** overlay shows INF/WRN/ERR; footer flashes **WARN/ERROR only**.
|
||||
- **Footer noise:** per-message dedupe within a rate-limit window (suppress the footer broadcast for an identical message seen recently; the event is still buffered for the overlay).
|
||||
|
||||
## Architecture
|
||||
|
||||
### Worker
|
||||
|
||||
- **`LogRingBuffer`** (singleton, `Logging/`): thread-safe, time-bounded (`TimeSpan` window, default 30 min) + hard cap (e.g. 5000) ring of `WorkerLogRecord(Message, Level, TimestampUtc)`. Evicts on append by age + cap. `Snapshot()` returns newest-last.
|
||||
- **`BroadcastLogSink : Serilog.Core.ILogEventSink`** (`Logging/`): for every `LogEvent` —
|
||||
- map level: Verbose/Debug/Information→`Info`, Warning→`Warn`, Error/Fatal→`Error`;
|
||||
- render `msg = evt.RenderMessage()` (+ `": {ex.GetType().Name}: {ex.Message}"` first-line if `evt.Exception != null`);
|
||||
- append to `LogRingBuffer` (all levels);
|
||||
- if `Warn|Error` **and** not rate-limited: fire-and-forget `HubBroadcaster.WorkerLog(msg, level, evt.Timestamp.UtcDateTime)`.
|
||||
- **Loop guard:** wrap the broadcast in try/catch and swallow; skip broadcasting events whose `SourceContext` is SignalR/connections plumbing (still buffered). Broadcasting must never itself log.
|
||||
- **Dedupe/rate-limit:** dict `message → lastBroadcastUtc`; suppress footer broadcast if `now - last < RateLimitWindow` (const, 120 s). Periodic prune of the dict.
|
||||
- **DI wiring (chicken-egg):** `LogRingBuffer` + `BroadcastLogSink` are created as locals in `Program.cs` *before* `builder.Build()`, captured into `UseSerilog(... .WriteTo.Sink(broadcastSink))`, and registered as singletons. `HubBroadcaster` doesn't exist until post-build, so the sink starts detached; after `builder.Build()` we call `broadcastSink.Attach(app.Services.GetRequiredService<HubBroadcaster>())`. Buffering works from process start; broadcasting begins once attached.
|
||||
- **Hub:** `WorkerHub.GetRecentLogs() -> IReadOnlyList<WorkerLogRecordDto>` reads `LogRingBuffer.Snapshot()`. (Read-only, no auth beyond existing hub.)
|
||||
|
||||
### UI
|
||||
|
||||
- **IWorkerClient / WorkerClient:** add `Task<IReadOnlyList<WorkerLogEntry>> GetRecentLogsAsync(CancellationToken ct = default)`. ⚠ Update hand-rolled fakes in **both** test projects (StubWorkerClient + Worker.Tests UiVm fake).
|
||||
- **Footer:** wrap the worker-log `TextBlock` so it's clickable (Button, transparent) → `IslandsShellViewModel.OpenLogVisualizerCommand`. Existing `OnWorkerLogReceived` already routes the (now more numerous) `WorkerLog` broadcasts to the strip — **no change needed** for footer routing itself.
|
||||
- **`LogVisualizerViewModel`** (Modals/): on open, `GetRecentLogsAsync()` → `ObservableCollection<LogLineViewModel>` (msg, level→brush, HH:mm:ss). A level filter (All / Warn+Err) and a Refresh command. MVP = snapshot on open + Refresh; live-tail is a later nicety.
|
||||
- **`LogVisualizerView`** (Modals/): `ModalShell`-based dialog (consistent with other modals), shown via `IDialogService.ShowLogVisualizerAsync(vm)`. Small, scrollable, monospaced, color-coded lines.
|
||||
- **Localization:** new `vm.logVisualizer` (+ any view keys) in **en.json + de.json** (parity test enforces).
|
||||
|
||||
## Out of scope / follow-ups
|
||||
|
||||
- Live-tail while the overlay is open (snapshot + Refresh for MVP).
|
||||
- The **OIDC-discovery-every-60s failure** is a *separate* bug (Online Inbox enabled, `auth.kuns.dev` SSL fails). Dedupe tames the footer symptom; the root cause is tracked separately.
|
||||
|
||||
## Tests
|
||||
|
||||
- Worker: `LogRingBufferTests` (age + cap eviction, snapshot order), `BroadcastLogSinkTests` (level mapping; all levels buffered; only Warn/Err broadcast; dedupe suppresses repeat broadcast within window but still buffers; exception rendering; loop-guard source filter).
|
||||
- UI: `LogVisualizerViewModelTests` (loads from worker, populates, filter). Footer-click wiring smoke.
|
||||
@@ -0,0 +1,102 @@
|
||||
# Interactive "Answer Claude's Questions" — Design
|
||||
|
||||
**Date:** 2026-06-25
|
||||
**Status:** Approved (brainstormed with Mika)
|
||||
|
||||
## Goal
|
||||
|
||||
Let the user answer a question Claude raises *mid-run* from inside Mission Control,
|
||||
without leaving the autonomous-execution model. Not a chat panel, not a terminal, not
|
||||
proactive steering — only: *Claude surfaces a question → the user types an answer → the
|
||||
run continues with that answer in context.*
|
||||
|
||||
User decisions (brainstorm):
|
||||
- Scope: "I mostly want to answer his questions if he surfaces any."
|
||||
- Trigger: **any running task** may ask, with a **3-minute** answer window.
|
||||
|
||||
## Why not the alternatives
|
||||
|
||||
- **Embedded terminal / PTY** — would destroy the NDJSON contract the whole worker
|
||||
pipeline depends on (StreamAnalyzer, token accounting, auto-commit, status flow) and
|
||||
needs a terminal-emulator control Avalonia doesn't have. Rejected.
|
||||
- **Streaming-stdin (`--input-format stream-json`)** — right tool for a free-form chat,
|
||||
overkill here. Rejected for v1.
|
||||
- **`--resume` per-turn** — already exists; not live (cold process per turn).
|
||||
|
||||
## Mechanism
|
||||
|
||||
The in-task MCP already blocks the `claude -p` process while a tool call is in flight.
|
||||
That blocking *is* the pause. Add one in-task MCP tool, `AskUser(question)`:
|
||||
|
||||
1. The tool resolves the caller task id, registers a pending question + a
|
||||
`TaskCompletionSource<string>` in a singleton `PendingQuestionRegistry`, and
|
||||
broadcasts `TaskQuestionAsked(taskId, questionId, question)`.
|
||||
2. Mission Control surfaces the question with an input box.
|
||||
3. The user answers → `WorkerHub.AnswerTaskQuestion` resolves the TCS → the tool
|
||||
returns the answer as its result → Claude continues.
|
||||
4. No answer within **3 minutes** → the tool returns *"No response received within 3
|
||||
minutes — proceed using your best judgment."* and the run carries on autonomously.
|
||||
|
||||
### Key facts that make this work
|
||||
|
||||
- **No persisted status change.** The task is still genuinely `Running` (process alive,
|
||||
blocked mid-tool-call). "Waiting for input" is **ephemeral**: in-memory registry +
|
||||
live SignalR events + a UI overlay. No `TaskStatus` enum value, no `TaskStateService`
|
||||
transition, **no EF migration**. If the worker dies mid-wait, `StaleTaskRecovery`
|
||||
flips the orphaned `Running` row to `Failed` like any interrupted run.
|
||||
- **`MCP_TOOL_TIMEOUT` must be raised.** Claude Code caps HTTP MCP tool calls at **60 s**
|
||||
by default. The `claudedo_run` MCP is HTTP, so `ClaudeProcess` must set
|
||||
`MCP_TOOL_TIMEOUT=200000` (≈3 min + margin) on the spawned process or the 3-min window
|
||||
is silently truncated to 60 s.
|
||||
- **MCP wired for all runs.** Today `TaskRunner` only mints the run MCP for standalone
|
||||
top-level tasks (for `SuggestImprovement`). To satisfy "any running task," move the
|
||||
MCP-identity setup out of that gate so every `RunAsync` gets `claudedo_run`.
|
||||
`AllowedTools` always includes `mcp__claudedo_run__AskUser`; `SuggestImprovement` stays
|
||||
gated to improvement-eligible (standalone) runs.
|
||||
|
||||
## Surface changes
|
||||
|
||||
**Worker (mostly new files):**
|
||||
- `Runner/PendingQuestionRegistry.cs` (new, singleton) — `Register`, `TryAnswer`, `Get`,
|
||||
`Remove`; one pending question per task.
|
||||
- `Runner/TaskRunMcpService.cs` (edit) — add `AskUser` `[McpServerTool]`; inject the
|
||||
registry.
|
||||
- `Runner/TaskRunner.cs` (edit) — wire MCP identity for all runs; add `AskUser` to
|
||||
allowed tools.
|
||||
- `Runner/ClaudeProcess.cs` (edit) — set `MCP_TOOL_TIMEOUT` env.
|
||||
- `Hub/HubBroadcaster.cs` (edit) — `TaskQuestionAsked`, `TaskQuestionResolved`.
|
||||
- `Hub/WorkerHub.cs` (edit) — `AnswerTaskQuestion`, `GetPendingQuestion` + DTO.
|
||||
- `Program.cs` (edit) — register `PendingQuestionRegistry` singleton.
|
||||
- System prompt (edit) — one line telling Claude the tool exists and to use it only when
|
||||
a wrong guess would be costly/irreversible (otherwise proceed).
|
||||
|
||||
**UI:**
|
||||
- `Services/IWorkerClient.cs` + `WorkerClient.cs` (edit) — `AnswerTaskQuestionAsync`,
|
||||
`GetPendingQuestionAsync`, `TaskQuestionAskedEvent`, `TaskQuestionResolvedEvent`.
|
||||
- `ViewModels/Islands/TaskMonitorViewModel.cs` (edit, **hot file**) — pending-question
|
||||
state, `AnswerDraft`, `SubmitAnswerCommand`, clear on finish/resolve.
|
||||
- `ViewModels/MissionControlViewModel.cs` (edit) — hydrate pending question on attach.
|
||||
- `Views/MissionControl/MonitorPaneView.axaml` (edit, **hot file**) — additive
|
||||
question/answer banner above the terminal.
|
||||
- `Localization/locales/en.json` + `de.json` — `missionControl.question.*` keys.
|
||||
|
||||
**Tests:** `PendingQuestionRegistry` (answer/timeout/unknown/overwrite), `AskUser` tool
|
||||
(answer + timeout fallback, fake broadcaster — no real Claude), `TaskMonitorViewModel`
|
||||
(surface/submit/clear). Update IWorkerClient fakes in both test projects.
|
||||
|
||||
## Concurrency note
|
||||
|
||||
Two files (`TaskMonitorViewModel.cs`, `MonitorPaneView.axaml`) are also being touched by
|
||||
a concurrent Mission Control drag-and-drop session on the shared main tree. Keep edits
|
||||
additive, commit explicit paths only (never `git add -A`).
|
||||
|
||||
## Verification gaps (manual)
|
||||
|
||||
1. **Real-Claude smoke test** — confirm a blocking `AskUser` call survives ≥3 min with
|
||||
`MCP_TOOL_TIMEOUT=200000` and that the model actually calls the tool when uncertain.
|
||||
2. **Visual** — the question banner + input box in the pane (Mika does the visual pass).
|
||||
|
||||
## Non-goals
|
||||
|
||||
Free-form chat panel; proactive steering; tool-permission prompts (stays `auto`);
|
||||
`ContinueAsync`/resumed runs gaining `AskUser` (deferred follow-up).
|
||||
@@ -0,0 +1,144 @@
|
||||
# Mission Control — multi-task live monitoring
|
||||
|
||||
Date: 2026-06-25
|
||||
Status: approved (design); implementation not started
|
||||
|
||||
## Problem
|
||||
|
||||
The UI can observe only **one** running task at a time. `DetailsIslandViewModel` is hard 1:1
|
||||
(single `Task`, single `_subscribedTaskId`); selecting another task in the middle pane *replaces*
|
||||
what Details shows. Yet the worker runs several tasks concurrently (`MaxParallelExecutions`) and
|
||||
already broadcasts every task's live output to all clients keyed by `taskId`. So the user cannot
|
||||
watch multiple in-flight sessions, and monitoring blocks normal work (adding tasks, reviewing).
|
||||
|
||||
## Goal
|
||||
|
||||
Watch several running tasks at once **without** giving up the normal app. Requirements drawn from
|
||||
the brainstorm:
|
||||
|
||||
- A **live console grid** — multiple full Claude output streams side by side.
|
||||
- Each pane also shows **task details, blocking reasons**, and a **navigation helper** to open the
|
||||
monitored task in the main app.
|
||||
- Lives in a **separate, always-available window** so the main window stays fully usable (adding
|
||||
tasks must never be blocked). Combines "full window" + "detachable".
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No worker/SignalR changes. The broadcast layer is already N-capable (`TaskMessage(taskId,line)`,
|
||||
`TaskStarted/Finished/Updated`, `GetActive()`). This is a UI/VM-only feature.
|
||||
- No second SignalR connection. The new window shares the existing singleton `IWorkerClient`.
|
||||
- No new merge/review engine. Review/merge stays in the main window's Details pane; Mission Control
|
||||
is read-mostly (monitor + cancel + navigate).
|
||||
|
||||
## Hard constraint: no duplicated components or features
|
||||
|
||||
This feature is an **extract-and-reuse** exercise, not a rebuild. The single biggest risk is
|
||||
forking a second live-streaming/parsing/status implementation. The reuse map below is binding.
|
||||
|
||||
### Reuse map (what already exists — use it, do not copy it)
|
||||
|
||||
| Concern | Existing asset | Location | How Mission Control uses it |
|
||||
|---|---|---|---|
|
||||
| Live console body (log list, LIVE/DONE/FAILED chip, auto-scroll) | `SessionTerminalView` (StyledProps `Entries`, `Label`, `IsRunning/IsDone/IsFailed`) | `Views/Islands/SessionTerminalView.axaml(.cs)` | Bind a pane's `Entries`→its `Log`, status flags + label. **No new console control.** |
|
||||
| Log line model | `LogLineViewModel` + `LogKind` | `ViewModels/Islands/DetailsIslandViewModel.cs` (top) | Shared model — move to its own file so both consumers reference one type. |
|
||||
| Live stream parse/replay | `OnTaskMessage` / `AppendStdoutLine` / `FlushClaudeBuffer` / `ReplayLogFileAsync` + `StreamLineFormatter` + `ExpandUserPath` | private in `DetailsIslandViewModel.cs` | **Extract to `TaskMonitorViewModel`** (Phase 1). One streaming engine, two consumers. |
|
||||
| Status state machine | `AgentState` + `Is*` flags + `StatusToStateKey` / `FinishedStatusToStateKey` | `DetailsIslandViewModel.cs` | Extract into `TaskMonitorViewModel`. |
|
||||
| Outcome / roadblock split | `ApplyOutcome` + `RoadblockMarker` constant | `DetailsIslandViewModel.cs` | Extract into `TaskMonitorViewModel`. |
|
||||
| Status chip / terminal styling | `live-chip`, `terminal`, `log-*` style classes | `Design/IslandStyles.axaml` | Reuse the classes as-is. |
|
||||
| Add a new task | `TasksIslandViewModel.AddAsync` (`NewTaskTitle`, user-list only, direct `TaskRepository`) | `TasksIslandViewModel.cs:406` | Optional quick-add reuses this path; **must not** introduce a second insert path. |
|
||||
| Live task list | `IWorkerClient.GetActive()` + `TaskStarted/Finished` events | worker hub / `WorkerClient` | Populate the grid; add/remove panes. |
|
||||
| DI / singletons | `IslandsShellViewModel`, `DetailsIslandViewModel`, `IWorkerClient` all singletons | `App/Program.cs` | Register `MissionControlViewModel` singleton; inject existing singletons. |
|
||||
|
||||
## Design
|
||||
|
||||
### TaskMonitorViewModel (the reusable core — new, but carved out of DetailsIslandViewModel)
|
||||
|
||||
One instance == one monitored task. Owns:
|
||||
|
||||
- `Log` (`ObservableCollection<LogLineViewModel>`), the filtered `TaskMessageEvent` subscription
|
||||
(by `taskId`), stdout buffering, and NDJSON replay from disk on attach.
|
||||
- `AgentState` + `Is*` flags; `SessionOutcome` / `Roadblocks` (the outcome split).
|
||||
- Lightweight display: `Title`, `TaskIdBadge`, `Model`, `TurnsText`, `TokensFormatted`,
|
||||
diff add/del, elapsed.
|
||||
- `BlockingReason` (string/visible flag) derived from existing data: `BlockedByTaskId`
|
||||
(planning/child chain), `WaitingForReview` / `WaitingForChildren` status, and roadblock markers.
|
||||
- Commands: `OpenInApp`, `Detach`, `Cancel`.
|
||||
- `IDisposable` — unsubscribes all worker events (mirror DetailsIslandViewModel.Dispose).
|
||||
|
||||
`DetailsIslandViewModel` is refactored to **own one `TaskMonitorViewModel` (`public Monitor`)** and
|
||||
delegate streaming/status/outcome to it. Its heavy concerns (subtasks, attachments, editing, merge
|
||||
cockpit, review verbs, child outcomes, notes/prep modes) stay put. **Phase 1 must be a no-behavior-
|
||||
change refactor** — all existing Ui.Tests stay green.
|
||||
|
||||
> Binding-surface decision (Phase 1): repoint `WorkConsole.axaml`'s Output-tab bindings that
|
||||
> reference streaming/status (`Log`, `IsRunning/IsDone/IsFailed`, `SessionOutcome`, `TurnsText`,
|
||||
> diff text, `Model`) to `Monitor.*`. `x:DataType` stays `DetailsIslandViewModel`; compiled bindings
|
||||
> handle the nested path. Review/merge/session bindings are untouched. Prefer repointing over adding
|
||||
> ~15 forwarding properties (one source of truth, no boilerplate).
|
||||
|
||||
### MissionControlViewModel (new)
|
||||
|
||||
- `ObservableCollection<TaskMonitorViewModel> Monitors`, keyed by `taskId`.
|
||||
- On open: seed from `GetActive()`. On `TaskStarted`: add a monitor. On `TaskFinished`: keep the
|
||||
pane (so the final output stays readable) but flip its state; a "clear finished" action prunes them.
|
||||
- Adaptive layout signal (column count) from `Monitors.Count`:
|
||||
`1→1col, 2→2col, 3–4→2col(2 rows), 5+→fixed-width panes, horizontal scroll`. Least-active panes
|
||||
beyond a threshold collapse to a compact card (title + last line + chip), click to expand — this is
|
||||
the readability fallback so we never render N unreadable slivers.
|
||||
- Optional `QuickAdd` (deferred within Phase 2): title + target user-list → the **same** creation
|
||||
path as `TasksIslandViewModel.AddAsync` (shared method, not a copy).
|
||||
- Disposes every monitor on window close.
|
||||
|
||||
### Windowing (new plumbing — thin)
|
||||
|
||||
- `MissionControlWindow` (Avalonia `Window`) hosting `MissionControlView`; DataContext =
|
||||
the singleton `MissionControlViewModel`.
|
||||
- No non-modal secondary-window precedent exists (all current dialogs use `ShowDialog(owner)`), so
|
||||
this is genuinely new but small:
|
||||
- Set `desktop.ShutdownMode = OnMainWindowClose` in `App.OnFrameworkInitializationCompleted` so
|
||||
closing Mission Control never quits the app, and closing the main window does.
|
||||
- Open via a **title-bar button in MainWindow** (toggle: show / focus-if-open). The window is
|
||||
created lazily and hidden (not destroyed) on close so its monitors persist cheaply.
|
||||
- Persist size/position (reuse the ui.config.json mechanism if present; otherwise defer).
|
||||
|
||||
### MonitorPaneView (new view, reuses SessionTerminalView)
|
||||
|
||||
```
|
||||
┌─ #142 Refactor auth module ───────── ● running ─┐ header: title, live chip, tok/turn/elapsed
|
||||
│ ⏱ 4m12s ◆ 18.3k tok ↻ turn 6 │
|
||||
├───────────────────────────────────────────────────┤
|
||||
│ ⚠ Blocked: waiting on #141 (planning parent) │ blocking banner (visible only when blocked)
|
||||
├───────────────────────────────────────────────────┤
|
||||
│ <SessionTerminalView Entries={Log} .../> │ the REUSED console
|
||||
├───────────────────────────────────────────────────┤
|
||||
│ [↗ Open in app] [⧉ Detach] [✕ Cancel] │ footer
|
||||
└───────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Navigation helper "Open in app" (new shell method)
|
||||
|
||||
No select-by-id exists today. Add `IslandsShellViewModel.RevealTaskAsync(taskId)`:
|
||||
1. resolve the task's list, set `Lists.SelectedList`; 2. await `Tasks.LoadForList`; 3. find the row in
|
||||
`Tasks.Items` by id, set `Tasks.SelectedTask` (→ `Details.Bind`); 4. bring MainWindow to front.
|
||||
`TaskMonitorViewModel.OpenInApp` calls this. Single navigation entry point — no duplicate selection logic.
|
||||
|
||||
### Detach (Phase 3)
|
||||
|
||||
`Detach` moves a `TaskMonitorViewModel` out of the grid into a small `TaskMonitorWindow`
|
||||
(reuses `MonitorPaneView`), optionally always-on-top; closing it re-docks. Lowest priority.
|
||||
|
||||
## Risks / open items
|
||||
|
||||
- **Phase 1 binding repoint** is the main risk: a missed `WorkConsole` binding shows as a blank
|
||||
field, not a build error. Mitigation: Ui.Tests + a manual visual pass on the Details pane.
|
||||
- **Localization parity** (Localization.Tests): every new visible string needs en + de keys under a
|
||||
`missionControl.*` namespace.
|
||||
- **Quick-add coupling** across windows is the weakest part; kept optional/deferrable.
|
||||
- Detached windows = most plumbing, least daily payoff → Phase 3, last.
|
||||
|
||||
## Verification
|
||||
|
||||
- Build `ClaudeDo.App` + run Ui.Tests / Localization.Tests after each phase.
|
||||
- Manual visual pass (cannot be auto-verified): Details pane unchanged after Phase 1; grid populates
|
||||
with 2+ concurrent tasks, blocking banner shows, Open-in-app surfaces the task, adding a task in the
|
||||
main window works while Mission Control is open.
|
||||
@@ -0,0 +1,147 @@
|
||||
# In-App Interactive Sessions — Design
|
||||
|
||||
**Date:** 2026-06-26
|
||||
**Status:** Proposed (awaiting approval)
|
||||
|
||||
## Goal
|
||||
|
||||
Replace the external Windows-Terminal "Run interactively" session with an **in-app
|
||||
streaming chat**, rendered in the existing `SessionTerminalView` in **both task detail and
|
||||
Mission Control**. Keep everything inside the app — no `wt.exe` pop-out. Autonomous task
|
||||
execution is **untouched** (stays one-shot, non-interactive).
|
||||
|
||||
## Decisions (brainstorm)
|
||||
|
||||
1. **Engine: persistent streaming session.** One `claude` process kept alive with
|
||||
`--input-format stream-json`; user messages pushed over stdin.
|
||||
2. **Scope: interactive sessions only.** The autonomous `TaskRunner`/`ClaudeProcess` run
|
||||
loop, review, queue, and worktree machinery are NOT changed.
|
||||
3. **Placement: shared `SessionTerminalView`** — the in-app session + composer appear in the
|
||||
task-detail session surface and in the Mission Control monitor pane.
|
||||
4. **Full replace.** "Run interactively" now opens the in-app session; the
|
||||
`WindowsTerminalLauncher.LaunchInteractiveAsync` path is removed. **Planning** sessions
|
||||
keep using `wt` (untouched).
|
||||
5. **Send semantics: interrupt + redirect** mid-turn (control protocol), with automatic
|
||||
*queue-for-next-turn* fallback if interrupt is unavailable.
|
||||
|
||||
## What an interactive session is (unchanged semantics, new transport)
|
||||
|
||||
Today (`PlanningSessionManager.OpenInteractiveAsync` + `WindowsTerminalLauncher`):
|
||||
`claude --model <PlanningAlias> --permission-mode auto "<task title+description>"` in the
|
||||
**list's working dir**, env `MAX_THINKING_TOKENS=20000`, full default toolset, relies on the
|
||||
globally-registered `claudedo` MCP. **Ephemeral** — no worktree, no `task_run` record, no
|
||||
status change, no review.
|
||||
|
||||
We keep all of that. Only the transport changes: instead of a `wt` window, the same
|
||||
`claude` invocation runs as a persistent stream-json process owned by the worker, its output
|
||||
streamed into the app and its stdin fed from an in-app composer.
|
||||
|
||||
> Honest tradeoff: the `wt` terminal gave the full Claude Code TUI (slash-command UX,
|
||||
> interactive prompts). An in-app stream-json chat is plainer — type messages, watch streamed
|
||||
> output. `--permission-mode auto` means no blocking permission prompts (so headless works),
|
||||
> but it is a simpler surface than the real TUI. Accepted per the "full replace" decision.
|
||||
|
||||
## The streaming engine
|
||||
|
||||
Flags: `--model <PlanningAlias> --permission-mode auto --input-format stream-json
|
||||
--output-format stream-json --verbose --replay-user-messages` in the list working dir, env
|
||||
`MAX_THINKING_TOKENS=20000`. No `--mcp-config`/`--allowedTools` (interactive uses the global
|
||||
MCP + default tools, exactly as today).
|
||||
|
||||
- First stdin message = the seeded interactive prompt:
|
||||
`{"type":"user","message":{"role":"user","content":[{"type":"text","text":"…"}]},"parent_tool_use_id":null}\n`
|
||||
(stdin stays open).
|
||||
- A stdout read task forwards each NDJSON line to a callback (→ broadcast + the session's log)
|
||||
and detects `result` events (turn boundary; the process then idles for the next message).
|
||||
- `SendUserMessageAsync(text)` writes a user-message JSON line; if a turn is in flight, also
|
||||
`InterruptAsync()` (control-protocol interrupt) so Claude pivots immediately. If interrupt
|
||||
is unavailable, the message lands when the current turn ends → automatic queue fallback.
|
||||
- **Interrupt is verified working** (spike, 2026-06-26, CLI 2.1.191). Exact shape:
|
||||
`{"type":"control_request","request_id":"<id>","request":{"subtype":"interrupt"}}` — no
|
||||
`initialize` handshake needed; `control_response {"subtype":"success"}` confirms
|
||||
synchronously; the same process then accepts the redirect and runs a fresh turn with
|
||||
context intact.
|
||||
- **Interrupt artifact:** the aborted turn emits a `result` with `is_error=true,
|
||||
subtype="error_during_execution"`. The session must treat an interrupt-induced result as
|
||||
*"turn aborted, continue"* (drain the queued redirect), **not** as a session failure.
|
||||
Tolerate the incidental `system:init`/`system:status`/`rate_limit_event`/hook events that
|
||||
also appear in the stream.
|
||||
- `--replay-user-messages` echoes each sent message back on stdout as a `user` event, so it
|
||||
rides the existing stream pipeline into the timeline (ordered + confirmed) with no extra
|
||||
broadcast surface.
|
||||
- The session ends only when the **user stops it** (kill the process tree) — an interactive
|
||||
session has no auto-finalize and never enters review. No queue slot is involved (it is
|
||||
launched directly, not via the autonomous picker).
|
||||
|
||||
## Surface changes
|
||||
|
||||
**Worker**
|
||||
- `Runner/StreamingClaudeSession.cs` (new) — persistent process + send/interrupt/stop; reuse
|
||||
the `ProcessStartInfo` shape + `MCP_TOOL_TIMEOUT` from `ClaudeProcess`; streams via a line
|
||||
callback; `IsTurnInFlight`. Cancellation kills the tree.
|
||||
- `Runner/LiveSessionRegistry.cs` (new, singleton) — `taskId → StreamingClaudeSession`
|
||||
(`Register`/`TryGet`/`Unregister`/`Stop`), mirrors `PendingQuestionRegistry`.
|
||||
- `Planning/InteractiveSessionService.cs` (new) — owns interactive lifecycle: `StartAsync(
|
||||
taskId)` resolves the list working dir + seeded prompt (reuse `OpenInteractiveAsync`'s
|
||||
body), spawns the session, registers it, wires output to `HubBroadcaster.TaskMessage`,
|
||||
broadcasts `InteractiveSessionStarted`; `SendAsync(taskId, text)`; `StopAsync(taskId)` →
|
||||
`InteractiveSessionEnded`.
|
||||
- `Planning/WindowsTerminalLauncher.cs` + `Planning/Interfaces/ITerminalLauncher.cs` — remove
|
||||
`LaunchInteractiveAsync` (+ `InteractiveLaunchContext`). Planning start/resume stay.
|
||||
- `Hub/WorkerHub.cs` — `OpenInteractiveTerminalAsync` re-pointed to
|
||||
`InteractiveSessionService.StartAsync` (no terminal); add `SendInteractiveMessage(taskId,
|
||||
text)`, `StopInteractiveSession(taskId)` (+ optional `InterruptInteractiveSession`).
|
||||
- `Hub/HubBroadcaster.cs` — `InteractiveSessionStarted(taskId)`,
|
||||
`InteractiveSessionEnded(taskId)`. Log lines reuse the existing `TaskMessage(taskId, line)`.
|
||||
- `Program.cs` — register `LiveSessionRegistry` + `InteractiveSessionService`.
|
||||
|
||||
**UI**
|
||||
- `Views/Islands/SessionTerminalView.axaml(.cs)` — add an optional composer (styled
|
||||
properties: `IsComposerVisible`, `ComposerText`, `SubmitCommand`, `ComposerPlaceholder`).
|
||||
Both hosts (task detail + Mission Control) get it by binding their VM's composer state.
|
||||
- `StreamLineFormatter` — render `type:"user"` NDJSON events as a `LogKind.User` bubble.
|
||||
- A small shared composer concept on `TaskMonitorViewModel` **and** `DetailsIslandViewModel`
|
||||
(factor a helper to avoid duplication): `ComposerDraft`, `SubmitComposerCommand`,
|
||||
`IsInteractiveLive` (set by `InteractiveSessionStarted/Ended`). Submit →
|
||||
`SendInteractiveMessageAsync`; clear draft. (If a pending AskUser question exists, the same
|
||||
composer answers it — keep the existing answer route.)
|
||||
- `MissionControlViewModel` — `EnsureMonitor(taskId)` on `InteractiveSessionStarted` so the
|
||||
session appears as a monitor; mark it interactive.
|
||||
- `Services/Interfaces/IWorkerClient.cs` + `WorkerClient.cs` — `SendInteractiveMessageAsync`,
|
||||
`StopInteractiveSessionAsync` (+ optional interrupt); events
|
||||
`InteractiveSessionStartedEvent`/`InteractiveSessionEndedEvent`. `OpenInteractiveTerminalAsync`
|
||||
keeps its name/signature (now starts the in-app session). Update hand-rolled fakes in **both**
|
||||
test projects (`iworkerclient_fakes_sync`).
|
||||
- `TasksIslandViewModel.RunInteractivelyAsync` — unchanged call site; now opens/focuses the
|
||||
in-app session surface instead of a terminal.
|
||||
- Localization `interactive.*` / `missionControl.chat.*` (en/de, parity enforced).
|
||||
|
||||
**Tests**
|
||||
- `StreamingClaudeSessionTests` (fake process stream, no real Claude): first message streams;
|
||||
`result` idles; a sent message starts another turn; mid-turn send calls `InterruptAsync`
|
||||
then delivers; interrupt-failure degrades to queue; stop kills.
|
||||
- `LiveSessionRegistryTests` — register/get/unregister/stop.
|
||||
- `InteractiveSessionServiceTests` — start resolves working dir + seeds prompt + registers +
|
||||
broadcasts started; send routes to the session; stop broadcasts ended (fake session +
|
||||
broadcaster).
|
||||
- `TaskMonitorViewModelTests` / `DetailsIslandViewModelTests` — composer enabled while
|
||||
interactive-live; submit invokes client + clears; `user` line renders; question route still
|
||||
answers.
|
||||
|
||||
## Risks / open questions
|
||||
|
||||
- **Interrupt protocol shape — RESOLVED** (spike 2026-06-26, see "The streaming engine").
|
||||
Mid-turn interrupt works on CLI 2.1.191 with the documented shape; the queue fallback is a
|
||||
genuine fallback now, not the expected path. Re-verify if the CLI version changes.
|
||||
- **Plainer than the TUI** — slash-command/interactive-prompt UX differs (accepted).
|
||||
- **Auto-mode editing the list working dir directly** (no worktree) — this is the *existing*
|
||||
interactive behavior, unchanged here.
|
||||
- **No real-Claude tests** (project rule) — the live loop is covered only by the fake stream;
|
||||
real interrupt/redirect is a **manual verification gap** to flag.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Changing autonomous task execution / review / queue / worktrees.
|
||||
- Interactive sessions producing run records, worktrees, or review (stays ephemeral).
|
||||
- Worktree isolation for interactive edits; image/attachment messages in the composer.
|
||||
- Removing planning's `wt` terminal launch.
|
||||
@@ -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,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.
|
||||
@@ -21,6 +21,7 @@
|
||||
<converters:DotBrushConverter x:Key="DotBrush"/>
|
||||
<converters:BoolToItalicConverter x:Key="BoolToItalic"/>
|
||||
<converters:BoolToDraftOpacityConverter x:Key="BoolToDraftOpacity"/>
|
||||
<converters:LogKindForegroundConverter x:Key="LogKindForeground"/>
|
||||
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using ClaudeDo.Ui.Services;
|
||||
@@ -32,6 +33,10 @@ public partial class App : Application
|
||||
|
||||
FocusClearing.Install();
|
||||
|
||||
// The main window is authoritative — closing it shuts the app down even if the
|
||||
// modeless Mission Control window is still open.
|
||||
desktop.ShutdownMode = ShutdownMode.OnMainWindowClose;
|
||||
|
||||
desktop.MainWindow = new MainWindow
|
||||
{
|
||||
DataContext = services.GetRequiredService<IslandsShellViewModel>(),
|
||||
|
||||
@@ -20,7 +20,7 @@ Desktop entry point for the ClaudeDo application. Configures DI, initializes the
|
||||
## DI Registration Pattern
|
||||
|
||||
- **Singletons**: `IDbContextFactory`, all Repositories, GitService, WorkerClient, `IReleaseClient`, `UpdateCheckService`, `IPrimeScheduleApi`/`WorkerPrimeScheduleApi`, `INotesApi`/`WorkerNotesApi`, `InstallerLocator` / `WorkerLocator`, the island VMs (`ListsIslandViewModel`, `TasksIslandViewModel`, `DetailsIslandViewModel`) and `IslandsShellViewModel` (the window's DataContext)
|
||||
- **Transients**: modal VMs (`SettingsModalViewModel`, `MergeModalViewModel`, `ListSettingsModalViewModel`, `RepoImportModalViewModel`, `WeeklyReportModalViewModel`, `WorktreeModalViewModel`, `WorktreesOverviewModalViewModel`, `PrimeClaudeTabViewModel`), several exposed as `Func<T>` factories for on-demand dialog creation; `ConflictResolverViewModel` via a `Func<string, ConflictResolverViewModel>` factory keyed by taskId (singleton factory, handed to `IslandsShellViewModel.ConflictResolverFactory`)
|
||||
- **Transients**: modal VMs (`SettingsModalViewModel`, `MergeModalViewModel`, `ListSettingsModalViewModel`, `RepoImportModalViewModel`, `WeeklyReportModalViewModel`, `DiffViewerViewModel`, `WorktreesOverviewModalViewModel`, `PrimeClaudeTabViewModel`), several exposed as `Func<T>` factories for on-demand dialog creation (`Func<DiffViewerViewModel>` for the diff viewer); `ConflictResolverViewModel` via a `Func<string, ConflictResolverViewModel>` factory keyed by taskId (singleton factory, handed to `IslandsShellViewModel.ConflictResolverFactory`)
|
||||
|
||||
## Notes
|
||||
|
||||
|
||||
@@ -116,9 +116,13 @@ sealed class Program
|
||||
return new UpdateCheckService(releases, version);
|
||||
});
|
||||
|
||||
// Conflict-merge coordinator: single seam the shell wires to its resolver entry.
|
||||
sc.AddSingleton<MergeCoordinator>();
|
||||
sc.AddSingleton<IMergeCoordinator>(sp => sp.GetRequiredService<MergeCoordinator>());
|
||||
|
||||
// ViewModels
|
||||
sc.AddTransient<WorktreeModalViewModel>();
|
||||
sc.AddTransient<Func<WorktreeModalViewModel>>(sp => () => sp.GetRequiredService<WorktreeModalViewModel>());
|
||||
sc.AddTransient<DiffViewerViewModel>();
|
||||
sc.AddTransient<Func<DiffViewerViewModel>>(sp => () => sp.GetRequiredService<DiffViewerViewModel>());
|
||||
sc.AddTransient<WorktreesOverviewModalViewModel>();
|
||||
sc.AddTransient<Func<WorktreesOverviewModalViewModel>>(sp => () => sp.GetRequiredService<WorktreesOverviewModalViewModel>());
|
||||
sc.AddSingleton<IPrimeScheduleApi, WorkerPrimeScheduleApi>();
|
||||
@@ -152,12 +156,18 @@ sealed class Program
|
||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||
sp.GetRequiredService<IWorkerClient>(),
|
||||
sp,
|
||||
sp.GetRequiredService<INotesApi>()));
|
||||
sp.GetRequiredService<INotesApi>(),
|
||||
sp.GetRequiredService<IMergeCoordinator>()));
|
||||
sc.AddSingleton<MissionControlViewModel>(sp =>
|
||||
new MissionControlViewModel(
|
||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||
sp.GetRequiredService<IWorkerClient>()));
|
||||
sc.AddSingleton<IslandsShellViewModel>(sp =>
|
||||
{
|
||||
var shell = ActivatorUtilities.CreateInstance<IslandsShellViewModel>(sp);
|
||||
shell.ConflictResolverFactory =
|
||||
sp.GetRequiredService<Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>>();
|
||||
sp.GetRequiredService<MergeCoordinator>().Handler = shell.RequestConflictResolutionAsync;
|
||||
return shell;
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
namespace ClaudeDo.Data;
|
||||
|
||||
public sealed class AttachmentStore
|
||||
{
|
||||
private const long MaxBytes = 5 * 1024 * 1024; // 5 MB
|
||||
|
||||
private readonly string _root;
|
||||
|
||||
public AttachmentStore(string? root = null)
|
||||
=> _root = root ?? Paths.Expand("~/.todo-app/attachments");
|
||||
|
||||
public string Root => _root;
|
||||
|
||||
public IReadOnlyList<string> EnumerateTaskIds()
|
||||
{
|
||||
if (!Directory.Exists(_root)) return Array.Empty<string>();
|
||||
return Directory.GetDirectories(_root)
|
||||
.Select(Path.GetFileName)
|
||||
.Where(n => n is not null)
|
||||
.Select(n => n!)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public string TaskDir(string taskId)
|
||||
=> Path.Combine(_root, taskId);
|
||||
|
||||
public async Task<long> SaveAsync(string taskId, string fileName, Stream content, CancellationToken ct = default)
|
||||
{
|
||||
if (Path.GetFileName(fileName) != fileName)
|
||||
throw new ArgumentException("fileName must not contain path separators or '..'.", nameof(fileName));
|
||||
|
||||
var dir = TaskDir(taskId);
|
||||
var resolvedPath = Path.GetFullPath(Path.Combine(dir, fileName));
|
||||
|
||||
// Containment guard: resolved path must stay inside TaskDir
|
||||
var resolvedDir = Path.GetFullPath(dir);
|
||||
if (!resolvedPath.StartsWith(resolvedDir + Path.DirectorySeparatorChar, StringComparison.Ordinal)
|
||||
&& !resolvedPath.Equals(resolvedDir, StringComparison.Ordinal))
|
||||
throw new ArgumentException("fileName resolves outside the task directory.", nameof(fileName));
|
||||
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
// Buffer up to MaxBytes + 1 to detect oversize without reading fully
|
||||
await using var fs = new FileStream(resolvedPath, FileMode.Create, FileAccess.Write, FileShare.None,
|
||||
bufferSize: 81920, useAsync: true);
|
||||
|
||||
var buffer = new byte[81920];
|
||||
long total = 0;
|
||||
int read;
|
||||
while ((read = await content.ReadAsync(buffer, ct)) > 0)
|
||||
{
|
||||
total += read;
|
||||
if (total > MaxBytes)
|
||||
{
|
||||
fs.Close();
|
||||
try { File.Delete(resolvedPath); } catch { }
|
||||
throw new InvalidOperationException($"Attachment exceeds the 5 MB size limit.");
|
||||
}
|
||||
await fs.WriteAsync(buffer.AsMemory(0, read), ct);
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
public void DeleteFile(string taskId, string fileName)
|
||||
{
|
||||
if (Path.GetFileName(fileName) != fileName)
|
||||
return; // traversal attempt — ignore silently
|
||||
|
||||
var dir = TaskDir(taskId);
|
||||
var resolvedPath = Path.GetFullPath(Path.Combine(dir, fileName));
|
||||
var resolvedDir = Path.GetFullPath(dir);
|
||||
if (!resolvedPath.StartsWith(resolvedDir + Path.DirectorySeparatorChar, StringComparison.Ordinal)
|
||||
&& !resolvedPath.Equals(resolvedDir, StringComparison.Ordinal))
|
||||
return; // containment violation — ignore silently
|
||||
|
||||
try { File.Delete(resolvedPath); } catch (DirectoryNotFoundException) { } catch (FileNotFoundException) { }
|
||||
}
|
||||
|
||||
public void DeleteTaskDir(string taskId)
|
||||
{
|
||||
var dir = TaskDir(taskId);
|
||||
try { Directory.Delete(dir, recursive: true); } catch (DirectoryNotFoundException) { } catch (IOException) { }
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
|
||||
- **PrimeScheduleEntity** — Id, Days (`[Flags] PrimeDays` weekday bitmask, stored as `days_of_week` int), TimeOfDay, Enabled, LastRunAt, PromptOverride, CreatedAt. Recurs on the selected weekdays; no date range.
|
||||
- **DailyNoteEntity** — Id, Date (DateOnly), Text, SortOrder, CreatedAt → table `daily_notes`
|
||||
- **WeekReportEntity** — Id, StartDate/EndDate (DateOnly), Markdown, GeneratedAt → table `week_reports`, unique index on (start_date, end_date)
|
||||
- **TaskAttachmentEntity** — Id, TaskId (FK to tasks, ON DELETE CASCADE), FileName, ByteSize, CreatedAt → table `task_attachments`
|
||||
- **AppSettingsEntity** also carries `ReportExcludedPaths` (string?, JSON array of excluded path prefixes, column `report_excluded_paths`), `StandupWeekday` (int DayOfWeek, default Wednesday, column `standup_weekday`), and `DailyPrepMaxTasks` (int, default 5, column `daily_prep_max_tasks` — hard cap on how many open tasks the daily-prep / "Prime Claude" feature may place in MyDay)
|
||||
- **SubtaskEntity**, **AppSettingsEntity**, **AgentInfo** — existing helpers / settings / record for scanned agent files
|
||||
|
||||
@@ -25,6 +26,7 @@ All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. The atomic `Q
|
||||
- **TaskRunRepository**, **SubtaskRepository**, **AppSettingsRepository**
|
||||
- **DailyNoteRepository** — `ListByDayAsync`, `ListBetweenAsync`, `AddAsync`, `UpdateAsync`, `DeleteAsync`
|
||||
- **WeekReportRepository** — `GetByRangeAsync`, `UpsertAsync`
|
||||
- **TaskAttachmentRepository** — `AddAsync`, `UpdateAsync`, `GetAsync(taskId, fileName)`, `ListByTaskIdAsync`, `DeleteAsync(taskId, fileName)`, `DeleteAllForTaskAsync`
|
||||
|
||||
## Infrastructure
|
||||
|
||||
@@ -32,14 +34,15 @@ All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. The atomic `Q
|
||||
- **IDbContextFactory<ClaudeDoDbContext>** — registered in DI; used by singleton consumers (e.g. Worker hosted service)
|
||||
- **Paths** — expands `~` and `%USERPROFILE%`, resolves relative paths. App root: `~/.todo-app`
|
||||
- **AppSettings** — loads `~/.todo-app/ui.config.json` (DbPath, SignalRUrl)
|
||||
- **AttachmentStore** — dependency-free file store; default root `~/.todo-app/attachments/<taskId>/`. `SaveAsync` enforces a 5 MB cap and path-traversal/containment guard. Also exposes `DeleteFile`, `DeleteTaskDir`, `TaskDir`, `Root`, and `EnumerateTaskIds` (used by the worker orphan sweep). Attachment files live outside git worktrees intentionally.
|
||||
|
||||
## Git
|
||||
|
||||
- **GitService** — async wrapper around git CLI (ProcessStartInfo, no shell). Worktree ops (add — serialized to avoid a commondir race —, remove, prune, list paths for branch), branch ops (current, list local, checkout, delete), staging/commit (status porcelain, add-all, add-path, commit via stdin), diffs (working tree, branch vs base, commit range `base..head` — used to show a merged task's diff after the worktree is gone —, per-file, diff-stat, committed files, has-changes), merge (ff-only, no-ff, abort, mid-merge detection, conflicted files, show-stage for conflict hunks), `PreviewMergeAsync` (non-destructive mergeability check via `git merge-tree --write-tree`), `CountChangedFilesAsync`, rev-parse, is-git-repo
|
||||
- **GitService** — async wrapper around git CLI (ProcessStartInfo, no shell). Worktree ops (add — serialized to avoid a commondir race —, remove, prune, list paths for branch), branch ops (current, list local, checkout, delete), staging/commit (status porcelain, add-all, add-path, commit via stdin), diffs (working tree, branch vs base, commit range `base..head` — used to show a merged task's diff after the worktree is gone —, per-file, diff-stat, committed files, has-changes), merge (ff-only, no-ff, abort, mid-merge detection, conflicted files), `PreviewMergeAsync` (non-destructive mergeability check via `git merge-tree --write-tree`), `CountChangedFilesAsync`, rev-parse, is-git-repo
|
||||
|
||||
## Schema
|
||||
|
||||
Tables: `lists`, `tasks`, `worktrees`, `list_config`, `task_runs`, `subtasks`, `app_settings`, `prime_schedules`, `daily_notes`, `week_reports`. Managed by EF Core migrations in the `Migrations/` folder. The `tasks` table holds `status`, `planning_phase` (default `none`), and `blocked_by_task_id` (FK to `tasks.id`, `ON DELETE SET NULL`). Migration `WeeklyReport` added `daily_notes`, `week_reports`, and the two new `app_settings` columns. Migration `DailyPrepMaxTasks` added the `daily_prep_max_tasks` column to `app_settings` (no new tables).
|
||||
Tables: `lists`, `tasks`, `worktrees`, `list_config`, `task_runs`, `subtasks`, `app_settings`, `prime_schedules`, `daily_notes`, `week_reports`, `task_attachments`. Managed by EF Core migrations in the `Migrations/` folder. The `tasks` table holds `status`, `planning_phase` (default `none`), and `blocked_by_task_id` (FK to `tasks.id`, `ON DELETE SET NULL`). Migration `WeeklyReport` added `daily_notes`, `week_reports`, and the two new `app_settings` columns. Migration `DailyPrepMaxTasks` added the `daily_prep_max_tasks` column to `app_settings` (no new tables). Migration `AddTaskAttachments` created the `task_attachments` table. `TaskRepository.DeleteAsync` and `ListRepository.DeleteAsync` also delete the on-disk attachment dir(s) via an optional `AttachmentStore` ctor param (defaults to the production store).
|
||||
|
||||
## Conventions
|
||||
|
||||
|
||||
@@ -46,10 +46,12 @@ public class ClaudeDoDbContext : DbContext
|
||||
public DbSet<WorktreeEntity> Worktrees => Set<WorktreeEntity>();
|
||||
public DbSet<TaskRunEntity> TaskRuns => Set<TaskRunEntity>();
|
||||
public DbSet<SubtaskEntity> Subtasks => Set<SubtaskEntity>();
|
||||
public DbSet<TaskAttachmentEntity> TaskAttachments => Set<TaskAttachmentEntity>();
|
||||
public DbSet<AppSettingsEntity> AppSettings => Set<AppSettingsEntity>();
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
|
||||
namespace ClaudeDo.Data.Configuration;
|
||||
|
||||
public class TaskAttachmentEntityConfiguration : IEntityTypeConfiguration<TaskAttachmentEntity>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<TaskAttachmentEntity> builder)
|
||||
{
|
||||
builder.ToTable("task_attachments");
|
||||
|
||||
builder.HasKey(a => a.Id);
|
||||
builder.Property(a => a.Id).HasColumnName("id");
|
||||
builder.Property(a => a.TaskId).HasColumnName("task_id").IsRequired();
|
||||
builder.Property(a => a.FileName).HasColumnName("file_name").IsRequired();
|
||||
builder.Property(a => a.ByteSize).HasColumnName("byte_size").IsRequired();
|
||||
builder.Property(a => a.CreatedAt).HasColumnName("created_at").IsRequired();
|
||||
|
||||
builder.HasOne(a => a.Task)
|
||||
.WithMany()
|
||||
.HasForeignKey(a => a.TaskId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.HasIndex(a => a.TaskId).HasDatabaseName("idx_task_attachments_task_id");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -280,17 +293,6 @@ public sealed class GitService
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a conflicted file's blob at a merge stage: 1=base, 2=ours, 3=theirs.
|
||||
/// Returns null when the stage doesn't exist (e.g. add/add conflict has no base).
|
||||
/// Output is NOT trimmed so file content round-trips exactly.
|
||||
/// </summary>
|
||||
public async Task<string?> ShowStageAsync(string repoDir, int stage, string path, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["show", $":{stage}:{path}"], ct, trimOutput: false);
|
||||
return exitCode == 0 ? stdout : null;
|
||||
}
|
||||
|
||||
public async Task AddPathAsync(string repoDir, string path, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["add", "--", path], ct);
|
||||
@@ -341,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,739 @@
|
||||
// <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("20260622150934_AddTaskAttachments")]
|
||||
partial class AddTaskAttachments
|
||||
{
|
||||
/// <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<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>("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.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<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,48 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace ClaudeDo.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddTaskAttachments : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "task_attachments",
|
||||
columns: table => new
|
||||
{
|
||||
id = table.Column<string>(type: "TEXT", nullable: false),
|
||||
task_id = table.Column<string>(type: "TEXT", nullable: false),
|
||||
file_name = table.Column<string>(type: "TEXT", nullable: false),
|
||||
byte_size = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
created_at = table.Column<DateTime>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_task_attachments", x => x.id);
|
||||
table.ForeignKey(
|
||||
name: "FK_task_attachments_tasks_task_id",
|
||||
column: x => x.task_id,
|
||||
principalTable: "tasks",
|
||||
principalColumn: "id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "idx_task_attachments_task_id",
|
||||
table: "task_attachments",
|
||||
column: "task_id");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "task_attachments");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
@@ -294,6 +337,38 @@ namespace ClaudeDo.Data.Migrations
|
||||
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")
|
||||
@@ -405,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")
|
||||
@@ -625,6 +704,17 @@ namespace ClaudeDo.Data.Migrations
|
||||
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)
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace ClaudeDo.Data.Models;
|
||||
|
||||
public sealed class TaskAttachmentEntity
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string TaskId { get; init; }
|
||||
public required string FileName { get; set; }
|
||||
public long ByteSize { get; set; }
|
||||
public required DateTime CreatedAt { get; init; }
|
||||
|
||||
// Navigation property
|
||||
public TaskEntity Task { get; set; } = null!;
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -105,9 +105,12 @@ public static class PromptFiles
|
||||
- Don't introduce injection/XSS/secret-leak issues. Never commit credentials.
|
||||
|
||||
## You are running unattended
|
||||
You run autonomously with no human watching. There is no one to answer mid-task
|
||||
questions, so never stop to ask — make the most reasonable decision, note the
|
||||
assumption, and continue.
|
||||
You run autonomously, usually with no one watching. Default to making the most
|
||||
reasonable decision yourself, noting the assumption, and continuing — do not stop
|
||||
for routine choices. The one exception: at a genuine fork where a wrong guess
|
||||
would be costly or hard to undo (an irreversible action, contradictory
|
||||
requirements), you may call AskUser(question) to ask the user and wait briefly for
|
||||
an answer. If no one responds in time, proceed on your best judgment.
|
||||
|
||||
## When you are blocked
|
||||
If something genuinely prevents you from completing part of the task (missing
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -6,8 +6,13 @@ namespace ClaudeDo.Data.Repositories;
|
||||
public sealed class ListRepository
|
||||
{
|
||||
private readonly ClaudeDoDbContext _context;
|
||||
private readonly AttachmentStore _attachments;
|
||||
|
||||
public ListRepository(ClaudeDoDbContext context) => _context = context;
|
||||
public ListRepository(ClaudeDoDbContext context, AttachmentStore? attachments = null)
|
||||
{
|
||||
_context = context;
|
||||
_attachments = attachments ?? new AttachmentStore();
|
||||
}
|
||||
|
||||
public async Task AddAsync(ListEntity entity, CancellationToken ct = default)
|
||||
{
|
||||
@@ -23,7 +28,13 @@ public sealed class ListRepository
|
||||
|
||||
public async Task DeleteAsync(string listId, CancellationToken ct = default)
|
||||
{
|
||||
var taskIds = await _context.Tasks
|
||||
.Where(t => t.ListId == listId)
|
||||
.Select(t => t.Id)
|
||||
.ToListAsync(ct);
|
||||
await _context.Lists.Where(l => l.Id == listId).ExecuteDeleteAsync(ct);
|
||||
foreach (var id in taskIds)
|
||||
_attachments.DeleteTaskDir(id);
|
||||
}
|
||||
|
||||
public async Task<ListEntity?> GetByIdAsync(string listId, CancellationToken ct = default)
|
||||
@@ -66,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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Data.Repositories;
|
||||
|
||||
public sealed class TaskAttachmentRepository
|
||||
{
|
||||
private readonly ClaudeDoDbContext _context;
|
||||
|
||||
public TaskAttachmentRepository(ClaudeDoDbContext context) => _context = context;
|
||||
|
||||
public async Task AddAsync(TaskAttachmentEntity entity, CancellationToken ct = default)
|
||||
{
|
||||
_context.TaskAttachments.Add(entity);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<List<TaskAttachmentEntity>> ListByTaskIdAsync(string taskId, CancellationToken ct = default)
|
||||
{
|
||||
return await _context.TaskAttachments
|
||||
.Where(a => a.TaskId == taskId)
|
||||
.OrderBy(a => a.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<TaskAttachmentEntity?> GetAsync(string taskId, string fileName, CancellationToken ct = default)
|
||||
{
|
||||
return await _context.TaskAttachments
|
||||
.FirstOrDefaultAsync(a => a.TaskId == taskId && a.FileName == fileName, ct);
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(TaskAttachmentEntity entity, CancellationToken ct = default)
|
||||
{
|
||||
_context.TaskAttachments.Update(entity);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string taskId, string fileName, CancellationToken ct = default)
|
||||
{
|
||||
await _context.TaskAttachments
|
||||
.Where(a => a.TaskId == taskId && a.FileName == fileName)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
}
|
||||
|
||||
public async Task DeleteAllForTaskAsync(string taskId, CancellationToken ct = default)
|
||||
{
|
||||
await _context.TaskAttachments
|
||||
.Where(a => a.TaskId == taskId)
|
||||
.ExecuteDeleteAsync(ct);
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,13 @@ namespace ClaudeDo.Data.Repositories;
|
||||
public sealed class TaskRepository
|
||||
{
|
||||
private readonly ClaudeDoDbContext _context;
|
||||
private readonly AttachmentStore _attachments;
|
||||
|
||||
public TaskRepository(ClaudeDoDbContext context) => _context = context;
|
||||
public TaskRepository(ClaudeDoDbContext context, AttachmentStore? attachments = null)
|
||||
{
|
||||
_context = context;
|
||||
_attachments = attachments ?? new AttachmentStore();
|
||||
}
|
||||
|
||||
#region CRUD
|
||||
|
||||
@@ -37,6 +42,7 @@ public sealed class TaskRepository
|
||||
public async Task DeleteAsync(string taskId, CancellationToken ct = default)
|
||||
{
|
||||
await _context.Tasks.Where(t => t.Id == taskId).ExecuteDeleteAsync(ct);
|
||||
_attachments.DeleteTaskDir(taskId);
|
||||
}
|
||||
|
||||
public async Task<TaskEntity?> GetByIdAsync(string taskId, CancellationToken ct = default)
|
||||
@@ -183,6 +189,7 @@ public sealed class TaskRepository
|
||||
string? systemPrompt,
|
||||
string? agentPath,
|
||||
int? maxTurns = null,
|
||||
string? sessionSkills = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
await _context.Tasks
|
||||
@@ -191,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
|
||||
|
||||
@@ -9,7 +9,8 @@ namespace ClaudeDo.Data;
|
||||
/// </summary>
|
||||
public static class TaskPromptComposer
|
||||
{
|
||||
public static string Compose(string title, string? description, IEnumerable<(string Title, bool Completed)> subtasks)
|
||||
public static string Compose(string title, string? description, IEnumerable<(string Title, bool Completed)> subtasks,
|
||||
IEnumerable<string>? attachmentPaths = null)
|
||||
{
|
||||
var sb = new StringBuilder((title ?? "").Trim());
|
||||
|
||||
@@ -24,6 +25,14 @@ public static class TaskPromptComposer
|
||||
sb.Append("- [ ] ").Append(s.Title).Append('\n');
|
||||
}
|
||||
|
||||
var paths = attachmentPaths?.ToList();
|
||||
if (paths is { Count: > 0 })
|
||||
{
|
||||
sb.Append("\n\n## Reference files\nThese files were attached to this task as read-only reference (they live outside the repo). Read them as needed:\n");
|
||||
foreach (var p in paths)
|
||||
sb.Append("- ").Append(p).Append('\n');
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,104 +38,6 @@ public partial class App : Application
|
||||
var localizer = new Localizer(localeStore, initialLang);
|
||||
TrExtension.Localizer = localizer;
|
||||
|
||||
// --- Self-update pre-flight ---
|
||||
// Resolve current exe path. Assembly.Location may point to a .dll for apphost-based
|
||||
// .NET apps; swap to the .exe companion when that happens.
|
||||
var currentExePath = Assembly.GetEntryAssembly()!.Location;
|
||||
if (currentExePath.EndsWith(".dll", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
currentExePath = System.IO.Path.ChangeExtension(currentExePath, ".exe");
|
||||
}
|
||||
|
||||
// Arg form: --replace-self "<old-path>"
|
||||
var replaceSelfIndex = Array.FindIndex(e.Args, a => a.Equals("--replace-self", StringComparison.OrdinalIgnoreCase));
|
||||
if (replaceSelfIndex >= 0 && replaceSelfIndex + 1 < e.Args.Length)
|
||||
{
|
||||
var oldPath = e.Args[replaceSelfIndex + 1];
|
||||
var relaunched = await SelfUpdater.HandleReplaceSelfAsync(
|
||||
oldPath: oldPath,
|
||||
currentExePath: currentExePath,
|
||||
launchProcess: path =>
|
||||
{
|
||||
try
|
||||
{
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
|
||||
return true;
|
||||
}
|
||||
catch { return false; }
|
||||
});
|
||||
if (relaunched)
|
||||
{
|
||||
Shutdown(0);
|
||||
return;
|
||||
}
|
||||
// Replacement failed — fall through to normal wizard from the temp location.
|
||||
}
|
||||
else
|
||||
{
|
||||
// Normal launch: check for a newer installer.
|
||||
using var selfUpdateHttp = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
|
||||
var selfUpdateReleases = new ReleaseClient(selfUpdateHttp);
|
||||
var currentVersion = GetInstallerVersion();
|
||||
|
||||
var decision = await SelfUpdater.DecideUpdateAsync(selfUpdateReleases, currentVersion, CancellationToken.None);
|
||||
if (decision.Kind == SelfUpdateDecisionKind.UpdateAvailable)
|
||||
{
|
||||
var prompt = new SelfUpdatePromptWindow(currentVersion, decision.LatestVersion!);
|
||||
DarkTitleBar.Apply(prompt);
|
||||
var ok = prompt.ShowDialog() == true;
|
||||
if (!ok)
|
||||
{
|
||||
Shutdown(0);
|
||||
return;
|
||||
}
|
||||
if (prompt.Choice == SelfUpdateChoice.Update)
|
||||
{
|
||||
prompt.ShowProgress("Downloading...");
|
||||
var tempDir = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "ClaudeDo.Installer.Update");
|
||||
var verifiedPath = await SelfUpdater.DownloadAndVerifyAsync(
|
||||
selfUpdateReleases,
|
||||
decision.InstallerAsset!,
|
||||
decision.ChecksumsAsset!,
|
||||
tempDir,
|
||||
new Progress<long>(_ => { }),
|
||||
CancellationToken.None);
|
||||
|
||||
if (verifiedPath is null)
|
||||
{
|
||||
MessageBox.Show(prompt,
|
||||
"Update download or verification failed. Continuing with current installer.",
|
||||
"ClaudeDo Installer", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
}
|
||||
else
|
||||
{
|
||||
try
|
||||
{
|
||||
var psi = new System.Diagnostics.ProcessStartInfo(verifiedPath)
|
||||
{
|
||||
UseShellExecute = true,
|
||||
};
|
||||
psi.ArgumentList.Add("--replace-self");
|
||||
psi.ArgumentList.Add(currentExePath);
|
||||
System.Diagnostics.Process.Start(psi);
|
||||
Shutdown(0);
|
||||
return;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show(prompt,
|
||||
"Failed to launch updated installer: " + ex.Message + "\nContinuing with current installer.",
|
||||
"ClaudeDo Installer", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||
}
|
||||
}
|
||||
}
|
||||
// SelfUpdateChoice.Continue — fall through to normal wizard.
|
||||
}
|
||||
// No-update or check failed — fall through to normal wizard.
|
||||
}
|
||||
|
||||
// --- Existing wizard start-up unchanged below this line ---
|
||||
|
||||
_services = BuildServices(localizer);
|
||||
|
||||
var context = _services.GetRequiredService<InstallContext>();
|
||||
|
||||
@@ -12,14 +12,22 @@ Note: this is the one project where `System.Windows` is correct (WPF, not Avalon
|
||||
- Entry point: `App.xaml` / `App.xaml.cs` (no `Program.cs`)
|
||||
- References: `ClaudeDo.Data`, `ClaudeDo.Releases`, `ClaudeDo.Localization`
|
||||
- Manifests: `app.manifest` (requireAdministrator, Release) / `app.debug.manifest` (asInvoker, Debug)
|
||||
- Only CLI arg: `--replace-self <old-path>` (self-update handoff)
|
||||
- No CLI args — mode is detected from `install.json` + the Gitea API
|
||||
|
||||
## Startup Sequence (`App.OnStartup`)
|
||||
|
||||
1. Load locale
|
||||
2. Self-update preflight — `SelfUpdater.DecideUpdateAsync` checks Gitea API; if a newer installer exists, download + checksum verify + relaunch with `--replace-self <old-path>`
|
||||
3. Detect mode — `InstallModeDetector` reads `install.json` + Gitea API
|
||||
4. Open `WizardWindow` (FreshInstall / Update) or `SettingsWindow` (Config)
|
||||
2. Detect mode — `InstallModeDetector` reads `install.json` + Gitea API
|
||||
3. Open `WizardWindow` (FreshInstall / Update) or `SettingsWindow` (Config)
|
||||
|
||||
The installer does **not** self-update. Each release ships a stable-named
|
||||
`ClaudeDo.Installer.exe` asset (permanent URL
|
||||
`…/releases/latest/download/ClaudeDo.Installer.exe`); the installer never checks for or
|
||||
replaces itself on launch. The in-app "Update" button relaunches the on-disk installer to
|
||||
run the app update — the installer binary itself only changes when the user downloads a
|
||||
fresh copy. App-update detection is unaffected: `WriteInstallManifestStep` records
|
||||
`ctx.InstalledVersion` (the release tag from `DownloadAndExtractStep`), which
|
||||
`InstallModeDetector` compares against the latest tag.
|
||||
|
||||
## Modes (`Core/InstallerMode.cs`)
|
||||
|
||||
@@ -56,8 +64,7 @@ Installer/
|
||||
Interfaces/ — IInstallStep + StepResult/StepStatus/StepProgress, IInstallerPage
|
||||
Pages/ — WelcomePage, PathsPage, ServicePage, UiSettingsPage, InstallPage
|
||||
(each: ViewModel + View.xaml)
|
||||
Views/ — WizardWindow(+WizardViewModel), SettingsWindow(+SettingsViewModel),
|
||||
SelfUpdatePromptWindow
|
||||
Views/ — WizardWindow(+WizardViewModel), SettingsWindow(+SettingsViewModel)
|
||||
```
|
||||
|
||||
## Key Step Behaviors
|
||||
|
||||
@@ -26,9 +26,9 @@ public sealed class WriteUninstallRegistryStep : IInstallStep
|
||||
// the single-file temp extract is gone once this process exits.
|
||||
var sourceExe = Environment.ProcessPath
|
||||
?? throw new InvalidOperationException("Cannot resolve running installer path.");
|
||||
// In the self-update path the installer already runs from uninstaller/ (the
|
||||
// --replace-self handoff put it there), so source == target and the copy would
|
||||
// throw. Skip it; the binary is already in place.
|
||||
// When relaunched from the installed copy (e.g. the Apps & Features "Rerun
|
||||
// Installer" entry points at uninstaller/ClaudeDo.Installer.exe), source == target
|
||||
// and the copy would throw. Skip it; the binary is already in place.
|
||||
var alreadyInPlace = string.Equals(
|
||||
Path.GetFullPath(sourceExe), Path.GetFullPath(targetExe), StringComparison.OrdinalIgnoreCase);
|
||||
if (!alreadyInPlace)
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<Window x:Class="ClaudeDo.Installer.Views.SelfUpdatePromptWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:loc="clr-namespace:ClaudeDo.Installer.Localization"
|
||||
Title="ClaudeDo Installer Update"
|
||||
Width="460" Height="200"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
ResizeMode="NoResize"
|
||||
Background="#1a1a1a" Foreground="#f0f0f0">
|
||||
<Grid Margin="20">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
<RowDefinition Height="*"/>
|
||||
<RowDefinition Height="Auto"/>
|
||||
</Grid.RowDefinitions>
|
||||
<TextBlock Grid.Row="0" FontSize="16" FontWeight="SemiBold" Text="{loc:Tr installer.selfUpdate.heading}"/>
|
||||
<TextBlock Grid.Row="1" Margin="0,8,0,0" TextWrapping="Wrap" x:Name="DetailText"/>
|
||||
<TextBlock Grid.Row="2" Margin="0,12,0,0" TextWrapping="Wrap" Foreground="#a0a0a0" x:Name="ProgressText" Visibility="Collapsed"/>
|
||||
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right">
|
||||
<Button x:Name="UpdateBtn" Content="{loc:Tr installer.selfUpdate.update}" MinWidth="90" Margin="4,0" Padding="10,4" Click="UpdateBtn_Click" IsDefault="True"/>
|
||||
<Button x:Name="ContinueBtn" Content="{loc:Tr installer.selfUpdate.continueAnyway}" MinWidth="140" Margin="4,0" Padding="10,4" Click="ContinueBtn_Click"/>
|
||||
<Button x:Name="CancelBtn" Content="{loc:Tr installer.nav.cancel}" MinWidth="90" Margin="4,0" Padding="10,4" Click="CancelBtn_Click" IsCancel="True"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -1,42 +0,0 @@
|
||||
using System.Windows;
|
||||
|
||||
namespace ClaudeDo.Installer.Views;
|
||||
|
||||
public enum SelfUpdateChoice { Update, Continue, Cancel }
|
||||
|
||||
public partial class SelfUpdatePromptWindow : Window
|
||||
{
|
||||
public SelfUpdateChoice Choice { get; private set; } = SelfUpdateChoice.Cancel;
|
||||
|
||||
public SelfUpdatePromptWindow(string currentVersion, string latestVersion)
|
||||
{
|
||||
InitializeComponent();
|
||||
DetailText.Text = $"Installer v{latestVersion} is available (you are running v{currentVersion}). Update before continuing?";
|
||||
}
|
||||
|
||||
public void ShowProgress(string text)
|
||||
{
|
||||
ProgressText.Text = text;
|
||||
ProgressText.Visibility = Visibility.Visible;
|
||||
UpdateBtn.IsEnabled = false;
|
||||
ContinueBtn.IsEnabled = false;
|
||||
}
|
||||
|
||||
private void UpdateBtn_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
Choice = SelfUpdateChoice.Update;
|
||||
DialogResult = true;
|
||||
}
|
||||
|
||||
private void ContinueBtn_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
Choice = SelfUpdateChoice.Continue;
|
||||
DialogResult = true;
|
||||
}
|
||||
|
||||
private void CancelBtn_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
Choice = SelfUpdateChoice.Cancel;
|
||||
DialogResult = false;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
@@ -88,6 +91,25 @@
|
||||
"inheritedFromGlobal": "geerbt · Global",
|
||||
"overrideBadge": "überschrieben",
|
||||
"resetToInherited": "Auf geerbt zurücksetzen"
|
||||
},
|
||||
"agentEditor": {
|
||||
"model": "Modell",
|
||||
"maxTurns": "Max. Durchläufe",
|
||||
"systemPrompt": "System-Prompt (angehängt)",
|
||||
"promptPrepended": "Wird automatisch vorangestellt:",
|
||||
"agentFile": "Agent-Datei",
|
||||
"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": {
|
||||
@@ -107,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",
|
||||
@@ -154,6 +177,7 @@
|
||||
"addReposTip": "Repos als Listen hinzufügen"
|
||||
},
|
||||
"details": {
|
||||
"pickUpInTerminalTip": "Diese Sitzung im Terminal fortsetzen",
|
||||
"deleteTaskTip": "Aufgabe löschen",
|
||||
"killSessionTip": "Laufende Sitzung beenden",
|
||||
"closeTip": "Schließen",
|
||||
@@ -161,16 +185,11 @@
|
||||
"starTip": "Favorit",
|
||||
"agentSettingsTip": "Agent-Einstellungen",
|
||||
"agentSettingsHeading": "Agent-Einstellungen (Überschreibungen)",
|
||||
"modelLabel": "Modell",
|
||||
"maxTurnsLabel": "Max. Durchläufe",
|
||||
"systemPromptLabel": "System-Prompt (angehängt)",
|
||||
"systemPromptPrepended": "Wird automatisch vorangestellt:",
|
||||
"agentFileLabel": "Agent-Datei",
|
||||
"mergeLabel": "MERGE",
|
||||
"mergeTargetLabel": "Merge-Ziel",
|
||||
"reviewCombinedDiff": "Kombiniertes Diff prüfen",
|
||||
"mergeAllSubtasks": "Alle Teilaufgaben mergen",
|
||||
"childOutcomesLabel": "VERBESSERUNGEN",
|
||||
"childOutcomesLabel": "TEILAUFGABEN",
|
||||
"stepsLabel": "SCHRITTE",
|
||||
"addStepPlaceholder": "Schritt hinzufügen...",
|
||||
"detailsLabel": "DETAILS",
|
||||
@@ -182,7 +201,23 @@
|
||||
"descriptionPlaceholder": "Aufgabendetails hinzufügen (Markdown unterstützt)...",
|
||||
"prepTitle": "Tagesvorbereitung",
|
||||
"planDay": "Tag planen",
|
||||
"prepEmpty": "Heute noch keine Vorbereitung — klick Tag planen"
|
||||
"prepEmpty": "Heute noch keine Vorbereitung — klick Tag planen",
|
||||
"attachments": {
|
||||
"sectionLabel": "ANHÄNGE",
|
||||
"dropToAttach": "Zum Anhängen ablegen",
|
||||
"addFile": "Datei hinzufügen…",
|
||||
"removeTip": "Anhang entfernen",
|
||||
"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": {
|
||||
"description": "Beschreibung",
|
||||
"steps": "Schritte",
|
||||
"files": "Dateien"
|
||||
}
|
||||
},
|
||||
"agent": {
|
||||
"stopTip": "Agent stoppen",
|
||||
@@ -215,7 +250,42 @@
|
||||
"reviewContinueTip": "Dieses Feedback senden und die Aufgabe erneut ausführen",
|
||||
"reviewResetTip": "Alle Änderungen verwerfen und die Aufgabe auf Leerlauf zurücksetzen"
|
||||
},
|
||||
"missionControl": {
|
||||
"openInApp": "In App öffnen",
|
||||
"cancel": "Abbrechen",
|
||||
"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}",
|
||||
"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…",
|
||||
"send": "Senden"
|
||||
}
|
||||
},
|
||||
"modals": {
|
||||
"logVisualizer": {
|
||||
"title": "WORKER-LOGS — LETZTE 30 MIN",
|
||||
"warnErrorOnly": "Nur Warnungen & Fehler",
|
||||
"refresh": "Aktualisieren",
|
||||
"empty": "Keine Logs in den letzten 30 Minuten.",
|
||||
"count": "{0} Einträge",
|
||||
"footerHint": "logs",
|
||||
"openTooltip": "Aktuelle Worker-Logs anzeigen"
|
||||
},
|
||||
"about": {
|
||||
"title": "ÜBER",
|
||||
"version": "Version",
|
||||
@@ -241,11 +311,7 @@
|
||||
"browse": "Durchsuchen...",
|
||||
"defaultCommitType": "Standard-Commit-Typ",
|
||||
"sectionAgent": "AGENT",
|
||||
"resetAgentSettings": "Agent-Einstellungen zurücksetzen",
|
||||
"model": "Modell",
|
||||
"maxTurns": "Max. Durchläufe",
|
||||
"systemPrompt": "System-Prompt (angehängt)",
|
||||
"agentFile": "Agent-Datei"
|
||||
"resetAgentSettings": "Agent-Einstellungen zurücksetzen"
|
||||
},
|
||||
"merge": {
|
||||
"title": "WORKTREE MERGEN",
|
||||
@@ -265,9 +331,6 @@
|
||||
"binary": "Binärdatei — kein Text-Diff",
|
||||
"empty": "Kein Inhalt"
|
||||
},
|
||||
"worktree": {
|
||||
"title": "Worktree"
|
||||
},
|
||||
"worktreesOverview": {
|
||||
"refresh": "Aktualisieren",
|
||||
"cleanupFinished": "Abgeschlossene aufräumen",
|
||||
@@ -390,8 +453,6 @@
|
||||
"abort": "Diesen Merge abbrechen"
|
||||
},
|
||||
"diff": {
|
||||
"windowTitle": "Planung — Kombiniertes Diff",
|
||||
"modalTitle": "PLANUNG — KOMBINIERTES DIFF",
|
||||
"previewCombined": "Kombinierte Vorschau",
|
||||
"loading": "Wird geladen…"
|
||||
}
|
||||
@@ -445,11 +506,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}" },
|
||||
"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}" },
|
||||
@@ -458,6 +519,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",
|
||||
@@ -88,6 +91,25 @@
|
||||
"inheritedFromGlobal": "inherited · Global",
|
||||
"overrideBadge": "override",
|
||||
"resetToInherited": "Reset to inherited"
|
||||
},
|
||||
"agentEditor": {
|
||||
"model": "Model",
|
||||
"maxTurns": "Max turns",
|
||||
"systemPrompt": "System prompt (appended)",
|
||||
"promptPrepended": "Prepended automatically:",
|
||||
"agentFile": "Agent file",
|
||||
"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": {
|
||||
@@ -107,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",
|
||||
@@ -154,6 +177,7 @@
|
||||
"addReposTip": "Add repos as lists"
|
||||
},
|
||||
"details": {
|
||||
"pickUpInTerminalTip": "Pick up this session in a terminal",
|
||||
"deleteTaskTip": "Delete task",
|
||||
"killSessionTip": "Kill the running session",
|
||||
"closeTip": "Close",
|
||||
@@ -161,16 +185,11 @@
|
||||
"starTip": "Star",
|
||||
"agentSettingsTip": "Agent settings",
|
||||
"agentSettingsHeading": "Agent settings (overrides)",
|
||||
"modelLabel": "Model",
|
||||
"maxTurnsLabel": "Max turns",
|
||||
"systemPromptLabel": "System prompt (appended)",
|
||||
"systemPromptPrepended": "Prepended automatically:",
|
||||
"agentFileLabel": "Agent file",
|
||||
"mergeLabel": "MERGE",
|
||||
"mergeTargetLabel": "Merge target",
|
||||
"reviewCombinedDiff": "Review combined diff",
|
||||
"mergeAllSubtasks": "Merge all subtasks",
|
||||
"childOutcomesLabel": "IMPROVEMENTS",
|
||||
"childOutcomesLabel": "SUBTASKS",
|
||||
"stepsLabel": "STEPS",
|
||||
"addStepPlaceholder": "Add a step...",
|
||||
"detailsLabel": "DETAILS",
|
||||
@@ -182,7 +201,23 @@
|
||||
"descriptionPlaceholder": "Add task details (markdown supported)...",
|
||||
"prepTitle": "Daily prep",
|
||||
"planDay": "Plan day",
|
||||
"prepEmpty": "No prep run today yet — click Plan day"
|
||||
"prepEmpty": "No prep run today yet — click Plan day",
|
||||
"attachments": {
|
||||
"sectionLabel": "ATTACHMENTS",
|
||||
"dropToAttach": "Drop to attach",
|
||||
"addFile": "Add file…",
|
||||
"removeTip": "Remove attachment",
|
||||
"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": {
|
||||
"description": "Description",
|
||||
"steps": "Steps",
|
||||
"files": "Files"
|
||||
}
|
||||
},
|
||||
"agent": {
|
||||
"stopTip": "Stop agent",
|
||||
@@ -215,7 +250,42 @@
|
||||
"reviewContinueTip": "Send this feedback and re-run the task",
|
||||
"reviewResetTip": "Discard all changes and reset the task to Idle"
|
||||
},
|
||||
"missionControl": {
|
||||
"openInApp": "Open in app",
|
||||
"cancel": "Cancel",
|
||||
"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}",
|
||||
"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…",
|
||||
"send": "Send"
|
||||
}
|
||||
},
|
||||
"modals": {
|
||||
"logVisualizer": {
|
||||
"title": "WORKER LOGS — LAST 30 MIN",
|
||||
"warnErrorOnly": "Warnings & errors only",
|
||||
"refresh": "Refresh",
|
||||
"empty": "No logs in the last 30 minutes.",
|
||||
"count": "{0} entries",
|
||||
"footerHint": "logs",
|
||||
"openTooltip": "View recent worker logs"
|
||||
},
|
||||
"about": {
|
||||
"title": "ABOUT",
|
||||
"version": "Version",
|
||||
@@ -241,11 +311,7 @@
|
||||
"browse": "Browse...",
|
||||
"defaultCommitType": "Default commit type",
|
||||
"sectionAgent": "AGENT",
|
||||
"resetAgentSettings": "Reset agent settings",
|
||||
"model": "Model",
|
||||
"maxTurns": "Max turns",
|
||||
"systemPrompt": "System prompt (appended)",
|
||||
"agentFile": "Agent file"
|
||||
"resetAgentSettings": "Reset agent settings"
|
||||
},
|
||||
"merge": {
|
||||
"title": "MERGE WORKTREE",
|
||||
@@ -265,9 +331,6 @@
|
||||
"binary": "Binary file — no text diff",
|
||||
"empty": "No content"
|
||||
},
|
||||
"worktree": {
|
||||
"title": "Worktree"
|
||||
},
|
||||
"worktreesOverview": {
|
||||
"refresh": "Refresh",
|
||||
"cleanupFinished": "Cleanup finished",
|
||||
@@ -390,8 +453,6 @@
|
||||
"abort": "Abort this merge"
|
||||
},
|
||||
"diff": {
|
||||
"windowTitle": "Planning — Combined diff",
|
||||
"modalTitle": "PLANNING — COMBINED DIFF",
|
||||
"previewCombined": "Preview combined",
|
||||
"loading": "Loading…"
|
||||
}
|
||||
@@ -445,11 +506,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}" },
|
||||
"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}" },
|
||||
@@ -458,6 +519,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" },
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
namespace ClaudeDo.Releases;
|
||||
|
||||
public sealed record InstallerAssetMatch(ReleaseAsset Asset, string Version);
|
||||
|
||||
public enum SelfUpdateDecisionKind
|
||||
{
|
||||
NoUpdate,
|
||||
UpdateAvailable,
|
||||
}
|
||||
|
||||
public sealed record SelfUpdateDecision(
|
||||
SelfUpdateDecisionKind Kind,
|
||||
string? LatestVersion = null,
|
||||
ReleaseAsset? InstallerAsset = null,
|
||||
ReleaseAsset? ChecksumsAsset = null);
|
||||
@@ -1,126 +0,0 @@
|
||||
using System.IO;
|
||||
using System.Net.Http;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace ClaudeDo.Releases;
|
||||
|
||||
public static partial class SelfUpdater
|
||||
{
|
||||
[GeneratedRegex(@"^ClaudeDo\.Installer-(?<version>[\d\.]+)\.exe$", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex InstallerAssetRegex();
|
||||
|
||||
public static InstallerAssetMatch? FindInstallerAsset(IEnumerable<ReleaseAsset> assets)
|
||||
{
|
||||
foreach (var asset in assets)
|
||||
{
|
||||
var m = InstallerAssetRegex().Match(asset.Name);
|
||||
if (m.Success)
|
||||
{
|
||||
return new InstallerAssetMatch(asset, m.Groups["version"].Value);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static async Task<SelfUpdateDecision> DecideUpdateAsync(
|
||||
IReleaseClient releases,
|
||||
string currentVersion,
|
||||
CancellationToken ct)
|
||||
{
|
||||
GiteaRelease? release;
|
||||
try
|
||||
{
|
||||
release = await releases.GetLatestReleaseAsync(ct);
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException)
|
||||
{
|
||||
return new SelfUpdateDecision(SelfUpdateDecisionKind.NoUpdate);
|
||||
}
|
||||
|
||||
if (release is null)
|
||||
return new SelfUpdateDecision(SelfUpdateDecisionKind.NoUpdate);
|
||||
|
||||
var match = FindInstallerAsset(release.Assets);
|
||||
if (match is null)
|
||||
return new SelfUpdateDecision(SelfUpdateDecisionKind.NoUpdate);
|
||||
|
||||
var cmp = VersionComparer.Compare(match.Version, currentVersion);
|
||||
if (!cmp.IsNewer)
|
||||
return new SelfUpdateDecision(SelfUpdateDecisionKind.NoUpdate);
|
||||
|
||||
var checksums = release.Assets.FirstOrDefault(
|
||||
a => string.Equals(a.Name, "checksums.txt", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
return new SelfUpdateDecision(
|
||||
SelfUpdateDecisionKind.UpdateAvailable,
|
||||
LatestVersion: match.Version,
|
||||
InstallerAsset: match.Asset,
|
||||
ChecksumsAsset: checksums);
|
||||
}
|
||||
|
||||
public static async Task<bool> HandleReplaceSelfAsync(
|
||||
string oldPath,
|
||||
string currentExePath,
|
||||
Func<string, bool> launchProcess,
|
||||
int maxWaitMs = 5000)
|
||||
{
|
||||
var deadline = DateTime.UtcNow.AddMilliseconds(maxWaitMs);
|
||||
while (DateTime.UtcNow < deadline)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(oldPath))
|
||||
{
|
||||
File.Delete(oldPath);
|
||||
}
|
||||
break;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
await Task.Delay(100);
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
await Task.Delay(100);
|
||||
}
|
||||
}
|
||||
|
||||
if (File.Exists(oldPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
File.Copy(currentExePath, oldPath, overwrite: false);
|
||||
return launchProcess(oldPath);
|
||||
}
|
||||
|
||||
public static async Task<string?> DownloadAndVerifyAsync(
|
||||
IReleaseClient releases,
|
||||
ReleaseAsset installerAsset,
|
||||
ReleaseAsset checksumsAsset,
|
||||
string tempDir,
|
||||
IProgress<long> progress,
|
||||
CancellationToken ct)
|
||||
{
|
||||
Directory.CreateDirectory(tempDir);
|
||||
var installerPath = Path.Combine(tempDir, installerAsset.Name);
|
||||
var checksumsPath = Path.Combine(tempDir, "checksums.txt");
|
||||
|
||||
try
|
||||
{
|
||||
await releases.DownloadAsync(installerAsset.BrowserDownloadUrl, installerPath, progress, ct);
|
||||
await releases.DownloadAsync(checksumsAsset.BrowserDownloadUrl, checksumsPath, new Progress<long>(_ => { }), ct);
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or IOException or TaskCanceledException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var checksumsText = await File.ReadAllTextAsync(checksumsPath, ct);
|
||||
var map = ChecksumVerifier.ParseChecksumsFile(checksumsText);
|
||||
if (!map.TryGetValue(installerAsset.Name, out var expected))
|
||||
return null;
|
||||
|
||||
return ChecksumVerifier.Verify(installerPath, expected) ? installerPath : null;
|
||||
}
|
||||
}
|
||||
+14
-13
@@ -19,33 +19,33 @@ ViewModels/
|
||||
IslandsShellViewModel.cs — root coordinator
|
||||
Islands/ — ListsIsland, TasksIsland, DetailsIsland, TaskRow, ListNavItem,
|
||||
NotesEditor, MergePreviewPresenter
|
||||
Modals/ — About, Diff, ListSettings, Merge, RepoImport, Settings (+ Settings/ tab VMs),
|
||||
UnfinishedPlanning, WeeklyReport, WorkerConnection, Worktree,
|
||||
WorktreesOverview, UnifiedDiffParser
|
||||
Planning/ — PlanningDiffViewModel
|
||||
Agent/ — AgentConfigEditorViewModel (scope-parameterized: List | Task)
|
||||
Modals/ — About, DiffViewer (+ DiffModels), ListSettings, Merge, RepoImport,
|
||||
Settings (+ Settings/ tab VMs), UnfinishedPlanning, WeeklyReport,
|
||||
WorkerConnection, WorktreesOverview, UnifiedDiffParser
|
||||
Conflicts/ — ConflictResolverViewModel + ConflictModels (MergeFile/MergeFileSegment/MergeConflictBlock)
|
||||
Views/ — mirrors the VM layout; Islands/Detail/ holds TaskHeaderBar,
|
||||
DescriptionStepsCard, WorkConsole; plus AgentStripView, SessionTerminalView
|
||||
Views/Controls/ — MarkdownView, ModalShell, ThemedDatePicker, DiffLinesView, InheritedBadge
|
||||
Views/Controls/ — MarkdownView, ModalShell, ThemedDatePicker, DiffLinesView, InheritedBadge, AgentConfigEditor
|
||||
Design/ — Tokens.axaml (design tokens; merged before styles) + IslandStyles.axaml
|
||||
(component styles + the filled icon geometry library)
|
||||
```
|
||||
|
||||
## ViewModels
|
||||
|
||||
- **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, responsive-layout flags (`ShowLists`/`ShowDetails` by window width), `PrimeStatus` flash, and the modal openers (About, RepoImport, WeeklyReport, WorktreesOverview, WorkerConnection help) plus `RestartWorkerAsync`/`CheckForUpdatesAsync`. Hosts `UpdateCheckService`.
|
||||
- **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: **AgentSettingsSectionViewModel** (per-task Model/MaxTurns/AgentPath overrides with `InheritedBadge` + `InheritanceResolver`, additive SystemPrompt, debounced save), **MergeSectionViewModel** (merge-target selection, mergeability indicator via `MergePreviewPresenter` over `PreviewMergeAsync`, `OpenDiffAsync` — live worktree or commit range after merge —, `ReviewCombinedDiffCommand` → `PlanningDiffViewModel`), **PrepPanelViewModel** (daily-prep panel: `PrepLog`, `PlanDayCommand` → `RunDailyPrepNowAsync`, persisted last run via `GetLastPrepLogAsync`). Helper rows (`ChildOutcomeRowViewModel`, `SubtaskRowViewModel`, `LogLineViewModel`) 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, per-list Model/SystemPrompt/AgentPath/MaxTurns with inherited-badge + reset, delete list), `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`.
|
||||
- **Diff stack** — `UnifiedDiffParser` (static; parses `git diff` output into `DiffFileViewModel`s, detecting added/deleted/renamed/binary files and per-line numbers; `Flatten` injects file-header rows for a combined single-pane view). `DiffModalViewModel` has two modes: live worktree (branch diff vs base, with a Merge action) and commit-range `base..head` (`FromCommitRange = true` — shows a merged task's diff after its worktree is gone; no merge action). The view renders a file list with binary/empty placeholders via `DiffLinesView`.
|
||||
- **Planning/Conflicts** — `PlanningDiffViewModel` (per-subtask diffs via `GetPlanningAggregateAsync`, toggle to combined integration-branch diff, conflict warnings), `ConflictResolverViewModel` (in-app **Rider-style 3-pane merge editor** for both single-task and planning unit-merge conflicts: single-task starts the conflict merge, parses each conflicted file into stable/conflict `MergeFileSegment`s via the worker's `GetMergeConflictDocuments`; exposes the active file's three reconstructed documents — `ActiveOursText` / `ActiveResultText` / `ActiveTheirsText` (from `MergeFile.OursText/ResultText/TheirsText`; Result seeds unresolved conflicts with Ours) — plus `ActiveFile`/`SelectFileCommand` (multi-file switcher), `Current`/`Next`/`Previous` (focused-conflict nav), a per-active-file `PositionText` readout, per-block `AcceptOurs/Theirs/Both/Base` + `MergeFile.Compose`, and `CanContinue` gated on every file resolved + no binary; writes each file via `WriteConflictResolution`, continue/abort; **planning mode** via `OpenForPlanningAsync(parentId, subtaskId)` loads the current subtask's mid-merge conflicts without re-starting the merge and routes continue/abort to `ContinuePlanningMerge`/`AbortPlanningMerge`, so a unit-merge conflict re-opens the editor per subtask via the `PlanningMergeConflict` broadcast). The view (`Views/Conflicts/ConflictResolverView`) shows the whole file in three **AvaloniaEdit** panes — MAIN/ours (read-only) | editable Result | INCOMING/theirs (read-only) — with TextMate highlighting by extension (theme `StyleInclude` in `App.axaml`); a code-behind `IBackgroundRenderer` tints each conflict block (unresolved/resolved) across panes, an `IReadOnlySectionProvider` + `TextAnchor` regions keep only conflict spans editable in Result (edits flow back to the block); each unresolved conflict starts EMPTY (a thin marker bar); the between-pane gutter controls **toggle** each side in/out of the result — `›`/`‹` add MAIN/INCOMING in click order (first pick on top), clicking again removes that side — so a conflict can take main, incoming, both, or neither; a `FilesSummary` readout shows how many files still have conflicts, and the three panes share a proportional synced vertical scroll. A conflict overview ruler right of the Result pane (`ConflictMap`) maps every conflict in the file proportionally (click a tick to jump) — handy for long files. Conflict block tints live in `Tokens.axaml` (`Merge*TintBrush`). The editor is reached from review **Approve** on conflict and from the **Merge** button in the Diff window (a conflicting `MergeTask` hands off to the resolver via `RequestConflictResolution`).
|
||||
- **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`).
|
||||
- **Diff stack** — `UnifiedDiffParser` (static; parses `git diff` output into `DiffFileViewModel`s, detecting added/deleted/renamed/binary files and per-line numbers; `Flatten` injects file-header rows for a combined single-pane view). `DiffModels.cs` holds shared types: `DiffLineViewModel`, `DiffFileViewModel`, `DiffLineKind`, `DiffFileStatus`, `SubtaskDiffRow`, `DiffTreeNodeViewModel`, `DiffTree`. `DiffViewerViewModel` is a single unified read-only diff viewer with two modes: **Files** (dirty worktree / branch-vs-base / commit-range — loads via GitService, shows a folder file-tree on the left + per-file diff pane on the right, Merge button for live branch source) and **Planning** (per-subtask diffs via `GetPlanningAggregateAsync`, subtask list left + flat diff right, combined integration-branch toggle). The Merge button opens the merge form, which routes to `ConflictResolverViewModel` on conflict. `DiffLinesView` renders per-file diff content with binary/empty placeholders.
|
||||
- **Conflicts** — `ConflictResolverViewModel` (in-app **Rider-style 3-pane merge editor** for both single-task and planning unit-merge conflicts: single-task starts the conflict merge, parses each conflicted file into stable/conflict `MergeFileSegment`s via the worker's `GetMergeConflictDocuments`; exposes the active file's three reconstructed documents — `ActiveOursText` / `ActiveResultText` / `ActiveTheirsText` (from `MergeFile.OursText/ResultText/TheirsText`; Result seeds unresolved conflicts with Ours) — plus `ActiveFile`/`SelectFileCommand` (multi-file switcher), `Current`/`Next`/`Previous` (focused-conflict nav), a per-active-file `PositionText` readout, per-block `AcceptOurs/Theirs/Both/Base` + `MergeFile.Compose`, and `CanContinue` gated on every file resolved + no binary; writes each file via `WriteConflictResolution`, continue/abort; **planning mode** via `OpenForPlanningAsync(parentId, subtaskId)` loads the current subtask's mid-merge conflicts without re-starting the merge and routes continue/abort to `ContinuePlanningMerge`/`AbortPlanningMerge`, so a unit-merge conflict re-opens the editor per subtask via the `PlanningMergeConflict` broadcast). The view (`Views/Conflicts/ConflictResolverView`) shows the whole file in three **AvaloniaEdit** panes — MAIN/ours (read-only) | editable Result | INCOMING/theirs (read-only) — with TextMate highlighting by extension (theme `StyleInclude` in `App.axaml`); a code-behind `IBackgroundRenderer` tints each conflict block (unresolved/resolved) across panes, an `IReadOnlySectionProvider` + `TextAnchor` regions keep only conflict spans editable in Result (edits flow back to the block); each unresolved conflict starts EMPTY (a thin marker bar); the between-pane gutter controls **toggle** each side in/out of the result — `›`/`‹` add MAIN/INCOMING in click order (first pick on top), clicking again removes that side — so a conflict can take main, incoming, both, or neither; a `FilesSummary` readout shows how many files still have conflicts, and the three panes share a proportional synced vertical scroll. A conflict overview ruler right of the Result pane (`ConflictMap`) maps every conflict in the file proportionally (click a tick to jump) — handy for long files. Conflict block tints live in `Tokens.axaml` (`Merge*TintBrush`). The editor is reached from review **Approve** on conflict and from the **Merge** button in the Diff window (a conflicting `MergeTask` hands off to the resolver via `RequestConflictResolution`).
|
||||
|
||||
## 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-conflicts/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. 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).
|
||||
@@ -54,7 +54,7 @@ Design/ — Tokens.axaml (design tokens; merged before styles) + IslandStyle
|
||||
|
||||
## Converters
|
||||
|
||||
`StatusColorConverter` (+ `ConnectionColorConverter` in the same file), `WorktreeStateColorConverter`, `WorkerLogLevelToBrushConverter`, `DotBrushConverter`, `EqStatusConverter`, `IconKeyConverter`, `CheckboxBorderConverter`, `StrikeIfTrueConverter`, `BoolToItalicConverter`, `BoolToDraftOpacityConverter`, `NotNullToBoolConverter`, `UpperCaseConverter`, `DateOnlyToDateTimeConverter`.
|
||||
`StatusColorConverter` (+ `ConnectionColorConverter` in the same file), `WorkerLogLevelToBrushConverter`, `DotBrushConverter`, `EqStatusConverter`, `IconKeyConverter`, `CheckboxBorderConverter`, `StrikeIfTrueConverter`, `BoolToItalicConverter`, `BoolToDraftOpacityConverter`, `NotNullToBoolConverter`, `UpperCaseConverter`, `DateOnlyToDateTimeConverter`.
|
||||
|
||||
## Dialog Pattern
|
||||
|
||||
@@ -66,3 +66,4 @@ Modals use `TaskCompletionSource` results behind the reusable `ModalShell` contr
|
||||
- "Run Now" CanExecute re-evaluates when worker connection state changes
|
||||
- Icon gotcha: `PathIcon` fills geometry. Line-art/stroke icons must be defined as filled geometry or rendered as a stroked `Path` (e.g. `Icon.PlanDay` via the `Path.plan-icon` style); a pure stroke path used with `PathIcon` is invisible.
|
||||
- `SessionTerminalView` is the reusable log terminal (StyledProperties `Entries`, `Label`, `IsRunning`, `IsDone`, `IsFailed`) used for both the task `Log` and the prep `PrepLog`.
|
||||
- `DetailsIslandView` is a pane-wide drag-and-drop file target (`DragDrop.AllowDrop`, Avalonia 12 `DataFormat.File`) with a "Drop to attach" hover overlay. `DescriptionStepsCard` shows an Attachments list (file name, size, remove button), an "Add file…" picker, and an explicit `DropStatus` confirmation line. Keys use the `details.attachments.*` localization namespace (en + de).
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.Globalization;
|
||||
using Avalonia;
|
||||
using Avalonia.Data.Converters;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Styling;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
|
||||
namespace ClaudeDo.Ui.Converters;
|
||||
|
||||
public sealed class LogKindForegroundConverter : IValueConverter
|
||||
{
|
||||
private static IBrush? Resolve(string key)
|
||||
{
|
||||
if (Application.Current is { } app &&
|
||||
app.Resources.TryGetResource(key, app.ActualThemeVariant, out var res) &&
|
||||
res is IBrush brush)
|
||||
{
|
||||
return brush;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
{
|
||||
var key = value is LogKind kind ? kind switch
|
||||
{
|
||||
LogKind.Sys => "TextMuteBrush",
|
||||
LogKind.Tool => "SageBrush",
|
||||
LogKind.Claude => "TextBrush",
|
||||
LogKind.Stdout => "TextDimBrush",
|
||||
LogKind.Stderr => "BloodBrush",
|
||||
LogKind.Done => "MossBrightBrush",
|
||||
LogKind.Msg => "TextDimBrush",
|
||||
LogKind.User => "AccentBrush",
|
||||
_ => "TextDimBrush",
|
||||
} : "TextDimBrush";
|
||||
|
||||
return Resolve(key) ?? AvaloniaProperty.UnsetValue;
|
||||
}
|
||||
|
||||
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
using System.Globalization;
|
||||
using Avalonia.Data.Converters;
|
||||
using Avalonia.Media;
|
||||
using ClaudeDo.Data.Models;
|
||||
|
||||
namespace ClaudeDo.Ui.Converters;
|
||||
|
||||
public sealed class WorktreeStateColorConverter : IValueConverter
|
||||
{
|
||||
private static readonly ISolidColorBrush Active = new SolidColorBrush(Color.Parse("#42A5F5"));
|
||||
private static readonly ISolidColorBrush Merged = new SolidColorBrush(Color.Parse("#66BB6A"));
|
||||
private static readonly ISolidColorBrush Discarded = new SolidColorBrush(Color.Parse("#9E9E9E"));
|
||||
private static readonly ISolidColorBrush Kept = new SolidColorBrush(Color.Parse("#FFA726"));
|
||||
private static readonly ISolidColorBrush Default = new SolidColorBrush(Colors.Gray);
|
||||
|
||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||
value is WorktreeState state
|
||||
? state switch
|
||||
{
|
||||
WorktreeState.Active => Active,
|
||||
WorktreeState.Merged => Merged,
|
||||
WorktreeState.Discarded => Discarded,
|
||||
WorktreeState.Kept => Kept,
|
||||
_ => Default,
|
||||
}
|
||||
: Default;
|
||||
|
||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
@@ -21,6 +21,8 @@
|
||||
<!-- Window control icons — filled geometries (PathIcon fills, not strokes) -->
|
||||
<StreamGeometry x:Key="Icon.WinMin">M4 9 H16 V11 H4 Z</StreamGeometry>
|
||||
<StreamGeometry x:Key="Icon.WinMax">M4 4 H16 V6 H4 Z M4 14 H16 V16 H4 Z M4 4 H6 V16 H4 Z M14 4 H16 V16 H14 Z</StreamGeometry>
|
||||
<!-- Icon.Grid (four filled panes — Mission Control launcher) -->
|
||||
<StreamGeometry x:Key="Icon.Grid">M3 3 H9 V9 H3 Z M11 3 H17 V9 H11 Z M3 11 H9 V17 H3 Z M11 11 H17 V17 H11 Z</StreamGeometry>
|
||||
<StreamGeometry x:Key="Icon.WinRestore">M4 7 H13 V9 H4 Z M4 14 H13 V16 H4 Z M4 7 H6 V16 H4 Z M11 7 H13 V16 H11 Z M7 4 H16 V6 H7 Z M14 4 H16 V13 H14 Z</StreamGeometry>
|
||||
<StreamGeometry x:Key="Icon.WinClose">M4 5 L5 4 L16 15 L15 16 Z M15 4 L16 5 L5 16 L4 15 Z</StreamGeometry>
|
||||
<!-- Brand check glyph — filled rounded square with inset tick -->
|
||||
@@ -55,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>
|
||||
@@ -79,8 +81,11 @@
|
||||
<!-- Icon.Refine (stroke-rendered via Path.plan-icon — list lines + two sparkles) -->
|
||||
<StreamGeometry x:Key="Icon.Refine">M3,6 L13,6 M3,11 L11,11 M3,16 L9,16 M18.5,3 L19.28,5.22 L21.5,6 L19.28,6.78 L18.5,9 L17.72,6.78 L15.5,6 L17.72,5.22 Z M19.5,14.9 L19.85,16.15 L21.1,16.5 L19.85,16.85 L19.5,18.1 L19.15,16.85 L17.9,16.5 L19.15,16.15 Z</StreamGeometry>
|
||||
|
||||
<!-- Icon.X -->
|
||||
<StreamGeometry x:Key="Icon.X">M6 6l12 12M18 6L6 18</StreamGeometry>
|
||||
<!-- Icon.X — filled X outline (PathIcon fills, so a stroke-only X renders invisible) -->
|
||||
<StreamGeometry x:Key="Icon.X">M6.4 4.6 L12 10.2 L17.6 4.6 L19.4 6.4 L13.8 12 L19.4 17.6 L17.6 19.4 L12 13.8 L6.4 19.4 L4.6 17.6 L10.2 12 L4.6 6.4 Z</StreamGeometry>
|
||||
|
||||
<!-- Icon.Stop — filled square (stop / interrupt) -->
|
||||
<StreamGeometry x:Key="Icon.Stop">M4 4 H20 V20 H4 Z</StreamGeometry>
|
||||
|
||||
<!-- Icon.Check -->
|
||||
<StreamGeometry x:Key="Icon.Check">M4 12l5 5 11-11</StreamGeometry>
|
||||
@@ -88,6 +93,10 @@
|
||||
<!-- Icon.ArrowOut — filled arrow for "open external" button -->
|
||||
<StreamGeometry x:Key="Icon.ArrowOut">M13 4 H20 V11 H18 V7.4 L11.4 14 L10 12.6 L16.6 6 H13 Z M4 6 H10 V8 H6 V18 H16 V14 H18 V20 H4 Z</StreamGeometry>
|
||||
|
||||
<!-- Icon.ChevronRight / Icon.ChevronDown — filled expand/collapse chevrons (PathIcon fills) -->
|
||||
<StreamGeometry x:Key="Icon.ChevronRight">M9 5 L16 12 L9 19 L6.8 16.8 L11.6 12 L6.8 7.2 Z</StreamGeometry>
|
||||
<StreamGeometry x:Key="Icon.ChevronDown">M5 9 L12 16 L19 9 L16.8 6.8 L12 11.6 L7.2 6.8 Z</StreamGeometry>
|
||||
|
||||
<!-- Icon.Text — three filled horizontal bars (paragraph / description icon) -->
|
||||
<StreamGeometry x:Key="Icon.Text">M4 6 H20 V8 H4 Z M4 11 H20 V13 H4 Z M4 16 H14 V18 H4 Z</StreamGeometry>
|
||||
|
||||
@@ -238,6 +247,43 @@
|
||||
<Setter Property="Foreground" Value="#8FB9D6" />
|
||||
</Style>
|
||||
|
||||
<!-- Worktree-state chips (worktrees overview) -->
|
||||
<!-- active → slate-blue (same hue as parked: a live worktree) -->
|
||||
<Style Selector="Border.chip.wt-active">
|
||||
<Setter Property="Background" Value="#22303A" />
|
||||
<Setter Property="BorderBrush" Value="#3A5060" />
|
||||
</Style>
|
||||
<Style Selector="Border.chip.wt-active > TextBlock">
|
||||
<Setter Property="Foreground" Value="#8FB9D6" />
|
||||
</Style>
|
||||
|
||||
<!-- merged → green -->
|
||||
<Style Selector="Border.chip.wt-merged">
|
||||
<Setter Property="Background" Value="{StaticResource DoneTintBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource DoneTintBorderBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Border.chip.wt-merged > TextBlock">
|
||||
<Setter Property="Foreground" Value="{StaticResource StatusDoneBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- kept → amber -->
|
||||
<Style Selector="Border.chip.wt-kept">
|
||||
<Setter Property="Background" Value="{StaticResource ReviewTintBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource ReviewTintBorderBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Border.chip.wt-kept > TextBlock">
|
||||
<Setter Property="Foreground" Value="{StaticResource StatusReviewBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- discarded → muted gray (same as idle) -->
|
||||
<Style Selector="Border.chip.wt-discarded">
|
||||
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Border.chip.wt-discarded > TextBlock">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- BUTTONS -->
|
||||
<!-- ============================================================ -->
|
||||
@@ -364,6 +410,8 @@
|
||||
<BrushTransition Property="Background" Duration="0:0:0.12" />
|
||||
<BrushTransition Property="BorderBrush" Duration="0:0:0.12" />
|
||||
<ThicknessTransition Property="Margin" Duration="0:0:0.15" />
|
||||
<TransformOperationsTransition Property="RenderTransform" Duration="0:0:0.12" />
|
||||
<DoubleTransition Property="Opacity" Duration="0:0:0.12" />
|
||||
</Transitions>
|
||||
</Setter>
|
||||
</Style>
|
||||
@@ -371,9 +419,16 @@
|
||||
<Setter Property="BorderBrush" Value="{StaticResource LineBrightBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Border.task-row.selected">
|
||||
<Setter Property="BorderBrush" Value="{StaticResource LineBrightBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
</Style>
|
||||
<!-- "Grabbed" row: lift + slight scale + lower opacity + shadow while the custom drag runs. -->
|
||||
<Style Selector="Border.task-row.dragging">
|
||||
<Setter Property="Opacity" Value="0.55" />
|
||||
<Setter Property="RenderTransform" Value="scale(1.03)" />
|
||||
<Setter Property="BoxShadow" Value="0 10 26 0 #66000000" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- Checkbox indicator (the 18px circle that replaces the native CheckBox template) -->
|
||||
<Style Selector="Ellipse.task-check">
|
||||
@@ -476,6 +531,10 @@
|
||||
<Style Selector="Border.terminal TextBlock[Tag=log-msg]">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextDimBrush}" />
|
||||
</Style>
|
||||
<!-- log-user: user's own messages in interactive sessions — accent color to stand out -->
|
||||
<Style Selector="Border.terminal TextBlock[Tag=log-user]">
|
||||
<Setter Property="Foreground" Value="{DynamicResource AccentBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- TERMINAL HEADER -->
|
||||
@@ -1086,6 +1145,30 @@
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
<Setter Property="FontWeight" Value="SemiBold" />
|
||||
</Style>
|
||||
<!-- Override Fluent's built-in accent button (SystemAccentColor = blue) at the
|
||||
ContentPresenter level so our moss tokens win across rest/hover/pressed. -->
|
||||
<Style Selector="Button.accent /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource AccentDimBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
|
||||
<Setter Property="TextElement.Foreground" Value="{StaticResource TextBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Button.accent:pointerover /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource AccentBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
|
||||
<Setter Property="TextElement.Foreground" Value="{StaticResource TextBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Button.accent:pressed /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource AccentSoftBrush}" />
|
||||
<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 -->
|
||||
@@ -1106,4 +1189,30 @@
|
||||
<Setter Property="Background" Value="{DynamicResource AccentBrush}"/>
|
||||
</Style>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- MISSION CONTROL PANE STATUS TINTING -->
|
||||
<!-- Base neutral grey; tints layer on by status. -->
|
||||
<!-- Running / idle / queued: no class → fall through to base. -->
|
||||
<!-- ============================================================ -->
|
||||
<Style Selector="Border.monitor-pane">
|
||||
<Setter Property="Background" Value="{DynamicResource SurfaceBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource LineBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Border.monitor-pane.mon-done">
|
||||
<Setter Property="Background" Value="{DynamicResource DoneTintBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource DoneTintBorderBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Border.monitor-pane.mon-review">
|
||||
<Setter Property="Background" Value="{DynamicResource DoneTintBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource DoneTintBorderBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Border.monitor-pane.mon-roadblock">
|
||||
<Setter Property="Background" Value="{DynamicResource RoadblockTintBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource RoadblockTintBorderBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Border.monitor-pane.mon-failed">
|
||||
<Setter Property="Background" Value="{DynamicResource ErrorTintBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource ErrorTintBorderBrush}" />
|
||||
</Style>
|
||||
|
||||
</Styles>
|
||||
|
||||
@@ -99,6 +99,8 @@
|
||||
<SolidColorBrush x:Key="QueuedTintBorderBrush" Color="#4C8B9D7A" />
|
||||
<SolidColorBrush x:Key="DoneTintBrush" Color="#1F6FA86B" />
|
||||
<SolidColorBrush x:Key="DoneTintBorderBrush" Color="#4C6FA86B" />
|
||||
<SolidColorBrush x:Key="RoadblockTintBrush" Color="#1FD4A574" />
|
||||
<SolidColorBrush x:Key="RoadblockTintBorderBrush" Color="#4CD4A574" />
|
||||
|
||||
<!-- Merge editor (3-pane conflict resolver) block tints -->
|
||||
<SolidColorBrush x:Key="MergeOursTintBrush" Color="#1F7C9166" /> <!-- ours side (moss) -->
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using ClaudeDo.Ui.ViewModels;
|
||||
using ClaudeDo.Ui.ViewModels.Conflicts;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
using ClaudeDo.Ui.ViewModels.Modals;
|
||||
|
||||
namespace ClaudeDo.Ui.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Single seam for opening modal dialogs. Replaces the per-modal <c>Show*Modal</c>
|
||||
/// Func callbacks that were previously wired separately on the shell and the lists
|
||||
/// island (and the Confirm/Error dialogs duplicated in both code-behinds). The view
|
||||
/// layer supplies the implementation (<see cref="ClaudeDo.Ui.Views.WindowDialogService"/>);
|
||||
/// callers build + initialize the VM and hand it here to be shown.
|
||||
/// </summary>
|
||||
public interface IDialogService
|
||||
{
|
||||
Task ShowAboutAsync(AboutModalViewModel vm);
|
||||
Task ShowWeeklyReportAsync(WeeklyReportModalViewModel vm);
|
||||
Task ShowSettingsAsync(SettingsModalViewModel vm);
|
||||
Task ShowListSettingsAsync(ListSettingsModalViewModel vm);
|
||||
Task ShowRepoImportAsync(RepoImportModalViewModel vm);
|
||||
Task ShowWorktreesOverviewAsync(WorktreesOverviewModalViewModel vm);
|
||||
Task ShowWorkerConnectionAsync(WorkerConnectionModalViewModel vm);
|
||||
Task ShowConflictResolverAsync(ConflictResolverViewModel vm);
|
||||
Task ShowLogVisualizerAsync(LogVisualizerViewModel vm);
|
||||
|
||||
/// <summary>Modal yes/no confirmation. Returns true only when confirmed.</summary>
|
||||
Task<bool> ConfirmAsync(string message);
|
||||
|
||||
/// <summary>Modal error notice with a single dismiss button.</summary>
|
||||
Task ShowErrorAsync(string message);
|
||||
|
||||
/// <summary>Show (or re-show + focus) the modeless Mission Control window. Lazily created; hides on close.</summary>
|
||||
void ShowMissionControl(MissionControlViewModel vm);
|
||||
|
||||
/// <summary>Show a detached monitor in its own window; <paramref name="onClosed"/> re-docks it when that window closes.</summary>
|
||||
void ShowDetachedMonitor(TaskMonitorViewModel monitor, Action onClosed);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ClaudeDo.Ui.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Single entry point for handing a conflicting merge to the in-app 3-pane resolver.
|
||||
/// Replaces the per-VM <c>RequestConflictResolution</c> Func seams that used to be
|
||||
/// hand-threaded shell → details → merge-section → diff → merge-modal. The shell wires
|
||||
/// <see cref="MergeCoordinator.Handler"/> once at composition; invokers depend only on
|
||||
/// this interface (injected via DI).
|
||||
/// </summary>
|
||||
public interface IMergeCoordinator
|
||||
{
|
||||
Task ResolveConflictAsync(string taskId, string targetBranch);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DI singleton holding the resolver entry. The holder breaks the shell↔island construction
|
||||
/// cycle: islands depend on the interface, the shell sets <see cref="Handler"/> after it is built.
|
||||
/// </summary>
|
||||
public sealed class MergeCoordinator : IMergeCoordinator
|
||||
{
|
||||
/// Set once at composition to the shell's resolver entry. Null (headless/tests) ⇒ no-op.
|
||||
public Func<string, string, Task>? Handler { get; set; }
|
||||
|
||||
public Task ResolveConflictAsync(string taskId, string targetBranch) =>
|
||||
Handler?.Invoke(taskId, targetBranch) ?? Task.CompletedTask;
|
||||
}
|
||||
@@ -20,6 +20,11 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
event Action<string, string>? TaskMessageEvent;
|
||||
event Action<WorkerLogEntry>? WorkerLogReceivedEvent;
|
||||
|
||||
/// <summary>A running task raised a question via AskUser: (taskId, questionId, question).</summary>
|
||||
event Action<string, string, string>? TaskQuestionAskedEvent;
|
||||
/// <summary>A pending question was answered, timed out, or the run ended: (taskId, questionId).</summary>
|
||||
event Action<string, string>? TaskQuestionResolvedEvent;
|
||||
|
||||
event Action? PrepStartedEvent;
|
||||
event Action<string>? PrepLineEvent;
|
||||
event Action<bool>? PrepFinishedEvent;
|
||||
@@ -34,9 +39,15 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
|
||||
string? LastApproveTarget { get; }
|
||||
|
||||
IReadOnlyList<ActiveTask> GetActiveTasks();
|
||||
|
||||
Task WakeQueueAsync();
|
||||
Task RunNowAsync(string taskId);
|
||||
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);
|
||||
/// <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);
|
||||
Task CancelTaskAsync(string taskId);
|
||||
Task<List<AgentInfo>> GetAgentsAsync();
|
||||
@@ -44,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);
|
||||
@@ -54,13 +69,27 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
|
||||
// ── Conflict resolution (worker hub side implemented by Layer C) ──
|
||||
Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch);
|
||||
Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId);
|
||||
Task<MergeConflictDocumentsDto> GetMergeConflictDocumentsAsync(string taskId);
|
||||
Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent);
|
||||
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>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);
|
||||
@@ -86,6 +115,7 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
Task UpdateDailyNoteAsync(string id, string text);
|
||||
Task DeleteDailyNoteAsync(string id);
|
||||
Task<string> GetLastPrepLogAsync();
|
||||
Task<IReadOnlyList<WorkerLogEntry>> GetRecentLogsAsync();
|
||||
|
||||
Task<List<PrimeScheduleDto>> GetPrimeSchedulesAsync();
|
||||
Task<PrimeScheduleDto?> UpsertPrimeScheduleAsync(PrimeScheduleDto dto);
|
||||
|
||||
@@ -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);
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using Avalonia.Threading;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
@@ -46,6 +47,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
public event Action<string, string, string, DateTime>? TaskFinishedEvent;
|
||||
public event Action<string, string>? TaskMessageEvent;
|
||||
public event Action<string>? TaskUpdatedEvent;
|
||||
public event Action<string, string, string>? TaskQuestionAskedEvent;
|
||||
public event Action<string, string>? TaskQuestionResolvedEvent;
|
||||
public event Action? ConnectionRestoredEvent;
|
||||
public event Action<string>? WorktreeUpdatedEvent;
|
||||
public event Action<string>? ListUpdatedEvent;
|
||||
@@ -68,6 +71,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
|
||||
public string? LastApproveTarget { get; private set; }
|
||||
|
||||
public IReadOnlyList<ActiveTask> GetActiveTasks() => ActiveTasks.ToList();
|
||||
|
||||
public WorkerClient(string signalRUrl)
|
||||
{
|
||||
_hub = new HubConnectionBuilder()
|
||||
@@ -133,6 +138,16 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
Dispatcher.UIThread.Post(() => TaskUpdatedEvent?.Invoke(taskId));
|
||||
});
|
||||
|
||||
_hub.On<string, string, string>("TaskQuestionAsked", (taskId, questionId, question) =>
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => TaskQuestionAskedEvent?.Invoke(taskId, questionId, question));
|
||||
});
|
||||
|
||||
_hub.On<string, string>("TaskQuestionResolved", (taskId, questionId) =>
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => TaskQuestionResolvedEvent?.Invoke(taskId, questionId));
|
||||
});
|
||||
|
||||
_hub.On<string>("WorktreeUpdated", taskId =>
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => WorktreeUpdatedEvent?.Invoke(taskId));
|
||||
@@ -258,6 +273,15 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
await _hub.InvokeAsync("ContinueTask", taskId, followUpPrompt);
|
||||
}
|
||||
|
||||
public async Task AnswerTaskQuestionAsync(string taskId, string questionId, string answer)
|
||||
{
|
||||
try { await _hub.InvokeAsync<bool>("AnswerTaskQuestion", taskId, questionId, answer); }
|
||||
catch { /* offline or already resolved — the UI clears optimistically */ }
|
||||
}
|
||||
|
||||
public Task<PendingQuestionDto?> GetPendingQuestionAsync(string taskId)
|
||||
=> TryInvokeAsync<PendingQuestionDto>("GetPendingQuestion", taskId);
|
||||
|
||||
public async Task ResetTaskAsync(string taskId)
|
||||
{
|
||||
await _hub.InvokeAsync("ResetTask", taskId);
|
||||
@@ -272,9 +296,6 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
public Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch)
|
||||
=> _hub.InvokeAsync<MergeResultDto>("StartConflictMerge", taskId, targetBranch);
|
||||
|
||||
public Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId)
|
||||
=> _hub.InvokeAsync<MergeConflictsDto>("GetMergeConflicts", taskId);
|
||||
|
||||
public Task<MergeConflictDocumentsDto> GetMergeConflictDocumentsAsync(string taskId)
|
||||
=> _hub.InvokeAsync<MergeConflictDocumentsDto>("GetMergeConflictDocuments", taskId);
|
||||
|
||||
@@ -391,6 +412,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
public async Task<string> GetLastPrepLogAsync()
|
||||
=> await TryInvokeAsync<string>("GetLastPrepLog") ?? string.Empty;
|
||||
|
||||
public async Task<IReadOnlyList<WorkerLogEntry>> GetRecentLogsAsync()
|
||||
=> await TryInvokeAsync<List<WorkerLogEntry>>("GetRecentLogs") ?? new List<WorkerLogEntry>();
|
||||
|
||||
public async Task UpdateListAsync(UpdateListDto dto)
|
||||
{
|
||||
await _hub.InvokeAsync("UpdateList", dto);
|
||||
@@ -409,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());
|
||||
@@ -474,8 +510,23 @@ 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> 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);
|
||||
@@ -552,23 +603,28 @@ 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);
|
||||
public record MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage);
|
||||
public record MergePreviewDto(string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount);
|
||||
public record MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalBranches);
|
||||
public record MergeConflictsDto(string TaskId, IReadOnlyList<ConflictFileDto> Files);
|
||||
public record ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks);
|
||||
public record ConflictHunkDto(string Ours, string Theirs, string? Base);
|
||||
public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDocumentDto> Files);
|
||||
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(
|
||||
@@ -585,7 +641,14 @@ 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);
|
||||
|
||||
public sealed record OnlineInboxStateDto(
|
||||
bool Enabled,
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
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;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using ClaudeDo.Ui.Services;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Agent;
|
||||
|
||||
public enum AgentConfigScope { List, Task }
|
||||
|
||||
/// <summary>
|
||||
/// One agent-config editor (Model / MaxTurns / SystemPrompt / AgentFile with inherited
|
||||
/// badges + reset) shared by the List Settings modal and the per-task gear flyout.
|
||||
/// Scope picks the inheritance depth (List: list→global; Task: task→list→global) and the
|
||||
/// persistence (List: explicit <see cref="SaveAsync"/>; Task: debounced auto-save).
|
||||
/// </summary>
|
||||
public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
private readonly IWorkerClient _worker;
|
||||
private readonly AgentConfigScope _scope;
|
||||
private readonly EventHandler _langChangedHandler;
|
||||
|
||||
/// scope==List ⇒ the list id; scope==Task ⇒ the task id. Null ⇒ no save target.
|
||||
internal string? TargetId { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsEnabled))]
|
||||
private bool _isRunning;
|
||||
|
||||
// Task scope gates the editor while the run is live; List scope is always enabled.
|
||||
public bool IsEnabled => !IsRunning;
|
||||
|
||||
[ObservableProperty] private string? _model;
|
||||
[ObservableProperty] private decimal? _maxTurns;
|
||||
[ObservableProperty] private string _systemPrompt = "";
|
||||
[ObservableProperty] private AgentInfo? _selectedAgent;
|
||||
|
||||
[ObservableProperty] private string _modelBadge = "";
|
||||
[ObservableProperty] private string _modelInheritedHint = "";
|
||||
[ObservableProperty] private string _turnsBadge = "";
|
||||
[ObservableProperty] private string _turnsInheritedHint = "";
|
||||
[ObservableProperty] private string _agentBadge = "";
|
||||
[ObservableProperty] private string _effectiveSystemPromptHint = "";
|
||||
|
||||
private string _globalModel = ModelRegistry.DefaultAlias;
|
||||
private int _globalMaxTurns = 100;
|
||||
private string? _listModel; // Task scope only
|
||||
private int? _listMaxTurns; // Task scope only
|
||||
private string? _listAgentName; // Task scope only
|
||||
|
||||
private bool _suppressSave;
|
||||
private CancellationTokenSource? _saveCts;
|
||||
|
||||
public int EffectiveMaxTurns =>
|
||||
MaxTurns is decimal t ? (int)t : (_listMaxTurns ?? _globalMaxTurns);
|
||||
|
||||
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)
|
||||
{
|
||||
_worker = worker;
|
||||
_scope = scope;
|
||||
_langChangedHandler = (_, _) => RecomputeBadges();
|
||||
// Only the long-lived Task editor needs live re-badging; the List editor is a
|
||||
// short-lived modal recreated with the current language on each open.
|
||||
if (scope == AgentConfigScope.Task)
|
||||
Loc.LanguageChanged += _langChangedHandler;
|
||||
|
||||
SessionSkills.CollectionChanged += OnSessionSkillsCollectionChanged;
|
||||
}
|
||||
|
||||
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(); }
|
||||
|
||||
partial void OnMaxTurnsChanged(decimal? value)
|
||||
{
|
||||
RecomputeTurnsBadge();
|
||||
OnPropertyChanged(nameof(EffectiveMaxTurns));
|
||||
QueueSave();
|
||||
}
|
||||
|
||||
partial void OnSystemPromptChanged(string value) => QueueSave();
|
||||
partial void OnSelectedAgentChanged(AgentInfo? value) { RecomputeAgentBadge(); QueueSave(); }
|
||||
|
||||
private void RecomputeBadges()
|
||||
{
|
||||
RecomputeModelBadge();
|
||||
RecomputeTurnsBadge();
|
||||
RecomputeAgentBadge();
|
||||
}
|
||||
|
||||
private void RecomputeModelBadge()
|
||||
{
|
||||
var own = string.IsNullOrWhiteSpace(Model) ? null : Model;
|
||||
var (value, source) = _scope == AgentConfigScope.Task
|
||||
? InheritanceResolver.Resolve(own, _listModel, _globalModel)
|
||||
: InheritanceResolver.ResolveList(own, _globalModel);
|
||||
ModelInheritedHint = value;
|
||||
ModelBadge = BadgeFor(source, own is not null);
|
||||
}
|
||||
|
||||
private void RecomputeTurnsBadge()
|
||||
{
|
||||
var own = MaxTurns?.ToString();
|
||||
var (value, source) = _scope == AgentConfigScope.Task
|
||||
? InheritanceResolver.Resolve(own, _listMaxTurns?.ToString(), _globalMaxTurns.ToString())
|
||||
: InheritanceResolver.ResolveList(own, _globalMaxTurns.ToString());
|
||||
TurnsInheritedHint = value;
|
||||
TurnsBadge = BadgeFor(source, MaxTurns is not null);
|
||||
}
|
||||
|
||||
private void RecomputeAgentBadge()
|
||||
{
|
||||
var agentSet = SelectedAgent is not null && !string.IsNullOrWhiteSpace(SelectedAgent.Path);
|
||||
var own = agentSet ? SelectedAgent!.Path : null;
|
||||
var (_, source) = _scope == AgentConfigScope.Task
|
||||
? InheritanceResolver.Resolve(own, _listAgentName, null)
|
||||
: InheritanceResolver.ResolveList(own, null);
|
||||
AgentBadge = BadgeFor(source, agentSet);
|
||||
}
|
||||
|
||||
private static string BadgeFor(InheritSource source, bool isSet) => isSet
|
||||
? Loc.T("settings.inherit.overrideBadge")
|
||||
: source == InheritSource.List
|
||||
? Loc.T("settings.inherit.inheritedFromList")
|
||||
: Loc.T("settings.inherit.inheritedFromGlobal");
|
||||
|
||||
private void QueueSave()
|
||||
{
|
||||
// List scope persists on the modal Save button; only Task auto-saves.
|
||||
if (_suppressSave || _scope != AgentConfigScope.Task || TargetId is null) return;
|
||||
_saveCts?.Cancel();
|
||||
_saveCts = new CancellationTokenSource();
|
||||
_ = DebouncedSaveAsync(_saveCts.Token);
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task DebouncedSaveAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await System.Threading.Tasks.Task.Delay(300, ct);
|
||||
if (TargetId is null) return;
|
||||
await SaveAsync();
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch { }
|
||||
}
|
||||
|
||||
public async System.Threading.Tasks.Task SaveAsync()
|
||||
{
|
||||
if (TargetId is null) return;
|
||||
var model = string.IsNullOrWhiteSpace(Model) ? null : Model;
|
||||
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, skills));
|
||||
else
|
||||
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)
|
||||
{
|
||||
_suppressSave = true;
|
||||
try
|
||||
{
|
||||
TargetId = listId;
|
||||
await ReloadAgentsAsync("(none)");
|
||||
await LoadGlobalDefaultsAsync();
|
||||
|
||||
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 = "";
|
||||
RecomputeBadges();
|
||||
OnPropertyChanged(nameof(EffectiveMaxTurns));
|
||||
}
|
||||
finally { _suppressSave = false; }
|
||||
}
|
||||
|
||||
public async System.Threading.Tasks.Task LoadForTaskAsync(TaskEntity entity, CancellationToken ct = default)
|
||||
{
|
||||
_suppressSave = true;
|
||||
try
|
||||
{
|
||||
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();
|
||||
_listModel = listCfg?.Model;
|
||||
_listMaxTurns = listCfg?.MaxTurns;
|
||||
_listAgentName = string.IsNullOrWhiteSpace(listCfg?.AgentPath)
|
||||
? null : System.IO.Path.GetFileName(listCfg!.AgentPath!);
|
||||
EffectiveSystemPromptHint = string.IsNullOrWhiteSpace(listCfg?.SystemPrompt)
|
||||
? "" : listCfg!.SystemPrompt!;
|
||||
|
||||
RecomputeBadges();
|
||||
OnPropertyChanged(nameof(EffectiveMaxTurns));
|
||||
}
|
||||
finally { _suppressSave = false; }
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
_suppressSave = true;
|
||||
try
|
||||
{
|
||||
Model = null;
|
||||
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();
|
||||
Agents.Add(new AgentInfo(placeholderName, "", ""));
|
||||
foreach (var a in await _worker.GetAgentsAsync()) Agents.Add(a);
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task LoadGlobalDefaultsAsync()
|
||||
{
|
||||
var app = await _worker.GetAppSettingsAsync();
|
||||
_globalModel = app?.DefaultModel ?? ModelRegistry.DefaultAlias;
|
||||
_globalMaxTurns = app?.DefaultMaxTurns ?? 100;
|
||||
}
|
||||
|
||||
private void ApplyConfig(string? model, int? maxTurns, string? systemPrompt, string? agentPath)
|
||||
{
|
||||
Model = string.IsNullOrWhiteSpace(model) ? null : model!;
|
||||
MaxTurns = maxTurns is int mt ? mt : (decimal?)null;
|
||||
SystemPrompt = systemPrompt ?? "";
|
||||
SelectedAgent = string.IsNullOrWhiteSpace(agentPath)
|
||||
? Agents[0]
|
||||
: (Agents.FirstOrDefault(a => a.Path == agentPath) ?? Agents[0]);
|
||||
}
|
||||
|
||||
[RelayCommand] private void ResetModel() => Model = null;
|
||||
[RelayCommand] private void ResetTurns() => MaxTurns = null;
|
||||
[RelayCommand] private void ResetAgent() => SelectedAgent = Agents.Count > 0 ? Agents[0] : null;
|
||||
|
||||
[RelayCommand]
|
||||
private void ResetAll()
|
||||
{
|
||||
Model = null;
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,196 +0,0 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Ui.Helpers;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using ClaudeDo.Ui.Services;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||
|
||||
public sealed partial class AgentSettingsSectionViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
private readonly IWorkerClient _worker;
|
||||
private readonly EventHandler _langChangedHandler;
|
||||
|
||||
internal string? TaskId { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsAgentSectionEnabled))]
|
||||
private bool _isRunning;
|
||||
|
||||
public bool IsAgentSectionEnabled => !IsRunning;
|
||||
|
||||
[ObservableProperty] private string? _taskModelSelection;
|
||||
[ObservableProperty] private string _taskSystemPrompt = "";
|
||||
[ObservableProperty] private AgentInfo? _taskSelectedAgent;
|
||||
[ObservableProperty] private decimal? _taskMaxTurns;
|
||||
[ObservableProperty] private string _modelBadge = "";
|
||||
[ObservableProperty] private string _modelInheritedHint = "";
|
||||
[ObservableProperty] private string _turnsBadge = "";
|
||||
[ObservableProperty] private string _turnsInheritedHint = "";
|
||||
[ObservableProperty] private string _agentBadge = "";
|
||||
[ObservableProperty] private string _effectiveSystemPromptHint = "";
|
||||
|
||||
private string _globalModel = ModelRegistry.DefaultAlias;
|
||||
private int _globalMaxTurns = 100;
|
||||
private string? _listModel;
|
||||
private int? _listMaxTurns;
|
||||
private string? _listAgentName;
|
||||
|
||||
private bool _suppressAgentSave;
|
||||
private CancellationTokenSource? _agentSaveCts;
|
||||
|
||||
public int EffectiveMaxTurns =>
|
||||
TaskMaxTurns is decimal t ? (int)t : (_listMaxTurns ?? _globalMaxTurns);
|
||||
|
||||
public ObservableCollection<string> TaskModelOptions { get; } = new(ModelRegistry.Aliases);
|
||||
public ObservableCollection<AgentInfo> TaskAgentOptions { get; } = new();
|
||||
|
||||
public AgentSettingsSectionViewModel(IWorkerClient worker)
|
||||
{
|
||||
_worker = worker;
|
||||
_langChangedHandler = (_, _) =>
|
||||
{
|
||||
RecomputeModelBadge();
|
||||
RecomputeTurnsBadge();
|
||||
RecomputeAgentBadge();
|
||||
};
|
||||
Loc.LanguageChanged += _langChangedHandler;
|
||||
}
|
||||
|
||||
public void Dispose() => Loc.LanguageChanged -= _langChangedHandler;
|
||||
|
||||
partial void OnTaskModelSelectionChanged(string? value) { RecomputeModelBadge(); QueueAgentSave(); }
|
||||
|
||||
partial void OnTaskMaxTurnsChanged(decimal? value)
|
||||
{
|
||||
RecomputeTurnsBadge();
|
||||
OnPropertyChanged(nameof(EffectiveMaxTurns));
|
||||
QueueAgentSave();
|
||||
}
|
||||
|
||||
partial void OnTaskSystemPromptChanged(string value) => QueueAgentSave();
|
||||
partial void OnTaskSelectedAgentChanged(AgentInfo? value) { RecomputeAgentBadge(); QueueAgentSave(); }
|
||||
|
||||
private void RecomputeModelBadge()
|
||||
{
|
||||
var (value, source) = InheritanceResolver.Resolve(TaskModelSelection, _listModel, _globalModel);
|
||||
ModelInheritedHint = value;
|
||||
ModelBadge = BadgeFor(source, !string.IsNullOrWhiteSpace(TaskModelSelection));
|
||||
}
|
||||
|
||||
private void RecomputeTurnsBadge()
|
||||
{
|
||||
var (value, source) = InheritanceResolver.Resolve(
|
||||
TaskMaxTurns?.ToString(), _listMaxTurns?.ToString(), _globalMaxTurns.ToString());
|
||||
TurnsInheritedHint = value;
|
||||
TurnsBadge = BadgeFor(source, TaskMaxTurns is not null);
|
||||
}
|
||||
|
||||
private void RecomputeAgentBadge()
|
||||
{
|
||||
var taskSet = TaskSelectedAgent is not null && !string.IsNullOrWhiteSpace(TaskSelectedAgent.Path);
|
||||
var (_, source) = InheritanceResolver.Resolve(
|
||||
taskSet ? TaskSelectedAgent!.Path : null, _listAgentName, null);
|
||||
AgentBadge = BadgeFor(source, taskSet);
|
||||
}
|
||||
|
||||
private static string BadgeFor(InheritSource source, bool taskSet) => taskSet
|
||||
? Loc.T("settings.inherit.overrideBadge")
|
||||
: source == InheritSource.List
|
||||
? Loc.T("settings.inherit.inheritedFromList")
|
||||
: Loc.T("settings.inherit.inheritedFromGlobal");
|
||||
|
||||
private void QueueAgentSave()
|
||||
{
|
||||
if (_suppressAgentSave || TaskId is null) return;
|
||||
_agentSaveCts?.Cancel();
|
||||
_agentSaveCts = new CancellationTokenSource();
|
||||
_ = SaveAgentSettingsAsync(_agentSaveCts.Token);
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task SaveAgentSettingsAsync(CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
await System.Threading.Tasks.Task.Delay(300, ct);
|
||||
if (TaskId is null) return;
|
||||
|
||||
var model = string.IsNullOrWhiteSpace(TaskModelSelection) ? null : TaskModelSelection;
|
||||
var sp = string.IsNullOrWhiteSpace(TaskSystemPrompt) ? null : TaskSystemPrompt;
|
||||
var ap = TaskSelectedAgent is null || string.IsNullOrWhiteSpace(TaskSelectedAgent.Path)
|
||||
? null : TaskSelectedAgent.Path;
|
||||
var turns = TaskMaxTurns is decimal d ? (int?)d : null;
|
||||
|
||||
await _worker.UpdateTaskAgentSettingsAsync(
|
||||
new UpdateTaskAgentSettingsDto(TaskId, model, sp, ap, turns));
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
catch { }
|
||||
}
|
||||
|
||||
internal async System.Threading.Tasks.Task LoadAsync(
|
||||
ClaudeDo.Data.Models.TaskEntity entity, CancellationToken ct)
|
||||
{
|
||||
_suppressAgentSave = true;
|
||||
try
|
||||
{
|
||||
TaskAgentOptions.Clear();
|
||||
TaskAgentOptions.Add(new AgentInfo("(inherited)", "", ""));
|
||||
var agents = await _worker.GetAgentsAsync();
|
||||
foreach (var a in agents) TaskAgentOptions.Add(a);
|
||||
|
||||
TaskModelSelection = string.IsNullOrWhiteSpace(entity.Model) ? null : entity.Model!;
|
||||
TaskMaxTurns = entity.MaxTurns is int tmt ? tmt : (decimal?)null;
|
||||
TaskSystemPrompt = entity.SystemPrompt ?? "";
|
||||
TaskSelectedAgent = string.IsNullOrWhiteSpace(entity.AgentPath)
|
||||
? TaskAgentOptions[0]
|
||||
: (TaskAgentOptions.FirstOrDefault(a => a.Path == entity.AgentPath) ?? TaskAgentOptions[0]);
|
||||
|
||||
var listCfg = await _worker.GetListConfigAsync(entity.ListId);
|
||||
var app = await _worker.GetAppSettingsAsync();
|
||||
_globalModel = app?.DefaultModel ?? ModelRegistry.DefaultAlias;
|
||||
_globalMaxTurns = app?.DefaultMaxTurns ?? 100;
|
||||
_listModel = listCfg?.Model;
|
||||
_listMaxTurns = listCfg?.MaxTurns;
|
||||
_listAgentName = string.IsNullOrWhiteSpace(listCfg?.AgentPath)
|
||||
? null : System.IO.Path.GetFileName(listCfg!.AgentPath!);
|
||||
|
||||
EffectiveSystemPromptHint = string.IsNullOrWhiteSpace(listCfg?.SystemPrompt)
|
||||
? "" : listCfg!.SystemPrompt!;
|
||||
|
||||
RecomputeModelBadge();
|
||||
RecomputeTurnsBadge();
|
||||
RecomputeAgentBadge();
|
||||
OnPropertyChanged(nameof(EffectiveMaxTurns));
|
||||
}
|
||||
finally
|
||||
{
|
||||
_suppressAgentSave = false;
|
||||
}
|
||||
}
|
||||
|
||||
internal void Clear()
|
||||
{
|
||||
_suppressAgentSave = true;
|
||||
try
|
||||
{
|
||||
TaskModelSelection = null;
|
||||
TaskMaxTurns = null;
|
||||
TaskSystemPrompt = "";
|
||||
TaskSelectedAgent = null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_suppressAgentSave = false;
|
||||
}
|
||||
EffectiveSystemPromptHint = "";
|
||||
TaskId = null;
|
||||
}
|
||||
|
||||
[RelayCommand] private void ResetTaskModel() => TaskModelSelection = null;
|
||||
[RelayCommand] private void ResetTaskTurns() => TaskMaxTurns = null;
|
||||
[RelayCommand] private void ResetTaskAgent() =>
|
||||
TaskSelectedAgent = TaskAgentOptions.Count > 0 ? TaskAgentOptions[0] : null;
|
||||
}
|
||||
@@ -1,60 +1,30 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Text;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Ui.Helpers;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.Services.Interfaces;
|
||||
using ClaudeDo.Ui.ViewModels.Agent;
|
||||
using ClaudeDo.Ui.ViewModels.Modals;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System.IO;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||
|
||||
public enum LogKind { Sys, Tool, Claude, Stdout, Stderr, Done, Msg }
|
||||
|
||||
public sealed class LogLineViewModel
|
||||
{
|
||||
public required LogKind Kind { get; init; }
|
||||
public required string Text { get; init; }
|
||||
public string TimestampFormatted { get; } = DateTime.Now.ToString("HH:mm:ss");
|
||||
public string KindMarker => Kind switch
|
||||
{
|
||||
LogKind.Sys => "sys",
|
||||
LogKind.Tool => "tool",
|
||||
LogKind.Claude => "claude",
|
||||
LogKind.Stdout => "out",
|
||||
LogKind.Stderr => "err",
|
||||
LogKind.Done => "done",
|
||||
LogKind.Msg => "claude",
|
||||
_ => "",
|
||||
};
|
||||
public string ClassName => Kind switch
|
||||
{
|
||||
LogKind.Sys => "log-sys",
|
||||
LogKind.Tool => "log-tool",
|
||||
LogKind.Claude => "log-claude",
|
||||
LogKind.Stdout => "log-stdout",
|
||||
LogKind.Stderr => "log-stderr",
|
||||
LogKind.Done => "log-done",
|
||||
LogKind.Msg => "log-msg",
|
||||
_ => "",
|
||||
};
|
||||
}
|
||||
|
||||
public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly IWorkerClient _worker;
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly INotesApi _notesApi;
|
||||
private readonly IMergeCoordinator _merge;
|
||||
|
||||
// ── Section view models ───────────────────────────────────────────────────
|
||||
public AgentSettingsSectionViewModel AgentSettings { get; }
|
||||
public AgentConfigEditorViewModel AgentSettings { get; }
|
||||
public MergeSectionViewModel Merge { get; }
|
||||
public PrepPanelViewModel Prep { get; }
|
||||
|
||||
@@ -115,29 +85,39 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
[RelayCommand]
|
||||
private void ToggleDescriptionExpanded() => IsDescriptionExpanded = !IsDescriptionExpanded;
|
||||
|
||||
[ObservableProperty] private bool _isStepsExpanded;
|
||||
// Which section of the details card is shown (header acts as a segment switcher).
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsDescriptionSection))]
|
||||
[NotifyPropertyChangedFor(nameof(IsStepsSection))]
|
||||
[NotifyPropertyChangedFor(nameof(IsFilesSection))]
|
||||
private string _detailSection = "description";
|
||||
|
||||
public bool IsDescriptionSection => DetailSection == "description";
|
||||
public bool IsStepsSection => DetailSection == "steps";
|
||||
public bool IsFilesSection => DetailSection == "files";
|
||||
|
||||
[RelayCommand]
|
||||
private void ToggleStepsExpanded() => IsStepsExpanded = !IsStepsExpanded;
|
||||
private void SelectDetailSection(string? section) => DetailSection = section ?? "description";
|
||||
|
||||
public int TotalStepCount => Subtasks.Count;
|
||||
public int OpenStepCount => Subtasks.Count(s => !s.Done);
|
||||
public string StepsSummary =>
|
||||
TotalStepCount == 0 ? "no steps yet"
|
||||
: OpenStepCount == 0 ? $"all done · {TotalStepCount} total"
|
||||
: $"{OpenStepCount} open · {TotalStepCount} total";
|
||||
public int DoneStepCount => Subtasks.Count(s => s.Done);
|
||||
public string StepsBadge => TotalStepCount > 0 ? $"{DoneStepCount}/{TotalStepCount}" : "";
|
||||
public string FilesBadge => Attachments.Count > 0 ? Attachments.Count.ToString() : "";
|
||||
|
||||
private void NotifyStepsChanged()
|
||||
{
|
||||
OnPropertyChanged(nameof(TotalStepCount));
|
||||
OnPropertyChanged(nameof(OpenStepCount));
|
||||
OnPropertyChanged(nameof(StepsSummary));
|
||||
OnPropertyChanged(nameof(DoneStepCount));
|
||||
OnPropertyChanged(nameof(StepsBadge));
|
||||
OnPropertyChanged(nameof(ComposedPreview));
|
||||
}
|
||||
|
||||
public string ComposedPreview =>
|
||||
ClaudeDo.Data.TaskPromptComposer.Compose(
|
||||
EditableTitle, EditableDescription, Subtasks.Select(s => (s.Title, s.Done)));
|
||||
EditableTitle, EditableDescription, Subtasks.Select(s => (s.Title, s.Done)),
|
||||
Task is not null
|
||||
? Attachments.Select(a => Path.Combine(new AttachmentStore().TaskDir(Task.Id), a.FileName))
|
||||
: null);
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsOutputTab))]
|
||||
@@ -166,98 +146,53 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
public string DiffAddText => $"+{DiffAdditions}";
|
||||
public string DiffDelText => $"-{DiffDeletions}";
|
||||
|
||||
public bool ShowRoadblock => IsFailed;
|
||||
public string RoadblockMessage =>
|
||||
IsFailed ? "The session ended with an error." : "";
|
||||
// ── Monitor forwarding ───────────────────────────────────────────────────
|
||||
public TaskMonitorViewModel Monitor { get; }
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ShowSessionOutcome))]
|
||||
private string? _sessionOutcome;
|
||||
public ObservableCollection<LogLineViewModel> Log => Monitor.Log;
|
||||
|
||||
public bool ShowSessionOutcome =>
|
||||
!string.IsNullOrWhiteSpace(SessionOutcome)
|
||||
&& (IsWaitingForReview || IsDone || IsFailed || IsCancelled);
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ShowRoadblockCard))]
|
||||
private string? _roadblocks;
|
||||
|
||||
public bool ShowRoadblockCard =>
|
||||
!string.IsNullOrWhiteSpace(Roadblocks)
|
||||
&& (IsWaitingForReview || IsDone || IsFailed || IsCancelled);
|
||||
|
||||
private const string RoadblockMarker = "Roadblocks reported during the run:";
|
||||
|
||||
private void ApplyOutcome(string? result, string? errorFallback)
|
||||
public string AgentState
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(result))
|
||||
{
|
||||
SessionOutcome = errorFallback;
|
||||
Roadblocks = null;
|
||||
return;
|
||||
}
|
||||
get => Monitor.AgentState;
|
||||
set => Monitor.AgentState = value;
|
||||
}
|
||||
|
||||
var idx = result.IndexOf(RoadblockMarker, StringComparison.Ordinal);
|
||||
if (idx < 0)
|
||||
{
|
||||
SessionOutcome = result;
|
||||
Roadblocks = null;
|
||||
return;
|
||||
}
|
||||
public string AgentStatusLabel => Monitor.AgentStatusLabel;
|
||||
public bool IsIdle => Monitor.IsIdle;
|
||||
public bool IsQueued => Monitor.IsQueued;
|
||||
public bool IsRunning => Monitor.IsRunning;
|
||||
public bool IsWaitingForReview => Monitor.IsWaitingForReview;
|
||||
public bool IsWaitingForChildren => Monitor.IsWaitingForChildren;
|
||||
public bool IsDone => Monitor.IsDone;
|
||||
public bool IsFailed => Monitor.IsFailed;
|
||||
public bool IsCancelled => Monitor.IsCancelled;
|
||||
public bool ShowContinue => Monitor.ShowContinue;
|
||||
public bool ShowResetAndRetry => Monitor.ShowResetAndRetry;
|
||||
public bool ShowRoadblock => Monitor.ShowRoadblock;
|
||||
public string RoadblockMessage => Monitor.RoadblockMessage;
|
||||
public bool ShowSessionOutcome => Monitor.ShowSessionOutcome;
|
||||
public bool ShowRoadblockCard => Monitor.ShowRoadblockCard;
|
||||
|
||||
var summary = result[..idx].TrimEnd().TrimEnd('⚠').TrimEnd();
|
||||
SessionOutcome = string.IsNullOrWhiteSpace(summary) ? null : summary;
|
||||
Roadblocks = result[(idx + RoadblockMarker.Length)..].Trim();
|
||||
public string? SessionOutcome
|
||||
{
|
||||
get => Monitor.SessionOutcome;
|
||||
set => Monitor.SessionOutcome = value;
|
||||
}
|
||||
|
||||
public string? Roadblocks
|
||||
{
|
||||
get => Monitor.Roadblocks;
|
||||
set => Monitor.Roadblocks = value;
|
||||
}
|
||||
|
||||
public string SessionLabel => "claude-session";
|
||||
|
||||
public string TaskIdBadge => Task != null ? $"#T{Task.Id[..Math.Min(3, Task.Id.Length)].ToUpperInvariant()}" : "";
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(EnqueueCommand))]
|
||||
[NotifyCanExecuteChangedFor(nameof(DequeueCommand))]
|
||||
[NotifyCanExecuteChangedFor(nameof(ResetAndRetryCommand))]
|
||||
[NotifyCanExecuteChangedFor(nameof(ContinueCommand))]
|
||||
private string _agentState = "idle";
|
||||
public string AgentStatusLabel => Loc.T($"vm.agentStatus.{AgentState}");
|
||||
public bool IsIdle => AgentState == "idle";
|
||||
public bool IsQueued => AgentState == "queued";
|
||||
public bool IsRunning => AgentState == "running";
|
||||
public bool IsWaitingForReview => AgentState == "review";
|
||||
public bool IsWaitingForChildren => AgentState == "children";
|
||||
public bool IsDone => AgentState == "done";
|
||||
public bool IsFailed => AgentState == "failed";
|
||||
public bool IsCancelled => AgentState == "cancelled";
|
||||
|
||||
public bool ShowContinue => IsFailed || IsCancelled;
|
||||
public bool ShowResetAndRetry => IsFailed || IsCancelled || IsDone;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(ContinueCommand))]
|
||||
private string? _latestRunSessionId;
|
||||
|
||||
partial void OnAgentStateChanged(string value)
|
||||
{
|
||||
OnPropertyChanged(nameof(AgentStatusLabel));
|
||||
OnPropertyChanged(nameof(IsIdle));
|
||||
OnPropertyChanged(nameof(IsQueued));
|
||||
OnPropertyChanged(nameof(IsRunning));
|
||||
OnPropertyChanged(nameof(IsWaitingForReview));
|
||||
OnPropertyChanged(nameof(IsWaitingForChildren));
|
||||
OnPropertyChanged(nameof(IsDone));
|
||||
OnPropertyChanged(nameof(IsFailed));
|
||||
OnPropertyChanged(nameof(IsCancelled));
|
||||
OnPropertyChanged(nameof(ShowContinue));
|
||||
OnPropertyChanged(nameof(ShowResetAndRetry));
|
||||
OnPropertyChanged(nameof(ShowRoadblock));
|
||||
OnPropertyChanged(nameof(RoadblockMessage));
|
||||
OnPropertyChanged(nameof(ShowSessionOutcome));
|
||||
OnPropertyChanged(nameof(ShowRoadblockCard));
|
||||
AgentSettings.IsRunning = IsRunning;
|
||||
NotifySessionSections();
|
||||
}
|
||||
|
||||
[ObservableProperty] private string? _model;
|
||||
|
||||
[ObservableProperty] private string? _worktreePath;
|
||||
@@ -289,9 +224,14 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
public ObservableCollection<LogLineViewModel> Log { get; } = new();
|
||||
public ObservableCollection<SubtaskRowViewModel> Subtasks { get; } = new();
|
||||
public ObservableCollection<ChildOutcomeRowViewModel> ChildOutcomes { get; } = new();
|
||||
public ObservableCollection<AttachmentRowViewModel> Attachments { get; } = new();
|
||||
|
||||
[ObservableProperty] private bool _isDragOver;
|
||||
[ObservableProperty] private string? _dropStatus;
|
||||
|
||||
public bool CanAcceptDrop => Task is not null && !Task.IsRunning;
|
||||
public bool HasChildOutcomes => ChildOutcomes.Count > 0;
|
||||
|
||||
public int ChildrenNeedingAttention => ChildOutcomes.Count(c =>
|
||||
@@ -313,11 +253,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
|
||||
[ObservableProperty] private string _newSubtaskTitle = "";
|
||||
|
||||
// Claude CLI stream-json parser + buffer for partial text deltas
|
||||
private readonly StreamLineFormatter _formatter = new();
|
||||
private readonly StringBuilder _claudeBuf = new();
|
||||
|
||||
private string? _subscribedTaskId;
|
||||
private CancellationTokenSource? _loadCts;
|
||||
|
||||
private bool _suppressDescSave;
|
||||
@@ -343,93 +278,49 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
|
||||
public bool HasReviewFeedback => !string.IsNullOrWhiteSpace(ReviewFeedback);
|
||||
|
||||
// Kept for backwards-compat surface — delegates to Merge.RequestConflictResolution
|
||||
public Func<string, string, System.Threading.Tasks.Task>? RequestConflictResolution
|
||||
{
|
||||
get => Merge.RequestConflictResolution;
|
||||
set => Merge.RequestConflictResolution = value;
|
||||
}
|
||||
|
||||
private static string StatusToStateKey(ClaudeDo.Data.Models.TaskStatus status) => status switch
|
||||
{
|
||||
ClaudeDo.Data.Models.TaskStatus.Queued => "queued",
|
||||
ClaudeDo.Data.Models.TaskStatus.Running => "running",
|
||||
ClaudeDo.Data.Models.TaskStatus.WaitingForReview => "review",
|
||||
ClaudeDo.Data.Models.TaskStatus.WaitingForChildren => "children",
|
||||
ClaudeDo.Data.Models.TaskStatus.Done => "done",
|
||||
ClaudeDo.Data.Models.TaskStatus.Failed => "failed",
|
||||
ClaudeDo.Data.Models.TaskStatus.Cancelled => "cancelled",
|
||||
_ => "idle",
|
||||
};
|
||||
|
||||
private static string FinishedStatusToStateKey(string status) => status switch
|
||||
{
|
||||
"done" => "done",
|
||||
"failed" => "failed",
|
||||
"cancelled" => "cancelled",
|
||||
"waiting_for_review" => "review",
|
||||
"waiting_for_children" => "children",
|
||||
_ => status.ToLowerInvariant(),
|
||||
};
|
||||
|
||||
private async System.Threading.Tasks.Task RefreshStatusAsync(string taskId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
var entity = await ctx.Tasks
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(t => t.Id == taskId);
|
||||
if (entity is null || Task?.Id != taskId) return;
|
||||
|
||||
AgentState = StatusToStateKey(entity.Status);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task RefreshOutcomeAsync(string taskId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
var entity = await ctx.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId);
|
||||
var latestRun = await new TaskRunRepository(ctx).GetLatestByTaskIdAsync(taskId);
|
||||
if (Task?.Id != taskId) return;
|
||||
ApplyOutcome(entity?.Result, latestRun?.ErrorMarkdown);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
public DetailsIslandViewModel(
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
IWorkerClient worker,
|
||||
IServiceProvider services,
|
||||
INotesApi notesApi)
|
||||
INotesApi notesApi,
|
||||
IMergeCoordinator merge)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_worker = worker;
|
||||
_services = services;
|
||||
_notesApi = notesApi;
|
||||
_merge = merge;
|
||||
|
||||
AgentSettings = new AgentSettingsSectionViewModel(worker);
|
||||
Monitor = new TaskMonitorViewModel(dbFactory, worker);
|
||||
Monitor.PropertyChanged += OnMonitorPropertyChanged;
|
||||
|
||||
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);
|
||||
Subtasks.CollectionChanged += (_, _) => NotifyStepsChanged();
|
||||
Subtasks.CollectionChanged += (_, _) => Merge.SyncChildOutcomes(HasChildOutcomes, Subtasks.Count);
|
||||
Attachments.CollectionChanged += (_, _) => OnPropertyChanged(nameof(FilesBadge));
|
||||
|
||||
AgentSettings.PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(AgentSettingsSectionViewModel.EffectiveMaxTurns))
|
||||
if (e.PropertyName == nameof(AgentConfigEditorViewModel.EffectiveMaxTurns))
|
||||
OnPropertyChanged(nameof(TurnsText));
|
||||
};
|
||||
|
||||
_langChangedHandler = (_, _) => OnPropertyChanged(nameof(AgentStatusLabel));
|
||||
Loc.LanguageChanged += _langChangedHandler;
|
||||
|
||||
_worker.TaskMessageEvent += OnTaskMessage;
|
||||
|
||||
_workerPropertyChangedHandler = (_, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(IWorkerClient.IsConnected))
|
||||
@@ -444,7 +335,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
|
||||
_workerTaskStartedHandler = (slot, taskId, startedAt) =>
|
||||
{
|
||||
if (Task?.Id == taskId) AgentState = "running";
|
||||
_ = RefreshChildOutcomeAsync(taskId);
|
||||
};
|
||||
_worker.TaskStartedEvent += _workerTaskStartedHandler;
|
||||
@@ -452,16 +342,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
_workerTaskFinishedHandler = (slot, taskId, status, finishedAt) =>
|
||||
{
|
||||
if (Task?.Id != taskId) return;
|
||||
FlushClaudeBuffer();
|
||||
Log.Add(new LogLineViewModel
|
||||
{
|
||||
Kind = LogKind.Done,
|
||||
Text = $"── {status.ToUpperInvariant()} · {finishedAt.ToLocalTime():HH:mm:ss} ──",
|
||||
});
|
||||
AgentState = FinishedStatusToStateKey(status);
|
||||
_ = RefreshWorktreeAsync(taskId);
|
||||
_ = RefreshChildOutcomeAsync(taskId);
|
||||
_ = RefreshOutcomeAsync(taskId);
|
||||
};
|
||||
_worker.TaskFinishedEvent += _workerTaskFinishedHandler;
|
||||
|
||||
@@ -475,7 +357,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
|
||||
_workerTaskUpdatedHandler = taskId =>
|
||||
{
|
||||
if (Task?.Id == taskId) _ = RefreshStatusAsync(taskId);
|
||||
if (Task?.IsPlanningParent == true) _ = RefreshPlanningChildAsync(taskId);
|
||||
_ = RefreshChildOutcomeAsync(taskId);
|
||||
};
|
||||
@@ -490,64 +371,63 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Monitor.PropertyChanged -= OnMonitorPropertyChanged;
|
||||
Monitor.Dispose();
|
||||
Loc.LanguageChanged -= _langChangedHandler;
|
||||
_worker.PropertyChanged -= _workerPropertyChangedHandler;
|
||||
_worker.TaskStartedEvent -= _workerTaskStartedHandler;
|
||||
_worker.TaskFinishedEvent -= _workerTaskFinishedHandler;
|
||||
_worker.WorktreeUpdatedEvent -= _workerWorktreeUpdatedHandler;
|
||||
_worker.TaskUpdatedEvent -= _workerTaskUpdatedHandler;
|
||||
_worker.TaskMessageEvent -= OnTaskMessage;
|
||||
AgentSettings.Dispose();
|
||||
Prep.Dispose();
|
||||
}
|
||||
|
||||
private void OnTaskMessage(string taskId, string line)
|
||||
private void OnMonitorPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
||||
{
|
||||
if (taskId != _subscribedTaskId) return;
|
||||
|
||||
if (line.StartsWith("[stdout]", StringComparison.OrdinalIgnoreCase))
|
||||
switch (e.PropertyName)
|
||||
{
|
||||
var body = line["[stdout]".Length..].TrimStart();
|
||||
AppendStdoutLine(body);
|
||||
return;
|
||||
case nameof(TaskMonitorViewModel.AgentState):
|
||||
OnPropertyChanged(nameof(AgentState));
|
||||
OnPropertyChanged(nameof(AgentStatusLabel));
|
||||
OnPropertyChanged(nameof(IsIdle));
|
||||
OnPropertyChanged(nameof(IsQueued));
|
||||
OnPropertyChanged(nameof(IsRunning));
|
||||
OnPropertyChanged(nameof(IsWaitingForReview));
|
||||
OnPropertyChanged(nameof(IsWaitingForChildren));
|
||||
OnPropertyChanged(nameof(IsDone));
|
||||
OnPropertyChanged(nameof(IsFailed));
|
||||
OnPropertyChanged(nameof(IsCancelled));
|
||||
OnPropertyChanged(nameof(ShowContinue));
|
||||
OnPropertyChanged(nameof(ShowResetAndRetry));
|
||||
OnPropertyChanged(nameof(ShowRoadblock));
|
||||
OnPropertyChanged(nameof(RoadblockMessage));
|
||||
OnPropertyChanged(nameof(ShowSessionOutcome));
|
||||
OnPropertyChanged(nameof(ShowRoadblockCard));
|
||||
EnqueueCommand.NotifyCanExecuteChanged();
|
||||
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));
|
||||
break;
|
||||
case nameof(TaskMonitorViewModel.SessionOutcome):
|
||||
OnPropertyChanged(nameof(SessionOutcome));
|
||||
OnPropertyChanged(nameof(ShowSessionOutcome));
|
||||
break;
|
||||
case nameof(TaskMonitorViewModel.Roadblocks):
|
||||
OnPropertyChanged(nameof(Roadblocks));
|
||||
OnPropertyChanged(nameof(ShowRoadblockCard));
|
||||
break;
|
||||
}
|
||||
|
||||
FlushClaudeBuffer();
|
||||
|
||||
var kind = line.StartsWith("[sys]", StringComparison.OrdinalIgnoreCase) ? LogKind.Sys
|
||||
: line.StartsWith("[tool]", StringComparison.OrdinalIgnoreCase) ? LogKind.Tool
|
||||
: line.StartsWith("[claude]", StringComparison.OrdinalIgnoreCase) ? LogKind.Claude
|
||||
: line.StartsWith("[stderr]", StringComparison.OrdinalIgnoreCase) ? LogKind.Stderr
|
||||
: line.StartsWith("[done]", StringComparison.OrdinalIgnoreCase) ? LogKind.Done
|
||||
: LogKind.Msg;
|
||||
Log.Add(new LogLineViewModel { Kind = kind, Text = line });
|
||||
}
|
||||
|
||||
private void AppendStdoutLine(string line)
|
||||
{
|
||||
var formatted = _formatter.FormatLine(line);
|
||||
if (formatted is null) return;
|
||||
_claudeBuf.Append(formatted);
|
||||
while (true)
|
||||
{
|
||||
var text = _claudeBuf.ToString();
|
||||
var nl = text.IndexOf('\n');
|
||||
if (nl < 0) break;
|
||||
var piece = text[..nl].TrimEnd('\r');
|
||||
if (!string.IsNullOrWhiteSpace(piece))
|
||||
Log.Add(new LogLineViewModel { Kind = LogKind.Claude, Text = piece });
|
||||
_claudeBuf.Clear();
|
||||
_claudeBuf.Append(text[(nl + 1)..]);
|
||||
}
|
||||
}
|
||||
|
||||
private void FlushClaudeBuffer()
|
||||
{
|
||||
if (_claudeBuf.Length == 0) return;
|
||||
var piece = _claudeBuf.ToString().TrimEnd();
|
||||
_claudeBuf.Clear();
|
||||
if (!string.IsNullOrWhiteSpace(piece))
|
||||
Log.Add(new LogLineViewModel { Kind = LogKind.Claude, Text = piece });
|
||||
}
|
||||
|
||||
partial void OnEditableDescriptionChanged(string value)
|
||||
@@ -602,18 +482,16 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
|
||||
Task = row;
|
||||
OnPropertyChanged(nameof(TaskIdBadge));
|
||||
Log.Clear();
|
||||
Monitor.Reset();
|
||||
Subtasks.Clear();
|
||||
ChildOutcomes.Clear();
|
||||
Attachments.Clear();
|
||||
DropStatus = null;
|
||||
OnPropertyChanged(nameof(HasChildOutcomes));
|
||||
SessionOutcome = null;
|
||||
Roadblocks = null;
|
||||
_claudeBuf.Clear();
|
||||
Merge.Clear();
|
||||
|
||||
if (row == null)
|
||||
{
|
||||
_subscribedTaskId = null;
|
||||
EditableTitle = "";
|
||||
EditableDescription = "";
|
||||
Model = null;
|
||||
@@ -624,7 +502,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
BranchLine = null;
|
||||
DiffAdditions = 0;
|
||||
DiffDeletions = 0;
|
||||
AgentState = "idle";
|
||||
LatestRunSessionId = null;
|
||||
AgentSettings.Clear();
|
||||
return;
|
||||
@@ -662,31 +539,40 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
var (add, del) = ParseDiffStat(entity.Worktree?.DiffStat);
|
||||
DiffAdditions = add;
|
||||
DiffDeletions = del;
|
||||
AgentState = StatusToStateKey(entity.Status);
|
||||
Monitor.ApplyState(entity.Status);
|
||||
|
||||
Merge.SyncTaskContext(row.Id, row.Title, row.IsPlanningParent);
|
||||
Merge.SyncWorktree(WorktreePath, WorktreeBaseCommit, WorktreeHeadCommit,
|
||||
WorktreeStateLabel, _listWorkingDir);
|
||||
|
||||
AgentSettings.TaskId = row.Id;
|
||||
await AgentSettings.LoadAsync(entity, ct);
|
||||
await AgentSettings.LoadForTaskAsync(entity, ct);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var runRepo = new TaskRunRepository(ctx);
|
||||
var latestRun = await runRepo.GetLatestByTaskIdAsync(row.Id, ct);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
LatestRunSessionId = latestRun?.SessionId;
|
||||
ApplyOutcome(entity.Result, latestRun?.ErrorMarkdown);
|
||||
// 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);
|
||||
|
||||
_subscribedTaskId = row.Id;
|
||||
Monitor.SetTaskId(row.Id);
|
||||
|
||||
await ReplayLogFileAsync(entity.LogPath, ct);
|
||||
await Monitor.ReplayLogFileAsync(entity.LogPath, ct);
|
||||
|
||||
var subs = await subtaskRepo.GetByTaskIdAsync(row.Id);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
foreach (var s in subs)
|
||||
Subtasks.Add(new SubtaskRowViewModel { Id = s.Id, Title = s.Title, Done = s.Completed });
|
||||
|
||||
var attachmentRepo = new TaskAttachmentRepository(ctx);
|
||||
var attachments = await attachmentRepo.ListByTaskIdAsync(row.Id, ct);
|
||||
ct.ThrowIfCancellationRequested();
|
||||
foreach (var a in attachments)
|
||||
Attachments.Add(new AttachmentRowViewModel { FileName = a.FileName, ByteSize = a.ByteSize });
|
||||
|
||||
if (entity.PlanningPhase != ClaudeDo.Data.Models.PlanningPhase.None)
|
||||
await LoadPlanningChildrenAsync(row.Id, ct);
|
||||
await LoadChildOutcomesAsync(row.Id, ct);
|
||||
@@ -754,56 +640,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task ReplayLogFileAsync(string? logPath, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(logPath)) return;
|
||||
var expanded = ExpandUserPath(logPath);
|
||||
if (!System.IO.File.Exists(expanded)) return;
|
||||
|
||||
try
|
||||
{
|
||||
const int maxLines = 2000;
|
||||
string[] all;
|
||||
await using (var fs = new System.IO.FileStream(
|
||||
expanded,
|
||||
System.IO.FileMode.Open,
|
||||
System.IO.FileAccess.Read,
|
||||
System.IO.FileShare.ReadWrite | System.IO.FileShare.Delete))
|
||||
using (var reader = new System.IO.StreamReader(fs))
|
||||
{
|
||||
var list = new List<string>();
|
||||
while (await reader.ReadLineAsync(ct) is { } line)
|
||||
list.Add(line);
|
||||
all = list.ToArray();
|
||||
}
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var start = Math.Max(0, all.Length - maxLines);
|
||||
for (int i = start; i < all.Length; i++)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
if (_subscribedTaskId is null) return;
|
||||
var line = all[i];
|
||||
var normalized = line.StartsWith("[", StringComparison.Ordinal) ? line : "[stdout] " + line;
|
||||
OnTaskMessage(_subscribedTaskId, normalized);
|
||||
}
|
||||
FlushClaudeBuffer();
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch { /* best-effort replay */ }
|
||||
}
|
||||
|
||||
private static string ExpandUserPath(string path)
|
||||
{
|
||||
if (path.StartsWith("~/", StringComparison.Ordinal) || path.StartsWith("~\\", StringComparison.Ordinal))
|
||||
return System.IO.Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
path[2..]);
|
||||
if (path == "~")
|
||||
return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
return path;
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task LoadPlanningChildrenAsync(string parentTaskId, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
@@ -907,7 +743,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
WorktreeHeadCommit = entity.Worktree?.HeadCommit;
|
||||
WorktreeStateLabel = entity.Worktree?.State.ToString();
|
||||
BranchLine = entity.Worktree is { } w ? $"{w.BranchName} ← main" : null;
|
||||
AgentState = StatusToStateKey(entity.Status);
|
||||
if (Task is { } row && entity.Worktree?.DiffStat is { } stat)
|
||||
row.DiffStat = stat;
|
||||
var (add, del) = ParseDiffStat(entity.Worktree?.DiffStat);
|
||||
@@ -925,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) =>
|
||||
@@ -933,8 +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]
|
||||
@@ -966,7 +806,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
? ClaudeDo.Data.Models.TaskStatus.Done
|
||||
: ClaudeDo.Data.Models.TaskStatus.Idle;
|
||||
Task.Status = entity.Status;
|
||||
AgentState = StatusToStateKey(entity.Status);
|
||||
Monitor.ApplyState(entity.Status);
|
||||
await repo.UpdateAsync(entity);
|
||||
}
|
||||
|
||||
@@ -1076,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()
|
||||
{
|
||||
@@ -1142,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;
|
||||
@@ -1151,20 +1022,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
var hasChildren = Subtasks.Count > 0 || ChildOutcomes.Count > 0;
|
||||
var result = await _worker.ApproveReviewAsync(Task.Id, Merge.SelectedMergeTarget ?? "");
|
||||
if (!hasChildren && result?.Status == "conflict")
|
||||
{
|
||||
if (Merge.RequestConflictResolution is not null)
|
||||
{
|
||||
await Merge.RequestConflictResolution(Task.Id, Merge.SelectedMergeTarget ?? "");
|
||||
}
|
||||
else
|
||||
{
|
||||
var (text, _, _) = MergePreviewPresenter.Describe(
|
||||
new MergePreviewDto("conflict", result.ConflictFiles, 0));
|
||||
Merge.MergePreviewText = text;
|
||||
Merge.MergeIsClean = false;
|
||||
Merge.MergeIsConflict = true;
|
||||
}
|
||||
}
|
||||
await _merge.ResolveConflictAsync(Task.Id, Merge.SelectedMergeTarget ?? "");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -1173,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()
|
||||
{
|
||||
@@ -1213,6 +1095,108 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
catch { /* stale review action; broadcast reconciles */ }
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task ReloadAttachmentsAsync()
|
||||
{
|
||||
if (Task is null) return;
|
||||
try
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
var attachments = await new TaskAttachmentRepository(ctx).ListByTaskIdAsync(Task.Id);
|
||||
Attachments.Clear();
|
||||
foreach (var a in attachments)
|
||||
Attachments.Add(new AttachmentRowViewModel { FileName = a.FileName, ByteSize = a.ByteSize });
|
||||
OnPropertyChanged(nameof(ComposedPreview));
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
public async System.Threading.Tasks.Task AddFilesAsync(IReadOnlyList<(string FileName, Stream Content)> files)
|
||||
{
|
||||
DetailSection = "files";
|
||||
if (Task is null || Task.IsRunning)
|
||||
{
|
||||
DropStatus = Loc.T("details.attachments.selectIdleTask");
|
||||
return;
|
||||
}
|
||||
|
||||
var store = new AttachmentStore();
|
||||
var successes = new List<string>();
|
||||
var failures = new List<string>();
|
||||
|
||||
foreach (var (fileName, content) in files)
|
||||
{
|
||||
try
|
||||
{
|
||||
var byteSize = await store.SaveAsync(Task.Id, fileName, content);
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
var repo = new TaskAttachmentRepository(ctx);
|
||||
var existing = await repo.GetAsync(Task.Id, fileName);
|
||||
if (existing is not null)
|
||||
{
|
||||
existing.ByteSize = byteSize;
|
||||
await repo.UpdateAsync(existing);
|
||||
}
|
||||
else
|
||||
{
|
||||
await repo.AddAsync(new ClaudeDo.Data.Models.TaskAttachmentEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
TaskId = Task.Id,
|
||||
FileName = fileName,
|
||||
ByteSize = byteSize,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
});
|
||||
}
|
||||
successes.Add(fileName);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
failures.Add(string.Format(Loc.T("details.attachments.overLimitError"), fileName, ex.Message));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
failures.Add(string.Format(Loc.T("details.attachments.invalidNameError"), fileName, ex.Message));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Keep the exception type in the message so an intermittent failure is analyzable.
|
||||
failures.Add($"{fileName}: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
await ReloadAttachmentsAsync();
|
||||
|
||||
if (failures.Count == 0)
|
||||
{
|
||||
var names = string.Join(", ", successes);
|
||||
DropStatus = string.Format(Loc.T("details.attachments.addedSummary"), names, successes.Count);
|
||||
}
|
||||
else if (successes.Count == 0)
|
||||
{
|
||||
DropStatus = string.Join(" · ", failures);
|
||||
}
|
||||
else
|
||||
{
|
||||
var names = string.Join(", ", successes);
|
||||
var addedPart = string.Format(Loc.T("details.attachments.addedSummary"), names, successes.Count);
|
||||
DropStatus = addedPart + " · " + string.Join(" · ", failures);
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async System.Threading.Tasks.Task RemoveAttachment(AttachmentRowViewModel? row)
|
||||
{
|
||||
if (row is null || Task is null) return;
|
||||
try
|
||||
{
|
||||
new AttachmentStore().DeleteFile(Task.Id, row.FileName);
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
await new TaskAttachmentRepository(ctx).DeleteAsync(Task.Id, row.FileName);
|
||||
await ReloadAttachmentsAsync();
|
||||
}
|
||||
catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
internal static (int Additions, int Deletions) ParseDiffStat(string? stat)
|
||||
{
|
||||
if (string.IsNullOrEmpty(stat)) return (0, 0);
|
||||
@@ -1225,6 +1209,18 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AttachmentRowViewModel
|
||||
{
|
||||
public required string FileName { get; init; }
|
||||
public required long ByteSize { get; init; }
|
||||
public string SizeText => ByteSize switch
|
||||
{
|
||||
>= 1024 * 1024 => $"{ByteSize / (1024.0 * 1024.0):F1} MB",
|
||||
>= 1024 => $"{ByteSize / 1024.0:F1} KB",
|
||||
_ => $"{ByteSize} B",
|
||||
};
|
||||
}
|
||||
|
||||
public sealed partial class SubtaskRowViewModel : ViewModelBase
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
|
||||
@@ -27,28 +27,25 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
public event EventHandler? FocusSearchRequested;
|
||||
public void RequestFocusSearch() => FocusSearchRequested?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
public Func<SettingsModalViewModel, Task>? ShowSettingsModal { get; set; }
|
||||
public Func<ListSettingsModalViewModel, System.Threading.Tasks.Task>? ShowListSettingsModal { get; set; }
|
||||
public Func<WorktreesOverviewModalViewModel, Task>? ShowWorktreesOverviewModal { get; set; }
|
||||
public Func<RepoImportModalViewModel, System.Threading.Tasks.Task>? ShowRepoImportModal { get; set; }
|
||||
public IDialogService? Dialogs { get; set; }
|
||||
|
||||
[RelayCommand]
|
||||
private async Task OpenSettings()
|
||||
{
|
||||
if (ShowSettingsModal is null || _services is null) return;
|
||||
if (Dialogs is null || _services is null) return;
|
||||
var settingsVm = _services.GetRequiredService<SettingsModalViewModel>();
|
||||
await settingsVm.LoadAsync();
|
||||
await ShowSettingsModal(settingsVm);
|
||||
await Dialogs.ShowSettingsAsync(settingsVm);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async System.Threading.Tasks.Task OpenListSettingsAsync(ListNavItemViewModel? row)
|
||||
{
|
||||
if (row is null || ShowListSettingsModal is null || _services is null) return;
|
||||
if (row is null || Dialogs is null || _services is null) return;
|
||||
var rawId = row.Id.StartsWith("user:", StringComparison.Ordinal) ? row.Id["user:".Length..] : row.Id;
|
||||
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
|
||||
await vm.LoadAsync(rawId, row.Name, row.WorkingDir, row.DefaultCommitType);
|
||||
await ShowListSettingsModal(vm);
|
||||
await Dialogs.ShowListSettingsAsync(vm);
|
||||
if (vm.Deleted) await LoadAsync();
|
||||
else await RefreshRowAsync(row.Id);
|
||||
}
|
||||
@@ -56,10 +53,10 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
[RelayCommand]
|
||||
private async System.Threading.Tasks.Task OpenRepoImportAsync()
|
||||
{
|
||||
if (ShowRepoImportModal is null || _services is null) return;
|
||||
if (Dialogs is null || _services is null) return;
|
||||
var vm = _services.GetRequiredService<RepoImportModalViewModel>();
|
||||
await vm.LoadAsync();
|
||||
await ShowRepoImportModal(vm);
|
||||
await Dialogs.ShowRepoImportAsync(vm);
|
||||
await LoadAsync();
|
||||
}
|
||||
|
||||
@@ -68,7 +65,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
[RelayCommand]
|
||||
private async Task OpenWorktreesOverviewAsync(ListNavItemViewModel? row)
|
||||
{
|
||||
if (row is null || ShowWorktreesOverviewModal is null || _services is null) return;
|
||||
if (row is null || Dialogs is null || _services is null) return;
|
||||
if (row.Kind != ListKind.User) return;
|
||||
if (_worktreesOverviewOpen) return;
|
||||
_worktreesOverviewOpen = true;
|
||||
@@ -78,7 +75,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
var vm = _services.GetRequiredService<WorktreesOverviewModalViewModel>();
|
||||
vm.Configure(rawId, row.Name);
|
||||
await vm.LoadAsync();
|
||||
await ShowWorktreesOverviewModal(vm);
|
||||
await Dialogs.ShowWorktreesOverviewAsync(vm);
|
||||
}
|
||||
finally { _worktreesOverviewOpen = false; }
|
||||
}
|
||||
@@ -297,11 +294,11 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
UserLists.Add(item);
|
||||
SelectedList = item;
|
||||
|
||||
if (ShowListSettingsModal is not null && _services is not null)
|
||||
if (Dialogs is not null && _services is not null)
|
||||
{
|
||||
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
|
||||
await vm.LoadAsync(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType);
|
||||
await ShowListSettingsModal(vm);
|
||||
await Dialogs.ShowListSettingsAsync(vm);
|
||||
if (vm.Deleted) await LoadAsync();
|
||||
else await RefreshRowAsync(item.Id);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||
|
||||
public enum LogKind { Sys, Tool, Claude, Stdout, Stderr, Done, Msg, User }
|
||||
|
||||
public sealed class LogLineViewModel
|
||||
{
|
||||
public required LogKind Kind { get; init; }
|
||||
public required string Text { get; init; }
|
||||
public string TimestampFormatted { get; } = DateTime.Now.ToString("HH:mm:ss");
|
||||
public string KindMarker => Kind switch
|
||||
{
|
||||
LogKind.Sys => "sys",
|
||||
LogKind.Tool => "tool",
|
||||
LogKind.Claude => "claude",
|
||||
LogKind.Stdout => "out",
|
||||
LogKind.Stderr => "err",
|
||||
LogKind.Done => "done",
|
||||
LogKind.Msg => "claude",
|
||||
LogKind.User => "you",
|
||||
_ => "",
|
||||
};
|
||||
public string ClassName => Kind switch
|
||||
{
|
||||
LogKind.Sys => "log-sys",
|
||||
LogKind.Tool => "log-tool",
|
||||
LogKind.Claude => "log-claude",
|
||||
LogKind.Stdout => "log-stdout",
|
||||
LogKind.Stderr => "log-stderr",
|
||||
LogKind.Done => "log-done",
|
||||
LogKind.Msg => "log-msg",
|
||||
LogKind.User => "log-user",
|
||||
_ => "",
|
||||
};
|
||||
}
|
||||
@@ -45,10 +45,17 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
|
||||
public bool ShowMergeSection =>
|
||||
_worktreePath != null || _isPlanningParent || _hasChildOutcomes;
|
||||
|
||||
public Func<DiffModalViewModel, System.Threading.Tasks.Task>? ShowDiffModal { get; set; }
|
||||
public Func<DiffViewerViewModel, System.Threading.Tasks.Task>? ShowDiffViewer { get; set; }
|
||||
public Func<MergeModalViewModel, System.Threading.Tasks.Task>? ShowMergeModal { get; set; }
|
||||
public Func<ViewModels.Planning.PlanningDiffViewModel, System.Threading.Tasks.Task>? ShowPlanningDiffModal { get; set; }
|
||||
public Func<string, string, System.Threading.Tasks.Task>? RequestConflictResolution { 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)
|
||||
{
|
||||
@@ -71,6 +78,7 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
|
||||
_worktreeStateLabel = worktreeState;
|
||||
_listWorkingDir = listWorkDir;
|
||||
OnPropertyChanged(nameof(ShowMergeSection));
|
||||
OnPropertyChanged(nameof(HasReviewableDiff));
|
||||
OpenDiffCommand.NotifyCanExecuteChanged();
|
||||
OpenWorktreeCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
@@ -81,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)
|
||||
@@ -88,6 +97,7 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
|
||||
_hasChildOutcomes = hasChildOutcomes;
|
||||
_subtaskCount = subtaskCount;
|
||||
OnPropertyChanged(nameof(ShowMergeSection));
|
||||
OnPropertyChanged(nameof(HasReviewableDiff));
|
||||
ReviewCombinedDiffCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
@@ -126,10 +136,12 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
|
||||
[RelayCommand(CanExecute = nameof(CanReviewDiff))]
|
||||
private async System.Threading.Tasks.Task ReviewCombinedDiffAsync()
|
||||
{
|
||||
if (TaskId is null || ShowPlanningDiffModal is null) return;
|
||||
var vm = new ViewModels.Planning.PlanningDiffViewModel(_worker, TaskId, SelectedMergeTarget ?? "main");
|
||||
await vm.InitializeAsync();
|
||||
await ShowPlanningDiffModal(vm);
|
||||
if (TaskId is null || ShowDiffViewer is null) return;
|
||||
var vm = _services.GetRequiredService<DiffViewerViewModel>();
|
||||
vm.ConfigurePlanning(TaskId, SelectedMergeTarget ?? "main");
|
||||
await vm.LoadAsync();
|
||||
DiffViewed?.Invoke();
|
||||
await ShowDiffViewer(vm);
|
||||
}
|
||||
|
||||
private bool CanReviewDiff() => (_isPlanningParent && _subtaskCount > 0) || _hasChildOutcomes;
|
||||
@@ -137,44 +149,29 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
|
||||
[RelayCommand(CanExecute = nameof(CanOpenDiff))]
|
||||
private async System.Threading.Tasks.Task OpenDiffAsync()
|
||||
{
|
||||
if (ShowDiffModal is null) return;
|
||||
var git = _services.GetRequiredService<ClaudeDo.Data.Git.GitService>();
|
||||
if (ShowDiffViewer is null) return;
|
||||
|
||||
var hasLiveWorktree =
|
||||
_worktreePath != null
|
||||
&& _worktreeStateLabel == "Active"
|
||||
&& System.IO.Directory.Exists(_worktreePath);
|
||||
|
||||
DiffModalViewModel diffVm;
|
||||
var vm = _services.GetRequiredService<DiffViewerViewModel>();
|
||||
if (hasLiveWorktree)
|
||||
{
|
||||
diffVm = new DiffModalViewModel(git)
|
||||
{
|
||||
WorktreePath = _worktreePath!,
|
||||
BaseRef = _worktreeBaseCommit,
|
||||
TaskId = TaskId,
|
||||
TaskTitle = TaskTitle ?? "",
|
||||
ShowMergeModal = ShowMergeModal,
|
||||
ResolveMergeVm = () => _services.GetRequiredService<MergeModalViewModel>(),
|
||||
RequestConflictResolution = RequestConflictResolution,
|
||||
};
|
||||
vm.ConfigureWorktree(_worktreePath!, _worktreeBaseCommit, TaskId, TaskTitle ?? "");
|
||||
vm.ShowMergeModal = ShowMergeModal;
|
||||
vm.ResolveMergeVm = () => _services.GetRequiredService<MergeModalViewModel>();
|
||||
}
|
||||
else if (CanDiffMergedRange)
|
||||
{
|
||||
diffVm = new DiffModalViewModel(git)
|
||||
{
|
||||
WorktreePath = _listWorkingDir!,
|
||||
BaseRef = _worktreeBaseCommit,
|
||||
HeadCommit = _worktreeHeadCommit,
|
||||
FromCommitRange = true,
|
||||
TaskId = TaskId,
|
||||
TaskTitle = TaskTitle ?? "",
|
||||
};
|
||||
vm.ConfigureCommitRange(_listWorkingDir!, _worktreeBaseCommit, _worktreeHeadCommit, TaskId, TaskTitle ?? "");
|
||||
}
|
||||
else return;
|
||||
|
||||
await diffVm.LoadAsync();
|
||||
await ShowDiffModal(diffVm);
|
||||
await vm.LoadAsync();
|
||||
DiffViewed?.Invoke();
|
||||
await ShowDiffViewer(vm);
|
||||
}
|
||||
|
||||
private bool CanDiffMergedRange =>
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using ClaudeDo.Data;
|
||||
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, IMissionControlPane, IDisposable
|
||||
{
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly IWorkerClient _worker;
|
||||
|
||||
private readonly StreamLineFormatter _formatter = new();
|
||||
private readonly StringBuilder _claudeBuf = new();
|
||||
private string? _subscribedTaskId;
|
||||
|
||||
public string? SubscribedTaskId => _subscribedTaskId;
|
||||
|
||||
public ObservableCollection<LogLineViewModel> Log { get; } = new();
|
||||
|
||||
[ObservableProperty] private string _agentState = "idle";
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(DisplayTitle))]
|
||||
private string? _title;
|
||||
|
||||
public string DisplayTitle =>
|
||||
string.IsNullOrWhiteSpace(Title) ? (SubscribedTaskId ?? "task") : Title!;
|
||||
|
||||
public string AgentStatusLabel => Loc.T($"vm.agentStatus.{AgentState}");
|
||||
public bool IsIdle => AgentState == "idle";
|
||||
public bool IsQueued => AgentState == "queued";
|
||||
public bool IsRunning => AgentState == "running";
|
||||
public bool IsWaitingForReview => AgentState == "review";
|
||||
public bool IsWaitingForChildren => AgentState == "children";
|
||||
public bool IsDone => AgentState == "done";
|
||||
public bool IsFailed => AgentState == "failed";
|
||||
public bool IsCancelled => AgentState == "cancelled";
|
||||
|
||||
public bool ShowContinue => IsFailed || IsCancelled;
|
||||
public bool ShowResetAndRetry => IsFailed || IsCancelled || IsDone;
|
||||
|
||||
public bool ShowRoadblock => IsFailed;
|
||||
public string RoadblockMessage => IsFailed ? "The session ended with an error." : "";
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ShowSessionOutcome))]
|
||||
private string? _sessionOutcome;
|
||||
|
||||
public bool ShowSessionOutcome =>
|
||||
!string.IsNullOrWhiteSpace(SessionOutcome)
|
||||
&& (IsWaitingForReview || IsDone || IsFailed || IsCancelled);
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ShowRoadblockCard))]
|
||||
[NotifyPropertyChangedFor(nameof(HasRoadblock))]
|
||||
private string? _roadblocks;
|
||||
|
||||
public bool HasRoadblock => !string.IsNullOrWhiteSpace(Roadblocks);
|
||||
|
||||
public bool ShowRoadblockCard =>
|
||||
!string.IsNullOrWhiteSpace(Roadblocks)
|
||||
&& (IsWaitingForReview || IsDone || IsFailed || IsCancelled);
|
||||
|
||||
private const string RoadblockMarker = "Roadblocks reported during the run:";
|
||||
|
||||
// Captured handler delegates for disposal
|
||||
private readonly Action<string, string> _onTaskMessage;
|
||||
private readonly Action<string, string, DateTime> _onTaskStarted;
|
||||
private readonly Action<string, string, string, DateTime> _onTaskFinished;
|
||||
private readonly Action<string> _onTaskUpdated;
|
||||
private readonly Action<string, string, string> _onTaskQuestionAsked;
|
||||
private readonly Action<string, string> _onTaskQuestionResolved;
|
||||
|
||||
// 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.
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(HasPendingQuestion))]
|
||||
[NotifyCanExecuteChangedFor(nameof(SubmitAnswerCommand))]
|
||||
private string? _pendingQuestionId;
|
||||
|
||||
[ObservableProperty] private string? _pendingQuestion;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(SubmitAnswerCommand))]
|
||||
private string _answerDraft = string.Empty;
|
||||
|
||||
public bool HasPendingQuestion => PendingQuestionId is not null;
|
||||
|
||||
public TaskMonitorViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient worker)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_worker = worker;
|
||||
|
||||
_onTaskMessage = OnTaskMessage;
|
||||
_worker.TaskMessageEvent += _onTaskMessage;
|
||||
|
||||
_onTaskStarted = (slot, taskId, startedAt) =>
|
||||
{
|
||||
if (taskId == _subscribedTaskId)
|
||||
AgentState = "running";
|
||||
};
|
||||
_worker.TaskStartedEvent += _onTaskStarted;
|
||||
|
||||
_onTaskFinished = (slot, taskId, status, finishedAt) =>
|
||||
{
|
||||
if (taskId != _subscribedTaskId) return;
|
||||
FlushClaudeBuffer();
|
||||
Log.Add(new LogLineViewModel
|
||||
{
|
||||
Kind = LogKind.Done,
|
||||
Text = $"── {status.ToUpperInvariant()} · {finishedAt.ToLocalTime():HH:mm:ss} ──",
|
||||
});
|
||||
AgentState = FinishedStatusToStateKey(status);
|
||||
ClearPendingQuestion();
|
||||
_ = RefreshOutcomeAsync(taskId);
|
||||
};
|
||||
_worker.TaskFinishedEvent += _onTaskFinished;
|
||||
|
||||
_onTaskUpdated = taskId =>
|
||||
{
|
||||
if (taskId == _subscribedTaskId)
|
||||
_ = RefreshStatusAsync(taskId);
|
||||
};
|
||||
_worker.TaskUpdatedEvent += _onTaskUpdated;
|
||||
|
||||
_onTaskQuestionAsked = (taskId, questionId, question) =>
|
||||
{
|
||||
if (taskId != _subscribedTaskId) return;
|
||||
PendingQuestionId = questionId;
|
||||
PendingQuestion = question;
|
||||
};
|
||||
_worker.TaskQuestionAskedEvent += _onTaskQuestionAsked;
|
||||
|
||||
_onTaskQuestionResolved = (taskId, questionId) =>
|
||||
{
|
||||
if (taskId == _subscribedTaskId && PendingQuestionId == questionId)
|
||||
ClearPendingQuestion();
|
||||
};
|
||||
_worker.TaskQuestionResolvedEvent += _onTaskQuestionResolved;
|
||||
}
|
||||
|
||||
// Surface a pending question (used by live event + re-attach hydration).
|
||||
public void SetPendingQuestion(string questionId, string question)
|
||||
{
|
||||
PendingQuestionId = questionId;
|
||||
PendingQuestion = question;
|
||||
}
|
||||
|
||||
private void ClearPendingQuestion()
|
||||
{
|
||||
PendingQuestionId = null;
|
||||
PendingQuestion = null;
|
||||
AnswerDraft = string.Empty;
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanSubmitAnswer))]
|
||||
private async System.Threading.Tasks.Task SubmitAnswer()
|
||||
{
|
||||
var questionId = PendingQuestionId;
|
||||
if (questionId is null || string.IsNullOrEmpty(_subscribedTaskId)) return;
|
||||
var answer = AnswerDraft;
|
||||
if (string.IsNullOrWhiteSpace(answer)) return;
|
||||
ClearPendingQuestion(); // optimistic; the resolved event also clears
|
||||
await _worker.AnswerTaskQuestionAsync(_subscribedTaskId, questionId, answer);
|
||||
}
|
||||
|
||||
private bool CanSubmitAnswer() => HasPendingQuestion && !string.IsNullOrWhiteSpace(AnswerDraft);
|
||||
|
||||
partial void OnAgentStateChanged(string value)
|
||||
{
|
||||
OnPropertyChanged(nameof(AgentStatusLabel));
|
||||
OnPropertyChanged(nameof(IsIdle));
|
||||
OnPropertyChanged(nameof(IsQueued));
|
||||
OnPropertyChanged(nameof(IsRunning));
|
||||
OnPropertyChanged(nameof(IsWaitingForReview));
|
||||
OnPropertyChanged(nameof(IsWaitingForChildren));
|
||||
OnPropertyChanged(nameof(IsDone));
|
||||
OnPropertyChanged(nameof(IsFailed));
|
||||
OnPropertyChanged(nameof(IsCancelled));
|
||||
OnPropertyChanged(nameof(ShowContinue));
|
||||
OnPropertyChanged(nameof(ShowResetAndRetry));
|
||||
OnPropertyChanged(nameof(ShowRoadblock));
|
||||
OnPropertyChanged(nameof(RoadblockMessage));
|
||||
OnPropertyChanged(nameof(ShowSessionOutcome));
|
||||
OnPropertyChanged(nameof(ShowRoadblockCard));
|
||||
}
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
Log.Clear();
|
||||
_claudeBuf.Clear();
|
||||
_subscribedTaskId = null;
|
||||
AgentState = "idle";
|
||||
SessionOutcome = null;
|
||||
Roadblocks = null;
|
||||
ClearPendingQuestion();
|
||||
}
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(DetachTooltip))]
|
||||
private bool _isDetached;
|
||||
|
||||
// Localized tooltip for the detach/re-dock toggle button.
|
||||
public string DetachTooltip => Loc.T(IsDetached ? "missionControl.redock" : "missionControl.detach");
|
||||
|
||||
// Set by the detached window so the re-dock action can close it.
|
||||
public Action? CloseWindowRequested { get; set; }
|
||||
|
||||
// Set by the host (e.g. Mission Control) to navigate the main app to this task.
|
||||
public Action<string>? OpenInAppRequested { get; set; }
|
||||
|
||||
// Set by the host (Mission Control) to pop this monitor out into its own window.
|
||||
public Action<TaskMonitorViewModel>? DetachRequested { get; set; }
|
||||
|
||||
[RelayCommand]
|
||||
private void Detach()
|
||||
{
|
||||
if (IsDetached) CloseWindowRequested?.Invoke(); // re-dock: close the detached window
|
||||
else DetachRequested?.Invoke(this); // detach: pop out to its own window
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenInApp()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_subscribedTaskId))
|
||||
OpenInAppRequested?.Invoke(_subscribedTaskId);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async System.Threading.Tasks.Task CancelTask()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_subscribedTaskId) && (IsRunning || IsQueued))
|
||||
await _worker.CancelTaskAsync(_subscribedTaskId);
|
||||
}
|
||||
|
||||
public void SetTaskId(string id) => _subscribedTaskId = id;
|
||||
|
||||
public void ApplyState(ClaudeDo.Data.Models.TaskStatus status) =>
|
||||
AgentState = StatusToStateKey(status);
|
||||
|
||||
public void ApplyOutcome(string? result, string? errorFallback)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(result))
|
||||
{
|
||||
SessionOutcome = errorFallback;
|
||||
Roadblocks = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var idx = result.IndexOf(RoadblockMarker, StringComparison.Ordinal);
|
||||
if (idx < 0)
|
||||
{
|
||||
SessionOutcome = UnwrapSummary(result);
|
||||
Roadblocks = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var summary = result[..idx].TrimEnd().TrimEnd('⚠').TrimEnd();
|
||||
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;
|
||||
var expanded = ExpandUserPath(logPath);
|
||||
if (!System.IO.File.Exists(expanded)) return;
|
||||
|
||||
try
|
||||
{
|
||||
const int maxLines = 2000;
|
||||
string[] all;
|
||||
await using (var fs = new System.IO.FileStream(
|
||||
expanded,
|
||||
System.IO.FileMode.Open,
|
||||
System.IO.FileAccess.Read,
|
||||
System.IO.FileShare.ReadWrite | System.IO.FileShare.Delete))
|
||||
using (var reader = new System.IO.StreamReader(fs))
|
||||
{
|
||||
var list = new List<string>();
|
||||
while (await reader.ReadLineAsync(ct) is { } line)
|
||||
list.Add(line);
|
||||
all = list.ToArray();
|
||||
}
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var start = Math.Max(0, all.Length - maxLines);
|
||||
for (int i = start; i < all.Length; i++)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
if (_subscribedTaskId is null) return;
|
||||
var line = all[i];
|
||||
var normalized = line.StartsWith("[", StringComparison.Ordinal) ? line : "[stdout] " + line;
|
||||
OnTaskMessage(_subscribedTaskId, normalized);
|
||||
}
|
||||
FlushClaudeBuffer();
|
||||
}
|
||||
catch (OperationCanceledException) { throw; }
|
||||
catch { /* best-effort replay */ }
|
||||
}
|
||||
|
||||
private void OnTaskMessage(string taskId, string line)
|
||||
{
|
||||
if (taskId != _subscribedTaskId) return;
|
||||
|
||||
if (line.StartsWith("[stdout]", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var body = line["[stdout]".Length..].TrimStart();
|
||||
AppendStdoutLine(body);
|
||||
return;
|
||||
}
|
||||
|
||||
FlushClaudeBuffer();
|
||||
|
||||
var kind = line.StartsWith("[sys]", StringComparison.OrdinalIgnoreCase) ? LogKind.Sys
|
||||
: line.StartsWith("[tool]", StringComparison.OrdinalIgnoreCase) ? LogKind.Tool
|
||||
: line.StartsWith("[claude]", StringComparison.OrdinalIgnoreCase) ? LogKind.Claude
|
||||
: line.StartsWith("[stderr]", StringComparison.OrdinalIgnoreCase) ? LogKind.Stderr
|
||||
: line.StartsWith("[done]", StringComparison.OrdinalIgnoreCase) ? LogKind.Done
|
||||
: LogKind.Msg;
|
||||
Log.Add(new LogLineViewModel { Kind = kind, Text = line });
|
||||
}
|
||||
|
||||
private void AppendStdoutLine(string line)
|
||||
{
|
||||
var formatted = _formatter.FormatLine(line);
|
||||
if (formatted is null) return;
|
||||
_claudeBuf.Append(formatted);
|
||||
while (true)
|
||||
{
|
||||
var text = _claudeBuf.ToString();
|
||||
var nl = text.IndexOf('\n');
|
||||
if (nl < 0) break;
|
||||
var piece = text[..nl].TrimEnd('\r');
|
||||
if (!string.IsNullOrWhiteSpace(piece))
|
||||
Log.Add(new LogLineViewModel { Kind = LogKind.Claude, Text = piece });
|
||||
_claudeBuf.Clear();
|
||||
_claudeBuf.Append(text[(nl + 1)..]);
|
||||
}
|
||||
}
|
||||
|
||||
private void FlushClaudeBuffer()
|
||||
{
|
||||
if (_claudeBuf.Length == 0) return;
|
||||
var piece = _claudeBuf.ToString().TrimEnd();
|
||||
_claudeBuf.Clear();
|
||||
if (!string.IsNullOrWhiteSpace(piece))
|
||||
Log.Add(new LogLineViewModel { Kind = LogKind.Claude, Text = piece });
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task RefreshStatusAsync(string taskId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
var entity = await ctx.Tasks
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(t => t.Id == taskId);
|
||||
if (entity is null || _subscribedTaskId != taskId) return;
|
||||
|
||||
AgentState = StatusToStateKey(entity.Status);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task RefreshOutcomeAsync(string taskId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
var entity = await ctx.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId);
|
||||
var latestRun = await new TaskRunRepository(ctx).GetLatestByTaskIdAsync(taskId);
|
||||
if (_subscribedTaskId != taskId) return;
|
||||
ApplyOutcome(entity?.Result, latestRun?.ErrorMarkdown);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
internal static string StatusToStateKey(ClaudeDo.Data.Models.TaskStatus status) => status switch
|
||||
{
|
||||
ClaudeDo.Data.Models.TaskStatus.Queued => "queued",
|
||||
ClaudeDo.Data.Models.TaskStatus.Running => "running",
|
||||
ClaudeDo.Data.Models.TaskStatus.WaitingForReview => "review",
|
||||
ClaudeDo.Data.Models.TaskStatus.WaitingForChildren => "children",
|
||||
ClaudeDo.Data.Models.TaskStatus.Done => "done",
|
||||
ClaudeDo.Data.Models.TaskStatus.Failed => "failed",
|
||||
ClaudeDo.Data.Models.TaskStatus.Cancelled => "cancelled",
|
||||
_ => "idle",
|
||||
};
|
||||
|
||||
internal static string FinishedStatusToStateKey(string status) => status switch
|
||||
{
|
||||
"done" => "done",
|
||||
"failed" => "failed",
|
||||
"cancelled" => "cancelled",
|
||||
"waiting_for_review" => "review",
|
||||
"waiting_for_children" => "children",
|
||||
_ => status.ToLowerInvariant(),
|
||||
};
|
||||
|
||||
private static string ExpandUserPath(string path)
|
||||
{
|
||||
if (path.StartsWith("~/", StringComparison.Ordinal) || path.StartsWith("~\\", StringComparison.Ordinal))
|
||||
return System.IO.Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
path[2..]);
|
||||
if (path == "~")
|
||||
return Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
return path;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_worker.TaskMessageEvent -= _onTaskMessage;
|
||||
_worker.TaskStartedEvent -= _onTaskStarted;
|
||||
_worker.TaskFinishedEvent -= _onTaskFinished;
|
||||
_worker.TaskUpdatedEvent -= _onTaskUpdated;
|
||||
_worker.TaskQuestionAskedEvent -= _onTaskQuestionAsked;
|
||||
_worker.TaskQuestionResolvedEvent -= _onTaskQuestionResolved;
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
[ObservableProperty] private bool _parentInView = true;
|
||||
[ObservableProperty] private int _roadblockCount;
|
||||
[ObservableProperty] private bool _isRefining;
|
||||
// Set by the custom drag while this row is being dragged — drives the "grabbed" row style.
|
||||
[ObservableProperty] private bool _isDragging;
|
||||
|
||||
public bool CanRefine => Status == TaskStatus.Idle && PlanningPhase == PlanningPhase.None && !IsRefining;
|
||||
|
||||
@@ -61,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"),
|
||||
@@ -121,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",
|
||||
@@ -137,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));
|
||||
@@ -181,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));
|
||||
|
||||
@@ -28,6 +28,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
public event EventHandler? TasksChanged;
|
||||
public event Action? NotesRequested;
|
||||
public event Action? PrepRequested;
|
||||
public event Action<string>? ErrorReported;
|
||||
public void RequestFocusAddTask() => FocusAddTaskRequested?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
[RelayCommand]
|
||||
@@ -69,6 +70,8 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
[ObservableProperty] private bool _showNotesRow;
|
||||
[ObservableProperty] private bool _isMyDayList;
|
||||
|
||||
internal Task? LoadTask { get; private set; }
|
||||
|
||||
public Func<UnfinishedPlanningModalViewModel, Task>? ShowUnfinishedPlanningModal { get; set; }
|
||||
|
||||
private readonly EventHandler _langChangedHandler;
|
||||
@@ -148,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)
|
||||
@@ -219,14 +234,14 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
HasCompleted = false;
|
||||
ShowOpenLabel = false;
|
||||
ShowNotesRow = false;
|
||||
if (list is null) return;
|
||||
if (list is null) { LoadTask = Task.CompletedTask; return; }
|
||||
|
||||
HeaderTitle = list.Name;
|
||||
HeaderEyebrow = DateTime.Now.ToString("dddd · MMM dd", CultureInfo.InvariantCulture).ToUpperInvariant();
|
||||
ShowNotesRow = list.Id == "smart:my-day";
|
||||
IsMyDayList = list.Id == "smart:my-day";
|
||||
|
||||
_ = LoadForListAsync(list, ct);
|
||||
LoadTask = LoadForListAsync(list, ct);
|
||||
}
|
||||
|
||||
private async Task LoadForListAsync(ListNavItemViewModel list, CancellationToken ct)
|
||||
@@ -300,27 +315,17 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
|
||||
internal void Regroup()
|
||||
{
|
||||
OverdueItems.Clear();
|
||||
OpenItems.Clear();
|
||||
CompletedItems.Clear();
|
||||
|
||||
// Auto-collapse planning parents whose every child is Done (unless the user
|
||||
// has explicitly toggled the row — saved state wins).
|
||||
// Collapse parents that have children by default, so subtasks stay tucked away until
|
||||
// the user expands the row (an explicit toggle is saved and wins over this default).
|
||||
var childrenByParent = Items
|
||||
.Where(r => r.IsChild && !string.IsNullOrEmpty(r.ParentTaskId))
|
||||
.GroupBy(r => r.ParentTaskId!)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
foreach (var parent in Items.Where(r => r.IsPlanningParent && !r.IsChild
|
||||
&& r.PlanningPhase == PlanningPhase.Finalized
|
||||
&& !r.Done))
|
||||
foreach (var parent in Items.Where(r => r.IsPlanningParent && !r.IsChild && !r.Done))
|
||||
{
|
||||
if (_expandedState.ContainsKey(parent.Id)) continue;
|
||||
if (childrenByParent.TryGetValue(parent.Id, out var kids)
|
||||
&& kids.Count > 0
|
||||
&& kids.All(c => c.Status == TaskStatus.Done))
|
||||
{
|
||||
if (childrenByParent.TryGetValue(parent.Id, out var kids) && kids.Count > 0)
|
||||
parent.IsExpanded = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Restore IsExpanded from saved state
|
||||
@@ -361,19 +366,29 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
}
|
||||
|
||||
var today = DateTime.Today;
|
||||
var overdue = new List<TaskRowViewModel>();
|
||||
var open = new List<TaskRowViewModel>();
|
||||
var completed = new List<TaskRowViewModel>();
|
||||
foreach (var r in flat)
|
||||
{
|
||||
var underOpenPlanningParent = r.IsChild &&
|
||||
flat.Any(p => p.Id == r.ParentTaskId && p.IsPlanningParent && !p.Done);
|
||||
|
||||
if (r.Done && !underOpenPlanningParent)
|
||||
CompletedItems.Add(r);
|
||||
completed.Add(r);
|
||||
else if (r.ScheduledFor is { } d && d.Date < today)
|
||||
OverdueItems.Add(r);
|
||||
overdue.Add(r);
|
||||
else
|
||||
OpenItems.Add(r);
|
||||
open.Add(r);
|
||||
}
|
||||
|
||||
// Reconcile the bound collections in place (granular Insert/Move/Remove) rather than
|
||||
// Clear+Add, so toggling a parent only touches its own child rows — the ItemsControl
|
||||
// keeps every unchanged container instead of tearing the whole list down on a Reset.
|
||||
SyncCollection(OverdueItems, overdue);
|
||||
SyncCollection(OpenItems, open);
|
||||
SyncCollection(CompletedItems, completed);
|
||||
|
||||
HasOverdue = OverdueItems.Count > 0;
|
||||
HasOpen = OpenItems.Count > 0;
|
||||
HasCompleted = CompletedItems.Count > 0;
|
||||
@@ -408,20 +423,15 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
if (string.IsNullOrWhiteSpace(NewTaskTitle) || _currentList?.Kind != ListKind.User) return;
|
||||
var listId = _currentList.Id["user:".Length..];
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
var maxSort = await db.Tasks
|
||||
.Where(t => t.ListId == listId)
|
||||
.Select(t => (int?)t.SortOrder)
|
||||
.MaxAsync();
|
||||
var entity = new TaskEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
ListId = listId,
|
||||
Title = NewTaskTitle.Trim(),
|
||||
Status = TaskStatus.Idle,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
SortOrder = (maxSort ?? -1) + 1,
|
||||
};
|
||||
db.Tasks.Add(entity);
|
||||
await db.SaveChangesAsync();
|
||||
await new TaskRepository(db).AddAsync(entity);
|
||||
var row = TaskRowViewModel.FromEntity(entity);
|
||||
row.ShowListChip = _currentList?.Kind == ListKind.Virtual;
|
||||
Items.Add(row);
|
||||
@@ -504,6 +514,28 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
coll.Move(srcIdx, finalIdx);
|
||||
}
|
||||
|
||||
// Reconcile a bound collection toward a target order using granular Remove/Move/Insert,
|
||||
// so unchanged rows keep their containers (no Reset-driven full re-render).
|
||||
private static void SyncCollection(
|
||||
System.Collections.ObjectModel.ObservableCollection<TaskRowViewModel> dst,
|
||||
List<TaskRowViewModel> target)
|
||||
{
|
||||
var keep = new HashSet<TaskRowViewModel>(target);
|
||||
for (int i = dst.Count - 1; i >= 0; i--)
|
||||
if (!keep.Contains(dst[i]))
|
||||
dst.RemoveAt(i);
|
||||
|
||||
for (int i = 0; i < target.Count; i++)
|
||||
{
|
||||
var item = target[i];
|
||||
if (i < dst.Count && ReferenceEquals(dst[i], item)) continue;
|
||||
|
||||
var cur = dst.IndexOf(item);
|
||||
if (cur >= 0) dst.Move(cur, i);
|
||||
else dst.Insert(i, item);
|
||||
}
|
||||
}
|
||||
|
||||
private System.Collections.ObjectModel.ObservableCollection<TaskRowViewModel>? SectionFor(TaskRowViewModel row)
|
||||
{
|
||||
if (OverdueItems.Contains(row)) return OverdueItems;
|
||||
@@ -712,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)
|
||||
@@ -760,6 +792,18 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
[RelayCommand]
|
||||
private void Select(TaskRowViewModel row) => SelectedTask = row;
|
||||
|
||||
public async System.Threading.Tasks.Task<bool> SelectByIdAsync(string taskId)
|
||||
{
|
||||
if (LoadTask is { } lt)
|
||||
{
|
||||
try { await lt; } catch { /* load cancelled/failed — fall through */ }
|
||||
}
|
||||
var row = Items.FirstOrDefault(r => r.Id == taskId);
|
||||
if (row is null) return false;
|
||||
SelectedTask = row;
|
||||
return true;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ToggleShowCompleted() => IsShowingCompleted = !IsShowingCompleted;
|
||||
|
||||
@@ -769,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 { }
|
||||
// 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 { }
|
||||
try { await _worker.ResumeTaskInTerminalAsync(row.Id); }
|
||||
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.pickUpInTerminalFailed", ex.Message)); }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
@@ -810,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);
|
||||
@@ -824,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]
|
||||
|
||||
@@ -10,7 +10,6 @@ using ClaudeDo.Ui.Localization;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
using ClaudeDo.Ui.ViewModels.Modals;
|
||||
using ClaudeDo.Ui.ViewModels.Planning;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels;
|
||||
@@ -21,6 +20,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
public TasksIslandViewModel? Tasks { get; }
|
||||
public DetailsIslandViewModel? Details { get; }
|
||||
public IWorkerClient? Worker { get; }
|
||||
public MissionControlViewModel? MissionControl { get; }
|
||||
public UpdateCheckService UpdateCheck => _updateCheck;
|
||||
|
||||
public string ConnectionText =>
|
||||
@@ -41,38 +41,62 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
|
||||
public Func<MergeModalViewModel> ResolveMergeVm => _mergeVmFactory;
|
||||
|
||||
// Layer C seam: composition root sets the factory; MainWindow sets the dialog opener.
|
||||
// The integrator connects Layer A/B's RequestConflictResolution(taskId, target) to this method.
|
||||
// Layer C seam: composition root sets the factory; the dialog service shows the resolver.
|
||||
public Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>? ConflictResolverFactory { get; set; }
|
||||
public Func<ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel, Task>? ShowConflictResolver { get; set; }
|
||||
|
||||
// Set by MainWindow so a reveal can bring the main window to the foreground.
|
||||
public Action? BringToFront { get; set; }
|
||||
|
||||
// Single dialog seam (set by MainWindow); propagated to the lists island.
|
||||
private IDialogService? _dialogs;
|
||||
public IDialogService? Dialogs
|
||||
{
|
||||
get => _dialogs;
|
||||
set
|
||||
{
|
||||
_dialogs = value;
|
||||
if (Lists is not null) Lists.Dialogs = value;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RevealTaskAsync(string taskId)
|
||||
{
|
||||
if (Tasks is null || Lists is null) { BringToFront?.Invoke(); return; }
|
||||
|
||||
string? listId = null;
|
||||
if (_dbFactory is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
var entity = await ctx.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId);
|
||||
listId = entity?.ListId;
|
||||
}
|
||||
catch { /* best-effort list resolution */ }
|
||||
}
|
||||
|
||||
if (listId is not null)
|
||||
{
|
||||
var navItem = Lists.Items.FirstOrDefault(i => i.Id == $"user:{listId}");
|
||||
if (navItem is not null && !ReferenceEquals(Lists.SelectedList, navItem))
|
||||
Lists.SelectedList = navItem; // raises SelectionChanged → Tasks.LoadForList
|
||||
}
|
||||
|
||||
await Tasks.SelectByIdAsync(taskId);
|
||||
BringToFront?.Invoke();
|
||||
}
|
||||
|
||||
public async Task RequestConflictResolutionAsync(string taskId, string targetBranch)
|
||||
{
|
||||
if (ConflictResolverFactory is null || ShowConflictResolver is null) return;
|
||||
if (ConflictResolverFactory is null || Dialogs is null) return;
|
||||
var vm = ConflictResolverFactory(taskId);
|
||||
var hasConflicts = await vm.OpenAsync(targetBranch);
|
||||
if (hasConflicts)
|
||||
await ShowConflictResolver(vm);
|
||||
await Dialogs.ShowConflictResolverAsync(vm);
|
||||
}
|
||||
|
||||
// Set by MainWindow to open the About dialog.
|
||||
public Func<AboutModalViewModel, Task>? ShowAboutModal { get; set; }
|
||||
|
||||
// Set by MainWindow to open the repo-import dialog.
|
||||
public Func<RepoImportModalViewModel, Task>? ShowRepoImportModal { get; set; }
|
||||
|
||||
// Set by MainWindow to open the global worktrees overview dialog.
|
||||
public Func<WorktreesOverviewModalViewModel, Task>? ShowWorktreesOverviewModal { get; set; }
|
||||
|
||||
// Set by MainWindow to open the weekly report dialog.
|
||||
public Func<WeeklyReportModalViewModel, Task>? ShowWeeklyReportModal { get; set; }
|
||||
|
||||
// Set by MainWindow to open the worker-connection help dialog.
|
||||
public Func<WorkerConnectionModalViewModel, Task>? ShowWorkerConnectionModal { get; set; }
|
||||
|
||||
[ObservableProperty] private bool _isUpdateBannerVisible;
|
||||
[ObservableProperty] private string? _updateBannerLatestVersion;
|
||||
[ObservableProperty] private string? _inlineUpdateStatus;
|
||||
private bool _bannerDismissedThisSession;
|
||||
|
||||
[ObservableProperty]
|
||||
@@ -130,6 +154,17 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
WorkerLogText = null;
|
||||
}
|
||||
|
||||
// Surfaces a UI-originated failure in the footer status strip (same line as the
|
||||
// worker log), color-coded as an error and auto-cleared by _clearTimer.
|
||||
public void FlashFooterError(string message)
|
||||
{
|
||||
WorkerLogText = $"{DateTime.Now:HH:mm} · {message}";
|
||||
WorkerLogLevel = WorkerLogLevel.Error;
|
||||
IsWorkerLogVisible = true;
|
||||
_clearTimer.Stop();
|
||||
_clearTimer.Start();
|
||||
}
|
||||
|
||||
private void OnPrimeFired(PrimeFiredEvent evt)
|
||||
{
|
||||
var when = evt.FiredAt.LocalDateTime.ToString("HH:mm");
|
||||
@@ -149,11 +184,11 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
|
||||
private async Task OpenPlanningConflictAsync(string planningTaskId, string subtaskId)
|
||||
{
|
||||
if (ConflictResolverFactory is null || ShowConflictResolver is null) return;
|
||||
if (ConflictResolverFactory is null || Dialogs is null) return;
|
||||
var vm = ConflictResolverFactory(subtaskId);
|
||||
var hasConflicts = await vm.OpenForPlanningAsync(planningTaskId, subtaskId);
|
||||
if (hasConflicts)
|
||||
await ShowConflictResolver(vm);
|
||||
await Dialogs.ShowConflictResolverAsync(vm);
|
||||
}
|
||||
|
||||
// For tests only — does NOT wire up events.
|
||||
@@ -171,9 +206,15 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
Func<WorktreesOverviewModalViewModel> worktreesOverviewVmFactory,
|
||||
Func<WeeklyReportModalViewModel> weeklyReportVmFactory,
|
||||
Func<MergeModalViewModel> mergeVmFactory,
|
||||
Func<RepoImportModalViewModel> repoImportVmFactory)
|
||||
Func<RepoImportModalViewModel> repoImportVmFactory,
|
||||
MissionControlViewModel missionControl)
|
||||
{
|
||||
Lists = lists; Tasks = tasks; Details = details; Worker = worker;
|
||||
MissionControl = missionControl;
|
||||
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;
|
||||
@@ -186,6 +227,17 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask);
|
||||
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);
|
||||
};
|
||||
Tasks.TasksChanged += (_, _) => _ = Lists.RefreshCountsAsync();
|
||||
Tasks.OpenListSettingsRequested += (_, _) =>
|
||||
{
|
||||
@@ -199,7 +251,6 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
_ = Lists.RefreshCountsAsync();
|
||||
return System.Threading.Tasks.Task.CompletedTask;
|
||||
};
|
||||
Details.RequestConflictResolution = RequestConflictResolutionAsync;
|
||||
Worker.PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.PropertyName is nameof(IWorkerClient.IsConnected) or nameof(IWorkerClient.IsReconnecting))
|
||||
@@ -258,30 +309,36 @@ 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)
|
||||
[RelayCommand]
|
||||
private void OpenMissionControl()
|
||||
{
|
||||
InlineUpdateStatus = text;
|
||||
await Task.Delay(3000);
|
||||
if (InlineUpdateStatus == text) InlineUpdateStatus = null;
|
||||
if (Dialogs is not null && MissionControl is not null)
|
||||
Dialogs.ShowMissionControl(MissionControl);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task OpenAbout()
|
||||
{
|
||||
var vm = new AboutModalViewModel();
|
||||
if (ShowAboutModal is not null) await ShowAboutModal(vm);
|
||||
if (Dialogs is not null) await Dialogs.ShowAboutAsync(vm);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task OpenLogVisualizer()
|
||||
{
|
||||
if (Dialogs is null || Worker is null) return;
|
||||
var vm = new LogVisualizerViewModel(Worker);
|
||||
await vm.RefreshAsync();
|
||||
await Dialogs.ShowLogVisualizerAsync(vm);
|
||||
}
|
||||
|
||||
private bool _connectionPromptShown;
|
||||
@@ -297,7 +354,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
private async Task OpenWorkerConnectionHelpAsync()
|
||||
{
|
||||
var vm = new WorkerConnectionModalViewModel(_workerLocator, _installerLocator);
|
||||
if (ShowWorkerConnectionModal is not null) await ShowWorkerConnectionModal(vm);
|
||||
if (Dialogs is not null) await Dialogs.ShowWorkerConnectionAsync(vm);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
@@ -306,10 +363,10 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
[RelayCommand]
|
||||
private async Task OpenRepoImport()
|
||||
{
|
||||
if (ShowRepoImportModal is null || _repoImportVmFactory is null) return;
|
||||
if (Dialogs is null || _repoImportVmFactory is null) return;
|
||||
var vm = _repoImportVmFactory();
|
||||
await vm.LoadAsync();
|
||||
await ShowRepoImportModal(vm);
|
||||
await Dialogs.ShowRepoImportAsync(vm);
|
||||
if (Lists is not null) await Lists.LoadAsync();
|
||||
}
|
||||
|
||||
@@ -318,14 +375,14 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
[RelayCommand]
|
||||
private async Task OpenWorktreesOverviewGlobalAsync()
|
||||
{
|
||||
if (ShowWorktreesOverviewModal is null || _worktreesOverviewOpen) return;
|
||||
if (Dialogs is null || _worktreesOverviewOpen) return;
|
||||
_worktreesOverviewOpen = true;
|
||||
try
|
||||
{
|
||||
var vm = _worktreesOverviewVmFactory();
|
||||
vm.Configure(null, null);
|
||||
await vm.LoadAsync();
|
||||
await ShowWorktreesOverviewModal(vm);
|
||||
await Dialogs.ShowWorktreesOverviewAsync(vm);
|
||||
}
|
||||
finally { _worktreesOverviewOpen = false; }
|
||||
}
|
||||
@@ -335,13 +392,13 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
[RelayCommand]
|
||||
private async Task OpenWeeklyReport()
|
||||
{
|
||||
if (ShowWeeklyReportModal is null || _weeklyReportOpen) return;
|
||||
if (Dialogs is null || _weeklyReportOpen) return;
|
||||
_weeklyReportOpen = true;
|
||||
try
|
||||
{
|
||||
var vm = _weeklyReportVmFactory();
|
||||
await vm.InitializeAsync();
|
||||
await ShowWeeklyReportModal(vm);
|
||||
await Dialogs.ShowWeeklyReportAsync(vm);
|
||||
}
|
||||
finally { _weeklyReportOpen = false; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
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;
|
||||
|
||||
public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly IWorkerClient _worker;
|
||||
private readonly Action<string, string, DateTime> _onTaskStarted;
|
||||
private readonly Action<string, string, string, DateTime> _onTaskFinished;
|
||||
private readonly Action<string> _onTaskUpdated;
|
||||
private readonly Action _onConnectionRestored;
|
||||
|
||||
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
|
||||
{
|
||||
get => _openInApp;
|
||||
set
|
||||
{
|
||||
_openInApp = value;
|
||||
foreach (var m in Monitors) m.OpenInAppRequested = value;
|
||||
}
|
||||
}
|
||||
|
||||
// View-layer seam: show a detached monitor in its own window. Second arg is the re-dock callback
|
||||
// invoked when that window closes.
|
||||
public Action<TaskMonitorViewModel, Action>? ShowDetached { get; set; }
|
||||
|
||||
// View-layer seam: open the app Settings modal from the Mission Control window.
|
||||
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();
|
||||
public bool HasQueued => Queued.Count > 0;
|
||||
|
||||
public MissionControlViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient worker)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_worker = worker;
|
||||
|
||||
Monitors.CollectionChanged += OnMonitorsChanged;
|
||||
ConPtySessions.CollectionChanged += OnConPtySessionsChanged;
|
||||
Panes.CollectionChanged += OnPanesChanged;
|
||||
|
||||
_onTaskStarted = (slot, taskId, startedAt) => { EnsureMonitor(taskId); _ = RefreshQueueAsync(); };
|
||||
_worker.TaskStartedEvent += _onTaskStarted;
|
||||
|
||||
_onTaskFinished = (slot, taskId, status, finishedAt) => _ = RefreshQueueAsync();
|
||||
_worker.TaskFinishedEvent += _onTaskFinished;
|
||||
|
||||
_onTaskUpdated = taskId => _ = RefreshQueueAsync();
|
||||
_worker.TaskUpdatedEvent += _onTaskUpdated;
|
||||
|
||||
_onConnectionRestored = () => { SeedActive(); _ = RefreshQueueAsync(); };
|
||||
_worker.ConnectionRestoredEvent += _onConnectionRestored;
|
||||
|
||||
SeedActive();
|
||||
_ = RefreshQueueAsync();
|
||||
}
|
||||
|
||||
internal async System.Threading.Tasks.Task RefreshQueueAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
var rows = await ctx.Tasks.AsNoTracking()
|
||||
.Where(t => t.Status == ClaudeDo.Data.Models.TaskStatus.Queued)
|
||||
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
||||
.Select(t => new { t.Id, t.Title, t.BlockedByTaskId })
|
||||
.ToListAsync();
|
||||
|
||||
Queued.Clear();
|
||||
foreach (var r in rows)
|
||||
Queued.Add(new QueuedTaskViewModel
|
||||
{
|
||||
Id = r.Id,
|
||||
Title = r.Title ?? string.Empty,
|
||||
IsBlocked = r.BlockedByTaskId != null,
|
||||
});
|
||||
OnPropertyChanged(nameof(HasQueued));
|
||||
}
|
||||
catch { /* best-effort queue refresh */ }
|
||||
}
|
||||
|
||||
// Drop-to-queue: a task dragged from the main app onto Mission Control gets queued.
|
||||
public async System.Threading.Tasks.Task EnqueueTaskAsync(string taskId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(taskId)) return;
|
||||
try
|
||||
{
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == taskId);
|
||||
if (entity is null
|
||||
|| entity.Status == ClaudeDo.Data.Models.TaskStatus.Running
|
||||
|| entity.Status == ClaudeDo.Data.Models.TaskStatus.Queued)
|
||||
return;
|
||||
entity.Status = ClaudeDo.Data.Models.TaskStatus.Queued;
|
||||
await db.SaveChangesAsync();
|
||||
await _worker.WakeQueueAsync();
|
||||
}
|
||||
catch { /* best-effort enqueue */ }
|
||||
await RefreshQueueAsync();
|
||||
}
|
||||
|
||||
private void SeedActive()
|
||||
{
|
||||
foreach (var a in _worker.GetActiveTasks())
|
||||
EnsureMonitor(a.TaskId);
|
||||
}
|
||||
|
||||
private void EnsureMonitor(string taskId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(taskId)) return;
|
||||
if (Monitors.Any(m => m.SubscribedTaskId == taskId)) return;
|
||||
|
||||
var monitor = new TaskMonitorViewModel(_dbFactory, _worker);
|
||||
monitor.SetTaskId(taskId);
|
||||
monitor.OpenInAppRequested = _openInApp;
|
||||
monitor.DetachRequested = Detach;
|
||||
Monitors.Add(monitor);
|
||||
_ = HydrateAsync(monitor, taskId);
|
||||
}
|
||||
|
||||
private void Detach(TaskMonitorViewModel monitor)
|
||||
{
|
||||
if (!Monitors.Contains(monitor)) return;
|
||||
monitor.IsDetached = true;
|
||||
Monitors.Remove(monitor); // drop from grid — do NOT dispose; it keeps streaming
|
||||
ShowDetached?.Invoke(monitor, () => ReDock(monitor));
|
||||
}
|
||||
|
||||
private void ReDock(TaskMonitorViewModel monitor)
|
||||
{
|
||||
monitor.IsDetached = false;
|
||||
if (!Monitors.Contains(monitor) && monitor.SubscribedTaskId is not null)
|
||||
Monitors.Add(monitor); // back into the grid
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task HydrateAsync(TaskMonitorViewModel monitor, string taskId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
var entity = await ctx.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId);
|
||||
if (entity is null || monitor.SubscribedTaskId != taskId) return;
|
||||
monitor.ApplyState(entity.Status);
|
||||
monitor.Title = entity.Title;
|
||||
var latestRun = await new TaskRunRepository(ctx).GetLatestByTaskIdAsync(taskId);
|
||||
monitor.ApplyOutcome(entity.Result, latestRun?.ErrorMarkdown);
|
||||
await monitor.ReplayLogFileAsync(entity.LogPath, CancellationToken.None);
|
||||
|
||||
// Re-attach: if the task is blocked on an AskUser question right now, surface it.
|
||||
var pending = await _worker.GetPendingQuestionAsync(taskId);
|
||||
if (pending is not null && monitor.SubscribedTaskId == taskId)
|
||||
monitor.SetPendingQuestion(pending.QuestionId, pending.Question);
|
||||
}
|
||||
catch { /* best-effort hydrate */ }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ClearFinished()
|
||||
{
|
||||
foreach (var m in Monitors.Where(m => m.IsDone || m.IsFailed || m.IsCancelled || m.IsWaitingForReview).ToList())
|
||||
{
|
||||
Monitors.Remove(m);
|
||||
m.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenSettings() => OpenSettingsRequested?.Invoke();
|
||||
|
||||
[RelayCommand]
|
||||
private void ToggleLayout() => IsFocusMode = !IsFocusMode;
|
||||
|
||||
public void MoveMonitor(TaskMonitorViewModel dragged, TaskMonitorViewModel target)
|
||||
{
|
||||
if (ReferenceEquals(dragged, target)) return;
|
||||
var from = Monitors.IndexOf(dragged);
|
||||
var to = Monitors.IndexOf(target);
|
||||
if (from < 0 || to < 0) return;
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
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(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()
|
||||
{
|
||||
_worker.TaskStartedEvent -= _onTaskStarted;
|
||||
_worker.TaskFinishedEvent -= _onTaskFinished;
|
||||
_worker.TaskUpdatedEvent -= _onTaskUpdated;
|
||||
_worker.ConnectionRestoredEvent -= _onConnectionRestored;
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Read-only display row for a queued task in the Mission Control side strip.</summary>
|
||||
public sealed class QueuedTaskViewModel
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Title { get; init; }
|
||||
public bool IsBlocked { get; init; }
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using ClaudeDo.Data.Git;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Modals;
|
||||
|
||||
public enum DiffLineKind { Add, Del, Ctx, File }
|
||||
|
||||
public enum DiffFileStatus { Modified, Added, Deleted, Renamed }
|
||||
|
||||
public sealed class DiffLineViewModel
|
||||
{
|
||||
public required DiffLineKind Kind { get; init; }
|
||||
public int? OldNo { get; init; }
|
||||
public int? NewNo { get; init; }
|
||||
public required string Text { get; init; }
|
||||
public string ClassName => Kind switch
|
||||
{
|
||||
DiffLineKind.Add => "add",
|
||||
DiffLineKind.Del => "del",
|
||||
DiffLineKind.File => "file",
|
||||
_ => "ctx",
|
||||
};
|
||||
|
||||
public string Sign => Kind switch
|
||||
{
|
||||
DiffLineKind.Add => "+",
|
||||
DiffLineKind.Del => "-",
|
||||
_ => " ",
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class DiffFileViewModel
|
||||
{
|
||||
public required string Path { get; set; }
|
||||
public string? OldPath { get; set; }
|
||||
public DiffFileStatus Status { get; set; } = DiffFileStatus.Modified;
|
||||
public bool IsBinary { get; set; }
|
||||
public int Additions { get; set; }
|
||||
public int Deletions { get; set; }
|
||||
public ObservableCollection<DiffLineViewModel> Lines { get; } = new();
|
||||
|
||||
/// Single-letter badge for the file's change kind (A/M/D/R).
|
||||
public string StatusCode => Status switch
|
||||
{
|
||||
DiffFileStatus.Added => "A",
|
||||
DiffFileStatus.Deleted => "D",
|
||||
DiffFileStatus.Renamed => "R",
|
||||
_ => "M",
|
||||
};
|
||||
|
||||
public bool HasLines => Lines.Count > 0;
|
||||
|
||||
/// A text file that produced no diff hunks (e.g. a newly added empty file).
|
||||
public bool IsEmptyContent => !IsBinary && Lines.Count == 0;
|
||||
}
|
||||
|
||||
public sealed partial class DiffModalViewModel : ViewModelBase
|
||||
{
|
||||
private readonly GitService _git;
|
||||
|
||||
public required string WorktreePath { get; init; }
|
||||
public string? BaseRef { get; init; }
|
||||
/// When set together with <see cref="FromCommitRange"/>, the diff is computed as
|
||||
/// <c>BaseRef..HeadCommit</c> inside <see cref="WorktreePath"/> (used as the repo
|
||||
/// dir) — lets a merged task's diff be viewed after its worktree is gone.
|
||||
public string? HeadCommit { get; init; }
|
||||
public bool FromCommitRange { get; init; }
|
||||
public string? TaskId { get; init; }
|
||||
public string TaskTitle { get; init; } = "";
|
||||
public Func<MergeModalViewModel, Task>? ShowMergeModal { get; set; }
|
||||
public Func<MergeModalViewModel>? ResolveMergeVm { get; set; }
|
||||
public Func<string, string, Task>? RequestConflictResolution { get; set; }
|
||||
|
||||
public ObservableCollection<DiffFileViewModel> Files { get; } = new();
|
||||
|
||||
[ObservableProperty] private DiffFileViewModel? _selectedFile;
|
||||
[ObservableProperty] private string? _statusMessage;
|
||||
|
||||
// Injected action to close the owning Window
|
||||
public Action? CloseAction { get; set; }
|
||||
|
||||
public DiffModalViewModel(GitService git)
|
||||
{
|
||||
_git = git;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Close() => CloseAction?.Invoke();
|
||||
|
||||
private bool CanMerge() =>
|
||||
!string.IsNullOrEmpty(TaskId)
|
||||
&& ShowMergeModal is not null
|
||||
&& ResolveMergeVm is not null;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanMerge))]
|
||||
private async Task MergeAsync()
|
||||
{
|
||||
if (TaskId is null || ShowMergeModal is null || ResolveMergeVm is null) return;
|
||||
var vm = ResolveMergeVm();
|
||||
vm.RequestConflictResolution = RequestConflictResolution;
|
||||
await vm.InitializeAsync(TaskId, TaskTitle);
|
||||
await ShowMergeModal(vm);
|
||||
// The diff is stale once the worktree merged away or a conflict opened the editor.
|
||||
if (vm.Merged || vm.RoutedToResolver) CloseAction?.Invoke();
|
||||
}
|
||||
|
||||
public async Task LoadAsync(CancellationToken ct = default)
|
||||
{
|
||||
Files.Clear();
|
||||
StatusMessage = null;
|
||||
|
||||
if (FromCommitRange && (BaseRef is null || HeadCommit is null))
|
||||
{
|
||||
StatusMessage = Loc.T("vm.diff.unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
string raw;
|
||||
try
|
||||
{
|
||||
raw = FromCommitRange && BaseRef is not null && HeadCommit is not null
|
||||
? await _git.GetCommitRangeDiffAsync(WorktreePath, BaseRef, HeadCommit, ct)
|
||||
: BaseRef is not null
|
||||
? await _git.GetBranchDiffAsync(WorktreePath, BaseRef, ct)
|
||||
: await _git.GetDiffAsync(WorktreePath, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = Loc.T("vm.diff.loadFailed", ex.Message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
StatusMessage = Loc.T("vm.diff.noChanges");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var file in UnifiedDiffParser.Parse(raw))
|
||||
Files.Add(file);
|
||||
|
||||
SelectedFile = Files.Count > 0 ? Files[0] : null;
|
||||
if (Files.Count == 0) StatusMessage = Loc.T("vm.diff.noChanges");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Modals;
|
||||
|
||||
// Shared diff models used by UnifiedDiffParser, DiffLinesView and DiffViewerViewModel.
|
||||
|
||||
public enum DiffLineKind { Add, Del, Ctx, File }
|
||||
|
||||
public enum DiffFileStatus { Modified, Added, Deleted, Renamed }
|
||||
|
||||
public sealed class DiffLineViewModel
|
||||
{
|
||||
public required DiffLineKind Kind { get; init; }
|
||||
public int? OldNo { get; init; }
|
||||
public int? NewNo { get; init; }
|
||||
public required string Text { get; init; }
|
||||
public string ClassName => Kind switch
|
||||
{
|
||||
DiffLineKind.Add => "add",
|
||||
DiffLineKind.Del => "del",
|
||||
DiffLineKind.File => "file",
|
||||
_ => "ctx",
|
||||
};
|
||||
|
||||
public string Sign => Kind switch
|
||||
{
|
||||
DiffLineKind.Add => "+",
|
||||
DiffLineKind.Del => "-",
|
||||
_ => " ",
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class DiffFileViewModel
|
||||
{
|
||||
public required string Path { get; set; }
|
||||
public string? OldPath { get; set; }
|
||||
public DiffFileStatus Status { get; set; } = DiffFileStatus.Modified;
|
||||
public bool IsBinary { get; set; }
|
||||
public int Additions { get; set; }
|
||||
public int Deletions { get; set; }
|
||||
public ObservableCollection<DiffLineViewModel> Lines { get; } = new();
|
||||
|
||||
/// Single-letter badge for the file's change kind (A/M/D/R).
|
||||
public string StatusCode => Status switch
|
||||
{
|
||||
DiffFileStatus.Added => "A",
|
||||
DiffFileStatus.Deleted => "D",
|
||||
DiffFileStatus.Renamed => "R",
|
||||
_ => "M",
|
||||
};
|
||||
|
||||
public bool HasLines => Lines.Count > 0;
|
||||
|
||||
/// 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).
|
||||
public sealed record SubtaskDiffRow(string Id, string Title, string? DiffStat, string UnifiedDiff);
|
||||
|
||||
/// Folder/file node for the file-tree nav (left pane in Files mode). File leaves carry
|
||||
/// their parsed <see cref="DiffFileViewModel"/> so selection swaps the right pane with no
|
||||
/// further git calls.
|
||||
public sealed partial class DiffTreeNodeViewModel : ViewModelBase
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public bool IsDirectory { get; init; }
|
||||
public string RelativePath { get; init; } = "";
|
||||
public DiffFileViewModel? File { get; init; }
|
||||
public ObservableCollection<DiffTreeNodeViewModel> Children { get; } = new();
|
||||
[ObservableProperty] private bool _isExpanded = true;
|
||||
|
||||
public string? StatusCode => File?.StatusCode;
|
||||
public bool ShowStats => File is { IsBinary: false, IsPureRename: false };
|
||||
public int Additions => File?.Additions ?? 0;
|
||||
public int Deletions => File?.Deletions ?? 0;
|
||||
}
|
||||
|
||||
/// Builds a folder-grouped tree from a flat list of parsed diff files.
|
||||
public static class DiffTree
|
||||
{
|
||||
public static List<DiffTreeNodeViewModel> Build(IEnumerable<DiffFileViewModel> files)
|
||||
{
|
||||
var roots = new List<DiffTreeNodeViewModel>();
|
||||
var dirs = new Dictionary<string, DiffTreeNodeViewModel>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
var path = file.Path.Replace('\\', '/');
|
||||
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (segments.Length == 0) continue;
|
||||
|
||||
DiffTreeNodeViewModel? parent = null;
|
||||
var accumulated = "";
|
||||
for (var i = 0; i < segments.Length - 1; i++)
|
||||
{
|
||||
accumulated = accumulated.Length == 0 ? segments[i] : accumulated + "/" + segments[i];
|
||||
if (!dirs.TryGetValue(accumulated, out var dir))
|
||||
{
|
||||
dir = new DiffTreeNodeViewModel { Name = segments[i], IsDirectory = true, RelativePath = accumulated };
|
||||
dirs[accumulated] = dir;
|
||||
if (parent is null) roots.Add(dir); else parent.Children.Add(dir);
|
||||
}
|
||||
parent = dir;
|
||||
}
|
||||
|
||||
var leaf = new DiffTreeNodeViewModel
|
||||
{
|
||||
Name = segments[^1],
|
||||
IsDirectory = false,
|
||||
RelativePath = path,
|
||||
File = file,
|
||||
};
|
||||
if (parent is null) roots.Add(leaf); else parent.Children.Add(leaf);
|
||||
}
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
||||
public static DiffTreeNodeViewModel? FirstLeaf(IEnumerable<DiffTreeNodeViewModel> nodes)
|
||||
{
|
||||
foreach (var n in nodes)
|
||||
{
|
||||
if (!n.IsDirectory) return n;
|
||||
var nested = FirstLeaf(n.Children);
|
||||
if (nested is not null) return nested;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using ClaudeDo.Data.Git;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using ClaudeDo.Ui.Services;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Modals;
|
||||
|
||||
public enum DiffViewerMode { Files, Planning }
|
||||
|
||||
/// <summary>
|
||||
/// One read-only diff viewer replacing DiffModal + WorktreeModal + PlanningDiff.
|
||||
/// <see cref="DiffViewerMode.Files"/> sources (dirty worktree / branch-vs-base / commit
|
||||
/// range) load the whole diff via <see cref="GitService"/> and present a folder tree;
|
||||
/// <see cref="DiffViewerMode.Planning"/> loads per-subtask diffs from the worker with a
|
||||
/// combined integration-branch toggle. The Merge button (branch source) opens the merge
|
||||
/// form, which routes to the 3-pane resolver on conflict — the resolver itself is untouched.
|
||||
/// </summary>
|
||||
public sealed partial class DiffViewerViewModel : ViewModelBase
|
||||
{
|
||||
private readonly GitService _git;
|
||||
private readonly IWorkerClient _worker;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsPlanning))]
|
||||
[NotifyPropertyChangedFor(nameof(ShowMerge))]
|
||||
[NotifyCanExecuteChangedFor(nameof(MergeCommand))]
|
||||
private DiffViewerMode _mode = DiffViewerMode.Files;
|
||||
|
||||
public bool IsPlanning => Mode == DiffViewerMode.Planning;
|
||||
|
||||
// ── File-source config ──────────────────────────────────────────────────
|
||||
public string? WorktreePath { get; set; }
|
||||
public string? BaseRef { get; set; }
|
||||
public string? HeadCommit { get; set; }
|
||||
public bool FromCommitRange { get; set; }
|
||||
public string? TaskId { get; set; }
|
||||
public string TaskTitle { get; set; } = "";
|
||||
public Func<MergeModalViewModel, Task>? ShowMergeModal { get; set; }
|
||||
public Func<MergeModalViewModel>? ResolveMergeVm { get; set; }
|
||||
|
||||
// ── Planning-source config ──────────────────────────────────────────────
|
||||
private string? _planningTaskId;
|
||||
private string _targetBranch = "";
|
||||
|
||||
// ── Left pane ───────────────────────────────────────────────────────────
|
||||
public ObservableCollection<DiffTreeNodeViewModel> FileTree { get; } = new();
|
||||
public ObservableCollection<SubtaskDiffRow> Subtasks { get; } = new();
|
||||
[ObservableProperty] private DiffTreeNodeViewModel? _selectedNode;
|
||||
[ObservableProperty] private SubtaskDiffRow? _selectedSubtask;
|
||||
|
||||
// ── Right pane ──────────────────────────────────────────────────────────
|
||||
[ObservableProperty] private DiffFileViewModel? _selectedFile; // Files mode
|
||||
public ObservableCollection<DiffLineViewModel> DiffLines { get; } = new(); // Planning mode
|
||||
[ObservableProperty] private string _displayedDiff = "";
|
||||
[ObservableProperty] private string? _statusMessage;
|
||||
|
||||
// ── Planning combined toggle ────────────────────────────────────────────
|
||||
[ObservableProperty] private bool _isCombinedMode;
|
||||
[ObservableProperty] private string? _combinedWarning;
|
||||
[ObservableProperty] private bool _isLoadingCombined;
|
||||
|
||||
public Action? CloseAction { get; set; }
|
||||
|
||||
public DiffViewerViewModel(GitService git, IWorkerClient worker)
|
||||
{
|
||||
_git = git;
|
||||
_worker = worker;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Close() => CloseAction?.Invoke();
|
||||
|
||||
// ── Configuration (called by the doors) ─────────────────────────────────
|
||||
|
||||
public void ConfigureWorktree(string worktreePath, string? baseRef, string? taskId = null, string taskTitle = "")
|
||||
{
|
||||
Mode = DiffViewerMode.Files;
|
||||
WorktreePath = worktreePath;
|
||||
BaseRef = string.IsNullOrEmpty(baseRef) ? null : baseRef;
|
||||
TaskId = taskId;
|
||||
TaskTitle = taskTitle;
|
||||
}
|
||||
|
||||
public void ConfigureCommitRange(string repoDir, string? baseRef, string? headCommit,
|
||||
string? taskId = null, string taskTitle = "")
|
||||
{
|
||||
Mode = DiffViewerMode.Files;
|
||||
WorktreePath = repoDir;
|
||||
BaseRef = baseRef;
|
||||
HeadCommit = headCommit;
|
||||
FromCommitRange = true;
|
||||
TaskId = taskId;
|
||||
TaskTitle = taskTitle;
|
||||
}
|
||||
|
||||
public void ConfigurePlanning(string planningTaskId, string targetBranch)
|
||||
{
|
||||
Mode = DiffViewerMode.Planning;
|
||||
_planningTaskId = planningTaskId;
|
||||
_targetBranch = targetBranch;
|
||||
}
|
||||
|
||||
// ── Load ────────────────────────────────────────────────────────────────
|
||||
|
||||
public Task LoadAsync(CancellationToken ct = default) =>
|
||||
Mode == DiffViewerMode.Planning ? LoadPlanningAsync() : LoadFilesAsync(ct);
|
||||
|
||||
private async Task LoadFilesAsync(CancellationToken ct)
|
||||
{
|
||||
FileTree.Clear();
|
||||
SelectedNode = null;
|
||||
SelectedFile = null;
|
||||
StatusMessage = null;
|
||||
|
||||
if ((FromCommitRange && (BaseRef is null || HeadCommit is null)) || WorktreePath is null)
|
||||
{
|
||||
StatusMessage = Loc.T("vm.diff.unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
string raw;
|
||||
try
|
||||
{
|
||||
raw = FromCommitRange && BaseRef is not null && HeadCommit is not null
|
||||
? await _git.GetCommitRangeDiffAsync(WorktreePath, BaseRef, HeadCommit, ct)
|
||||
: BaseRef is not null
|
||||
? await _git.GetBranchDiffAsync(WorktreePath, BaseRef, ct)
|
||||
: await _git.GetDiffAsync(WorktreePath, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = Loc.T("vm.diff.loadFailed", ex.Message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
{
|
||||
StatusMessage = Loc.T("vm.diff.noChanges");
|
||||
return;
|
||||
}
|
||||
|
||||
var files = UnifiedDiffParser.Parse(raw).ToList();
|
||||
foreach (var node in DiffTree.Build(files))
|
||||
FileTree.Add(node);
|
||||
|
||||
SelectedNode = DiffTree.FirstLeaf(FileTree);
|
||||
if (files.Count == 0) StatusMessage = Loc.T("vm.diff.noChanges");
|
||||
}
|
||||
|
||||
partial void OnSelectedNodeChanged(DiffTreeNodeViewModel? value)
|
||||
{
|
||||
if (value is { IsDirectory: false, File: { } f })
|
||||
SelectedFile = f;
|
||||
}
|
||||
|
||||
private async Task LoadPlanningAsync()
|
||||
{
|
||||
if (_planningTaskId is null) return;
|
||||
var items = await _worker.GetPlanningAggregateAsync(_planningTaskId);
|
||||
Subtasks.Clear();
|
||||
foreach (var i in items)
|
||||
Subtasks.Add(new SubtaskDiffRow(i.SubtaskId, i.Title, i.DiffStat, i.UnifiedDiff));
|
||||
SelectedSubtask = Subtasks.FirstOrDefault();
|
||||
}
|
||||
|
||||
partial void OnSelectedSubtaskChanged(SubtaskDiffRow? value)
|
||||
{
|
||||
if (!IsCombinedMode)
|
||||
DisplayedDiff = value?.UnifiedDiff ?? "";
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ToggleCombinedAsync()
|
||||
{
|
||||
if (IsCombinedMode)
|
||||
{
|
||||
IsLoadingCombined = true;
|
||||
try
|
||||
{
|
||||
var result = await _worker.BuildPlanningIntegrationBranchAsync(_planningTaskId!, _targetBranch);
|
||||
if (result is null)
|
||||
{
|
||||
DisplayedDiff = "";
|
||||
CombinedWarning = Loc.T("vm.planningDiff.hubError");
|
||||
}
|
||||
else if (result.Success)
|
||||
{
|
||||
DisplayedDiff = result.UnifiedDiff ?? "";
|
||||
CombinedWarning = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
var files = result.ConflictedFiles?.Count ?? 0;
|
||||
CombinedWarning = Loc.T("vm.planningDiff.conflict", result.FirstConflictSubtaskId ?? "", files);
|
||||
DisplayedDiff = "";
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLoadingCombined = false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
DisplayedDiff = SelectedSubtask?.UnifiedDiff ?? "";
|
||||
CombinedWarning = null;
|
||||
}
|
||||
}
|
||||
|
||||
partial void OnIsCombinedModeChanged(bool value) => ToggleCombinedCommand.Execute(null);
|
||||
|
||||
partial void OnDisplayedDiffChanged(string value)
|
||||
{
|
||||
DiffLines.Clear();
|
||||
foreach (var line in UnifiedDiffParser.Flatten(UnifiedDiffParser.Parse(value)))
|
||||
DiffLines.Add(line);
|
||||
}
|
||||
|
||||
// ── Merge (Files mode, branch source) ───────────────────────────────────
|
||||
|
||||
/// Whether the Merge button is offered — only a live branch source with a task and the
|
||||
/// merge delegates wired (set before the view binds, so a plain computed read suffices).
|
||||
public bool ShowMerge =>
|
||||
Mode == DiffViewerMode.Files
|
||||
&& !string.IsNullOrEmpty(TaskId)
|
||||
&& ShowMergeModal is not null
|
||||
&& ResolveMergeVm is not null;
|
||||
|
||||
private bool CanMerge() => ShowMerge;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanMerge))]
|
||||
private async Task MergeAsync()
|
||||
{
|
||||
if (TaskId is null || ShowMergeModal is null || ResolveMergeVm is null) return;
|
||||
var vm = ResolveMergeVm();
|
||||
await vm.InitializeAsync(TaskId, TaskTitle);
|
||||
await ShowMergeModal(vm);
|
||||
// The diff is stale once the worktree merged away or a conflict opened the editor.
|
||||
if (vm.Merged || vm.RoutedToResolver) CloseAction?.Invoke();
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.Agent;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -28,25 +29,11 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
[ObservableProperty] private string _workingDir = "";
|
||||
[ObservableProperty] private string _defaultCommitType = CommitTypeRegistry.DefaultType;
|
||||
|
||||
[ObservableProperty] private string? _selectedModel; // null = inherit from global
|
||||
[ObservableProperty] private decimal? _maxTurns; // null = inherit from global
|
||||
[ObservableProperty] private string _modelInheritedHint = ""; // resolved value placeholder, e.g. "sonnet"
|
||||
[ObservableProperty] private string _modelBadge = "";
|
||||
[ObservableProperty] private string _turnsInheritedHint = "";
|
||||
[ObservableProperty] private string _turnsBadge = "";
|
||||
[ObservableProperty] private string _agentBadge = "";
|
||||
|
||||
[ObservableProperty] private string _systemPrompt = "";
|
||||
[ObservableProperty] private AgentInfo? _selectedAgent;
|
||||
|
||||
private string _globalModel = ModelRegistry.DefaultAlias;
|
||||
private int _globalMaxTurns = 100;
|
||||
|
||||
public ObservableCollection<string> ModelOptions { get; } = new(ModelRegistry.Aliases);
|
||||
|
||||
public ObservableCollection<string> CommitTypeOptions { get; } = new(CommitTypeRegistry.Types);
|
||||
|
||||
public ObservableCollection<AgentInfo> Agents { get; } = new();
|
||||
// The shared agent-config editor (Model / MaxTurns / SystemPrompt / AgentFile),
|
||||
// scoped to this list (list → global inheritance).
|
||||
public AgentConfigEditorViewModel Agent { get; }
|
||||
|
||||
public Action? CloseAction { get; set; }
|
||||
|
||||
@@ -54,34 +41,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
{
|
||||
_worker = worker;
|
||||
_dbFactory = dbFactory;
|
||||
}
|
||||
|
||||
partial void OnSelectedModelChanged(string? value) => RecomputeModelBadge();
|
||||
partial void OnMaxTurnsChanged(decimal? value) => RecomputeTurnsBadge();
|
||||
partial void OnSelectedAgentChanged(AgentInfo? value) => RecomputeAgentBadge();
|
||||
|
||||
private void RecomputeModelBadge()
|
||||
{
|
||||
ModelInheritedHint = _globalModel;
|
||||
ModelBadge = !string.IsNullOrWhiteSpace(SelectedModel)
|
||||
? Loc.T("settings.inherit.overrideBadge")
|
||||
: Loc.T("settings.inherit.inheritedFromGlobal");
|
||||
}
|
||||
|
||||
private void RecomputeTurnsBadge()
|
||||
{
|
||||
TurnsInheritedHint = _globalMaxTurns.ToString();
|
||||
TurnsBadge = MaxTurns is not null
|
||||
? Loc.T("settings.inherit.overrideBadge")
|
||||
: Loc.T("settings.inherit.inheritedFromGlobal");
|
||||
}
|
||||
|
||||
private void RecomputeAgentBadge()
|
||||
{
|
||||
var overridden = SelectedAgent is not null && !string.IsNullOrWhiteSpace(SelectedAgent.Path);
|
||||
AgentBadge = overridden
|
||||
? Loc.T("settings.inherit.overrideBadge")
|
||||
: Loc.T("settings.inherit.inheritedFromGlobal");
|
||||
Agent = new AgentConfigEditorViewModel(worker, AgentConfigScope.List);
|
||||
}
|
||||
|
||||
public async Task LoadAsync(
|
||||
@@ -96,44 +56,19 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
WorkingDir = workingDir ?? "";
|
||||
DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? CommitTypeRegistry.DefaultType : defaultCommitType;
|
||||
|
||||
Agents.Clear();
|
||||
Agents.Add(new AgentInfo("(none)", "", ""));
|
||||
var agents = await _worker.GetAgentsAsync();
|
||||
foreach (var a in agents) Agents.Add(a);
|
||||
|
||||
var config = await _worker.GetListConfigAsync(listId);
|
||||
|
||||
var app = await _worker.GetAppSettingsAsync();
|
||||
_globalModel = app?.DefaultModel ?? ModelRegistry.DefaultAlias;
|
||||
_globalMaxTurns = app?.DefaultMaxTurns ?? 100;
|
||||
|
||||
SelectedModel = string.IsNullOrWhiteSpace(config?.Model) ? null : config!.Model!;
|
||||
MaxTurns = config?.MaxTurns is int mt ? mt : (decimal?)null;
|
||||
SystemPrompt = config?.SystemPrompt ?? "";
|
||||
SelectedAgent = string.IsNullOrWhiteSpace(config?.AgentPath)
|
||||
? Agents[0]
|
||||
: (Agents.FirstOrDefault(a => a.Path == config!.AgentPath) ?? Agents[0]);
|
||||
|
||||
RecomputeModelBadge();
|
||||
RecomputeTurnsBadge();
|
||||
RecomputeAgentBadge();
|
||||
await Agent.LoadForListAsync(listId, ct);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
var model = string.IsNullOrWhiteSpace(SelectedModel) ? null : SelectedModel;
|
||||
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;
|
||||
|
||||
await _worker.UpdateListAsync(new UpdateListDto(
|
||||
ListId,
|
||||
string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name,
|
||||
string.IsNullOrWhiteSpace(WorkingDir) ? null : WorkingDir,
|
||||
DefaultCommitType));
|
||||
|
||||
await _worker.UpdateListConfigAsync(new UpdateListConfigDto(ListId, model, sp, ap, turns));
|
||||
await Agent.SaveAsync();
|
||||
|
||||
CloseAction?.Invoke();
|
||||
}
|
||||
@@ -171,17 +106,4 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
|
||||
[RelayCommand]
|
||||
private void Cancel() => CloseAction?.Invoke();
|
||||
|
||||
[RelayCommand] private void ResetModel() => SelectedModel = null;
|
||||
[RelayCommand] private void ResetTurns() => MaxTurns = null;
|
||||
[RelayCommand] private void ResetAgent() => SelectedAgent = Agents.Count > 0 ? Agents[0] : null;
|
||||
|
||||
[RelayCommand]
|
||||
private void ResetAgentSettings()
|
||||
{
|
||||
SelectedModel = null;
|
||||
MaxTurns = null;
|
||||
SystemPrompt = "";
|
||||
SelectedAgent = Agents.Count > 0 ? Agents[0] : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Modals;
|
||||
|
||||
/// <summary>
|
||||
/// Log Visualizer overlay — shows the worker's last 30 min of log records (all levels),
|
||||
/// fetched once on open via <see cref="IWorkerClient.GetRecentLogsAsync"/> with a manual
|
||||
/// Refresh and a "warnings & errors only" filter.
|
||||
/// </summary>
|
||||
public sealed partial class LogVisualizerViewModel : ViewModelBase
|
||||
{
|
||||
private readonly IWorkerClient _worker;
|
||||
private IReadOnlyList<WorkerLogEntry> _all = Array.Empty<WorkerLogEntry>();
|
||||
|
||||
public ObservableCollection<LogVisualizerRow> Rows { get; } = new();
|
||||
|
||||
[ObservableProperty] private bool _warnErrorOnly;
|
||||
[ObservableProperty] private string _statusText = "";
|
||||
|
||||
public Action? CloseAction { get; set; }
|
||||
|
||||
public LogVisualizerViewModel(IWorkerClient worker) => _worker = worker;
|
||||
|
||||
[RelayCommand]
|
||||
public async Task RefreshAsync()
|
||||
{
|
||||
_all = await _worker.GetRecentLogsAsync();
|
||||
Apply();
|
||||
}
|
||||
|
||||
partial void OnWarnErrorOnlyChanged(bool value) => Apply();
|
||||
|
||||
private void Apply()
|
||||
{
|
||||
Rows.Clear();
|
||||
IEnumerable<WorkerLogEntry> items = WarnErrorOnly
|
||||
? _all.Where(e => e.Level is WorkerLogLevel.Warn or WorkerLogLevel.Error)
|
||||
: _all;
|
||||
foreach (var e in items)
|
||||
Rows.Add(new LogVisualizerRow(e.TimestampUtc.ToLocalTime().ToString("HH:mm:ss"), e.Message, e.Level));
|
||||
StatusText = Rows.Count == 0
|
||||
? Loc.T("modals.logVisualizer.empty")
|
||||
: Loc.T("modals.logVisualizer.count", Rows.Count);
|
||||
}
|
||||
|
||||
[RelayCommand] private void Close() => CloseAction?.Invoke();
|
||||
}
|
||||
|
||||
public sealed record LogVisualizerRow(string Time, string Message, WorkerLogLevel Level);
|
||||
@@ -9,6 +9,7 @@ namespace ClaudeDo.Ui.ViewModels.Modals;
|
||||
public sealed partial class MergeModalViewModel : ViewModelBase
|
||||
{
|
||||
private readonly IWorkerClient _worker;
|
||||
private readonly IMergeCoordinator _merge;
|
||||
|
||||
public string TaskId { get; set; } = "";
|
||||
public string TaskTitle { get; set; } = "";
|
||||
@@ -28,10 +29,6 @@ public sealed partial class MergeModalViewModel : ViewModelBase
|
||||
|
||||
public Action? CloseAction { get; set; }
|
||||
|
||||
/// Set by the caller to hand a conflicting merge off to the in-app 3-pane editor
|
||||
/// instead of dead-ending on the conflict message.
|
||||
public Func<string, string, Task>? RequestConflictResolution { get; set; }
|
||||
|
||||
/// True once a merge has succeeded — lets the caller (e.g. the diff window)
|
||||
/// close itself after this modal closes.
|
||||
public bool Merged { get; private set; }
|
||||
@@ -39,9 +36,10 @@ public sealed partial class MergeModalViewModel : ViewModelBase
|
||||
/// True once a conflict has been handed off to the resolver — also a cue to close the diff window.
|
||||
public bool RoutedToResolver { get; private set; }
|
||||
|
||||
public MergeModalViewModel(IWorkerClient worker)
|
||||
public MergeModalViewModel(IWorkerClient worker, IMergeCoordinator merge)
|
||||
{
|
||||
_worker = worker;
|
||||
_merge = merge;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync(string taskId, string taskTitle)
|
||||
@@ -103,21 +101,11 @@ public sealed partial class MergeModalViewModel : ViewModelBase
|
||||
});
|
||||
break;
|
||||
case "conflict":
|
||||
// Hand off to the in-app 3-pane merge editor when wired (MergeTask aborted
|
||||
// cleanly, so the resolver re-starts the merge leaving conflicts in the tree).
|
||||
if (RequestConflictResolution is not null)
|
||||
{
|
||||
var branch = SelectedBranch!;
|
||||
RoutedToResolver = true;
|
||||
CloseAction?.Invoke();
|
||||
await RequestConflictResolution(TaskId, branch);
|
||||
}
|
||||
else
|
||||
{
|
||||
HasConflict = true;
|
||||
ConflictFiles = result.ConflictFiles;
|
||||
ErrorMessage = Loc.T("vm.merge.conflict");
|
||||
}
|
||||
// MergeTask aborted cleanly; hand the conflict to the in-app 3-pane editor,
|
||||
// which re-starts the merge leaving conflicts in the tree.
|
||||
RoutedToResolver = true;
|
||||
CloseAction?.Invoke();
|
||||
await _merge.ResolveConflictAsync(TaskId, SelectedBranch!);
|
||||
break;
|
||||
case "blocked":
|
||||
ErrorMessage = Loc.T("vm.merge.blocked", result.ErrorMessage ?? "");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user