Compare commits
125 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
bae8921201 | ||
|
|
23a93ce0bb | ||
|
|
29a294b7f3 | ||
|
|
ca4377e641 | ||
|
|
d5eec75bea | ||
|
|
18479c023e | ||
|
|
869dd25a23 | ||
|
|
c4d1acc75b | ||
|
|
378a92c156 | ||
|
|
983c177c9a | ||
|
|
3e4e4a03f7 | ||
|
|
92767c646e | ||
|
|
e779e13654 | ||
|
|
4847c5c0a4 | ||
|
|
43fb506e87 | ||
|
|
b75a7b1b5a | ||
|
|
824f785fd0 | ||
|
|
0d1475cb7a | ||
|
|
cfe23cdd23 | ||
|
|
cee051bb6d | ||
|
|
23c3065f20 | ||
|
|
80a2de6c74 | ||
|
|
17c7ff517a | ||
|
|
8b347de131 | ||
|
|
619bc0c38d | ||
|
|
96da9fbae5 | ||
|
|
1ac9ced0bd | ||
|
|
8cbe1adb32 | ||
|
|
23ff3916cc | ||
|
|
360ff77e18 | ||
|
|
e272053e72 | ||
|
|
74ca2e0dcd | ||
|
|
0cba9f9640 | ||
|
|
c6534165b2 | ||
|
|
290b4a602a | ||
|
|
fe73f45b74 | ||
|
|
d2a08d2cda | ||
|
|
8194dadb6a | ||
|
|
fb1d799b82 | ||
|
|
12fdb55a8e | ||
|
|
eee5c99e2f | ||
|
|
37df51475e | ||
|
|
53b666dfbd | ||
|
|
cd5501e6a6 | ||
|
|
b5417f6b09 | ||
|
|
7e739afafb | ||
|
|
e9e4ad8fbc | ||
|
|
d4af345ac3 | ||
|
|
ddeded988a | ||
|
|
c27a179d2b | ||
|
|
1448794748 | ||
|
|
51ef488d2f | ||
|
|
49046310ef | ||
|
|
f8f20bf6ed |
@@ -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
.gitignore
vendored
1
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
# Local dev worktrees (created by using-git-worktrees skill)
|
||||
.worktrees/
|
||||
.claude/worktrees/
|
||||
|
||||
# Brainstorming visual companion artifacts
|
||||
.superpowers/
|
||||
|
||||
984
CHANGELOG.md
984
CHANGELOG.md
@@ -1,36 +1,970 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to ClaudeDo are documented here.
|
||||
## v1.9.0 — 2026-06-19
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
Versions are derived from git tags (`vX.Y.Z`) via MinVer.
|
||||
### Features
|
||||
|
||||
## [Unreleased]
|
||||
- diff Merge opens the 3-pane editor + conflict overview ruler (29a294b)
|
||||
- toggle add/remove per side, MAIN/INCOMING labels, files readout (ca4377e)
|
||||
- additive conflict accept — stack ours/theirs in click order (d5eec75)
|
||||
- add accept-both control to the 3-pane conflict gutter (18479c0)
|
||||
- Rider-style 3-pane conflict editor view (c4d1acc)
|
||||
- unify planning conflicts onto the resolver + 3-pane VM foundation (378a92c)
|
||||
- move review feedback to the Output tab + review/worktree polish (3e4e4a0)
|
||||
- in-app 3-way merge editor (chunk 2b) (92767c6)
|
||||
- real conflict-hunk parsing pipeline (chunk 2 backend) (e779e13)
|
||||
- My Day actions, orphan-aware grouping, menu restructure (4847c5c)
|
||||
- unify review actions into the Git-tab cockpit (43fb506)
|
||||
- carry ownerId on sync to prepare for multi-user (cee051b)
|
||||
- gate access on Zitadel "user" project role (23c3065)
|
||||
- Online Inbox settings tab + auth-code/PKCE login (80a2de6)
|
||||
- Online Inbox config + auth hub plumbing (Phase 2) (17c7ff5)
|
||||
- real ZitadelAuthProvider (refresh-token grant, auth-code+PKCE) (619bc0c)
|
||||
- Online Inbox sync engine (Phase 1) (1ac9ced)
|
||||
- let Claude set the cheapest model per generated task via MCP (c27a179)
|
||||
|
||||
### Fixed
|
||||
### Fixes
|
||||
|
||||
- Review queue is no longer permanently empty: the review virtual list now
|
||||
matches tasks in `WaitingForReview` (it previously matched `Done` + active
|
||||
worktree, a state successful runs never land in).
|
||||
- UI ViewModels (`Details`, `Tasks`, `Lists` islands and the shell) now dispose
|
||||
their `Loc.LanguageChanged`, worker-event, and timer subscriptions, fixing
|
||||
long-lived subscription leaks.
|
||||
- Stopping a task while the worker is offline no longer throws: `StopAsync`
|
||||
guards on task/running/connection state and handles hub errors.
|
||||
- unresolved conflicts compose to empty, not Ours (+ review nits) (23a93ce)
|
||||
- harden 3-pane editor + document the new conflict resolver (869dd25)
|
||||
- invalidate cached access token when the signed-in user changes (cfe23cd)
|
||||
- preserve API base path in Online Inbox client (8b347de)
|
||||
- queue dispatches skip the StartRunning re-claim (74ca2e0)
|
||||
- document and test Queued→Failed guard in FailAsync (fe73f45)
|
||||
- stateless AbortPlanningMerge after worker restart mid-merge (fb1d799)
|
||||
- route FinalizeParentDoneAsync through TaskStateService (e9e4ad8)
|
||||
|
||||
### Hardening
|
||||
### Refactoring
|
||||
|
||||
- Release-readiness audit pass across Worker, Data, UI, MCP, App and Installer
|
||||
(see `docs/open.md` backlog for tracked follow-ups).
|
||||
- bring IWorkerClient to parity with WorkerClient (b5417f6)
|
||||
|
||||
## [1.7.0]
|
||||
### Documentation
|
||||
|
||||
- Agent roadblock + run-outcome surfacing in the task detail pane.
|
||||
- i18n: localized task-header, task-row and prime-schedule tooltips.
|
||||
- CI: dependency-audit and changelog Gitea workflows.
|
||||
- Layer A/B/C git merge & conflict-resolution cockpit (multi-worktree
|
||||
batch-merge, inline conflict resolver).
|
||||
- spec + plan for Rider-style 3-pane merge editor (983c177)
|
||||
- KunsZitadel is server-side only; desktop uses an OIDC client flow (96da9fb)
|
||||
- API contract, desktop design spec, and implementation plan (8cbe1ad)
|
||||
- close out the review round in open.md, sync CLAUDE.md with merges (23ff391)
|
||||
- record correctness-review findings (4 confirmed as tasks) (ddeded9)
|
||||
- record review findings as refactoring backlog (1448794)
|
||||
- spec + plan for per-task model override via MCP (51ef488)
|
||||
- refresh CLAUDE.md files and open.md to current code state (4904631)
|
||||
- update for v1.8.0 (f8f20bf)
|
||||
|
||||
## v1.8.0 — 2026-06-09
|
||||
|
||||
### Features
|
||||
|
||||
- richer diff viewer + surface child roadblocks on parents (f21c65b)
|
||||
- allow cancelling a WaitingForChildren parent (d6e0953)
|
||||
- single approve action merges the whole unit (a8b86e2)
|
||||
- approve drives the unit merge for parents with children (1abb429)
|
||||
- planning finalize enters WaitingForChildren (12732d6)
|
||||
- add get_task_config, continue_task; fix status enum, branchDeleted, merge-from-review (9f19a71)
|
||||
- surface agent roadblocks and run outcome in the detail pane (763732a)
|
||||
- localize task-header, task-row and prime-schedule tooltips (a41b8de)
|
||||
- wire layer A/B conflict seams to the inline resolver (7f173da)
|
||||
- expose conflict-resolver factory and dialog seam for integrator (72687e9)
|
||||
- add inline conflict resolver view and localization (8cafad3)
|
||||
- add inline conflict resolver view-model (d8a973d)
|
||||
- add inline conflict model (file/hunk with resolution) (0b623b8)
|
||||
- batch-merge cockpit view with checkboxes and conflicts panel (5edb433)
|
||||
- add batch-merge cockpit strings (en/de) (c8f82ed)
|
||||
- wire batch selection, target loading and resolve seam (1aa0607)
|
||||
- expose conflict-resolution merge methods (cb20877)
|
||||
- read conflict stages and write user resolutions (dcbf67c)
|
||||
- add skip-and-continue batch merge orchestration (02b11c7)
|
||||
- add conflict-stage blob reads and single-path staging (74afc46)
|
||||
- add batch-merge row state to worktrees cockpit VM (ef3fba1)
|
||||
- fuse git tab into one approve+merge cockpit (3596053)
|
||||
- route single-task merge conflicts into a resolution seam (4bf4a27)
|
||||
- maximize work console via green traffic-light dot (de4ad5d)
|
||||
- add conflict-resolution worker contract (foundation for merge rework) (2dfc455)
|
||||
- rename review Retry to Continue and make Reset discard the worktree (42bb79e)
|
||||
- send Retry on Enter in the review prompt (9c5872e)
|
||||
- rework review into terminal footer and add Git tab (8819a56)
|
||||
- add IsGitTab flag to work console view model (6c65158)
|
||||
- add mergeability indicator and Merge button to work console (de01579)
|
||||
- show mergeability and surface approve conflicts in the work console (0d8999d)
|
||||
- wire merge-aware approve and preview into the worker client (3202c76)
|
||||
- expose PreviewMerge hub method and merge-on-approve (43f8f7f)
|
||||
- approve merges worktree before marking task done (b817c87)
|
||||
- add Refine button, icon, and command to task card (2a6781f)
|
||||
- add non-destructive merge-tree conflict probe (4098f7f)
|
||||
- add RefineTask client call and refine events (8239004)
|
||||
- wire RefineTask hub method, broadcaster events, and DI (e523ed8)
|
||||
- add RefineRunner, prompt/args helper, and interfaces (0460d7b)
|
||||
- add Refine prompt kind and default (eca6813)
|
||||
- add add_subtask tool to claudedo MCP (22830d3)
|
||||
- resize detail split by dragging the console's top edge (b840655)
|
||||
- rework work console — single Session tab, right-aligned header, turns x/y (ac9bae9)
|
||||
- make steps visible at a glance; lift details card off background (99c6bf4)
|
||||
- wire redesigned detail island (header + description/steps card + work console) (c71026d)
|
||||
- add WorkConsole detail component (ce50f9f)
|
||||
- add DescriptionStepsCard detail component (c323953)
|
||||
- add TaskHeaderBar detail component (9f95942)
|
||||
- compose task prompt from title + description + open steps only (299867d)
|
||||
- fold parent branch into combined-diff for improvement parents (469e68b)
|
||||
- focused custom prompt for improvement children so they stay narrow (176b985)
|
||||
- show improvement-child outcomes on the parent review card + enable tree-merge (5d34f95)
|
||||
- mark agent-suggested improvement children in the task tree (0e13017)
|
||||
- surface WaitingForChildren status (chip, color, agent-strip, labels) (5363570)
|
||||
- instruct agents to offload out-of-scope work via SuggestImprovement (f60beca)
|
||||
- fold parent branch into tree-merge for improvement parents (519bfbe)
|
||||
- mint per-run MCP token + emit run-scoped --mcp-config (06e3acd)
|
||||
- resolve per-run tokens in MCP auth + register TaskRunMcpService (f3052dc)
|
||||
- add SuggestImprovement tool (server-stamped, one layer deep) (9d133e2)
|
||||
- add TaskRunMcpContext + accessor (7542bc2)
|
||||
- add per-run TaskRunTokenRegistry (ef86a8c)
|
||||
- base improvement-child worktree on parent HEAD (da23b6c)
|
||||
- route standalone success with children to WaitingForChildren + enqueue them (c10f564)
|
||||
- advance WaitingForChildren parent to review when children terminal (7873e60)
|
||||
- add SubmitForChildrenAsync (Running -> WaitingForChildren) (6f4b5d5)
|
||||
- generalize CreateChildAsync for any parent + CreatedBy stamp (6fdf04d)
|
||||
- add WaitingForChildren task status value (ee0d125)
|
||||
- roadblock badge on the task card; relocate review actions off the row (2455eac)
|
||||
- host review actions in the details panel; show review state and diff meter (d8b86e3)
|
||||
- persist roadblock count on the task (49b9f1f)
|
||||
- surface reported roadblocks in the review result (1e547de)
|
||||
- carry blocks through RunResult (56ebc28)
|
||||
- collect and strip CLAUDEDO_BLOCKED markers in StreamAnalyzer (cf7f0da)
|
||||
- weekly-report instructions from file, point at data sections (ac1e9b0)
|
||||
- daily-prep prompt from file, English default (79bfc79)
|
||||
- expose all editable prompt files, drop agent prompt (bd1e3db)
|
||||
- retry prompt from file, append only real captured errors (edc9f77)
|
||||
- externalize prompt kinds with defaults and token renderer (9bdf99d)
|
||||
- show inherited markers and max-turns override in task flyout (cd683ba)
|
||||
- show inherited markers and max-turns override in list settings (d0ab382)
|
||||
- add reusable inherited-source badge control (3e3041c)
|
||||
- add inheritance resolver returning value and source (92cee12)
|
||||
- add inherited-marker, turns, and prepended-prompt strings (bba3c55)
|
||||
- mirror max-turns field on signalr config dtos (26f5936)
|
||||
- expose max-turns override over signalr and mcp config tools (b72a788)
|
||||
- resolve max-turns from task then list then global default (beae2d6)
|
||||
- persist max_turns in list and task repositories (ac137f7)
|
||||
- add nullable max_turns override to list_config and tasks (97e38fb)
|
||||
- replace Plan-My-Day header icon with a stroked sun icon (52e3980)
|
||||
- trigger planning from inside the prep-log window with an empty-state hint (7d743f1)
|
||||
- load persisted prep log into the terminal on open (914095d)
|
||||
- persist last prep run to a log file and serve it via GetLastPrepLog (4d82079)
|
||||
- move Clear-day and Prep-log into MyDay header icon row (c764b2b)
|
||||
- reuse SessionTerminal for prep log; fix invisible Sort icon; add Broom/List icons (f7d1b37)
|
||||
- clear textbox focus on click outside any text box (fab1772)
|
||||
- add Prep-log and Clear-day buttons to MyDay header (c45f892)
|
||||
- add live prep-output mode to the Details island (a8670ee)
|
||||
- expose prep stream events and ClearMyDay on the UI worker client (7676ecf)
|
||||
- add ClearMyDay hub method (fa83d7f)
|
||||
- stream prep output via PrepStarted/PrepLine/PrepFinished (e48475d)
|
||||
- add Prepare-day button to MyDay header (46ac3fc)
|
||||
- add DailyPrepMaxTasks editor to Prime Claude settings (5e0859f)
|
||||
- add RunDailyPrepNow hub method and expose DailyPrepMaxTasks (2d00160)
|
||||
- run daily prep from PrimeRunner via allowed MCP tools (20b3a29)
|
||||
- add set_my_day MCP tool with cap-guard (fd7f8ac)
|
||||
- add get_daily_prep_candidates MCP tool (0bb8094)
|
||||
- add DailyPrepMaxTasks app setting (3c66d65)
|
||||
|
||||
### Fixes
|
||||
|
||||
- mark task Done on every successful merge path, not just approve (f56cc61)
|
||||
- merge_task marks the task Done after a successful merge (ca8326c)
|
||||
- drop unique index on lists.name (allow duplicate list names) (f5d165b)
|
||||
- harden FK pragma per-connection and seed concurrency (7f1a14a)
|
||||
- harden CLI injection, stuck-Running, chain wedge, and Fail guard (33bdff8)
|
||||
- serialize concurrent worktree add to prevent commondir race (b672c9a)
|
||||
- dispose VM subscriptions/timers, guard offline Stop, align review delta-path (01e0c1d)
|
||||
- populate review queue from WaitingForReview tasks (00a065b)
|
||||
- set prompt-action resting color on ContentPresenter (561028e)
|
||||
- discard stale mergeability probe after task or target switch (6e3f90d)
|
||||
- guard blank working dir in approve-merge before resolving target (f1cf29b)
|
||||
- update TaskMergeService ctor calls after ITaskStateService injection (98b0d58)
|
||||
- stop the console clipping the last log line (1603be0)
|
||||
- render Output log directly on the console, not as a nested card (71a3765)
|
||||
- stop app crash when approving review after Merge all (cc7355e)
|
||||
- live-update child outcomes + enable Review combined diff for improvement parents (a3f407b)
|
||||
- only planning-active children are drafts; allow improvement children to queue (8036de1)
|
||||
- exempt improvement children from orphan-dequeue sweep (f25c759)
|
||||
- populate diff meter when selecting a finished task (c035720)
|
||||
- warning icon fill-rule and dedicated review section header (4522ac9)
|
||||
- apply system default on every run; dedupe roadblocks (9a117a5)
|
||||
- clean up orphaned worktree when the DB row insert fails (71ac481)
|
||||
- hide task header, footer and agent strip in prep/notes mode (39fa83a)
|
||||
- register IWorkerClient mapping for WeeklyReportModalViewModel (46f42a4)
|
||||
|
||||
### Refactoring
|
||||
|
||||
- single parent-advance path for planning + improvement (b3a2daf)
|
||||
- render worktree modal diff via canonical DiffLinesView (d52243c)
|
||||
- blend review prompt into the terminal instead of a boxed footer (e22a326)
|
||||
- remove dead inline-layout handlers from DetailsIslandView (3e84871)
|
||||
- share color-coded diff rendering between per-task and combined diff viewers (22a1ba7)
|
||||
- planning prompts read from editable files (1b3c6bd)
|
||||
- collapse agent prompt into system prompt (883dbc6)
|
||||
- address code smells (run-dir helper, App DI injection) (3756b81)
|
||||
- remove unused Sort button from MyDay header (3a40e39)
|
||||
|
||||
### Documentation
|
||||
|
||||
- document the unified parent-task model (c300f8c)
|
||||
- Task 4 = full approve/merge UX consolidation (803c04d)
|
||||
- spec + plan for unifying the parent-task model (8f49ebb)
|
||||
- add CHANGELOG (Keep a Changelog format) (384e058)
|
||||
- Layer C inline conflict resolver (ef2f5c5)
|
||||
- Layer B multi-worktree merge cockpit plan (3060cb0)
|
||||
- foundation + Layer A plan and Layer B/C parallel kickoff prompts (dd3b03b)
|
||||
- git tab merge & review rework — shared foundation + 3 layers (f4416ee)
|
||||
- add implementation plan for terminal-style review controls (096519b)
|
||||
- spec terminal-style review with Git tab and footer actions (266e6d1)
|
||||
- document real git merge on approve, PreviewMerge hub method, and new GitService/WorkerClient members (cb4c396)
|
||||
- add approve-merge + conflict-preview implementation plan (75ad7b1)
|
||||
- add approve-merge + conflict-preview design spec (66a7b23)
|
||||
- add Refine Task implementation plan (3573548)
|
||||
- add Refine Task design spec (0867bc8)
|
||||
- add ClaudeDo distribution website design spec (a2c339c)
|
||||
- task-detail island redesign spec + component build prompts (8f7e289)
|
||||
- implementation plan for build-config logging + traceability (c5a4e35)
|
||||
- runtime build-config detection, Warning in Release, retain 2 (e547921)
|
||||
- design for build-config debug logging + task traceability (f1316df)
|
||||
- align Task 6 with rebased HandleSuccess (preserve SetRoadblockCount) (204b089)
|
||||
- child tasks + agent improvement loop implementation plan (da4ab0c)
|
||||
- plan for review & roadblock UX follow-up (4d52845)
|
||||
- refresh prompt inventory for externalized prompts + roadblock marker (202e8de)
|
||||
- implementation plan for bundled-prompts overhaul (c8f468f)
|
||||
- child base off parent HEAD, shared planning-style tree merge (84fd2c1)
|
||||
- design for reusable child tasks + agent improvement loop (30b49d1)
|
||||
- design for bundled-prompts overhaul (ad7d748)
|
||||
- note max-turns override and inherited markers in module docs (75aa42b)
|
||||
- implementation plan for inherited markers, overrides, and Turns (b63c78c)
|
||||
- spec for inherited-settings display, overrides, and Turns (37ce673)
|
||||
- slim open.md down to open items only (b9741ef)
|
||||
- park mailbox proposal; skip architecture.md and ADRs (0a0d7e8)
|
||||
- drop CI-pipeline item (push-to-main + release workflow makes it redundant) (72a86fc)
|
||||
- regenerate open.md against verified current state (bcf5e2f)
|
||||
- document daily-prep across area CLAUDE.md files; add Installer CLAUDE.md (fb055ce)
|
||||
- add autonomous working-style loop and agent gotchas to CLAUDE.md (9e7f37b)
|
||||
- add plan-day-in-log-window plan (53d897a)
|
||||
- add prep-log persistence plan (26758b6)
|
||||
- add MyDay icons + terminal-reuse plan (2e73d33)
|
||||
- add design specs and implementation plans (9470c5b)
|
||||
|
||||
## v1.7.0 — 2026-06-03
|
||||
|
||||
### Features
|
||||
|
||||
- localize installer with language picker and config write-through (364a037)
|
||||
- add WPF localization primitives and Language config to installer (2fbf054)
|
||||
- localize ViewModel-built strings via ambient Loc accessor (350a89f)
|
||||
- localize Avalonia view strings via loc:Tr markup (086c6f6)
|
||||
- add language dropdown to settings and persist selection (070f5de)
|
||||
- initialize Localizer at app startup from config/OS culture (f529a5f)
|
||||
- add Language preference and Save() to AppSettings (6a85d82)
|
||||
- add Avalonia loc:Tr markup extension and LocalizedString (35ad171)
|
||||
- seed en.json and wire locale copy to app output (3c40bb5)
|
||||
- add CultureResolver for OS-culture mapping (d95d55e)
|
||||
- add Localizer with fallback chain and change event (d22b50e)
|
||||
- add LocaleStore folder discovery (a83a0c4)
|
||||
- add ClaudeDo.Localization project with nested-JSON locale parser (9efde2b)
|
||||
- pinned Notes row in My Day opens the notes editor (a8943a9)
|
||||
- notes mode in the Details island (eccd06e)
|
||||
- NotesEditorView (731c291)
|
||||
- NotesEditorViewModel with day navigation and bullet CRUD (c8b5ed3)
|
||||
- INotesApi wrapper for daily notes (9bf44da)
|
||||
- open Weekly Report modal from the menu (b748c15)
|
||||
- WeeklyReportModalView (74fc39f)
|
||||
- WeeklyReportModalViewModel with default-range logic (ccd2ee2)
|
||||
- persist report excluded paths and standup weekday (5b89e3d)
|
||||
- WorkerClient methods for week report and daily notes (e106b00)
|
||||
- hub methods for week report and daily notes (d7558ef)
|
||||
- register report reader and service in DI (4aa4353)
|
||||
- WeekReportService orchestrates generate + store (50d84f1)
|
||||
- week report prompt builder (day-major pivot) (e2271b5)
|
||||
- ClaudeHistoryReader distills session logs (bec87b3)
|
||||
- report activity models and reader interface (4cb7ad8)
|
||||
- add WeekReportRepository with tests (992fbf0)
|
||||
- add DailyNoteRepository with tests (1d7b86d)
|
||||
- migration for daily notes and week reports (036586e)
|
||||
- configure daily note + week report tables (d9e5d26)
|
||||
- add daily note + week report entities and report settings (10d86b4)
|
||||
|
||||
### Fixes
|
||||
|
||||
- live-refresh smart/virtual list names on language change (00ef11a)
|
||||
- notes add row stays visible, English 'Add' label, Enter to add (2d55f88)
|
||||
- sanitize report model arg, fix multi-repo summary attribution and standup-weekday sentinel (a8d8a8b)
|
||||
|
||||
### Documentation
|
||||
|
||||
- localization implementation plan (8dc8b8b)
|
||||
- localization (i18n) design spec (baeea9c)
|
||||
- document weekly report and daily notes feature (0bc3d2a)
|
||||
- add weekly report implementation plan (f72cfae)
|
||||
- add report prompt and day-major pivot to weekly report spec (e5a2ed2)
|
||||
- add weekly report feature design spec (536d819)
|
||||
|
||||
## v1.6.0 — 2026-06-02
|
||||
|
||||
### Features
|
||||
|
||||
- use a 24h TimePicker for prime schedule time entry (869cf72)
|
||||
- replace prime date range with weekday toggle buttons (7db8f21)
|
||||
- drive prime schedule rows from weekday toggles (37738e3)
|
||||
- map prime schedule weekday bitmask over the hub (81fd186)
|
||||
- compute prime due-time from weekday bitmask (bed4255)
|
||||
- migrate prime schedules to days_of_week bitmask (dff06d9)
|
||||
- persist weekday bitmask in prime schedule repo (0efad7a)
|
||||
- model Prime schedule as weekday bitmask (eaf27e8)
|
||||
- surface review actions and WaitingForReview status in task rows (6c27ffb)
|
||||
- add review hub methods and worker client wrappers (21f1cf2)
|
||||
- add review_task MCP tool and status reference updates (c88ed9d)
|
||||
- route standalone success to review and resume on re-queue (9c1f20f)
|
||||
- add review state transitions to TaskStateService (e8d018d)
|
||||
- add WaitingForReview status and review_feedback column (1ca32a6)
|
||||
|
||||
### Fixes
|
||||
|
||||
- manual modal dragging, maximize/restore icon, day-toggle style (f1715a3)
|
||||
- harden review re-run, timestamps, and queue affordance (1cb5171)
|
||||
|
||||
### Documentation
|
||||
|
||||
- describe recurring-weekday Prime schedule (26998f0)
|
||||
- implementation plan for recurring-weekday Prime (13c3393)
|
||||
- spec for recurring-weekday Prime schedules (4704a28)
|
||||
- document WaitingForReview state across project CLAUDE.md files (4684a0a)
|
||||
- waiting-for-review implementation plan (b86677d)
|
||||
- waiting-for-review task state design (3e072fa)
|
||||
|
||||
## v1.5.0 — 2026-06-01
|
||||
|
||||
### Features
|
||||
|
||||
- replay run log in session terminal, drop per-row live tail (4a36fbe)
|
||||
- optionally register ClaudeDo MCP server with Claude (5170914)
|
||||
- configurable max parallel task executions (b1f4349)
|
||||
- list reordering, quick actions, and resizable modals (ab44ba5)
|
||||
|
||||
## v1.4.2 — 2026-06-01
|
||||
|
||||
### Fixes
|
||||
|
||||
- stop the running app before updating, not just the worker (4148dcd)
|
||||
- keep step badges green and reset state on re-run (5783790)
|
||||
|
||||
## v1.4.1 — 2026-06-01
|
||||
|
||||
### Fixes
|
||||
|
||||
- track EF migration Designer files (were gitignored) (edfb702)
|
||||
|
||||
## v1.4.0 — 2026-06-01
|
||||
|
||||
### Features
|
||||
|
||||
- wire worker connection modal and make status pill clickable (1246bf7)
|
||||
- prompt once on worker connection failure with grace timer (00dc7eb)
|
||||
- add worker connection help modal (0139607)
|
||||
- remove Startup worker shortcut on uninstall (759d905)
|
||||
- start worker via Process.Start, drop schtasks stop (2f1dcdc)
|
||||
- register autostart via Startup shortcut, drop scheduled task (133f2d2)
|
||||
- add AutostartShortcut helper for Startup-folder lnk (e2bb43a)
|
||||
- unify type scale to 11/13/18/24 and add canonical text classes (b00e4d9)
|
||||
- add reusable ModalShell control (c20fbe3)
|
||||
- set global Inter Tight font default on all windows (5a25818)
|
||||
- add named tint and hairline overlay brush tokens (f0f8cd1)
|
||||
|
||||
### Fixes
|
||||
|
||||
- restore resize and full-width rows in WorktreesOverview modal (16717ab)
|
||||
- unclip Edit/Preview buttons; enlarge section labels and use mono field labels (e86464e)
|
||||
- correct SettingsModal font snap (11px is Mono, not Body) (b1006ac)
|
||||
- use LineBrush for schedule flyout border and tokenize TaskRowView (3d4a64a)
|
||||
|
||||
### Refactoring
|
||||
|
||||
- rename StopWorkerStep.TaskName to LegacyTaskName (400a078)
|
||||
- stop auto-spawning the worker on app start (4ecd855)
|
||||
- extract ShortcutFactory COM helper (867dc37)
|
||||
- migrate PlanningDiffView to ModalShell (926471d)
|
||||
- drop double padding in Tasks island header (9be8e6b)
|
||||
- drop double padding in Lists island header (b9e5dfc)
|
||||
- class schedule-flyout cancel in TaskRowView (c669370)
|
||||
- class merge-section buttons in DetailsIslandView (4688e88)
|
||||
- class update-banner buttons in MainWindow (8b21b0e)
|
||||
- normalize buttons/footer/padding in ConflictResolutionView (4a786eb)
|
||||
- normalize buttons/footer/padding in DiffModal (cd64f28)
|
||||
- normalize buttons/footer/padding in WorktreesOverviewModal (3585ad5)
|
||||
- normalize buttons/footer/padding in RepoImportModal (990935e)
|
||||
- normalize buttons/footer/padding in UnfinishedPlanningModal (1b5a928)
|
||||
- normalize buttons/footer/padding in AboutModal (e8f880e)
|
||||
- normalize buttons/footer/padding in MergeModal (3228a08)
|
||||
- normalize buttons/footer/padding in ListSettingsModal (ccec791)
|
||||
- normalize buttons/footer/padding in SettingsModal (187fb64)
|
||||
- make primary/danger buttons self-contained, drop unused btn.primary (0a71956)
|
||||
- inherit terminal font for SelectableTextBlock (ccec591)
|
||||
- use sidebar-pane in PlanningDiffView (a4cb03b)
|
||||
- use diff-lineno and sidebar-pane in DiffModal (f53292e)
|
||||
- use danger-box in MergeModal (539ebec)
|
||||
- use danger-box in SettingsModal (dff5651)
|
||||
- use shared section style in ListSettingsModal (9f49b01)
|
||||
- reuse task-row style for worktree rows (fb3a6ac)
|
||||
- use section-divider in DetailsIslandView (4f84b15)
|
||||
- drop duplicate converters and normalize binding in ListsIslandView (27b0d51)
|
||||
- merge task-row styles and add shared section/danger-box/sidebar/accent styles (2a38104)
|
||||
- unify text and close button in ThemedDatePicker (bddef5a)
|
||||
- unify text and close button in ConflictResolutionView (51d3ea2)
|
||||
- unify text and close button in PlanningDiffView (335b422)
|
||||
- unify text and close button in DiffModalView (08f3bab)
|
||||
- unify text and close button in WorktreeModalView (9082f2e)
|
||||
- unify text and close button in WorktreesOverviewModalView (0f64b1c)
|
||||
- unify text and close button in RepoImportModalView (dd45387)
|
||||
- unify text and close button in UnfinishedPlanningModalView (00e1d2d)
|
||||
- unify text and close button in AboutModalView (9a91135)
|
||||
- unify text and close button in MergeModalView (8e595a1)
|
||||
- unify text and close button in ListSettingsModalView (97fc715)
|
||||
- unify text and close button in SettingsModalView (ed8607d)
|
||||
- apply text classes to SessionTerminalView (929e0ca)
|
||||
- apply text classes to AgentStripView (40a3630)
|
||||
- apply text classes to TaskRowView (b9f5d82)
|
||||
- apply text classes to DetailsIslandView (e0dda3e)
|
||||
- apply text classes to TasksIslandView (d4c66de)
|
||||
- apply text classes to ListsIslandView (a132127)
|
||||
- apply text classes to MainWindow (6e3125e)
|
||||
- consolidate list-section-label into shared section-label (7af892f)
|
||||
- fold selected-day White to TextBrush token (8944074)
|
||||
- tokenize WorktreeModalView font sizes (fbd5d9f)
|
||||
- tokenize and dynamic-ize PlanningDiffView (5fdd9f0)
|
||||
- migrate ConflictResolutionView to ModalShell and use dynamic resources (bce4e0a)
|
||||
- migrate DiffModal to ModalShell and use dynamic resources (229f865)
|
||||
- migrate WorktreesOverviewModal to ModalShell (a444033)
|
||||
- migrate RepoImportModal to ModalShell (2265829)
|
||||
- migrate UnfinishedPlanningModal to ModalShell (50e05b9)
|
||||
- migrate AboutModal to ModalShell (538839c)
|
||||
- migrate MergeModal to ModalShell (8d07fc2)
|
||||
- migrate ListSettingsModal to ModalShell (e1bfbb0)
|
||||
- migrate SettingsModal to ModalShell (4f5db36)
|
||||
- tokenize ThemedDatePicker (16b0d11)
|
||||
- tokenize SessionTerminalView (a1f05da)
|
||||
- tokenize AgentStripView (0c0c73b)
|
||||
- tokenize DetailsIslandView (bff15c9)
|
||||
- tokenize TasksIslandView (f40de4b)
|
||||
- tokenize ListsIslandView (e120b0f)
|
||||
- tokenize MainWindow (e8ce725)
|
||||
- tokenize IslandStyles values and add shared modal styles (7a6bfbe)
|
||||
|
||||
### Documentation
|
||||
|
||||
- reflect Startup-shortcut worker autostart (549b87b)
|
||||
- add worker lifecycle implementation plan (5baa1d7)
|
||||
- add worker lifecycle redesign spec (4963a72)
|
||||
- add visual-check checklist for normalization pass (df73378)
|
||||
- add UI normalization design spec and implementation plan (d52f23f)
|
||||
|
||||
## v1.3.0 — 2026-05-30
|
||||
|
||||
### Features
|
||||
|
||||
- register new external MCP tool classes (b41a78e)
|
||||
- add external MCP app-settings read tool (9ea6070)
|
||||
- add external MCP reset-failed-task tool (5a592c4)
|
||||
- add external MCP agent-listing tool (7196aab)
|
||||
- add external MCP run-history and log tools (3afe29d)
|
||||
- add external MCP list/task config tools (c3493a3)
|
||||
- add external MCP list-management tools (53f4e2d)
|
||||
- run worker as per-user logon task instead of Windows service (26c4e57)
|
||||
- repo-import modal — remember folders, search, compact rows, no auto-select (6d0973c)
|
||||
- add delete-list button to List Settings modal (128fb7d)
|
||||
- add 'Add repos as lists' Help-menu entry point (9c638e7)
|
||||
- add repo import button to Lists island (c43b06d)
|
||||
- add RepoImportModalView (e4d958d)
|
||||
- add RepoImportModalViewModel with candidate merge logic (50b1589)
|
||||
- add RepoImportItemViewModel (1c689a8)
|
||||
- add RepoScanner for git repo discovery (03617ee)
|
||||
- gate subtask queueing behind plan finalization (ce79a2d)
|
||||
- merge action and robust jump-to-task in worktrees overview (967e0cd)
|
||||
- hide list chip outside virtual list views (2223839)
|
||||
- auto-select first changed file in diff modal (3587703)
|
||||
- polish worktrees overview modal (ca71275)
|
||||
- wire worktree overview modal entry points (789094f)
|
||||
- add WorktreesOverviewModalView (9f70f67)
|
||||
- add WorktreesOverviewModalViewModel (182a9df)
|
||||
- add WorktreeStateColorConverter (79131f8)
|
||||
- expose worktree overview client methods (b888a5f)
|
||||
- expose worktree overview, state mutation, force-remove (046da0f)
|
||||
- add ForceRemoveAsync for targeted removal (b095a29)
|
||||
- add GetOverviewAsync for overview modal (ce30d01)
|
||||
- allow CleanupFinishedAsync to filter by list (89f6b83)
|
||||
- add Restart worker menu entry under Help (8d34db3)
|
||||
- prevent orphaned subtasks via guards + startup repair (d094a21)
|
||||
- add Claude CLI preflight on startup (df66c4a)
|
||||
- cascade dequeue to queued children for any parent (4c92da5)
|
||||
- refine planning chain re-shape on re-run (d4d5a4b)
|
||||
- status/tag context menu + ThemedDatePicker in task row (9ba238f)
|
||||
- editable task status and tags from details panel (c185665)
|
||||
- add ThemedDatePicker control and adopt in Prime settings (47b0737)
|
||||
- add hub methods to set task status and tags freely (121e8cd)
|
||||
- drop 'agent' tag gate from queue claim (cfbe2fd)
|
||||
- show transient prime status in footer (5079a5f)
|
||||
- add About modal opened from Help menu (618235d)
|
||||
- refactor Settings to TabControl + add Prime Claude tab (bca8c9e)
|
||||
- split SettingsModalViewModel into per-tab VMs + add PrimeClaudeTabViewModel (8b02b63)
|
||||
- add Prime schedule client + PrimeFired event (f890fa8)
|
||||
- register Prime services in DI (71c6c68)
|
||||
- add Prime schedule hub methods (507f59f)
|
||||
- broadcast PrimeFired SignalR event (13c280f)
|
||||
- add PrimeScheduler hosted service (09e3e7e)
|
||||
- add NextDueCalculator with workday + catch-up logic (975db8a)
|
||||
- add Prime scheduler abstractions + runner (f383645)
|
||||
- add PrimeScheduleDto (4e90828)
|
||||
- add PrimeScheduleRepository (a335a3b)
|
||||
- add AddPrimeSchedules migration (0b90df6)
|
||||
- add PrimeScheduleEntity + configuration (6c9ccf6)
|
||||
- consolidate finalize+chain via TaskStateService, fix queue pickup (4ab906f)
|
||||
- add Idle/Cancelled status, PlanningPhase enum, BlockedByTaskId field (7b737e6)
|
||||
- show dequeue affordance on planning parents with queued children (bdb709b)
|
||||
- allow status changes and post-finalize edits in active session (2d7f825)
|
||||
- add SetTaskTags (59dc1e2)
|
||||
- add DeleteTask (31a394e)
|
||||
- add UpdateTask for content/tag patching (d99cb68)
|
||||
- AddTask accepts tags on creation (1a74e1c)
|
||||
- add ListTags + inject TagRepository (e6846b7)
|
||||
- add TaskRepository.SetTagsAsync for full tag-set replacement (2549352)
|
||||
- default permission mode to auto and surface it in UI (14cc9fb)
|
||||
- add editable system/planning/agent prompt files (7f96ae9)
|
||||
- add Run interactively action to task context menu (6c54759)
|
||||
- make island layout user-resizable with grid splitters (e192285)
|
||||
- add MarkdownView control and editable description in details island (a6ca1c0)
|
||||
- queue planning subtasks sequentially and surface waiting status (8f94ddd)
|
||||
- add external MCP endpoint with API-key auth (4532042)
|
||||
- add PlanningChainCoordinator for sequential subtask execution (16e1ddd)
|
||||
- add Waiting task status and CreatedBy column (288d2ec)
|
||||
- run planning agent in plan permission mode and enforce brainstorming skill (8e9f09a)
|
||||
- register planning services and add Merge-all hub methods (3008c36)
|
||||
- add pre-flight checks and idempotent restart to PlanningMergeOrchestrator (e58cac2)
|
||||
- add PlanningMergeOrchestrator.AbortAsync (b989639)
|
||||
- add PlanningMergeOrchestrator.ContinueAsync to resume merge after conflict (7d87c03)
|
||||
- add PlanningMergeOrchestrator happy path with merge event broadcasts (3142ba2)
|
||||
- add conflict resolution dialog for planning merge-all (bc788e1)
|
||||
- add aggregated diff viewer for planning tasks (a6ebff3)
|
||||
- add PlanningAggregator.CleanupIntegrationBranchAsync (389d904)
|
||||
- add merge-target dropdown and merge-all controls to planning detail (4c6fd9f)
|
||||
- add PlanningAggregator.BuildIntegrationBranchAsync (2cab33d)
|
||||
- add PlanningAggregator.GetAggregatedDiffAsync (a1727b6)
|
||||
- add AbortMergeAsync to cancel a conflicted merge (bc0f1e3)
|
||||
- add ContinueMergeAsync to resume a conflicted merge (62106ff)
|
||||
- add leaveConflictsInTree option to TaskMergeService.MergeAsync (e77ba35)
|
||||
- broadcast child TaskUpdated events on planning CRUD (5a03dc8)
|
||||
- launcher passes planning token via env, drops --mcp-config (6800852)
|
||||
- cleanup planning worktree and branch on finalize/discard (48899b3)
|
||||
- create ephemeral worktree and write .mcp.json in StartAsync (fce91bc)
|
||||
- live task updates from worker events + planning polish (b7c60f5)
|
||||
- SignalR hub endpoints for planning sessions (7b67e35)
|
||||
- map MCP HTTP endpoint and broadcast TaskUpdated (6cb20a9)
|
||||
- MCP tools update_planning_task and finalize (99c6a71)
|
||||
- MCP tools for child-task CRUD (0088d6e)
|
||||
- MCP bearer-token auth middleware (b115a4c)
|
||||
- WindowsTerminalPlanningLauncher with pre-flight checks (43a3740)
|
||||
- PlanningSessionManager.GetPendingDraftCountAsync (d28164c)
|
||||
- PlanningSessionManager.FinalizeAsync (77f7cf1)
|
||||
- PlanningSessionManager.DiscardAsync (84e6c2d)
|
||||
- PlanningSessionManager.ResumeAsync (84b0ba8)
|
||||
- PlanningSessionManager.StartAsync (b6bec1e)
|
||||
- friendly error when deleting task with children (0e116be)
|
||||
- unfinished planning session dialog (47b4974)
|
||||
- draft and planning badge styles (506caa2)
|
||||
- planning entries in task context menu (388a8c1)
|
||||
- TaskRowView hierarchy indentation, chevron, badges, draft italic (42b208f)
|
||||
- planning commands and expand/collapse in TasksIslandViewModel (309f84b)
|
||||
- WorkerClient planning-session methods (0060840)
|
||||
- TaskRowViewModel gains planning hierarchy flags (229d4bb)
|
||||
- hook TryCompleteParentAsync after MarkDone/MarkFailed (d4a4642)
|
||||
- TaskRepository.TryCompleteParentAsync (b7464c9)
|
||||
- TaskRepository.DiscardPlanningAsync (524aaf8)
|
||||
- TaskRepository.FinalizePlanningAsync (a9e7479)
|
||||
- TaskRepository.FindByPlanningTokenAsync (2e80cc6)
|
||||
- TaskRepository.UpdatePlanningSessionIdAsync (d099138)
|
||||
- TaskRepository.SetPlanningStartedAsync (2278d97)
|
||||
- TaskRepository.CreateChildAsync (74255dd)
|
||||
- TaskRepository.GetChildrenAsync (b466246)
|
||||
- migration AddPlanningSupport (b3eb39a)
|
||||
- configure planning columns and self-ref FK with Restrict (253e6f0)
|
||||
- add planning columns and self-ref navigations to TaskEntity (042a1b4)
|
||||
- add Planning, Planned, Draft task statuses (7a20534)
|
||||
- move list-settings access from lists pane to tasks header (ee2cbc9)
|
||||
- add update banner and Help menu to MainWindow (00c6217)
|
||||
- wire update-check state and commands into shell VM (bbe7d73)
|
||||
- register UpdateCheckService and InstallerLocator in DI (0934b29)
|
||||
- show worker log line in footer (b28d8f2)
|
||||
- add worker log state and 30s timer to shell VM (ec4ec44)
|
||||
- add InstallerLocator (ee09706)
|
||||
- add UpdateCheckService (c06d1d6)
|
||||
- add WorkerLogLevelToBrushConverter with tests (f906e70)
|
||||
- self-update pre-flight before wizard (caf900b)
|
||||
- subscribe to WorkerLog SignalR event (e80e3fc)
|
||||
- emit WorkerLog for merge, discard, reset (e805655)
|
||||
- emit WorkerLog events from TaskRunner (ea4d2d7)
|
||||
- add SelfUpdater.DownloadAndVerifyAsync (98c188a)
|
||||
- add SelfUpdater.HandleReplaceSelfAsync (0c3dcb0)
|
||||
- add SelfUpdater.DecideUpdateAsync (e017d66)
|
||||
- add SelfUpdater installer-asset matching (ba0b38b)
|
||||
- add VersionComparer (7c0f8d8)
|
||||
- add WorkerLog SignalR event (0a7fcae)
|
||||
- add WorkerLogLevel enum (80f6669)
|
||||
- add empty ClaudeDo.Releases library (86012e0)
|
||||
- replay persisted task log when selecting a task (c8c8bb4)
|
||||
- add queueing and scheduling from task row context menu (6f725d1)
|
||||
- use ClaudeTask icon for window and taskbar (9952ff9)
|
||||
- show version info and offer worker restart in settings (4a6d96b)
|
||||
- record data directory in install manifest (2690332)
|
||||
- harden database init and service setup steps (31218fc)
|
||||
- add Restore default agents button to Settings modal (e70ae7f)
|
||||
- add RestoreDefaultAgentsAsync to WorkerClient (1830273)
|
||||
- expose RestoreDefaultAgents hub method (1a10e6f)
|
||||
- seed default agents on startup (df57c2b)
|
||||
- add DefaultAgentSeeder for first-launch agent seeding (990be09)
|
||||
- add bundled default agent definitions (ff3de1d)
|
||||
- always-visible Steps section at top of DetailsIsland with add-step input (b0b15e4)
|
||||
- per-task agent settings in DetailsIsland (bba5778)
|
||||
- open ListSettingsModal via context menu and gear button (5784dbe)
|
||||
- add ListSettingsModalView (5348220)
|
||||
- add ListSettingsModalViewModel (cd0b95e)
|
||||
- WorkerClient supports list/task agent settings + ListUpdated event (fc1cfe5)
|
||||
- add hub methods for list and task agent settings (7c31216)
|
||||
- add TaskRepository.UpdateAgentSettingsAsync (480eb08)
|
||||
- add ListRepository.DeleteConfigAsync (1b94fa5)
|
||||
- show status messages and real diff-stats in DiffModal (3142057)
|
||||
- add Merge button to DiffModal (1bc7fcc)
|
||||
- add Merge command to DiffModal (c911717)
|
||||
- attach MergeModal to DetailsIsland (949911f)
|
||||
- wire DetailsIsland ApproveMerge through MergeModal (f3a58a6)
|
||||
- add MergeModalView (e11b019)
|
||||
- add MergeModalViewModel (3d0cc4f)
|
||||
- add MergeTaskAsync and GetMergeTargetsAsync to WorkerClient (4585b20)
|
||||
- expose MergeTask and GetMergeTargets on WorkerHub (c53b587)
|
||||
- implement TaskMergeService happy path (3331c24)
|
||||
- scaffold TaskMergeService with pre-flight checks (1c20d8f)
|
||||
- add ListConflictedFilesAsync (77a1460)
|
||||
- add MergeAbortAsync (21a1870)
|
||||
- add MergeNoFfAsync returning (exitCode, stderr) (3ebbdb3)
|
||||
- add IsMidMergeAsync (535d0c5)
|
||||
- add ListLocalBranchesAsync (2d807aa)
|
||||
- add GetCurrentBranchAsync (93ee7b7)
|
||||
- add Continue and Reset buttons to agent strip (2ce6b7b)
|
||||
- add Continue and Reset commands to DetailsIslandViewModel (b03e858)
|
||||
- add ContinueTaskAsync and ResetTaskAsync to WorkerClient (2278b51)
|
||||
- expose ResetTask hub method (219a231)
|
||||
- add TaskResetService for discard + reset flow (74eb36d)
|
||||
- add TaskRepository.ResetToManualAsync (202236a)
|
||||
- add WorktreeManager.DiscardAsync for task reset (44203f3)
|
||||
- add settings modal and wire to worker hub (e6b3762)
|
||||
- extend ClaudeArgsBuilder with MaxTurns and PermissionMode (fca5d57)
|
||||
- add WorktreeMaintenanceService for idle-worktree cleanup (cfb9ca1)
|
||||
- add AppSettings entity, migration, and repository (62a1121)
|
||||
- render user tool_result blocks as one-line summaries (374e811)
|
||||
- render assistant tool_use blocks with per-tool args (3a67fe8)
|
||||
- render assistant text blocks, skip thinking (dc6e3fe)
|
||||
- format system init message in StreamLineFormatter (b525498)
|
||||
- keyboard shortcuts (/ Ctrl+N Space Esc) (6dade01)
|
||||
- pulse, hover, modal, and row-add animations (47e8e1f)
|
||||
- worktree modal with tree view and M/A badges (abd7733)
|
||||
- diff modal with file sidebar and tinted hunks (4d68543)
|
||||
- tasks island with rows, chips, add-task, selection (f94bb35)
|
||||
- details island with agent strip, terminal, subtasks, notes (4f41b08)
|
||||
- DetailsIslandViewModel with agent state and log (fcf53ab)
|
||||
- TasksIslandViewModel with smart/virtual/user filtering (0034acc)
|
||||
- Lists island view with search and nav items (f167120)
|
||||
- TaskRowViewModel with status chip mapping (dc1b648)
|
||||
- ListsIslandViewModel with smart/virtual/user lists (06cc141)
|
||||
- chromeless three-island shell (05404f4)
|
||||
- scaffold islands shell and child VMs (8909119)
|
||||
- merge Tokens and IslandStyles into App (55917c9)
|
||||
- embed Inter Tight and JetBrains Mono fonts (1893576)
|
||||
- add design Tokens resource dictionary (92a6e06)
|
||||
- add island control styles (579b527)
|
||||
- seed default Lists (My Day, Important, Planned) (bd8a4d0)
|
||||
- migration for IsStarred/IsMyDay/Notes columns (928dde1)
|
||||
- add IsStarred, IsMyDay, Notes to TaskEntity (a1190a3)
|
||||
|
||||
### Fixes
|
||||
|
||||
- cap run-log read size and harden run-history tests (fec2fe2)
|
||||
- reuse shared hub fake and guard blank list name (ac2f1d8)
|
||||
- apply blue PLANNED badge for finalized planning, drop dead converter statics (7a88e8a)
|
||||
- strip prerelease and build metadata before version compare (b84716f)
|
||||
- narrow delete-list FK catch to SqliteException (6e3947c)
|
||||
- narrow RepoScanner catch to filesystem exceptions (4877c11)
|
||||
- widen About modal so folder Open buttons are not clipped (c1c7862)
|
||||
- restore Ui.Tests build by implementing ListUpdatedEvent in fakes (12668f6)
|
||||
- dispatch WorkerLog events to UI thread (7d61d38)
|
||||
- wire details-island buttons and drop dead handlers (e55367a)
|
||||
- default-expand diff tree; reliable row-click toggle (7e3ae70)
|
||||
- toggle expand on full folder row click (232d7cb)
|
||||
- use BorderOnly chrome; color diff +/- lines (6c8048d)
|
||||
- make overview modal resizable; add diff content pane (6670771)
|
||||
- resizable modal, drop branch column, show committed diff (bc15c16)
|
||||
- preserve status message after cleanup; English label (8f4e37e)
|
||||
- restore green test suite across all projects (8eafa71)
|
||||
- attach agent tag to chained children for queue pickup (721c36a)
|
||||
- emit PlanningMergeAborted (not Conflict) on non-conflict merge failures (ce23f64)
|
||||
- prevent PlanningMergeOrchestrator double-drain race and orphaned state (ef070dd)
|
||||
- reorder PlanningAggregator checkout/delete and kill git on cancel (9d04d1d)
|
||||
- align virtual list semantics and complete planning roll-up coverage (6bdfa73)
|
||||
- wrap MergeAbortAsync in AbortMergeAsync for consistent error handling (ada4d9f)
|
||||
- planning parents roll up child status; children stay nested until parent Done (6d460ea)
|
||||
- tighten ContinueMergeAsync guards and commit error handling (63759ee)
|
||||
- derive planning MCP URL from configured SignalRPort (e62485d)
|
||||
- register TaskRepository in DI and guard null WorkingDir (c048264)
|
||||
- planning launcher — avoid cmd shell to prevent prompt injection (9e09ae6)
|
||||
- enable foreign_keys pragma in MigrateAndConfigure (7821106)
|
||||
- select task on left-click even when reorder is disabled (1344beb)
|
||||
- session terminal scrolls to end after layout so last line is fully visible (7de5510)
|
||||
- pin AgentStrip above metadata footer, terminal sits above it (5e54275)
|
||||
- session terminal auto-sizes to output, caps at 420px before scrolling (6ac8823)
|
||||
- move agent-settings expander out of capped scroller so it expands properly (839f862)
|
||||
- use PlaceholderText instead of obsolete Watermark in ListSettingsModalView (2901a76)
|
||||
- use UTF-8 encoding for git process stdio (07dee31)
|
||||
- disable Merge button after worktree is no longer Active (4debd5c)
|
||||
- return Blocked when MergeAbortAsync fails to avoid stuck repo (1495c63)
|
||||
- honour targetBranch in MergeAsync by checking out before merge (953d931)
|
||||
- correct Reset button tooltip wording (58c8210)
|
||||
- early-return in ResetAsync when ConfirmAsync is unwired (f90d3d8)
|
||||
- prefix broadcast lines with [stdout] so UI parser routes them (4283c67)
|
||||
- truncate WebFetch URL in tool_use arg (ec679e4)
|
||||
- filled window icons, boxed task rows, proper explorer button (e19a9d3)
|
||||
- NAVIGATOR eyebrow — drop broken converter binding (42fb7ce)
|
||||
- wire delete confirm, close-details, uppercase eyebrow, explorer button (5acc896)
|
||||
- drop icon-btn sizing from AgentStrip text buttons (27c6a4b)
|
||||
- use Tag-attribute selectors for terminal log colors (2d1a488)
|
||||
- guard Bind/LoadForList against interleaved DbContext awaits (62aac7e)
|
||||
- wire modal delegates from DetailsIslandView owner (279f2c7)
|
||||
- remove stale brush overrides in App.axaml (9514651)
|
||||
- restore ViewModels using for IslandsShellViewModel (eee98b7)
|
||||
|
||||
### Refactoring
|
||||
|
||||
- merge TaskRunner failure handlers and reuse NullIfBlank (1856943)
|
||||
- fold single-consumer helper types into their owners (ce9fadc)
|
||||
- remove dead PlanningMergeEvents records and unused RunNowRequestedEvent (25ee623)
|
||||
- extract interfaces to Interfaces folders and consolidate filters (41da124)
|
||||
- consolidate commit types into CommitTypeRegistry (5da69ee)
|
||||
- consolidate permission modes into PermissionModeRegistry (5308ba3)
|
||||
- consolidate model aliases into ModelRegistry (a62ef24)
|
||||
- remove tag entity and all references (623ebf1)
|
||||
- dequeue orphans instead of promoting, restore lost lineage (0d55002)
|
||||
- consolidate task list filters into single strategy registry (e68bb73)
|
||||
- retire legacy TaskStatus values and backfill existing rows (dc3fc44)
|
||||
- extract OverrideSlotService and reorganize Worker/Services into domain folders (ff7c239)
|
||||
- split queue waker and picker, auto-wake on enqueue (064a903)
|
||||
- introduce TaskStateService and route mutations through it (8823265)
|
||||
- use --permission-mode auto instead of --dangerously-skip-permissions (b2eb5fc)
|
||||
- test planning detail pane via real ViewModel and restore merge-all IsEnabled binding (1aead9d)
|
||||
- switch MCP config to env-var token expansion (975e1ce)
|
||||
- add worktree path and token file helpers (1d61df8)
|
||||
- inject GitService and WorkerConfig into PlanningSessionManager (1370bf3)
|
||||
- drop McpConfigPath from PlanningSessionFiles (f2db5f4)
|
||||
- extend planning contexts with token and worktree (fd2ac48)
|
||||
- use shared VersionComparer in InstallModeDetector (5b4cdd3)
|
||||
- move release-API + checksum types to ClaudeDo.Releases (46e01ae)
|
||||
- redesign list settings and merge modals with custom chrome (5ced1b9)
|
||||
- single scrollable DetailsIsland body with agent-settings gear flyout, remove Notes (c599fdc)
|
||||
- skeleton dispatch for StreamLineFormatter rewrite (668087c)
|
||||
- centralize list seeding in MigrateAndConfigure, add default-value test (9a05907)
|
||||
|
||||
### Documentation
|
||||
|
||||
- sync CLAUDE.md files with current architecture (cfc4511)
|
||||
- correct external MCP tool inventory, drop removed tag tools (32daa4a)
|
||||
- clarify SetTaskConfig null-clears-override wording (f3f8af4)
|
||||
- add external MCP UI-parity spec and plan (99dc084)
|
||||
- clarify repo-import checkbox default intent (2f7f00d)
|
||||
- add repo import list helper implementation plan (5b15e30)
|
||||
- document RepoImportModalView (e5bce07)
|
||||
- add repo import list helper design spec (7869c2a)
|
||||
- add planning draft/planned queue gate design spec (09a930e)
|
||||
- add worktree overview modal spec and plan (b944597)
|
||||
- regenerate against current code state (a6608bf)
|
||||
- add design + plan for tabbed settings + Prime Claude (2ff0971)
|
||||
- add session prompts for worker state consolidation slices 2-6 (cf7a6e4)
|
||||
- add worker state and queue consolidation spec (43af17e)
|
||||
- add external MCP CRUD extensions spec and plan (10b2ca8)
|
||||
- document new external MCP tools (1b9f2d4)
|
||||
- add planning UX spec/plan and prompts/mailbox proposals (615c1da)
|
||||
- add spec and plan for planning merge-all feature (8afbf20)
|
||||
- add worktree-isolated MCP session design and plan (4de2dea)
|
||||
- add planning-session manual verification checklist (450e685)
|
||||
- add planning sessions implementation plans A, B, C (43d517d)
|
||||
- add planning sessions design (8891d48)
|
||||
- add self-update manual verification checklist (a41e5b5)
|
||||
- add worker-log footer implementation plan (ea76945)
|
||||
- add worker-log footer implementation plan (41e0bea)
|
||||
- add worker-log footer design spec (da19eb8)
|
||||
- add implementation plan (0d37473)
|
||||
- add design spec for app + installer self-update (6a4bf67)
|
||||
- add default-agents plan and design spec (a135485)
|
||||
- refresh CLAUDE.md files for agent settings UI (e74e7ee)
|
||||
- agent settings UI implementation plan (02464b7)
|
||||
- agent settings per list and per task UI reimplementation (68f461d)
|
||||
- add 2026-04-21 open-items consolidation (cb43bcd)
|
||||
- clarify merged-with-cleanup-warning result shape (32ef1b3)
|
||||
- add worktree merge implementation plan (0885518)
|
||||
- add worktree merge design spec (944d3bd)
|
||||
- note ResetTask hub method and TaskResetService (fb89e02)
|
||||
- add implementation plan for continue and reset buttons (133774c)
|
||||
- add spec for continue and reset buttons on failed tasks (a3bb557)
|
||||
- add UI-rewrite notes, plans, and stream-formatter spec (23f8fdd)
|
||||
- add design spec (b474113)
|
||||
|
||||
## v1.2.0 — 2026-04-17
|
||||
|
||||
### Features
|
||||
|
||||
- add subtask tree view with expand/collapse in task list (32bb528)
|
||||
|
||||
### Fixes
|
||||
|
||||
- expand ~ in UiDbPath (2a8cd97)
|
||||
- init editor TCS before dialog can complete (09e8b1f)
|
||||
- reset stale worktree state on TaskDetail reload (92d8d90)
|
||||
- capture CurrentListId before await in AddTask (aa1008d)
|
||||
- make user-data deletion on uninstall opt-in (5f3d41e)
|
||||
- rollback-safe extract with .bak stash (7d48f34)
|
||||
- move service start out of RegisterServiceStep (51a1bbe)
|
||||
- escape newline/tab in CLI args (ad7c9fa)
|
||||
- guard against same task in queue and override slot (11a4376)
|
||||
- reject CurrentUser service account without password (f10ad69)
|
||||
- swallow DB errors in TaskListViewModel.OnTaskUpdated (dc4571a)
|
||||
- emit RunCreated after run row exists (4fb6ba6)
|
||||
- resolve critical bugs and improve reliability across worker, data, UI (3423919)
|
||||
|
||||
### Documentation
|
||||
|
||||
- add subtask tree view design spec (4f25c3d)
|
||||
|
||||
## v1.1.0 — 2026-04-16
|
||||
|
||||
### Features
|
||||
|
||||
- wire EF Core into DI and update all consumers to IDbContextFactory (36484ed)
|
||||
- rewrite all repositories to use EF Core ClaudeDoDbContext (34ca1b0)
|
||||
- add ClaudeDoDbContext with Fluent API configurations (51a5dcb)
|
||||
- add navigation properties to all entity models (f8f1386)
|
||||
|
||||
### Fixes
|
||||
|
||||
- address code review findings (611454d)
|
||||
|
||||
### Refactoring
|
||||
|
||||
- switch InitDatabaseStep to EF Core migrations (7d0ca45)
|
||||
|
||||
### Documentation
|
||||
|
||||
- update CLAUDE.md files for EF Core migration (8d61b05)
|
||||
- add EF Core migration implementation plan (9236ca6)
|
||||
- add EF Core migration design spec (9e1f137)
|
||||
|
||||
## v1.0.0 — 2026-04-15
|
||||
|
||||
### Features
|
||||
|
||||
- add app and installer icons (3b1f148)
|
||||
- agent config inline in detail panel, file picker, subtask UI (9a407bd)
|
||||
- add subtasks table, repository and prompt integration (8c051d8)
|
||||
- remove MaxWidth on main columns to use full window width (8577c55)
|
||||
- mode-aware wizard page list + Update-mode step pipeline (b5455a1)
|
||||
- Config view — Save/Repair/Uninstall commands + footer buttons (2898bec)
|
||||
- add UninstallRunner (service + shortcuts + dirs) (ac38ea8)
|
||||
- rewrite WelcomePage for download-mode + update heading (da1fe21)
|
||||
- async mode detection + mode-aware DI wiring (01c29bb)
|
||||
- add WriteInstallManifestStep (5482518)
|
||||
- add DownloadAndExtractStep with SHA256 verify (c1e3301)
|
||||
- add Stop/StartServiceStep sc.exe wrappers (d87de15)
|
||||
- replace sync ModeDetector with async InstallModeDetector (97fb215)
|
||||
- add IReleaseClient + Gitea ReleaseClient (5603fd4)
|
||||
- add ChecksumVerifier (SHA256 + checksums.txt parser) (d0c0e2c)
|
||||
- add InstallManifest + json-backed store (921e626)
|
||||
- add Gitea Actions release workflow (aea0909)
|
||||
- add WPF installer/configurator project (78831b2)
|
||||
- add config override fields to TaskEditorView (f8be2c1)
|
||||
- complete Batch 2 — LiveText display, start feedback, modal theming, ListEditor config (699fe8a)
|
||||
- replace LiveLines with formatted LiveText, add log reload and start feedback (0764bb3)
|
||||
- add starting state feedback to task list (503fd69)
|
||||
- add StreamLineFormatter for NDJSON stream parsing (365ecba)
|
||||
- default to claude-sonnet-4-6 when no model configured (945a1ee)
|
||||
- add RunNowRequestedEvent and GetAgentsAsync to WorkerClient (026df8d)
|
||||
- add ContinueTask routing to QueueService (adc5a16)
|
||||
- add ContinueTask, GetAgents, RefreshAgents hub methods and RunCreated broadcast (6cb8012)
|
||||
- add AgentFileService for filesystem agent management (8825351)
|
||||
- extend RunResult with structured output, session ID, and token metrics (54c4d3c)
|
||||
- extend TaskRepository with model, system_prompt, agent_path columns (f57cdb7)
|
||||
- add StreamAnalyzer for rich NDJSON stream parsing (8b342bc)
|
||||
- add ClaudeArgsBuilder for dynamic CLI argument construction (dab461c)
|
||||
- add GetConfigAsync and SetConfigAsync to ListRepository (5232d5f)
|
||||
- add TaskRunRepository with CRUD and query methods (19a2104)
|
||||
- add ListConfigEntity, TaskRunEntity, AgentInfo models and task config fields (02aaa9d)
|
||||
- add list_config, task_runs tables and task config columns (36ae653)
|
||||
- add global keyboard shortcuts (Ctrl+N, Ctrl+L, Ctrl+R, Ctrl+Shift+N) (ff5e56a)
|
||||
- add inline add handlers, checkbox click, and task keyboard shortcuts (2dcfc7e)
|
||||
- add auto-save LostFocus handlers and tag input KeyDown (a44c104)
|
||||
- wire TaskDetail changes back to task list refresh (f51278e)
|
||||
- make TaskDetailViewModel editable with auto-save and tag CRUD (28a0d9b)
|
||||
- add inline task creation, toggle-done, and list name to TaskListViewModel (a4da2e2)
|
||||
- add ToggleDone command and checkbox state to TaskItemViewModel (0796b3c)
|
||||
- add colored dot brush to ListItemViewModel (3c52e9c)
|
||||
- add CheckboxBorderConverter for task status circles (a548d41)
|
||||
- add context menus for lists and tasks (3653dca)
|
||||
- open editor on double-click for lists and tasks (db5a447)
|
||||
- wire avalonia desktop ui to data and worker (48e4aab)
|
||||
- add git worktree support and conventional commits (01235d9)
|
||||
- add claude-cli runner, queue service, and hub api (e5038d7)
|
||||
- add repositories, stale-task recovery, and test foundation (9f51ff0)
|
||||
- add db schema init and signalr hub skeleton (f81ef02)
|
||||
|
||||
### Fixes
|
||||
|
||||
- prevent async void races and leak-on-exit (2b3fe02)
|
||||
- address concurrency, cancellation, and resource issues (d3b85f2)
|
||||
- wait for prior service registration to clear before create (fc9029d)
|
||||
- publish framework-dependent single-file (1c764da)
|
||||
- disable single-file compression to prevent WPF startup AV (cfec329)
|
||||
- service hosting, dark theme, uninstall polish (f599f8d)
|
||||
- set EnableWindowsTargeting so Linux Gitea runners can publish (9b928c6)
|
||||
- UninstallRunner abort-on-stop-fail + path guard + partial-failure reporting (5d42438)
|
||||
- null-defensive WelcomePage heading + guard unreachable modes (8d2f7e9)
|
||||
- fall back to Config on detection timeout when install.json exists (5e432a4)
|
||||
- wrap WriteInstallManifestStep I/O in try/catch like sibling steps (12e5327)
|
||||
- harden DownloadAndExtractStep per review (ea32a74)
|
||||
- check exit code (not stdout) for ERROR_SERVICE_ALREADY_RUNNING (5b4af29)
|
||||
- propagate cancellation + defensive asset parsing in ReleaseClient (83d7058)
|
||||
- fix live output visibility and editor dialog graying out (2a1f26d)
|
||||
- address code review findings (7363e48)
|
||||
- allow RunNow for any non-running task, not just queued (95c8cc8)
|
||||
- update QueueServiceTests for new TaskRunner constructor signature (26c2445)
|
||||
- replace deprecated Watermark with PlaceholderText (5f51fe9)
|
||||
- register TagRepository in TaskDetailViewModel constructor (5b6c095)
|
||||
- re-evaluate RunNow CanExecute when worker connection changes (473e0f7)
|
||||
- make list and task rows fully hit-testable for clicks (981b8e4)
|
||||
- context menu operates on right-clicked item and gates new-task on list selection (5d5a583)
|
||||
- harden worker auto-reconnect lifecycle (fdf357b)
|
||||
- cancel retry loop before disposing worker connection (36ef624)
|
||||
- auto-reconnect worker connection with retry backoff (c6522cf)
|
||||
|
||||
### Refactoring
|
||||
|
||||
- replace SourceDirectory with Mode/Version fields in InstallContext (4fab048)
|
||||
- remove source-build steps (replaced by DownloadAndExtractStep) (0989176)
|
||||
- remove MessageParser (replaced by StreamAnalyzer) (c1c4c75)
|
||||
- rewrite TaskRunner with config resolution, retry, and continue support (76473dd)
|
||||
- simplify ClaudeProcess to accept pre-built args and use StreamAnalyzer (1cdaaf9)
|
||||
- harden context menu event handling and simplify bindings (7838f08)
|
||||
- harden double-click edit handlers (6727cc4)
|
||||
|
||||
### Documentation
|
||||
|
||||
- add download-mode implementation plan (c0bd465)
|
||||
- finalize decisions — self-contained, auto-check, full uninstall (0498fba)
|
||||
- pin release target to releases/ClaudeDo (43a10cf)
|
||||
- add download-mode + Gitea Releases design spec (bd7d594)
|
||||
- add implementation plan for UI fixes (a6fe91d)
|
||||
- add design spec for post-integration UI fixes (fb3c96c)
|
||||
- update CLAUDE.md with CLI modernization changes (03728c8)
|
||||
- add UX redesign implementation plan (16 tasks) (9f61cd1)
|
||||
- add UX redesign spec (Microsoft To Do style) (0e41c37)
|
||||
|
||||
[Unreleased]: https://git.kuns.dev/releases/ClaudeDo/compare/v1.7.0...HEAD
|
||||
[1.7.0]: https://git.kuns.dev/releases/ClaudeDo/releases/tag/v1.7.0
|
||||
|
||||
14
CLAUDE.md
14
CLAUDE.md
@@ -10,7 +10,11 @@ Two-process system communicating over SignalR (`127.0.0.1:47821`):
|
||||
- **ClaudeDo.Ui** — Views, ViewModels, SignalR client (MVVM with CommunityToolkit.Mvvm)
|
||||
- **ClaudeDo.Data** — SQLite data layer, repositories, models, GitService
|
||||
- **ClaudeDo.Worker** — ASP.NET Core hosted service, task queue, Claude CLI runner
|
||||
- **ClaudeDo.Worker.Tests** — xUnit integration tests with real SQLite and real git
|
||||
- **ClaudeDo.Localization** — `locales/en.json` + `locales/de.json` and the lookup service
|
||||
- **ClaudeDo.Installer** — WPF (`UseWPF`) setup app; install/update/uninstall step pipeline
|
||||
- **tests/** — six xUnit projects (Worker, Data, Ui, Localization, Installer, Releases); Worker.Tests run real SQLite and real git
|
||||
|
||||
Each project has its own `CLAUDE.md` — those are the living per-project docs.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
@@ -75,6 +79,8 @@ dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
|
||||
|
||||
## Docs
|
||||
|
||||
- `docs/plan.md` — full architecture and design spec
|
||||
- `docs/open.md` — verification checklist and improvement backlog
|
||||
- `docs/improvement-plan.md` — prioritized improvement items
|
||||
- `docs/open.md` — open verification items and remaining code TODOs (the only doc kept current besides the CLAUDE.md files)
|
||||
- `docs/plan.md` — original design spec (historical; tag-queue/schema.sql parts are outdated)
|
||||
- `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
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# ClaudeDo — Improvement Plan (Session 2026-04-13)
|
||||
|
||||
> **Hinweis (2026-06-09):** Historischer Snapshot — bewusst nicht nachgepflegt. U.a. erledigt/überholt: IP-1 (Auto-Reconnect ist implementiert), `schema.sql` → EF-Core-Migrations, `StatusBarViewModel` existiert nicht mehr (Connection-State lebt in `IslandsShellViewModel`), Tags sind Junction-Tabellen statt JSON-Spalten. Offene Punkte stehen in `open.md`.
|
||||
|
||||
Erfasst während manuellem Walkthrough der App. Priorisiert nach Schmerz/Aufwand.
|
||||
|
||||
---
|
||||
|
||||
173
docs/online-inbox-api-contract.md
Normal file
173
docs/online-inbox-api-contract.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# ClaudeDo Online Inbox — API Contract & VPS build prompt
|
||||
|
||||
Status: handoff doc. The **server side** (API + minimal web client) is built and deployed
|
||||
VPS-side by a separate Claude instance. This file is the source of truth for the contract
|
||||
both ends implement against. The desktop client in this repo is built to match it.
|
||||
|
||||
---
|
||||
|
||||
## 1. Concept
|
||||
|
||||
ClaudeDo is a local desktop app that runs tasks autonomously via the Claude CLI; it is
|
||||
normally fully local (SQLite). The **Online Inbox** is an optional service that lets the
|
||||
single owner view their task lists and add new tasks from a phone/browser. The desktop app
|
||||
syncs against it.
|
||||
|
||||
**Governing rule:** the online store mirrors EXACTLY the desktop's `Idle` backlog — nothing
|
||||
else. A task is present online only while it is `Idle` on the desktop. The moment the user
|
||||
queues it locally, the desktop removes it from the online store. Running / WaitingForReview /
|
||||
Done / Failed / Cancelled tasks never appear online.
|
||||
|
||||
Sync directions (each one-way per entity → no conflict resolution needed):
|
||||
|
||||
- **Lists**: desktop → online only. Desktop is the source of truth (full-replace catalog).
|
||||
- **Idle tasks**: desktop mirrors its Idle backlog up; the web can create new ones, which the
|
||||
desktop pulls down and then owns.
|
||||
|
||||
Single user today. Both the desktop and the web client authenticate as the **same Zitadel
|
||||
user**.
|
||||
|
||||
**Multi-user readiness (`ownerId`).** Each resource is owned by a Zitadel subject (`sub`).
|
||||
`RemoteList`, `RemoteTask`, and `MirrorTask` carry an optional `ownerId` field. The desktop
|
||||
stamps its own `sub` (decoded from the access token) onto everything it pushes, and
|
||||
defensively ignores any pulled task whose `ownerId` is set to a *different* user; an absent
|
||||
`ownerId` is treated as unowned/legacy and still syncs. This keeps the contract ready for
|
||||
multiple users **without enforcing isolation client-side** — the server remains the
|
||||
authority that scopes every request by the token's `sub`. When the server goes multi-user it
|
||||
should partition all rows by owner and ignore (or validate) the client-supplied `ownerId`.
|
||||
|
||||
**Access control (as of 2026-06-10).** Access is granted by assigning the **"user" project
|
||||
role** in the Zitadel project "ClaudeDo" (id `376787351902355727`, issuer
|
||||
`https://auth.kuns.dev`) — there is no app-side allowlist (the former `ALLOWED_USER_IDS`
|
||||
env var is gone). The access token carries the role in the claim
|
||||
`urn:zitadel:iam:org:project:roles` (or the project-scoped variant
|
||||
`urn:zitadel:iam:org:project:376787351902355727:roles`), an object keyed by role key, e.g.
|
||||
`{ "user": { "<orgId>": "<orgDomain>" } }`. The desktop OIDC client
|
||||
(id `376787352137302287`) has `accessTokenRoleAssertion` enabled, so any token issued
|
||||
after login/refresh includes the claim automatically — no extra scopes are needed.
|
||||
Granting/revoking access is purely a Zitadel role grant, nothing app-side.
|
||||
|
||||
## 2. Idle backlog definition (desktop side)
|
||||
|
||||
The desktop mirrors only "real" backlog items, not planning internals:
|
||||
|
||||
- `Status == Idle`
|
||||
- `ParentTaskId == null` (no planning/improvement children)
|
||||
- `PlanningPhase == None`
|
||||
- `BlockedByTaskId == null`
|
||||
|
||||
## 3. Data model (Postgres)
|
||||
|
||||
```
|
||||
lists
|
||||
id text primary key -- GUID supplied by the desktop; reuse verbatim
|
||||
name text not null
|
||||
updated_at timestamptz not null default now()
|
||||
|
||||
tasks
|
||||
id text primary key -- GUID; SHARED id space (see below)
|
||||
list_id text not null references lists(id) on delete cascade
|
||||
title text not null
|
||||
description text
|
||||
imported boolean not null default false -- false = web-created, awaiting desktop pull
|
||||
-- true = desktop-owned (mirrored or handed off)
|
||||
created_at timestamptz not null default now()
|
||||
updated_at timestamptz not null default now()
|
||||
```
|
||||
|
||||
**Shared GUID id space.** Web-created tasks get a server-generated GUID; the desktop imports
|
||||
under that SAME id, so it never duplicates. Desktop-mirrored tasks arrive with their own GUID.
|
||||
All task writes are idempotent upserts keyed on id.
|
||||
|
||||
**`imported` flag = ownership.**
|
||||
- Web `POST /tasks` inserts `imported=false`.
|
||||
- Desktop pulls `imported=false`, creates the task locally (reusing the id), then `POST
|
||||
/tasks/{id}/imported` flips it to `true`. From then on the task belongs to the desktop
|
||||
mirror.
|
||||
- `PUT /tasks/mirror` only ever inserts/updates/deletes within the `imported=true` partition.
|
||||
It never touches `imported=false` rows (those are pending handoff).
|
||||
|
||||
## 4. Endpoints
|
||||
|
||||
All endpoints require a valid Zitadel access token (`Authorization: Bearer <token>`) that
|
||||
carries the **"user" project role** (see §1). Missing/invalid/expired token, or a valid
|
||||
token without the role → `401`. No anonymous access (imported tasks can trigger code
|
||||
execution on the user's machine). The desktop client treats a `401` as: force a
|
||||
refresh-token exchange and retry once; if a freshly issued token is still rejected, it
|
||||
surfaces "missing 'user' role in Zitadel" and pauses sync until the user signs in again.
|
||||
|
||||
> **Auth (VPS/.NET):** use the in-house `KunsZitadel` nuget package (feed
|
||||
> `https://git.kuns.dev/api/packages/kuns/nuget/index.json`) — call `AddKunsZitadel(...)`
|
||||
> with the Zitadel authority/audience/client id to wire `JwtBearer` validation + CORS for
|
||||
> the web client origin. (`KunsZitadel` is server-side token *validation* only; the desktop
|
||||
> client acquires tokens via its own OIDC flow.)
|
||||
|
||||
| Method & path | Caller | Body | Response |
|
||||
|---|---|---|---|
|
||||
| `PUT /lists` | desktop | `[{ "id", "name", "ownerId"? }]` — the FULL catalog | `200` |
|
||||
| `GET /lists` | web | — | `200 [{ "id", "name", "ownerId"? }]` |
|
||||
| `GET /lists/{id}/tasks` | web | — | `200` tasks in that list (`404` if list unknown) |
|
||||
| `POST /tasks` | web | `{ "title", "description"?, "listId" }` | `201` created task incl. `id` |
|
||||
| `GET /tasks?imported=false` | desktop | — | `200 [{ "id","listId","title","description","createdAt","ownerId"? }]` |
|
||||
| `POST /tasks/{id}/imported` | desktop | — | `200` (`404` if unknown) |
|
||||
| `PUT /tasks/mirror` | desktop | `[{ "id","listId","title","description","ownerId"? }]` — full Idle set | `200` |
|
||||
|
||||
`ownerId` (optional, see §1) is the Zitadel `sub` of the owner. The desktop sends it on push
|
||||
and ignores pulled tasks owned by a different user; the server should derive/validate it from
|
||||
the token rather than trust the client value.
|
||||
|
||||
Semantics:
|
||||
|
||||
- **`PUT /lists`** — full replace: upsert all supplied, DELETE any list not in the payload
|
||||
(cascades its tasks). Idempotent.
|
||||
- **`POST /tasks`** — `listId` must exist (`400`/`404` otherwise). Server generates the id.
|
||||
- **`PUT /tasks/mirror`** — full replace of the `imported=true` partition: upsert every task
|
||||
in the payload (insert with `imported=true`, or update), and DELETE any `imported=true`
|
||||
task whose id is not in the payload. `imported=false` rows are untouched. Idempotent.
|
||||
- All task ids are client-trusted within the shared space; the server never rewrites an id.
|
||||
|
||||
## 5. Reconcile loop (desktop, runs each poll cycle)
|
||||
|
||||
```
|
||||
1. PULL: GET /tasks?imported=false
|
||||
for each: if no local task with that id → create local TaskEntity
|
||||
{ Id = remote.id, ListId = remote.listId, Title, Description,
|
||||
Status = Idle, CreatedBy = "online" }
|
||||
(skip + log if remote.listId has no local list)
|
||||
then POST /tasks/{id}/imported
|
||||
2. PUSH LISTS: PUT /lists with the full local catalog [{id, name}]
|
||||
3. PUSH TASKS: PUT /tasks/mirror with the current local Idle backlog set (§2)
|
||||
```
|
||||
|
||||
Ordering matters: pull+import+flag first, so the just-imported tasks are part of the local
|
||||
Idle set computed in step 3 and survive the mirror replace.
|
||||
|
||||
## 6. Minimal web client
|
||||
|
||||
Integrate into the existing Nuxt app at claudedo.kuns.dev if present; else a minimal page.
|
||||
|
||||
- Zitadel login.
|
||||
- Show lists (`GET /lists`); select one to see its Idle tasks (`GET /lists/{id}/tasks`).
|
||||
- Add-task form → `POST /tasks`.
|
||||
- Mobile-first (main use: jotting ideas from a phone).
|
||||
- **Create + read only.** No editing, reordering, status changes, or deletes.
|
||||
|
||||
## 7. Security
|
||||
|
||||
- Every route auth-gated (`401` on bad token); only static assets / login are public.
|
||||
- Validate `listId` on task creation; parameterized queries only.
|
||||
- CORS restricted to the web client origin.
|
||||
- Don't log task titles/descriptions at info level (user content).
|
||||
|
||||
## 8. Deliverables from the VPS build
|
||||
|
||||
Report back so the desktop can be configured:
|
||||
|
||||
1. **API base URL.**
|
||||
2. **Zitadel app/client config the desktop must use**: issuer/authority, client id, scopes,
|
||||
and the OAuth flow to use for a desktop app (device-code or auth-code + PKCE), plus how
|
||||
refresh tokens are issued.
|
||||
3. Any env vars / README.
|
||||
|
||||
Out of scope server-side: task execution (the desktop runs Claude), any task state other
|
||||
than the Idle mirror, multi-user / sharing / notifications.
|
||||
36
docs/open.md
36
docs/open.md
@@ -1,6 +1,6 @@
|
||||
# ClaudeDo — Offene Punkte
|
||||
|
||||
Stand: 2026-06-04. **Nur noch offene Punkte.** Was erledigt ist, steht in den Commits und im Code — nicht hier.
|
||||
Stand: 2026-06-10. **Nur noch offene Punkte.** Was erledigt ist, steht in den Commits und im Code — nicht hier.
|
||||
|
||||
---
|
||||
|
||||
@@ -13,11 +13,45 @@ Kein Code-Aufwand, nur Durchspielen mit explizit notiertem Pass-Kriterium. Der G
|
||||
- No-Changes-Run → `status='Done'`, `head_commit IS NULL`, `diff_stat IS NULL`.
|
||||
- Kein Git-Repo (`working_dir=C:\Temp`) → `status='Failed'`, **keine** `worktrees`-Row, Git-Fehler im Log.
|
||||
- **Feature-Walkthroughs:** Planning-Session-Flow (Draft→Finalize→Chain), Prime/Daily-Prep-Trigger, Weekly-Report-Generierung, Self-Update (Banner → Update → „up to date").
|
||||
- **UI-Sichtprüfung (neu, 2026-06-09):** Diff-Viewer (Dateiliste, Added/Deleted/Renamed/Binary-Erkennung, Commit-Range-Diff nach Merge) und das „children need attention"-Band auf dem Session-Tab des Parents.
|
||||
- **UI-Sichtprüfung (neu, 2026-06-10, nach Refactoring-Merges):** Detail-Insel komplett durchklicken (Output/Git/Session-Tabs, Merge-Sektion, Agent-Settings-Overrides, Prep-Panel) — `DetailsIslandViewModel` wurde in Sektions-VMs aufgeteilt, Bindings angepasst. Außerdem: DiffModal-Fehler-State „Diff nicht mehr verfügbar" (Commit-Range ohne aufgezeichnete Commits) und der In-App-Konflikt-Resolver (Hub-Methoden umbenannt).
|
||||
- **UI-Sichtprüfung (neu, 2026-06-19, Rider-Style 3-Pane Merge-Editor):** Echten Konflikt auslösen (Single-Task-Approve mit Konflikt **und** Planning-Unit-Merge) und prüfen: drei Panes (Ours read-only | Result editierbar | Theirs read-only), Konfliktblöcke rot / aufgelöst grün in allen Panes, Inline-Accept `›`/`‹` in den Zwischen-Guttern landen die jeweilige Seite im Result, nur Konfliktregionen im Result editierbar (Stable read-only), synchrones vertikales Scrollen, File-Switcher bei mehreren Dateien, `M conflicts · K resolved`-Readout, Continue erst bei allen Konflikten gelöst, Binär-Guard. **Bekannte Kanten:** (1) Konflikt mit leerer Ours-Seite → Result-Region ist null-lang (Gutter via 1-Zeichen-Probe positioniert, Accept funktioniert; nur Hand-Tippen in die leere Region ist fummelig). (2) Gutter-Y nutzt `TranslatePoint` vom Result-`TextView` — bei sehr hohen Fenstern / großen Scrollständen die Ausrichtung gegenprüfen. (3) Blöcke richten sich nur über Stable-Text aus; nach einem Konflikt mit unterschiedlicher Zeilenzahl je Seite driften nachfolgende Blöcke vertikal (aligned/virtual-space Scroll ist bewusst zurückgestellt).
|
||||
- **Worker-Autostart am Gerät:** Logoff/Logon-Autostart, Update-Pfad, Uninstall entfernt die Startup-`.lnk`.
|
||||
- **In-App Interactive Sessions (neu, 2026-06-26):** ersetzt den externen `wt`-„Run interactively"-Launch durch einen In-App-Streaming-Chat (`StreamingClaudeSession`, `claude --input-format stream-json`). Real-CLI-Smoke (kein xUnit, kein Claude in Tests):
|
||||
- Task rechtsklick → „Run interactively" startet **keinen** Terminal mehr; der Stream erscheint im Detail-Output-Tab des (selektierten) Tasks und als Monitor in Mission Control.
|
||||
- Composer: Nachricht tippen + Enter/Send → erscheint sofort als `log-user`-Zeile in **Akzentfarbe** (via `LogKindForegroundConverter`, lokale Bindung schlägt den dim Style), Claude antwortet im selben Prozess.
|
||||
- **Senden während Claude arbeitet = Queue (Default):** die Nachricht wird gepuffert und beim `result` des laufenden Turns abgeschickt (kein Interrupt). Mehrere Queue-Nachrichten FIFO, eine pro Turn. Gequeute Nachrichten erscheinen in einem **Pending-Streifen über der Eingabezeile** (⧗-Liste, via `InteractiveQueueChanged`); eine Nachricht landet erst im Transkript (`log-user`-Zeile via `InteractiveMessageSent`), wenn sie tatsächlich an Claude zugestellt wird. Der seeded Erst-Prompt erscheint als erste User-Zeile. Jede gequeute Zeile hat ein **✕ zum Entfernen** (`RemoveQueuedInteractiveMessage`, by-text first-match; Worker re-broadcastet die Queue).
|
||||
- **Interrupt opt-in:** der kleine ■-Stop-Button neben Send unterbricht den laufenden Turn (`control_request`/`interrupt`, verifiziert mit CLI 2.1.191; Abbruch-`result` = `error_during_execution`, als Turn-Ende behandelt) — danach flusht die ggf. gequeute Nachricht im selben Prozess mit erhaltenem Kontext. Stop-Button ist immer sichtbar solange live (Interrupt im Idle ist ein No-op; Turn-in-flight wird nicht in die UI gebroadcastet).
|
||||
- Session-Ende: Prozess-Exit/Stop → `InteractiveSessionEnded`, Composer verschwindet, Monitor wird „done".
|
||||
- **Sicht-Konsistenz:** Mission-Control-Composer (SessionTerminalView-Bottom-Row mit Send-Button) vs. Detail-Composer (WorkConsole-Shell-Prompt `❯ … [Send]`) sehen unterschiedlich aus — ggf. angleichen.
|
||||
- **Drag-and-drop file attachments on the detail pane:** verify the "Drop to attach" hover overlay, drop round-trip (file appears in the list), "Add file…" picker, remove button, and that files land under `~/.todo-app/attachments/<taskId>/`. Also verify the MCP `AddTaskAttachment`/`ListTaskAttachments`/`RemoveTaskAttachment` tools and that a Running task refuses add/remove. (Manual; can't be unit-tested.)
|
||||
|
||||
## Offene Code-Punkte
|
||||
|
||||
- **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.
|
||||
|
||||
## Nachklapp Refactoring-/Bug-Runde (2026-06-09/10)
|
||||
|
||||
Alle 9 Review-Tasks (5 Refactorings, 4 Bugfixes) sind umgesetzt und gemerged; Details in den Commits. Offen geblieben:
|
||||
|
||||
- **`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.
|
||||
|
||||
## 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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# ToDo-App mit autonomem Agent-Worker — Design
|
||||
|
||||
> **Hinweis (2026-06-09):** Historisches Design-Dokument vom Projektstart — bewusst nicht nachgepflegt. Überholt sind insbesondere: die Tag-basierte Queue (entfernt; der Picker nutzt `Status=Queued` + `BlockedByTaskId IS NULL`), `schema.sql` (Schema läuft über EF-Core-Migrations) und das Projektlayout (inzwischen sechs Testprojekte). Lebende Doku sind die `CLAUDE.md`-Dateien pro Projekt.
|
||||
|
||||
## Context
|
||||
|
||||
Ziel: eine persönliche ToDo-App als Desktop-Anwendung, in der mehrere Listen verwaltet werden können. Ein Teil der Tasks soll autonom von Claude abgearbeitet werden (z.B. Recherche, Code-Aufgaben, Notizen-Verarbeitung). Die Autonomie läuft in einem getrennten Hintergrund-Prozess, damit die UI davon entkoppelt bleibt.
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# Plan: Per-task model override via MCP + cheapest-model prompt guidance
|
||||
|
||||
Spec: `docs/superpowers/specs/2026-06-09-per-task-model-override-design.md`
|
||||
|
||||
TDD, one focused commit per task. Build with `-c Release` per project; run
|
||||
`ClaudeDo.Worker.Tests` (and `Data.Tests` if touched).
|
||||
|
||||
## Task 1 — ModelRegistry: cost ordering + alias validation
|
||||
|
||||
- Add `ByCostAscending = ["haiku","sonnet","opus"]`.
|
||||
- Add `string? NormalizeAlias(string? model)`: trim; null/blank → null;
|
||||
case-insensitive match against `Aliases` → canonical lowercase; else throw
|
||||
`ArgumentException($"Unknown model '{model}'. Allowed: {join(Aliases)}.")`.
|
||||
- Tests (Data.Tests): "sonnet"/"OPUS"/" haiku " → normalized; ""/null/" " →
|
||||
null; "gpt4" → throws.
|
||||
|
||||
## Task 2 — CreateChildAsync accepts model
|
||||
|
||||
- `TaskRepository.CreateChildAsync`: add `string? model = null` (before the
|
||||
trailing `CancellationToken ct = default`); set
|
||||
`child.Model = ModelRegistry.NormalizeAlias(model)`.
|
||||
- Update the two existing callers to compile (named pass-through added in
|
||||
Tasks 3–4; keep default null here).
|
||||
|
||||
## Task 3 — Planning + improvement MCP tools forward model
|
||||
|
||||
- `PlanningMcpService.CreateChildTask`: add `string? model` param after
|
||||
`commitType`; pass to `CreateChildAsync`. Extend `[Description]` to document
|
||||
the model arg (haiku/sonnet/opus; cheapest capable).
|
||||
- `TaskRunMcpService.SuggestImprovement`: add `string? model` param after
|
||||
`description`; pass to `CreateChildAsync`. Extend `[Description]`.
|
||||
- Tests: each tool persists the model; invalid value throws.
|
||||
|
||||
## Task 4 — External AddTask forwards model
|
||||
|
||||
- `ExternalMcpService.AddTask`: add `string? model = null` param (before the
|
||||
trailing `CancellationToken`); `entity.Model = ModelRegistry.NormalizeAlias(model)`.
|
||||
Extend `[Description]`.
|
||||
- Test: AddTask persists model; invalid value rejected.
|
||||
|
||||
## Task 5 — Prompt guidance
|
||||
|
||||
- `PromptFiles.PlanningSystemDefault`: add a short paragraph — assign each
|
||||
subtask the cheapest model that does it well, with ordering haiku < sonnet <
|
||||
opus and the heuristic; pass it as `CreateChildTask(model=...)`.
|
||||
- `PromptFiles.SystemDefault` Out-of-scope section: when filing via
|
||||
`SuggestImprovement`, pass the cheapest capable `model`.
|
||||
- `PromptFiles.ImprovementChildDefault`: one-line minimality reminder.
|
||||
- No test (static prompt text); verify build only.
|
||||
|
||||
## Task 6 — Verify
|
||||
|
||||
- Build App + Worker `-c Release`; run Worker.Tests + Data.Tests.
|
||||
- Update `ClaudeDo.Worker/CLAUDE.md` (ConfigMcpTools/creation-tool notes) and
|
||||
`ClaudeDo.Data/CLAUDE.md` (ModelRegistry) if needed.
|
||||
72
docs/superpowers/plans/2026-06-10-online-inbox.md
Normal file
72
docs/superpowers/plans/2026-06-10-online-inbox.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# Online Inbox — implementation plan
|
||||
|
||||
Date: 2026-06-10
|
||||
Spec: `docs/superpowers/specs/2026-06-10-online-inbox-design.md`
|
||||
Contract: `docs/online-inbox-api-contract.md`
|
||||
|
||||
TDD, one commit per task, Conventional Commits. Build with `-c Release` per CLAUDE.md.
|
||||
|
||||
## Phase 1 — Worker sync engine (buildable now, no Zitadel package needed)
|
||||
|
||||
### Task 1 — Config
|
||||
- Add `OnlineInboxConfig` + nested `ZitadelClientConfig` records.
|
||||
- Add `online_inbox` (`OnlineInbox`) property to `WorkerConfig`; default `enabled=false`.
|
||||
- `Load` leaves it untouched when absent (defaults = disabled).
|
||||
- Test: missing section → disabled defaults; populated section round-trips.
|
||||
|
||||
### Task 2 — DTOs + Idle-backlog helper
|
||||
- `Online/Dtos.cs`: `RemoteList(Id, Name)`, `RemoteTask(Id, ListId, Title, Description, CreatedAt)`,
|
||||
`MirrorTask(Id, ListId, Title, Description)`.
|
||||
- `Online/OnlineBacklog.cs`: `static Task<List<MirrorTask>> CurrentAsync(TaskRepository/ctx)` +
|
||||
the filter predicate (Idle, no parent, PlanningPhase None, BlockedBy null).
|
||||
- Test the filter against real SQLite seeded with mixed tasks.
|
||||
|
||||
### Task 3 — Auth abstraction + token store
|
||||
- `Online/Interfaces/IOnlineAuthProvider.cs`.
|
||||
- `Online/OnlineTokenStore.cs`: DPAPI CurrentUser persistence at `~/.todo-app/online-inbox.token`;
|
||||
`Save(refreshToken)`, `Read()`, `Clear()`. (Windows-only encryption; thin + guarded.)
|
||||
- A trivial `StaticTokenAuthProvider` (returns a configured token or null) for tests + as the
|
||||
temporary default until Zitadel is wired.
|
||||
- Test: token store round-trip (Windows); static provider returns/omits token.
|
||||
|
||||
### Task 4 — API client
|
||||
- `Online/IOnlineInboxApi.cs` + `Online/OnlineInboxApiClient.cs` (typed `HttpClient`).
|
||||
- Attaches `Authorization: Bearer` from `IOnlineAuthProvider`; refuses non-HTTPS non-loopback
|
||||
base URLs; throws a typed `OnlineInboxException` on non-2xx.
|
||||
- Test with a stubbed `HttpMessageHandler`: each method hits the right path/verb/body; 401
|
||||
surfaces; bearer attached.
|
||||
|
||||
### Task 5 — Sync service
|
||||
- `Online/OnlineSyncService.cs` (`BackgroundService`) implementing the §5 reconcile loop.
|
||||
- DI: register only when `enabled`; resolve repos per-cycle via a scope.
|
||||
- Per-cycle try/catch + structured logging; skip when no token; unknown-list skip.
|
||||
- Test against a **fake `IOnlineInboxApi`** + real SQLite: pull→import→flag creates local Idle
|
||||
tasks; mirror payload == Idle backlog; lists pushed; unknown list skipped & not flagged;
|
||||
disabled/no-token = no api calls.
|
||||
|
||||
### Task 6 — Wire-up + docs
|
||||
- Register the stack in `Program.cs` behind the enabled flag.
|
||||
- Update `src/ClaudeDo.Worker/CLAUDE.md` (new `Online/` area) and `src/ClaudeDo.Worker/Config`
|
||||
notes. Add `online_inbox` to the config section.
|
||||
|
||||
## Phase 2 — UI + real auth (AFTER the VPS reports client config)
|
||||
|
||||
### Task 7 — Hub + config plumbing
|
||||
- Hub: `GetOnlineInboxConfig` / `SetOnlineInboxConfig` / `SetOnlineInboxAuth(refreshToken)` /
|
||||
`ClearOnlineInboxAuth`. Update `IWorkerClient` + `WorkerClient` + test fakes (both test
|
||||
projects — see the IWorkerClient-fakes memory).
|
||||
|
||||
### Task 8 — Settings UI
|
||||
- "Online Inbox" section in `SettingsModalViewModel`: enable toggle, base URL, Sign in/out,
|
||||
status. Localized keys in en.json + de.json (parity).
|
||||
- Visual verification = manual (flag it).
|
||||
|
||||
### Task 9 — ZitadelAuthProvider
|
||||
- Add the Zitadel package reference; implement `ZitadelAuthProvider` (refresh-token → access
|
||||
token, cached to expiry) using the reported authority/client-id/flow.
|
||||
- Swap it in for `StaticTokenAuthProvider` in DI when enabled.
|
||||
- Manual smoke against the live VPS API (tracked, not an automated test).
|
||||
|
||||
## Notes
|
||||
- No real network / no real Zitadel / no real Claude in any automated test.
|
||||
- Stage files by explicit path in subagents; sonnet model; build+test+commit by the orchestrator.
|
||||
104
docs/superpowers/plans/2026-06-19-feature-unification-plan.md
Normal file
104
docs/superpowers/plans/2026-06-19-feature-unification-plan.md
Normal file
@@ -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.
|
||||
92
docs/superpowers/plans/2026-06-19-rider-merge-editor.md
Normal file
92
docs/superpowers/plans/2026-06-19-rider-merge-editor.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# Plan: Rider-style 3-pane merge editor
|
||||
|
||||
Spec: `docs/superpowers/specs/2026-06-19-rider-merge-editor-design.md`
|
||||
|
||||
TDD, one focused commit per task (Conventional Commits, `feat(merge): …`).
|
||||
Build with `-c Release` per project (a running Worker locks `Debug`).
|
||||
Run `ClaudeDo.Ui.Tests` (and `Localization.Tests` for Task 6). No real `claude` CLI in tests.
|
||||
Stage ONLY the files each task touches, by explicit path (parallel sessions leave WIP).
|
||||
Backend + seam stay unchanged. Implementer/reviewer subagents use **sonnet**.
|
||||
|
||||
## Task 1 — VM: active-file model + 3-pane reconstruction + readout
|
||||
|
||||
`ConflictResolverViewModel` / `ConflictModels.cs`, additive (seam untouched).
|
||||
|
||||
- Add `ActiveFile` (`MergeFile?`), `SelectFileCommand(MergeFile)`, default to first file
|
||||
after load. Keep `Files`, `Current`/`CurrentIndex`/`Next`/`Previous` (focused conflict
|
||||
for the header arrows), `CanContinue`, binary guard, planning routing — all unchanged.
|
||||
- Add computed, per `ActiveFile`:
|
||||
- `ActiveOursText` = concat(stable.Text | conflict.Ours)
|
||||
- `ActiveTheirsText` = concat(stable.Text | conflict.Theirs)
|
||||
- `ActiveResultText` = concat(stable.Text | conflict.Resolution ?? conflict.Ours)
|
||||
- `ActiveConflicts` = ordered descriptors (block + segment index) for the view.
|
||||
- `PositionText` → `"{conflicts} conflicts · {resolved} resolved"` for the active file;
|
||||
keep `CanContinue` = every file resolved AND no binary.
|
||||
- Switching files raises a change event the view listens to (reuse/extend
|
||||
`CurrentChanged` → e.g. `ActiveFileChanged`).
|
||||
- Tests (Ui.Tests): reconstruction text for ours/theirs/result (result seeds unresolved
|
||||
with Ours); resolving a block updates `ActiveResultText` + readout; switching files
|
||||
preserves each block's `Resolution`; `CanContinue` blocks until all files resolved;
|
||||
binary file still blocks. Keep all existing tests green.
|
||||
|
||||
## Task 2 — View: 3-pane AXAML shell + document assembly + synced scroll
|
||||
|
||||
`Views/Conflicts/ConflictResolverView.axaml(.cs)`. Visual — verified by running.
|
||||
|
||||
- Replace AXAML: ModalShell host kept; header row (◀/▶ focus arrows bound to
|
||||
Previous/Next, file switcher `ItemsControl`/`ComboBox` over `Files` bound to
|
||||
`SelectFileCommand`, right-aligned `PositionText`); `Grid ColumnDefinitions="*,*,*"`
|
||||
of three bordered panes with headers **Ours · current (merge target)** /
|
||||
**Result** / **Theirs · incoming (task)** (drop Base); footer Continue
|
||||
(`IsEnabled=CanContinue`) / Abort; binary banner (kept); `Escape`→Abort (kept).
|
||||
- Code-behind: build three `TextDocument`s from `ActiveFile` segments, recording each
|
||||
conflict's start line + line count per document; install TextMate per pane by file
|
||||
extension; rebuild on `ActiveFileChanged`; Ours/Theirs `IsReadOnly=true`.
|
||||
- Proportional synced vertical scroll across the three panes (re-entrancy guard).
|
||||
- Push Result edits back to the active block `Resolution` (refined in Task 4).
|
||||
|
||||
## Task 3 — Result pane: read-only stable, editable conflicts
|
||||
|
||||
`ConflictResolverView.axaml.cs` + a small `IReadOnlySectionProvider` helper.
|
||||
|
||||
- Track each conflict's result span in a `TextSegmentCollection<…>` over the Result
|
||||
document (anchors auto-adjust on edit).
|
||||
- `IReadOnlySectionProvider`: `CanInsert` only strictly inside a conflict span;
|
||||
`GetDeletableSegments` intersects with conflict spans only. Stable text becomes
|
||||
immutable; conflict regions stay editable.
|
||||
- Editing inside a conflict span writes the span text back to the block `Resolution`
|
||||
and flips it resolved (updates readout + `CanContinue`).
|
||||
|
||||
## Task 4 — Color blocks (IBackgroundRenderer) + accept overlay
|
||||
|
||||
`ConflictResolverView.axaml.cs` + renderer/overlay helpers.
|
||||
|
||||
- `IBackgroundRenderer` per pane: unresolved conflict = red (Blood tint), resolved =
|
||||
green/muted, Ours side = Moss tint, Theirs side = Accent tint — driven by recorded
|
||||
spans + block `IsResolved`.
|
||||
- Between-pane overlay Canvas (Ours|Result and Result|Theirs): `›` accept-ours / `‹`
|
||||
accept-theirs + `✕` dismiss per conflict, positioned at the block's `TextView` visual
|
||||
top, recomputed on scroll/resize. Click → `block.AcceptOurs/AcceptTheirs` and replace
|
||||
the tracked Result span; resolved blocks recolor.
|
||||
|
||||
## Task 5 — Polish: readout, focus arrows scroll-to-conflict, resolved styling
|
||||
|
||||
- ◀/▶ arrows move `Current` and scroll all three panes to that conflict.
|
||||
- `M conflicts · K resolved` live readout; Continue tooltip/hint when blocked.
|
||||
- Resolved conflict recolors and drops its accept overlay; unresolved stays red.
|
||||
(Fold into Task 4 if small.)
|
||||
|
||||
## Task 6 — Localization + tokens
|
||||
|
||||
- Add `conflictResolver.*` keys (pane headers, readout, accept tooltips, hints) to
|
||||
`locales/en.json` AND `locales/de.json` (keep key parity).
|
||||
- Add Tokens.axaml color tokens only if a needed conflict/resolved shade is missing.
|
||||
- Run Localization.Tests (parity) + a quick scan for hard-coded strings in the view.
|
||||
|
||||
## Task 7 — Verify
|
||||
|
||||
- Build `ClaudeDo.App` + `ClaudeDo.Ui` `-c Release`; run `Ui.Tests` + `Localization.Tests`.
|
||||
- Update `src/ClaudeDo.Ui/CLAUDE.md` (Planning/Conflicts paragraph → new 3-pane editor).
|
||||
- **Visual verification gap (flag to Mika):** run the app, trigger a real conflict
|
||||
(single-task approve + planning unit-merge) and confirm panes/colors/accept/scroll/
|
||||
gating/binary render correctly — cannot be asserted in tests.
|
||||
131
docs/superpowers/plans/2026-06-19-unify-agent-config.md
Normal file
131
docs/superpowers/plans/2026-06-19-unify-agent-config.md
Normal file
@@ -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.
|
||||
111
docs/superpowers/plans/2026-06-19-unify-diff-viewer.md
Normal file
111
docs/superpowers/plans/2026-06-19-unify-diff-viewer.md
Normal file
@@ -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.
|
||||
56
docs/superpowers/plans/2026-06-25-interactive-ask-user.md
Normal file
56
docs/superpowers/plans/2026-06-25-interactive-ask-user.md
Normal file
@@ -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.
|
||||
98
docs/superpowers/plans/2026-06-25-mission-control.md
Normal file
98
docs/superpowers/plans/2026-06-25-mission-control.md
Normal file
@@ -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.
|
||||
101
docs/superpowers/plans/2026-06-26-in-app-interactive-sessions.md
Normal file
101
docs/superpowers/plans/2026-06-26-in-app-interactive-sessions.md
Normal file
@@ -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,80 @@
|
||||
# Per-task model override via MCP + cheapest-model prompt guidance
|
||||
|
||||
Date: 2026-06-09
|
||||
|
||||
## Goal
|
||||
|
||||
Let Claude pick the model for each task it generates (planning subtasks,
|
||||
improvement follow-ups, external task creation) directly at creation time via
|
||||
MCP, and instruct Claude — in the relevant prompts — to choose the *cheapest*
|
||||
model that can do the job well.
|
||||
|
||||
## Background
|
||||
|
||||
- `TaskEntity.Model` (nullable) already exists and is resolved
|
||||
task → list-config → global default in `TaskRunner.ResolveConfigAsync`, then
|
||||
passed to the CLI as `--model` by `ClaudeArgsBuilder`.
|
||||
- Today the model can only be set *after* creation via `set_task_config`
|
||||
(`ConfigMcpTools.SetTaskConfig`). The creation tools (`CreateChildTask`,
|
||||
`SuggestImprovement`, `AddTask`) accept no model, so assigning one is a
|
||||
two-call dance.
|
||||
- `ModelRegistry.Aliases = ["sonnet","opus","haiku"]`; no cost ordering or
|
||||
validation helper exists.
|
||||
|
||||
No schema change is required — only plumbing a `model` argument through the
|
||||
creation paths plus prompt edits.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Validation:** strict alias-only. `model` must be one of haiku/sonnet/opus
|
||||
(case-insensitive); blank/null means "inherit" (no override); anything else
|
||||
throws an MCP error so Claude self-corrects immediately rather than the task
|
||||
failing later at CLI runtime.
|
||||
- **`AddSubtask` is out of scope:** it creates a `SubtaskEntity` (a checklist
|
||||
step), which is never independently executed — a model there is a no-op.
|
||||
- **Improvement-child prompt:** the child's model is fixed at filing time and
|
||||
it cannot re-pick, so only a one-line "this is an intentionally small/cheap
|
||||
unit — stay minimal" reminder is added. The real model-choice instruction
|
||||
lives in the main system prompt's SuggestImprovement guidance.
|
||||
|
||||
## Cost ordering & heuristic (single source: `ModelRegistry.ByCostAscending`)
|
||||
|
||||
`haiku < sonnet < opus`
|
||||
|
||||
- **haiku** — trivial/mechanical: doc tweaks, simple renames, small localized edits.
|
||||
- **sonnet** — normal coding work (default).
|
||||
- **opus** — complex architecture, cross-cutting changes, hard debugging.
|
||||
|
||||
## Changes
|
||||
|
||||
1. **`ClaudeDo.Data/Models/ModelRegistry.cs`**
|
||||
- `ByCostAscending = ["haiku","sonnet","opus"]`.
|
||||
- `string? NormalizeAlias(string? model)` — trim; null/blank → null;
|
||||
case-insensitive match → canonical lowercase alias; else throw
|
||||
`ArgumentException` with the allowed list.
|
||||
|
||||
2. **`TaskRepository.CreateChildAsync`** — add optional `string? model = null`;
|
||||
set `child.Model = ModelRegistry.NormalizeAlias(model)`. Single choke-point
|
||||
for both child-creation MCP tools.
|
||||
|
||||
3. **MCP creation tools** (add `model` param, document in `[Description]`):
|
||||
- `PlanningMcpService.CreateChildTask` → forward to `CreateChildAsync`.
|
||||
- `TaskRunMcpService.SuggestImprovement` → forward to `CreateChildAsync`.
|
||||
- `ExternalMcpService.AddTask` → `NormalizeAlias` then set `entity.Model`.
|
||||
|
||||
4. **Prompts (`PromptFiles.cs`)**
|
||||
- `PlanningSystemDefault` — instruct the planner to pass each
|
||||
`CreateChildTask` the cheapest capable model (with the ordering/heuristic).
|
||||
- `SystemDefault` (Out-of-scope improvements) — when filing via
|
||||
`SuggestImprovement`, pass the cheapest capable `model`.
|
||||
- `ImprovementChildDefault` — one-line minimality reminder.
|
||||
|
||||
5. **Tests** (no real CLI):
|
||||
- `NormalizeAlias`: valid aliases (any case), blank/null → null, unknown → throws.
|
||||
- `CreateChildTask` / `SuggestImprovement` / `AddTask` persist the model;
|
||||
invalid model is rejected.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- No DB migration. No locale changes (prompts and MCP descriptions are not
|
||||
localized). No UI changes (existing per-task model display already covers it).
|
||||
142
docs/superpowers/specs/2026-06-10-online-inbox-design.md
Normal file
142
docs/superpowers/specs/2026-06-10-online-inbox-design.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# Online Inbox — desktop-side design
|
||||
|
||||
Date: 2026-06-10
|
||||
Status: approved, implementing
|
||||
Related: `docs/online-inbox-api-contract.md` (the API both ends share)
|
||||
|
||||
## Goal
|
||||
|
||||
Let the owner add task ideas and view their Idle backlog from a phone/browser. The desktop
|
||||
ClaudeDo opts in to an online service, syncs its list catalog + Idle backlog up, and pulls
|
||||
web-created tasks down as local `Idle` tasks. Execution stays 100% local.
|
||||
|
||||
This spec covers only the **desktop side** (this repo). The API + web client are built
|
||||
VPS-side against the shared contract.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No remote execution; the Worker still runs everything locally.
|
||||
- No syncing of any task state other than the `Idle` mirror.
|
||||
- No multi-user. Single Zitadel user = the owner.
|
||||
- Web client is create + read only.
|
||||
|
||||
## Opt-in & where things live
|
||||
|
||||
- **Off by default.** When disabled: zero network, zero auth — byte-for-byte today's
|
||||
behaviour. Auth only matters once enabled.
|
||||
- Sync runs in the **Worker** (it owns the DB and already hosts `BackgroundService`s). The
|
||||
opt-in config and the stored refresh token live in `worker.config.json`-adjacent state.
|
||||
- Interactive Zitadel login happens in the **UI** (browser flow), which hands the resulting
|
||||
refresh token to the Worker over SignalR; the Worker persists it (DPAPI) and uses it for
|
||||
headless token refresh during polling.
|
||||
|
||||
## Config (`WorkerConfig`, new `online_inbox` section)
|
||||
|
||||
```jsonc
|
||||
"online_inbox": {
|
||||
"enabled": false,
|
||||
"api_base_url": "", // e.g. https://inbox.claudedo.kuns.dev
|
||||
"poll_interval_seconds": 60,
|
||||
"zitadel": {
|
||||
"authority": "", // issuer URL (from VPS report)
|
||||
"client_id": "",
|
||||
"scopes": "openid offline_access" // offline_access → refresh token
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The refresh token is NOT stored in this file. It lives encrypted via
|
||||
`System.Security.Cryptography.ProtectedData` (DPAPI, CurrentUser) at
|
||||
`~/.todo-app/online-inbox.token` and is read/written only by the Worker.
|
||||
|
||||
## Components (Worker, new `Online/` folder)
|
||||
|
||||
```
|
||||
Worker/Online/
|
||||
OnlineInboxConfig.cs — the config record (bound from WorkerConfig.OnlineInbox)
|
||||
Dtos.cs — RemoteList, RemoteTask, MirrorTask DTOs (match the contract)
|
||||
IOnlineInboxApi.cs — typed client surface (one method per endpoint)
|
||||
OnlineInboxApiClient.cs — HttpClient impl; attaches bearer via IOnlineAuthProvider
|
||||
Interfaces/IOnlineAuthProvider.cs — Task<string?> GetAccessTokenAsync(ct)
|
||||
ZitadelAuthProvider.cs — concrete (PENDING: needs the Zitadel package + client config)
|
||||
OnlineTokenStore.cs — DPAPI-backed refresh-token persistence
|
||||
OnlineSyncService.cs — BackgroundService: the reconcile loop (§contract 5)
|
||||
OnlineBacklog.cs — static helper: the Idle-backlog query/filter (§contract 2)
|
||||
```
|
||||
|
||||
### `IOnlineInboxApi`
|
||||
```
|
||||
Task PutListsAsync(IReadOnlyList<RemoteList> lists, ct)
|
||||
Task<IReadOnlyList<RemoteTask>> GetUnimportedTasksAsync(ct) // GET /tasks?imported=false
|
||||
Task MarkImportedAsync(string id, ct) // POST /tasks/{id}/imported
|
||||
Task PutMirrorAsync(IReadOnlyList<MirrorTask> tasks, ct) // PUT /tasks/mirror
|
||||
```
|
||||
(The desktop never calls `POST /tasks`, `GET /lists`, or `GET /lists/{id}/tasks` — those are
|
||||
web-only.)
|
||||
|
||||
### `IOnlineAuthProvider`
|
||||
Single method `Task<string?> GetAccessTokenAsync(CancellationToken)` returning a bearer token
|
||||
(refreshing transparently), or `null` if not logged in / refresh failed. Abstracting it lets
|
||||
us:
|
||||
- ship and test the sync engine now with a fake provider,
|
||||
- wire the real `ZitadelAuthProvider` once the VPS reports authority/client-id and we add the
|
||||
Zitadel package reference.
|
||||
|
||||
`ZitadelAuthProvider` reads the refresh token from `OnlineTokenStore`, exchanges it for an
|
||||
access token, caches the access token until near expiry. **Marked with a
|
||||
`// TODO(online-inbox)` until the flow is wired.**
|
||||
|
||||
> **Auth correction (2026-06-10):** the `KunsZitadel` nuget package is a *server-side*
|
||||
> resource-server helper (`AddKunsZitadel` → `JwtBearer` token *validation*). It belongs on
|
||||
> the VPS API, NOT the desktop. The desktop must *acquire* tokens, so `ZitadelAuthProvider`
|
||||
> uses a client OIDC flow — `IdentityModel.OidcClient` (auth-code + PKCE, loopback redirect)
|
||||
> or the device-authorization grant — against Zitadel's OIDC endpoints, then persists the
|
||||
> refresh token via `OnlineTokenStore`.
|
||||
|
||||
### `OnlineSyncService` (the loop)
|
||||
- Hosted only when `online_inbox.enabled == true` (guarded at registration).
|
||||
- Every `poll_interval_seconds`: create a DI scope, resolve `TaskRepository` + `ListRepository`
|
||||
(same pattern as the External MCP app), run the §5 reconcile loop.
|
||||
- Skips a cycle (logs at debug) if `GetAccessTokenAsync` returns null (not logged in).
|
||||
- All failures are caught per-cycle and logged; never crashes the Worker. Network errors back
|
||||
off to the next interval.
|
||||
- Import safety: a pulled task whose `listId` has no local list is skipped + logged (not
|
||||
imported), and NOT marked imported, so it retries once the list exists. Imported tasks land
|
||||
as `Status=Idle, CreatedBy="online"` — they never auto-run; the user queues them locally.
|
||||
|
||||
## UI (later increment, after VPS report)
|
||||
|
||||
- Settings modal → new "Online Inbox" section: enable toggle, API base URL, **Sign in /
|
||||
Sign out** (Zitadel browser/device flow via the OIDC client lib), connection status.
|
||||
- Login produces a refresh token; UI sends it to the Worker via a new hub method
|
||||
`SetOnlineInboxAuth(refreshToken)` → Worker writes it through `OnlineTokenStore`.
|
||||
- Config read/write via hub methods `GetOnlineInboxConfig` / `SetOnlineInboxConfig`
|
||||
(mirrors the existing `GetAppSettings`/`UpdateAppSettings` pattern).
|
||||
- Visual verification is a manual step (flagged — never claimed working without a run).
|
||||
|
||||
## Security
|
||||
|
||||
- Disabled → no network, no token read.
|
||||
- Bearer attached only over HTTPS `api_base_url`; refuse `http://` non-loopback base URLs.
|
||||
- Refresh token encrypted at rest (DPAPI CurrentUser). Never logged.
|
||||
- Imported tasks are `Idle` only — no auto-execution path from the web.
|
||||
|
||||
## Testing
|
||||
|
||||
- `OnlineSyncService` reconcile logic tested against a **fake `IOnlineInboxApi`** + real
|
||||
SQLite (Worker.Tests style): pull→import→flag, mirror set = Idle backlog, list catalog push,
|
||||
unknown-list skip, disabled = no calls, not-logged-in = skipped cycle.
|
||||
- `OnlineBacklog` filter tested directly (excludes children/planning/blocked/non-Idle).
|
||||
- **No real network and no real Zitadel** in tests — fake the api + auth provider. (Consistent
|
||||
with the no-real-Claude-in-tests rule.)
|
||||
- DPAPI token store: round-trip test is Windows-only; guard or keep as a thin wrapper.
|
||||
|
||||
## Open items (need the VPS report)
|
||||
|
||||
- Exact Zitadel authority/issuer, client id, scopes, and **which grant the Zitadel app is
|
||||
registered for** (auth-code+PKCE with which loopback redirect URI, or device-code). This
|
||||
drives the desktop OIDC client implementation.
|
||||
- Final API base URL.
|
||||
- Desktop client OIDC library decision: `IdentityModel.OidcClient` (recommended) vs
|
||||
hand-rolled device-code. (`KunsZitadel` is server-side only — see the auth correction
|
||||
above; it's for the VPS API.)
|
||||
@@ -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.
|
||||
132
docs/superpowers/specs/2026-06-19-rider-merge-editor-design.md
Normal file
132
docs/superpowers/specs/2026-06-19-rider-merge-editor-design.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# Rider-style 3-pane merge editor (conflict resolver redesign)
|
||||
|
||||
Date: 2026-06-19
|
||||
|
||||
## Goal
|
||||
|
||||
Replace ClaudeDo's current conflict resolver (3 read-only columns Base|Ours|Theirs,
|
||||
one conflict at a time, accept buttons + editable result below) with a JetBrains
|
||||
Rider-style **3-pane merge editor**:
|
||||
|
||||
- LEFT = **Ours** (read-only) · current branch / merge target
|
||||
- MIDDLE = **Result** (editable) · the merged file being assembled
|
||||
- RIGHT = **Theirs** (read-only) · incoming task branch
|
||||
|
||||
Whole file per pane (not one conflict at a time), color-coded conflict blocks,
|
||||
inline per-hunk accept controls (`›` accept a side into the result, `✕` dismiss),
|
||||
a `M conflicts · K resolved` readout, synced scrolling, Continue gated until every
|
||||
conflict is resolved, Abort, and a binary-file guard. Visual reference: the
|
||||
attached "Merge Revisions" screenshot.
|
||||
|
||||
## Background
|
||||
|
||||
- Avalonia 12 desktop app; the conflict editor already uses **AvaloniaEdit 12.0.0**
|
||||
+ `AvaloniaEdit.TextMate` (theme `StyleInclude` in `src/ClaudeDo.App/App.axaml`).
|
||||
- **Backend is kept unchanged.** `WorkerHub.GetMergeConflictDocuments(taskId)` returns
|
||||
each conflicted file as ordered `MergeSegment`s: *stable* text (git's already
|
||||
auto-merged content) interleaved with *conflict* blocks carrying `Ours/Base/Theirs`.
|
||||
`StartConflictMerge` / `WriteConflictResolution` / `Continue[Planning]ConflictMerge` /
|
||||
`Abort[Planning]ConflictMerge` and their `IWorkerClient` mirrors stay as-is.
|
||||
`ConflictMarkerParser` (Data) already produces the segments. **ours = merge target
|
||||
(current branch); theirs = incoming task branch.** Merges are LOCAL-only (no push).
|
||||
- **Seam kept unchanged** so single-task AND planning conflict paths keep working:
|
||||
`IslandsShellViewModel.ConflictResolverFactory` + `ShowConflictResolver`
|
||||
(wired in `MainWindow.axaml.cs`), VM ctor `(IWorkerClient, taskId)`,
|
||||
`OpenAsync(targetBranch)`, `OpenForPlanningAsync(parentId, subtaskId)`, `CloseRequested`.
|
||||
The planning-path WIP currently uncommitted in the tree (`OpenForPlanningAsync`,
|
||||
`_conflictTaskId`, `LoadDocumentsAsync`) is part of this seam and is preserved.
|
||||
|
||||
### Key insight: the segments already line the panes up
|
||||
|
||||
Because every conflicted file is split into *stable* (identical on both sides, git
|
||||
auto-merged) and *conflict* (divergent) segments, reconstructing three documents —
|
||||
|
||||
- **Ours** = Σ over segments of (stable.Text | conflict.Ours)
|
||||
- **Theirs** = Σ over segments of (stable.Text | conflict.Theirs)
|
||||
- **Result** = Σ over segments of (stable.Text | conflict.Resolution ?? conflict.Ours)
|
||||
|
||||
— yields three documents that are byte-identical in their stable regions and differ
|
||||
only inside conflict blocks. So the panes align line-for-line for free, and a real
|
||||
client-side 3-way diff is **not** needed for the core feature.
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Data source = segment-based (no backend change, no DiffPlex).** The worker already
|
||||
applied git's auto-merge; only conflicts remain actionable. The screenshot's
|
||||
"N changes" (non-conflicting hunks shown as separately flippable) are already merged
|
||||
and have nothing to accept, so the readout is **`M conflicts · K resolved`**. True
|
||||
"N changes" parity (raw `:1/:2/:3` blobs + DiffPlex 3-way) is an explicit later
|
||||
add-on that does not touch the seam — see *Out of scope / fast-follow*.
|
||||
- **One file at a time + file switcher.** Like Rider's title bar ("Merge Revisions for
|
||||
…file"). When more than one file conflicts, a compact switcher selects the active
|
||||
file; Continue still requires *all* files resolved. (Replaces today's cross-file
|
||||
flattened one-at-a-time navigation as the primary model.)
|
||||
- **Result-pane editing model.** The middle document is the merged file. Stable text is
|
||||
read-only via `IReadOnlySectionProvider`; only conflict regions are editable. Each
|
||||
conflict's result span is tracked in a `TextSegmentCollection` (anchors auto-adjust on
|
||||
edit). Accepting `›`(ours)/`‹`(theirs) replaces that span; editing inside it or
|
||||
accepting flips the block to **resolved**. Unresolved regions are seeded with the Ours
|
||||
text and painted red until acted on.
|
||||
- **Accept controls = overlay between panes** (not an AvaloniaEdit margin). A thin Canvas
|
||||
overlay between Ours|Result and Result|Theirs hosts `›`/`✕` (and `‹`) per conflict,
|
||||
positioned at each block's visual Y (recomputed on scroll/resize). This matches the
|
||||
screenshot's between-pane gutters and avoids the lack of a built-in right-side margin.
|
||||
- **Synced scroll = proportional (Green).** Mirror each pane's vertical scroll offset to
|
||||
the other two with a re-entrancy guard. Aligned/virtual-space scroll + bezier connector
|
||||
curves are a deferred stretch.
|
||||
- **Seam + existing VM tests preserved.** Keep `MergeConflictBlock` with its
|
||||
`AcceptOurs/Theirs/Both/Base` commands and `MergeFile.Compose`; keep
|
||||
`Current`/`CurrentIndex`/`Next`/`Previous` repurposed as the focused-conflict the top
|
||||
arrows jump to. New state (active file, readout) is additive.
|
||||
|
||||
## Architecture
|
||||
|
||||
### ViewModel (`ConflictResolverViewModel`, `ConflictModels.cs`)
|
||||
|
||||
Unchanged seam: ctor, `OpenAsync`, `OpenForPlanningAsync`, `CloseRequested`,
|
||||
`Continue`/`Abort` (incl. planning routing), `CanContinue` gating, binary guard.
|
||||
|
||||
Additive:
|
||||
- `ActiveFile` (`MergeFile`) + the switcher list (`Files`) + `SelectFileCommand`.
|
||||
- Per-active-file reconstruction exposed for the view and for tests:
|
||||
`ActiveOursText`, `ActiveTheirsText`, `ActiveResultText` (result seeds unresolved =
|
||||
Ours), plus an ordered list of conflict descriptors (the block + its segment index)
|
||||
so the view can compute offsets/spans as it assembles each document.
|
||||
- Readout `PositionText` → `"{M} conflicts · {K} resolved"` (active file and/or total);
|
||||
`CanContinue` stays "all files resolved AND no binary".
|
||||
- On switching files, block `Resolution` persists (state lives on `MergeConflictBlock`),
|
||||
so progress survives navigation; the view rebuilds documents from the active file.
|
||||
|
||||
### View (`Views/Conflicts/ConflictResolverView.axaml` + `.cs`)
|
||||
|
||||
- AXAML: ModalShell host (kept), header (prev/next arrows, file switcher, readout),
|
||||
`Grid` of three bordered panes with headers, two between-pane overlay Canvases,
|
||||
footer (Continue/Abort), binary banner, `Escape`→Abort. Drop the Base column.
|
||||
- Code-behind builds three `TextDocument`s from `ActiveFile`'s segments, recording each
|
||||
conflict's line span per document; installs TextMate by file extension on all three;
|
||||
rebuilds on file switch; pushes result-pane edits back into the active block's
|
||||
`Resolution` and flips resolved.
|
||||
- `IReadOnlySectionProvider` on the Result `TextArea` (stable = read-only, conflicts =
|
||||
editable) backed by a `TextSegmentCollection` of the conflict result-spans.
|
||||
- One `IBackgroundRenderer` per pane painting unresolved-conflict (red), resolved
|
||||
(green/muted), and ours/theirs side tints, driven by the recorded spans + block state.
|
||||
- Overlay accept controls positioned at each block's `TextView` visual top; click →
|
||||
`block.AcceptOurs/AcceptTheirs` and the code-behind replaces the tracked result span.
|
||||
- Proportional synced vertical scroll across the three panes.
|
||||
|
||||
### Localization / tokens
|
||||
|
||||
- New `conflictResolver.*` keys (pane headers, readout, accept tooltips) in
|
||||
`en.json` + `de.json` (parity enforced by Localization.Tests).
|
||||
- Block colors from `Tokens.axaml` (reuse Blood/Moss/Accent tints; add tokens only if a
|
||||
needed shade is missing).
|
||||
|
||||
## Out of scope / fast-follow (not in this plan)
|
||||
|
||||
- **Raw 3-way diff "N changes" parity (Option B):** a new worker method returning raw
|
||||
`:1/:2/:3` blobs per conflicted file + DiffPlex client-side 3-way diff so
|
||||
non-conflicting changes also appear as accept-able hunks. Seam-preserving; later.
|
||||
- **Intra-conflict word/line highlighting** (Rider's "Highlight words") via a line
|
||||
transformer.
|
||||
- **Bezier connector curves + aligned / virtual-space synced scroll** (Red stretch).
|
||||
- No DB migration, no backend/seam changes, no push.
|
||||
@@ -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.
|
||||
102
docs/superpowers/specs/2026-06-25-interactive-ask-user-design.md
Normal file
102
docs/superpowers/specs/2026-06-25-interactive-ask-user-design.md
Normal file
@@ -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).
|
||||
144
docs/superpowers/specs/2026-06-25-mission-control-design.md
Normal file
144
docs/superpowers/specs/2026-06-25-mission-control-design.md
Normal file
@@ -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.
|
||||
@@ -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>
|
||||
@@ -31,6 +32,7 @@
|
||||
|
||||
<Application.Styles>
|
||||
<FluentTheme />
|
||||
<StyleInclude Source="avares://AvaloniaEdit/Themes/Fluent/AvaloniaEdit.xaml" />
|
||||
<StyleInclude Source="avares://ClaudeDo.Ui/Design/IslandStyles.axaml" />
|
||||
<!-- Global defaults: every Window inherits Inter Tight + body size.
|
||||
Controls that need mono opt in via their own class/style. -->
|
||||
|
||||
@@ -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>(),
|
||||
|
||||
@@ -19,8 +19,8 @@ Desktop entry point for the ClaudeDo application. Configures DI, initializes the
|
||||
|
||||
## DI Registration Pattern
|
||||
|
||||
- **Singletons**: `IDbContextFactory`, all Repositories, GitService, WorkerClient, `IReleaseClient`, `InstallerLocator` / `WorkerLocator`, the island VMs (`ListsIslandViewModel`, `TasksIslandViewModel`, `DetailsIslandViewModel`) and `IslandsShellViewModel` (the window's DataContext)
|
||||
- **Transients**: modal VMs (`SettingsModalViewModel`, `MergeModalViewModel`, `ListSettingsModalViewModel`, `RepoImportModalViewModel`, `WorktreeModalViewModel`, `WorktreesOverviewModalViewModel`, `PrimeClaudeTabViewModel`), several exposed as `Func<T>` factories for on-demand dialog creation
|
||||
- **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`, `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
|
||||
|
||||
|
||||
@@ -14,10 +14,12 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="12.0.0" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="12.0.0" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.0" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.0" />
|
||||
<PackageReference Include="Avalonia" Version="12.0.4" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="12.0.4" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.4" />
|
||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.4" />
|
||||
<!-- Direct ref so the App.axaml AvaloniaEdit theme (avares://AvaloniaEdit/...) resolves at runtime. -->
|
||||
<PackageReference Include="Avalonia.AvaloniaEdit" Version="12.0.0" />
|
||||
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.0">
|
||||
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
||||
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
||||
|
||||
@@ -116,13 +116,18 @@ 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>();
|
||||
sc.AddSingleton<INotesApi, WorkerNotesApi>();
|
||||
sc.AddSingleton<IOnlineLoginService, OnlineLoginService>();
|
||||
sc.AddTransient<PrimeClaudeTabViewModel>();
|
||||
sc.AddTransient<SettingsModalViewModel>();
|
||||
sc.AddTransient<MergeModalViewModel>();
|
||||
@@ -134,29 +139,35 @@ sealed class Program
|
||||
sc.AddTransient<Func<WeeklyReportModalViewModel>>(sp => () => sp.GetRequiredService<WeeklyReportModalViewModel>());
|
||||
sc.AddSingleton<Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>>(sp =>
|
||||
taskId => new ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel(
|
||||
sp.GetRequiredService<WorkerClient>(), taskId));
|
||||
sp.GetRequiredService<IWorkerClient>(), taskId));
|
||||
|
||||
// Islands shell VMs
|
||||
sc.AddSingleton<ListsIslandViewModel>(sp =>
|
||||
new ListsIslandViewModel(
|
||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||
sp,
|
||||
sp.GetRequiredService<WorkerClient>()));
|
||||
sp.GetRequiredService<IWorkerClient>()));
|
||||
sc.AddSingleton<TasksIslandViewModel>(sp =>
|
||||
new TasksIslandViewModel(
|
||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||
sp.GetRequiredService<WorkerClient>()));
|
||||
sp.GetRequiredService<IWorkerClient>()));
|
||||
sc.AddSingleton<DetailsIslandViewModel>(sp =>
|
||||
new DetailsIslandViewModel(
|
||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||
sp.GetRequiredService<WorkerClient>(),
|
||||
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;
|
||||
});
|
||||
|
||||
|
||||
85
src/ClaudeDo.Data/AttachmentStore.cs
Normal file
85
src/ClaudeDo.Data/AttachmentStore.cs
Normal file
@@ -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). Operations: worktree add/remove, add all, commit (stdin for message), merge ff-only, rev-parse, diff-stat, has-changes, is-git-repo, `PreviewMergeAsync` (non-destructive mergeability check via `git merge-tree --write-tree`), `CountChangedFilesAsync`
|
||||
- **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,6 +46,7 @@ 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>();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
134
src/ClaudeDo.Data/Git/ConflictMarkerParser.cs
Normal file
134
src/ClaudeDo.Data/Git/ConflictMarkerParser.cs
Normal file
@@ -0,0 +1,134 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace ClaudeDo.Data.Git;
|
||||
|
||||
/// <summary>
|
||||
/// One piece of a conflicted file: either common ("stable") text both sides agree on,
|
||||
/// or a conflict region holding the two — or, with diff3 markers, three — competing versions.
|
||||
/// </summary>
|
||||
public sealed record MergeSegment
|
||||
{
|
||||
public bool IsConflict { get; init; }
|
||||
|
||||
/// <summary>Stable text (verbatim, line endings preserved) when <see cref="IsConflict"/> is false.</summary>
|
||||
public string Text { get; init; } = "";
|
||||
|
||||
/// <summary>"Ours" side (the target branch) when <see cref="IsConflict"/> is true.</summary>
|
||||
public string Ours { get; init; } = "";
|
||||
|
||||
/// <summary>Merge base, present only when the merge used diff3 conflict style; null otherwise.</summary>
|
||||
public string? Base { get; init; }
|
||||
|
||||
/// <summary>"Theirs" side (the incoming branch) when <see cref="IsConflict"/> is true.</summary>
|
||||
public string Theirs { get; init; } = "";
|
||||
|
||||
public static MergeSegment Stable(string text) => new() { Text = text };
|
||||
|
||||
public static MergeSegment Conflict(string ours, string? @base, string theirs) =>
|
||||
new() { IsConflict = true, Ours = ours, Base = @base, Theirs = theirs };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses a conflicted file's text into ordered stable / conflict segments and reassembles it.
|
||||
/// Reads git conflict markers verbatim, so a file with no markers yields a single stable
|
||||
/// segment, and reassembling the stable text plus one chosen resolution per conflict
|
||||
/// round-trips the file exactly (line endings included).
|
||||
/// </summary>
|
||||
public static class ConflictMarkerParser
|
||||
{
|
||||
private const string OursMarker = "<<<<<<<";
|
||||
private const string BaseMarker = "|||||||";
|
||||
private const string SepMarker = "=======";
|
||||
private const string TheirsMarker = ">>>>>>>";
|
||||
|
||||
public static IReadOnlyList<MergeSegment> Parse(string fileText)
|
||||
{
|
||||
var segments = new List<MergeSegment>();
|
||||
var lines = SplitKeepLineEndings(fileText);
|
||||
var stable = new StringBuilder();
|
||||
var i = 0;
|
||||
|
||||
while (i < lines.Count)
|
||||
{
|
||||
if (!IsMarker(lines[i], OursMarker))
|
||||
{
|
||||
stable.Append(lines[i++]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (stable.Length > 0)
|
||||
{
|
||||
segments.Add(MergeSegment.Stable(stable.ToString()));
|
||||
stable.Clear();
|
||||
}
|
||||
|
||||
i++; // consume "<<<<<<<"
|
||||
var ours = new StringBuilder();
|
||||
while (i < lines.Count && !IsMarker(lines[i], BaseMarker) && !IsMarker(lines[i], SepMarker))
|
||||
ours.Append(lines[i++]);
|
||||
|
||||
string? @base = null;
|
||||
if (i < lines.Count && IsMarker(lines[i], BaseMarker))
|
||||
{
|
||||
i++; // consume "|||||||"
|
||||
var baseText = new StringBuilder();
|
||||
while (i < lines.Count && !IsMarker(lines[i], SepMarker))
|
||||
baseText.Append(lines[i++]);
|
||||
@base = baseText.ToString();
|
||||
}
|
||||
|
||||
if (i < lines.Count && IsMarker(lines[i], SepMarker)) i++; // consume "======="
|
||||
|
||||
var theirs = new StringBuilder();
|
||||
while (i < lines.Count && !IsMarker(lines[i], TheirsMarker))
|
||||
theirs.Append(lines[i++]);
|
||||
|
||||
if (i < lines.Count && IsMarker(lines[i], TheirsMarker)) i++; // consume ">>>>>>>"
|
||||
|
||||
segments.Add(MergeSegment.Conflict(ours.ToString(), @base, theirs.ToString()));
|
||||
}
|
||||
|
||||
if (stable.Length > 0)
|
||||
segments.Add(MergeSegment.Stable(stable.ToString()));
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
/// <summary>True when the file still contains an opening conflict marker.</summary>
|
||||
public static bool HasConflicts(string fileText) =>
|
||||
SplitKeepLineEndings(fileText).Any(l => IsMarker(l, OursMarker));
|
||||
|
||||
/// <summary>
|
||||
/// Reassembles a file from its segments. Stable segments emit their text verbatim;
|
||||
/// each conflict segment emits whatever <paramref name="resolveConflict"/> returns for it.
|
||||
/// </summary>
|
||||
public static string Compose(
|
||||
IEnumerable<MergeSegment> segments, Func<MergeSegment, string> resolveConflict) =>
|
||||
string.Concat(segments.Select(s => s.IsConflict ? resolveConflict(s) : s.Text));
|
||||
|
||||
// A marker line starts with exactly the 7-char marker, then end-of-line or whitespace/label.
|
||||
private static bool IsMarker(string line, string marker)
|
||||
{
|
||||
if (!line.StartsWith(marker, StringComparison.Ordinal)) return false;
|
||||
if (line.Length == marker.Length) return true;
|
||||
return line[marker.Length] is ' ' or '\t' or '\r' or '\n';
|
||||
}
|
||||
|
||||
// Splits into physical lines, each retaining its trailing "\n" (and "\r" if present).
|
||||
private static List<string> SplitKeepLineEndings(string s)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
var i = 0;
|
||||
while (i < s.Length)
|
||||
{
|
||||
var nl = s.IndexOf('\n', i);
|
||||
if (nl < 0) { lines.Add(s[i..]); break; }
|
||||
lines.Add(s[i..(nl + 1)]);
|
||||
i = nl + 1;
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
@@ -252,8 +252,11 @@ public sealed class GitService
|
||||
public async Task<(int ExitCode, string Stderr)> MergeNoFfAsync(
|
||||
string repoDir, string sourceBranch, string message, CancellationToken ct = default)
|
||||
{
|
||||
// diff3 conflict style writes the merge base (|||||||) into conflict markers so the
|
||||
// in-app resolver can show a true three-way view. It only enriches conflicted hunks;
|
||||
// clean merges are unaffected.
|
||||
var (exitCode, _, stderr) = await RunGitAsync(repoDir,
|
||||
["merge", "--no-ff", "-m", message, sourceBranch], ct);
|
||||
["-c", "merge.conflictStyle=diff3", "merge", "--no-ff", "-m", message, sourceBranch], ct);
|
||||
return (exitCode, stderr);
|
||||
}
|
||||
|
||||
@@ -277,17 +280,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);
|
||||
|
||||
739
src/ClaudeDo.Data/Migrations/20260622150934_AddTaskAttachments.Designer.cs
generated
Normal file
739
src/ClaudeDo.Data/Migrations/20260622150934_AddTaskAttachments.Designer.cs
generated
Normal file
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -294,6 +294,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")
|
||||
@@ -625,6 +657,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)
|
||||
|
||||
@@ -4,9 +4,26 @@ public static class ModelRegistry
|
||||
{
|
||||
public static readonly IReadOnlyList<string> Aliases = new[] { "sonnet", "opus", "haiku" };
|
||||
|
||||
/// <summary>Model aliases ordered cheapest → most capable. Single source for prompt cost guidance.</summary>
|
||||
public static readonly IReadOnlyList<string> ByCostAscending = new[] { "haiku", "sonnet", "opus" };
|
||||
|
||||
public const string DefaultAlias = "sonnet";
|
||||
public const string PlanningAlias = "opus";
|
||||
|
||||
public const string ListDefaultSentinel = "(default)";
|
||||
public const string TaskInheritSentinel = "(inherit)";
|
||||
|
||||
/// <summary>
|
||||
/// Validate a model alias from external input. Null/blank → null (inherit).
|
||||
/// Returns the canonical lowercase alias; throws on an unknown value.
|
||||
/// </summary>
|
||||
public static string? NormalizeAlias(string? model)
|
||||
{
|
||||
var m = model?.Trim();
|
||||
if (string.IsNullOrEmpty(m)) return null;
|
||||
foreach (var alias in Aliases)
|
||||
if (string.Equals(alias, m, StringComparison.OrdinalIgnoreCase))
|
||||
return alias;
|
||||
throw new ArgumentException($"Unknown model '{model}'. Allowed: {string.Join(", ", Aliases)}.");
|
||||
}
|
||||
}
|
||||
|
||||
13
src/ClaudeDo.Data/Models/TaskAttachmentEntity.cs
Normal file
13
src/ClaudeDo.Data/Models/TaskAttachmentEntity.cs
Normal file
@@ -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!;
|
||||
}
|
||||
@@ -82,7 +82,10 @@ public static class PromptFiles
|
||||
## Out-of-scope improvements
|
||||
If you notice worthwhile work that is genuinely outside this task's scope
|
||||
(a refactor, a follow-up, tech debt), do NOT do it here. File it with
|
||||
SuggestImprovement(title, description) and stay focused on the task at hand.
|
||||
SuggestImprovement(title, description, model) and stay focused on the task at hand.
|
||||
Set `model` to the cheapest model that can do the follow-up well — 'haiku' for
|
||||
trivial/mechanical work, 'sonnet' for normal coding, 'opus' only for genuinely
|
||||
complex work (cheapest to most capable: haiku < sonnet < opus).
|
||||
|
||||
## Working in the repo
|
||||
- Read a file before editing it. Match the conventions already in this codebase —
|
||||
@@ -102,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
|
||||
@@ -122,8 +128,8 @@ public static class PromptFiles
|
||||
# Out-of-scope follow-up
|
||||
|
||||
You are an improvement follow-up that another task filed via SuggestImprovement.
|
||||
It was deliberately scoped narrow. Do EXACTLY what this task's title and
|
||||
description ask — nothing more.
|
||||
It was deliberately scoped narrow, and is intentionally a small, cheap unit of
|
||||
work. Do EXACTLY what this task's title and description ask — nothing more.
|
||||
|
||||
- Make the smallest change that satisfies the task. No opportunistic refactors,
|
||||
renames, reformatting, or "while I'm here" cleanup beyond what is asked.
|
||||
@@ -150,6 +156,14 @@ public static class PromptFiles
|
||||
Once the design is approved, create the child tasks with CreateChildTask, then
|
||||
call Finalize. Keep each subtask concrete and self-contained with a clear
|
||||
done-state, ordered so dependencies come first.
|
||||
|
||||
For each subtask, pass CreateChildTask's `model` argument set to the CHEAPEST
|
||||
model that can do that subtask well. Models, cheapest to most capable:
|
||||
haiku < sonnet < opus.
|
||||
- haiku — trivial/mechanical work: doc tweaks, simple renames, small localized edits.
|
||||
- sonnet — normal coding work; the sensible default when unsure.
|
||||
- opus — only for genuinely complex, cross-cutting, or hard-to-debug work.
|
||||
Do not default everything to opus — most subtasks are haiku or sonnet.
|
||||
""";
|
||||
|
||||
private const string PlanningInitialDefault = """
|
||||
|
||||
@@ -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)
|
||||
|
||||
51
src/ClaudeDo.Data/Repositories/TaskAttachmentRepository.cs
Normal file
51
src/ClaudeDo.Data/Repositories/TaskAttachmentRepository.cs
Normal file
@@ -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)
|
||||
@@ -87,6 +93,22 @@ public sealed class TaskRepository
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns all tasks that qualify as "real" Idle backlog items for online mirroring:
|
||||
/// Status==Idle, no parent, PlanningPhase==None, not blocked.
|
||||
/// </summary>
|
||||
public async Task<List<TaskEntity>> GetAllIdleBacklogAsync(CancellationToken ct = default)
|
||||
{
|
||||
return await _context.Tasks
|
||||
.AsNoTracking()
|
||||
.Where(t => t.Status == TaskStatus.Idle
|
||||
&& t.ParentTaskId == null
|
||||
&& t.PlanningPhase == PlanningPhase.None
|
||||
&& t.BlockedByTaskId == null)
|
||||
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Status transitions
|
||||
@@ -197,6 +219,7 @@ public sealed class TaskRepository
|
||||
string? description,
|
||||
string? commitType,
|
||||
string? createdBy = null,
|
||||
string? model = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// AsNoTracking: SetPlanningStartedAsync mutates via ExecuteUpdate which
|
||||
@@ -223,6 +246,7 @@ public sealed class TaskRepository
|
||||
ParentTaskId = parentId,
|
||||
SortOrder = (maxSort ?? -1) + 1,
|
||||
CreatedBy = createdBy,
|
||||
Model = ModelRegistry.NormalizeAlias(model),
|
||||
};
|
||||
_context.Tasks.Add(child);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -63,11 +63,39 @@
|
||||
"daySa": "Sa",
|
||||
"daySu": "So"
|
||||
},
|
||||
"onlineInbox": {
|
||||
"tabHeader": "Online-Posteingang",
|
||||
"enabledLabel": "Online-Posteingang-Sync aktivieren",
|
||||
"restartHint": "Aktivieren oder Deaktivieren wird erst nach einem Worker-Neustart wirksam.",
|
||||
"apiBaseUrlLabel": "API-Basis-URL",
|
||||
"apiBaseUrlPlaceholder": "https://inbox.claudedo.kuns.dev",
|
||||
"authorityLabel": "Zitadel-Authority (Issuer-URL)",
|
||||
"authorityPlaceholder": "https://auth.example.com",
|
||||
"clientIdLabel": "Client-ID",
|
||||
"scopesLabel": "Scopes",
|
||||
"redirectUriLabel": "Redirect-URI",
|
||||
"pollIntervalLabel": "Abfrageintervall (Sekunden)",
|
||||
"statusSection": "AUTH-STATUS",
|
||||
"signedInStatus": "Angemeldet",
|
||||
"signedOutStatus": "Nicht angemeldet",
|
||||
"signInButton": "Im Browser anmelden",
|
||||
"signOutButton": "Abmelden",
|
||||
"configSection": "KONFIGURATION",
|
||||
"saveButton": "Konfiguration speichern"
|
||||
},
|
||||
"inherit": {
|
||||
"inheritedFromList": "geerbt · Liste",
|
||||
"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..."
|
||||
}
|
||||
},
|
||||
"tasks": {
|
||||
@@ -90,10 +118,12 @@
|
||||
"ctxRunInteractively": "Interaktiv ausführen",
|
||||
"ctxOpenPlanningSession": "Planungssitzung öffnen",
|
||||
"ctxResumePlanningSession": "Planungssitzung fortsetzen",
|
||||
"ctxFinalizePlanningSession": "Plan finalisieren",
|
||||
"ctxDiscardPlanningSession": "Planungssitzung verwerfen",
|
||||
"ctxQueueSubtasks": "Teilaufgaben nacheinander einreihen",
|
||||
"ctxScheduleFor": "Planen für...",
|
||||
"ctxClearSchedule": "Zeitplan entfernen",
|
||||
"ctxRemoveFromMyDay": "Aus Mein Tag entfernen",
|
||||
"ctxAddToMyDay": "Zu Mein Tag hinzufügen",
|
||||
"badgeDraft": "ENTWURF",
|
||||
"badgePlanned": "GEPLANT",
|
||||
"approve": "Genehmigen",
|
||||
@@ -139,11 +169,6 @@
|
||||
"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",
|
||||
@@ -160,7 +185,22 @@
|
||||
"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}",
|
||||
"selectIdleTask": "Zuerst eine inaktive Aufgabe auswählen"
|
||||
},
|
||||
"sections": {
|
||||
"description": "Beschreibung",
|
||||
"steps": "Schritte",
|
||||
"files": "Dateien"
|
||||
}
|
||||
},
|
||||
"agent": {
|
||||
"stopTip": "Agent stoppen",
|
||||
@@ -191,9 +231,43 @@
|
||||
"chipDone": "FERTIG",
|
||||
"chipFailed": "FEHLGESCHLAGEN",
|
||||
"reviewContinueTip": "Dieses Feedback senden und die Aufgabe erneut ausführen",
|
||||
"reviewResetTip": "Alle Änderungen verwerfen und die Aufgabe auf Leerlauf zurücksetzen"
|
||||
"reviewResetTip": "Alle Änderungen verwerfen und die Aufgabe auf Leerlauf zurücksetzen",
|
||||
"composer": {
|
||||
"placeholder": "Nachricht an die Sitzung…",
|
||||
"send": "Senden",
|
||||
"stop": "Sitzung beenden",
|
||||
"interrupt": "Aktuellen Zug unterbrechen",
|
||||
"queued": "Wartet — wird nach dem aktuellen Zug gesendet",
|
||||
"unqueue": "Aus Warteschlange entfernen"
|
||||
}
|
||||
},
|
||||
"missionControl": {
|
||||
"openInApp": "In App öffnen",
|
||||
"cancel": "Abbrechen",
|
||||
"detach": "Abdocken",
|
||||
"redock": "Andocken",
|
||||
"windowTitle": "Mission Control",
|
||||
"clearFinished": "Erledigte entfernen",
|
||||
"empty": "Keine laufenden Aufgaben",
|
||||
"settings": "Einstellungen",
|
||||
"queue": "Warteschlange",
|
||||
"blocked": "Blockiert",
|
||||
"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",
|
||||
@@ -219,11 +293,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",
|
||||
@@ -243,9 +313,6 @@
|
||||
"binary": "Binärdatei — kein Text-Diff",
|
||||
"empty": "Kein Inhalt"
|
||||
},
|
||||
"worktree": {
|
||||
"title": "Worktree"
|
||||
},
|
||||
"worktreesOverview": {
|
||||
"refresh": "Aktualisieren",
|
||||
"cleanupFinished": "Abgeschlossene aufräumen",
|
||||
@@ -368,8 +435,6 @@
|
||||
"abort": "Diesen Merge abbrechen"
|
||||
},
|
||||
"diff": {
|
||||
"windowTitle": "Planung — Kombiniertes Diff",
|
||||
"modalTitle": "PLANUNG — KOMBINIERTES DIFF",
|
||||
"previewCombined": "Kombinierte Vorschau",
|
||||
"loading": "Wird geladen…"
|
||||
}
|
||||
@@ -378,13 +443,17 @@
|
||||
"windowTitle": "Merge-Konflikte lösen",
|
||||
"modalTitle": "KONFLIKTE LÖSEN",
|
||||
"loading": "Konflikte werden geladen…",
|
||||
"current": "Aktuell (unsere)",
|
||||
"incoming": "Eingehend (ihre)",
|
||||
"mergedResult": "Zusammengeführtes Ergebnis",
|
||||
"acceptCurrent": "Aktuelle übernehmen",
|
||||
"acceptIncoming": "Eingehende übernehmen",
|
||||
"acceptBoth": "Beide übernehmen",
|
||||
"editManually": "Manuell bearbeiten",
|
||||
"ours": "MAIN · Ziel-Branch",
|
||||
"result": "ERGEBNIS",
|
||||
"theirs": "INCOMING · Task-Branch",
|
||||
"binaryHint": "Binärdateien können hier nicht zusammengeführt werden — brich ab und löse sie in deinem Editor:",
|
||||
"prevConflict": "Vorheriger Konflikt (Umschalt+F8)",
|
||||
"nextConflict": "Nächster Konflikt (F8)",
|
||||
"conflictMap": "Konflikte in dieser Datei — Marker anklicken zum Springen",
|
||||
"acceptOurs": "Main hinzufügen",
|
||||
"acceptTheirs": "Incoming hinzufügen",
|
||||
"removeOurs": "Main entfernen",
|
||||
"removeTheirs": "Incoming entfernen",
|
||||
"continue": "Lösen & fortfahren",
|
||||
"abort": "Merge abbrechen"
|
||||
},
|
||||
@@ -401,6 +470,8 @@
|
||||
"shell": {
|
||||
"menu": {
|
||||
"help": "Hilfe",
|
||||
"worker": "Worker",
|
||||
"repositories": "Repositories",
|
||||
"checkForUpdates": "Nach Updates suchen",
|
||||
"restartWorker": "Worker neu starten",
|
||||
"worktrees": "Worktrees…",
|
||||
@@ -418,15 +489,16 @@
|
||||
"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" },
|
||||
"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" },
|
||||
"planningBadge": { "active": "PLANUNG", "finalized": "GEPLANT" },
|
||||
"taskRow": { "createdPrefix": "Erstellt {0}", "stepsText": "{0}/{1} Schritte" },
|
||||
"tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}" },
|
||||
"diff": { "loadFailed": "Diff konnte nicht geladen werden: {0}", "noChanges": "Keine Änderungen anzuzeigen." },
|
||||
"tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "runInteractiveFailed": "Interaktiv ausführen fehlgeschlagen: {0}", "planningOpenFailed": "Planungssitzung konnte nicht geöffnet werden: {0}" },
|
||||
"diff": { "loadFailed": "Diff konnte nicht geladen werden: {0}", "noChanges": "Keine Änderungen anzuzeigen.", "unavailable": "Diff nicht mehr verfügbar — Commit-Bereich unvollständig." },
|
||||
"planningDiff": { "hubError": "Kombinierte Vorschau konnte nicht erstellt werden (Hub-Fehler).", "conflict": "Kombinierte Vorschau nicht möglich: Teilaufgabe {0} steht im Konflikt mit einer früheren Teilaufgabe ({1} Dateien)." },
|
||||
"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}" },
|
||||
"conflictResolution": { "vsCodeError": "VS Code konnte nicht gestartet werden: {0}. Die Pfade sind oben aufgeführt — kopiere sie manuell.", "subtaskPrefix": "Konflikte in Teilaufgabe: {0}", "targetPrefix": "Zusammenführen in: {0}" },
|
||||
"settingsModal": { "workerOffline": "Worker offline — Einstellungen schreibgeschützt.", "saveFailed": "Speichern fehlgeschlagen: {0}" },
|
||||
"onlineInbox": { "workerOffline": "Worker offline — Konfiguration kann nicht geladen werden.", "saved": "Konfiguration gespeichert.", "saveFailed": "Speichern fehlgeschlagen: {0}", "signedIn": "Erfolgreich angemeldet.", "signedInNoRole": "Angemeldet, aber diesem Konto fehlt die Rolle 'user' in Zitadel — die Online-Synchronisierung wird abgelehnt, bis die Rolle im ClaudeDo-Projekt zugewiesen wird.", "signInFailed": "Anmeldung fehlgeschlagen: {0}", "signedOut": "Abgemeldet.", "signOutFailed": "Abmeldung fehlgeschlagen: {0}" },
|
||||
"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}" },
|
||||
"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." },
|
||||
|
||||
@@ -63,11 +63,39 @@
|
||||
"daySa": "Sa",
|
||||
"daySu": "Su"
|
||||
},
|
||||
"onlineInbox": {
|
||||
"tabHeader": "Online Inbox",
|
||||
"enabledLabel": "Enable online inbox sync",
|
||||
"restartHint": "Enabling or disabling takes effect after a Worker restart.",
|
||||
"apiBaseUrlLabel": "API base URL",
|
||||
"apiBaseUrlPlaceholder": "https://inbox.claudedo.kuns.dev",
|
||||
"authorityLabel": "Zitadel authority (issuer URL)",
|
||||
"authorityPlaceholder": "https://auth.example.com",
|
||||
"clientIdLabel": "Client ID",
|
||||
"scopesLabel": "Scopes",
|
||||
"redirectUriLabel": "Redirect URI",
|
||||
"pollIntervalLabel": "Poll interval (seconds)",
|
||||
"statusSection": "AUTH STATUS",
|
||||
"signedInStatus": "Signed in",
|
||||
"signedOutStatus": "Not signed in",
|
||||
"signInButton": "Sign in via browser",
|
||||
"signOutButton": "Sign out",
|
||||
"configSection": "CONFIGURATION",
|
||||
"saveButton": "Save config"
|
||||
},
|
||||
"inherit": {
|
||||
"inheritedFromList": "inherited · List",
|
||||
"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..."
|
||||
}
|
||||
},
|
||||
"tasks": {
|
||||
@@ -90,10 +118,12 @@
|
||||
"ctxRunInteractively": "Run interactively",
|
||||
"ctxOpenPlanningSession": "Open planning Session",
|
||||
"ctxResumePlanningSession": "Resume planning Session",
|
||||
"ctxFinalizePlanningSession": "Finalize plan",
|
||||
"ctxDiscardPlanningSession": "Discard planning session",
|
||||
"ctxQueueSubtasks": "Queue subtasks sequentially",
|
||||
"ctxScheduleFor": "Schedule for...",
|
||||
"ctxClearSchedule": "Clear schedule",
|
||||
"ctxRemoveFromMyDay": "Remove from My Day",
|
||||
"ctxAddToMyDay": "Add to My Day",
|
||||
"badgeDraft": "DRAFT",
|
||||
"badgePlanned": "PLANNED",
|
||||
"approve": "Approve",
|
||||
@@ -139,11 +169,6 @@
|
||||
"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",
|
||||
@@ -160,7 +185,22 @@
|
||||
"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}",
|
||||
"selectIdleTask": "Select an idle task first"
|
||||
},
|
||||
"sections": {
|
||||
"description": "Description",
|
||||
"steps": "Steps",
|
||||
"files": "Files"
|
||||
}
|
||||
},
|
||||
"agent": {
|
||||
"stopTip": "Stop agent",
|
||||
@@ -191,9 +231,43 @@
|
||||
"chipDone": "DONE",
|
||||
"chipFailed": "FAILED",
|
||||
"reviewContinueTip": "Send this feedback and re-run the task",
|
||||
"reviewResetTip": "Discard all changes and reset the task to Idle"
|
||||
"reviewResetTip": "Discard all changes and reset the task to Idle",
|
||||
"composer": {
|
||||
"placeholder": "Message the session…",
|
||||
"send": "Send",
|
||||
"stop": "Stop session",
|
||||
"interrupt": "Interrupt current turn",
|
||||
"queued": "Queued — sends after the current turn",
|
||||
"unqueue": "Remove from queue"
|
||||
}
|
||||
},
|
||||
"missionControl": {
|
||||
"openInApp": "Open in app",
|
||||
"cancel": "Cancel",
|
||||
"detach": "Detach",
|
||||
"redock": "Re-dock",
|
||||
"windowTitle": "Mission Control",
|
||||
"clearFinished": "Clear finished",
|
||||
"empty": "No running tasks",
|
||||
"settings": "Settings",
|
||||
"queue": "Queue",
|
||||
"blocked": "Blocked",
|
||||
"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",
|
||||
@@ -219,11 +293,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",
|
||||
@@ -243,9 +313,6 @@
|
||||
"binary": "Binary file — no text diff",
|
||||
"empty": "No content"
|
||||
},
|
||||
"worktree": {
|
||||
"title": "Worktree"
|
||||
},
|
||||
"worktreesOverview": {
|
||||
"refresh": "Refresh",
|
||||
"cleanupFinished": "Cleanup finished",
|
||||
@@ -368,8 +435,6 @@
|
||||
"abort": "Abort this merge"
|
||||
},
|
||||
"diff": {
|
||||
"windowTitle": "Planning — Combined diff",
|
||||
"modalTitle": "PLANNING — COMBINED DIFF",
|
||||
"previewCombined": "Preview combined",
|
||||
"loading": "Loading…"
|
||||
}
|
||||
@@ -378,13 +443,17 @@
|
||||
"windowTitle": "Resolve merge conflicts",
|
||||
"modalTitle": "RESOLVE CONFLICTS",
|
||||
"loading": "Loading conflicts…",
|
||||
"current": "Current (ours)",
|
||||
"incoming": "Incoming (theirs)",
|
||||
"mergedResult": "Merged result",
|
||||
"acceptCurrent": "Accept Current",
|
||||
"acceptIncoming": "Accept Incoming",
|
||||
"acceptBoth": "Accept Both",
|
||||
"editManually": "Edit manually",
|
||||
"ours": "MAIN · merge target",
|
||||
"result": "RESULT",
|
||||
"theirs": "INCOMING · task branch",
|
||||
"binaryHint": "Binary files can't be merged here — abort and resolve them in your editor:",
|
||||
"prevConflict": "Previous conflict (Shift+F8)",
|
||||
"nextConflict": "Next conflict (F8)",
|
||||
"conflictMap": "Conflicts in this file — click a marker to jump",
|
||||
"acceptOurs": "Add main",
|
||||
"acceptTheirs": "Add incoming",
|
||||
"removeOurs": "Remove main",
|
||||
"removeTheirs": "Remove incoming",
|
||||
"continue": "Resolve & continue",
|
||||
"abort": "Abort merge"
|
||||
},
|
||||
@@ -401,6 +470,8 @@
|
||||
"shell": {
|
||||
"menu": {
|
||||
"help": "Help",
|
||||
"worker": "Worker",
|
||||
"repositories": "Repositories",
|
||||
"checkForUpdates": "Check for updates",
|
||||
"restartWorker": "Restart worker",
|
||||
"worktrees": "Worktrees…",
|
||||
@@ -418,15 +489,16 @@
|
||||
"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" },
|
||||
"taskStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "waitingForReview": "Waiting for Review", "waitingForChildren": "Waiting for Improvements", "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}" },
|
||||
"diff": { "loadFailed": "Failed to load diff: {0}", "noChanges": "No changes to show." },
|
||||
"tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "runInteractiveFailed": "Run interactively failed: {0}", "planningOpenFailed": "Couldn't open planning session: {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}" },
|
||||
"conflictResolution": { "vsCodeError": "Could not launch VS Code: {0}. Paths are listed above — copy them manually.", "subtaskPrefix": "Conflicts in subtask: {0}", "targetPrefix": "Merging into: {0}" },
|
||||
"settingsModal": { "workerOffline": "Worker offline — settings read-only.", "saveFailed": "Save failed: {0}" },
|
||||
"onlineInbox": { "workerOffline": "Worker offline — cannot load config.", "saved": "Config saved.", "saveFailed": "Save failed: {0}", "signedIn": "Signed in successfully.", "signedInNoRole": "Signed in, but this account is missing the 'user' role in Zitadel — online sync will be rejected until the role is granted in the ClaudeDo project.", "signInFailed": "Sign-in failed: {0}", "signedOut": "Signed out.", "signOutFailed": "Sign-out failed: {0}" },
|
||||
"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}" },
|
||||
"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)." },
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -8,56 +8,62 @@ MVVM with CommunityToolkit.Mvvm source generators:
|
||||
- `[ObservableProperty]` for bindable properties
|
||||
- `[RelayCommand]` for commands (supports async and CanExecute)
|
||||
- All ViewModels inherit `ViewModelBase` (extends `ObservableObject`)
|
||||
- All views use compiled bindings (`x:DataType`)
|
||||
|
||||
## Views
|
||||
## Layout: Islands
|
||||
|
||||
- **MainWindow** — 3-column DockPanel layout (lists | tasks | detail) with GridSplitter, status bar at bottom
|
||||
- **TaskListView** — ListBox of tasks with add/edit/delete toolbar
|
||||
- **TaskDetailView** — Task info, live log output, worktree section (merge/keep/discard)
|
||||
- **TaskEditorView** — Modal dialog for task create/edit
|
||||
- **ListEditorView** — Modal dialog for list create/edit
|
||||
- **StatusBarView** — Connection status indicator, active task display
|
||||
- **ListSettingsModalView** — edits list name, working dir, default commit type, and per-list Model/SystemPrompt/AgentPath/MaxTurns, each showing the inherited (global) value with a source-aware "inherited · Global / override" badge and a reset-to-inherited button; also deletes the list (and its tasks) via a confirmed "Delete list" button. Opened via context menu or gear button on a list row.
|
||||
- **RepoImportModalView** — bulk-creates lists from git repos discovered under chosen parent folders. Opened via the folder button beside "New list" in the Lists island, or the "Add repos as lists…" Help-menu item. Repos already wired to a list show as disabled/"(already added)".
|
||||
- **DetailsIslandView** — contains an "Agent settings (overrides)" expander with per-task Model/MaxTurns/AgentPath (override semantics, each showing the resolved inherited value as a placeholder plus a source-aware "inherited · List / inherited · Global / override" badge and reset button via the reusable `InheritedBadge` control + `InheritanceResolver`) and a SystemPrompt text box (additive — shows the inherited prompt as a "prepended automatically" note). Disabled while task is running. When notes mode is active (`IsNotesMode`), it hosts **NotesEditorView** instead of the task detail. When prep mode is active (`IsPrepMode`), it hosts the daily-prep panel (Plan day button, empty-state hint, embedded **SessionTerminalView**). The task header, metadata footer (delete/close), and **AgentStripView** are gated on `IsTaskDetailVisible` — they are hidden in both notes and prep mode.
|
||||
- **WeeklyReportModalView** — opened from Help menu ("Wochenbericht…"); date-range pickers default to "since last standup weekday → today"; Generate/Regenerate button; renders markdown via MarkdownView; reports are cached per range.
|
||||
- **NotesEditorView** — day navigator (prev/next/date-picker/Today), bullet add/edit/delete for daily notes.
|
||||
- **SessionTerminalView** — reusable log terminal; exposes StyledProperties `Entries`, `Label`, `IsRunning`, `IsDone`, `IsFailed`. Used for both the task `Log` and the prep `PrepLog`.
|
||||
- **SettingsModalView** — Prime Claude tab contains a `DailyPrepMaxTasks` numeric editor.
|
||||
- **TasksIslandView** (MyDay header) — icon buttons visible only when `IsMyDayList`: broom icon = `ClearDayCommand`, stroked-sun icon ("Plan My Day") = `ShowPrepLogCommand`.
|
||||
`MainWindow` hosts three "islands" (lists | tasks | details). There is no MainWindowViewModel, StatusBarView, or task/list editor modal — the root coordinator is **IslandsShellViewModel**, and task/list editing happens inline in the islands.
|
||||
|
||||
All views use compiled bindings (`x:DataType`).
|
||||
```
|
||||
ViewModels/
|
||||
IslandsShellViewModel.cs — root coordinator
|
||||
Islands/ — ListsIsland, TasksIsland, DetailsIsland, TaskRow, ListNavItem,
|
||||
NotesEditor, MergePreviewPresenter
|
||||
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, AgentConfigEditor
|
||||
Design/ — Tokens.axaml (design tokens; merged before styles) + IslandStyles.axaml
|
||||
(component styles + the filled icon geometry library)
|
||||
```
|
||||
|
||||
## ViewModels
|
||||
|
||||
- **MainWindowViewModel** — root coordinator; manages list collection, selected list, dialog creation via `Func<T>` factories
|
||||
- **TaskListViewModel** — manages task collection for selected list; handles CRUD, "Run Now"
|
||||
- **TaskDetailViewModel** — displays task details, streams live log, controls worktree operations
|
||||
- **TaskItemViewModel** / **ListItemViewModel** — lightweight display VMs
|
||||
- **TaskEditorViewModel** / **ListEditorViewModel** — dialog VMs with validation
|
||||
- **StatusBarViewModel** — connection state and active tasks
|
||||
- **WeeklyReportModalViewModel** — drives the weekly report modal
|
||||
- **NotesEditorViewModel** — manages daily note bullet CRUD for the selected day
|
||||
- **DetailsIslandViewModel** gains `IsNotesMode`, `ShowNotes()`, and hosts `NotesEditorViewModel`. Also gains daily-prep mode: `IsPrepMode`, `PrepLog` (`ObservableCollection<LogLineViewModel>`), `ShowPrep()`, `PlanDayCommand` (calls `RunDailyPrepNowAsync`), `ShowPrepEmptyState`, and computed `IsTaskDetailVisible` (= `!IsNotesMode && !IsPrepMode`). Subscribes to `PrepStartedEvent`, `PrepLineEvent`, `PrepFinishedEvent`; streams lines into `PrepLog` via `StreamLineFormatter` (same path as task logs). On open, if log is empty and no run is in progress, loads persisted last run via `GetLastPrepLogAsync`. The WorkConsole Session tab gains a mergeability indicator (`MergePreviewPresenter`) and a single-task Merge button; indicator is populated via `PreviewMergeAsync` and displayed for tasks in WaitingForReview.
|
||||
- **TasksIslandViewModel** gains a pinned "Notes" pseudo-row (`ShowNotesRow`, `OpenNotesCommand`, `NotesRequested` event) that switches the Details island to notes mode. Also gains `IsMyDayList` (true when selected list is `smart:my-day`), `ShowPrepLogCommand` (raises `PrepRequested` event → shell calls `Details.ShowPrep()`), and `ClearDayCommand` (calls `ClearMyDayAsync`).
|
||||
- **IslandsShellViewModel** — root coordinator; owns the three island VMs and the `WorkerClient`, wires cross-island events (selection, notes/prep mode, conflict resolution), owns connection state, the update banner, the inline worker-log strip (clickable → Log Visualizer overlay via `OpenLogVisualizerCommand`; `FlashFooterError` surfaces UI-action failures + the worker's Serilog Warn/Error there), responsive-layout flags (`ShowLists`/`ShowDetails` by window width), `PrimeStatus` flash, and the modal openers (About, RepoImport, WeeklyReport, WorktreesOverview, WorkerConnection help, LogVisualizer) plus `RestartWorkerAsync`/`CheckForUpdatesAsync`. Hosts `UpdateCheckService`.
|
||||
- **ListsIslandViewModel** — smart lists (My Day, Important, Planned, virtual queued/running/review), user lists, selection, list CRUD, drag-reorder, badge counts, opens list settings / repo import / worktrees overview, `OpenInExplorer`/`OpenInTerminal`.
|
||||
- **TasksIslandViewModel** — open/overdue/completed groups for the selected list with hierarchy-aware regrouping; task CRUD, drag-reorder, toggle done/star, schedule, enqueue/dequeue, cancel; review actions (approve, reject-rerun, reject-park, cancel); planning session lifecycle (open/resume/discard/finalize, `QueuePlanningSubtasksAsync`); `RunInteractivelyAsync`, `RefineTask`; MyDay extras (`IsMyDayList`, `ClearDayCommand`, `ShowPrepLogCommand`) and the pinned Notes pseudo-row (`ShowNotesRow`, `OpenNotesCommand`). Raises `NotesRequested`/`PrepRequested` events consumed by the shell.
|
||||
- **DetailsIslandViewModel** — the detail pane for a bound `TaskRowViewModel`. Owns live-log streaming (`Log` via `StreamLineFormatter`), debounced title/description editing, subtasks, session-outcome/roadblock split (splits `Result` at the roadblock marker into two cards), the three-tab work console (`output`/`git`/`session`), child surfacing (`ChildOutcomes` rows plus `ChildrenNeedingAttention`/`HasChildrenNeedingAttention` — children that failed, were cancelled, await review, or reported roadblocks — drive an attention band on the Session tab, which is only visible when `HasChildOutcomes`), and the modes: `IsNotesMode` (hosts `NotesEditorViewModel`), `IsPrepMode`, computed `IsTaskDetailVisible = !IsNotesMode && !IsPrepMode`. Three concerns are extracted into section VMs exposed as properties: **AgentConfigEditorViewModel** (scope=Task; per-task Model/MaxTurns/AgentPath overrides with `InheritedBadge` + `InheritanceResolver`, additive SystemPrompt, debounced auto-save; exposed as `AgentSettings`), **MergeSectionViewModel** (merge-target selection, mergeability indicator via `MergePreviewPresenter` over `PreviewMergeAsync`, `OpenDiffAsync` and `ReviewCombinedDiffCommand` — both build a `DiffViewerViewModel` and call `ShowDiffViewer`), **PrepPanelViewModel** (daily-prep panel: `PrepLog`, `PlanDayCommand` → `RunDailyPrepNowAsync`, persisted last run via `GetLastPrepLogAsync`). Attachments: `Attachments` (`ObservableCollection<AttachmentRowViewModel>`), `IsDragOver`, `DropStatus`, `CanAcceptDrop`, `AddFilesAsync`, `RemoveAttachmentCommand`; loads on task change; `ComposedPreview` includes attachment paths. Writes directly via `new AttachmentStore()` + `new TaskAttachmentRepository(ctx)`. Helper rows (`ChildOutcomeRowViewModel`, `SubtaskRowViewModel`, `LogLineViewModel`, `AttachmentRowViewModel`) live in the same file.
|
||||
- **TaskRowViewModel** / **ListNavItemViewModel** — lightweight display VMs (task row: status, planning phase, parent/blocked links, roadblock count, computed `IsDraft`/`IsPlanned`/`IsChild`/`IsPlanningParent`/`CanRefine`; list row: kind Smart/Virtual/User, count, icon/dot keys, drop hints).
|
||||
- **NotesEditorViewModel** — day navigator + bullet CRUD for daily notes via `INotesApi`.
|
||||
- **Modal VMs** — `SettingsModalViewModel` (four tabs: General, Worktrees, Files prompt-paths, Prime Claude incl. `DailyPrepMaxTasks` + prime-schedule rows), `ListSettingsModalViewModel` (name, working dir, commit type, delete list; hosts shared `AgentConfigEditorViewModel` as `Agent` property (scope=List) — save delegates to `Agent.SaveAsync()`), `RepoImportModalViewModel` (bulk-create lists from git repos found under chosen parents; already-wired repos disabled), `WeeklyReportModalViewModel` (range pickers default "since last standup weekday → today", cached per range, markdown via MarkdownView), `MergeModalViewModel` (single-task merge form, called from the diff modal), `WorktreesOverviewModalViewModel` (global/per-list worktree rows, batch merge + state ops), `UnfinishedPlanningModalViewModel` (Resume/FinalizeNow/Discard for a draft planning session), `WorkerConnectionModalViewModel` (offline help), `AboutModalViewModel`, `LogVisualizerViewModel` (worker logs, last 30 min, all levels + a warn/error-only filter; loads via `GetRecentLogsAsync`).
|
||||
- **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. Methods: StartAsync, RunNowAsync, CancelTaskAsync, WakeQueueAsync, ContinueTaskAsync, ResetTaskAsync, GetAgentsAsync, UpdateAppSettingsAsync, UpdateListAsync, UpdateListConfigAsync, GetListConfigAsync, UpdateTaskAgentSettingsAsync, `ApproveReviewAsync(taskId, targetBranch) -> MergeResultDto`, `PreviewMergeAsync(taskId, targetBranch) -> MergePreviewDto`, `MergeTaskAsync`, `GetWeekReportAsync`, `GenerateWeekReportAsync`, `GetDailyNotesAsync`, `AddDailyNoteAsync`, `UpdateDailyNoteAsync`, `DeleteDailyNoteAsync`, `RunDailyPrepNowAsync`, `ClearMyDayAsync`, `GetLastPrepLogAsync`. Events: TaskStarted, TaskFinished, TaskMessage, TaskUpdated, WorktreeUpdated, ListUpdated, `PrepStartedEvent`, `PrepLineEvent`, `PrepFinishedEvent`
|
||||
- **INotesApi** / **WorkerNotesApi** — thin wrapper over the daily-note WorkerClient methods (mirrors `IPrimeScheduleApi`). UI DTO: `DailyNoteDto(Id, Date, Text, SortOrder)`.
|
||||
- **WorkerClient** / **IWorkerClient** — SignalR client connecting to `http://127.0.0.1:47821/hub`, auto-reconnect with exponential backoff. The surface tracks `WorkerHub` (see `src/ClaudeDo.Worker/CLAUDE.md` for the canonical method/event list); groups: task execution (RunNow/Cancel/Continue/Reset/SetTaskStatus), review (`ApproveReviewAsync(taskId, targetBranch) -> MergeResultDto`, reject-to-queue/idle, cancel review, `PreviewMergeAsync -> MergePreviewDto`), planning sessions (start/resume/discard/finalize, queue subtasks, pending draft count, interactive terminal, refine), planning aggregate/integration-branch diffs, unit-merge continue/abort, single-task conflict resolving (start/get-conflict-documents/write-resolution/continue/abort), worktrees (overview, set state, force remove, cleanup, reset all), agents, app settings, lists/config, weekly report, daily notes, daily prep (`RunDailyPrepNowAsync`, `ClearMyDayAsync`, `GetLastPrepLogAsync`), prime schedules, recent worker logs (`GetRecentLogsAsync`). Events mirror `HubBroadcaster` (task/worktree/list/run updates, prep events, planning-merge events, refine events, worker log). Lifecycle (`StartAsync`/`StopAsync`) and a few admin methods live only on the concrete `WorkerClient`.
|
||||
- **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).
|
||||
- **InheritanceResolver** — resolves the task → list → global override chain to `(value, source)` for the inherited badges.
|
||||
- **RepoScanner**, **InstallArtifactLocator**/**InstallerLocator**/**WorkerLocator**, **ForegroundHelper** (Win32 foreground before launching a terminal), **FocusClearing**.
|
||||
|
||||
## Converters
|
||||
|
||||
- **StatusColorConverter** — task status string -> color (Queued=Blue, Running=Orange, Done=Green, Failed=Red, Manual=Gray)
|
||||
- **ConnectionColorConverter** — connection state -> color (Online=Green, Offline=Red)
|
||||
`StatusColorConverter` (+ `ConnectionColorConverter` in the same file), `WorkerLogLevelToBrushConverter`, `DotBrushConverter`, `EqStatusConverter`, `IconKeyConverter`, `CheckboxBorderConverter`, `StrikeIfTrueConverter`, `BoolToItalicConverter`, `BoolToDraftOpacityConverter`, `NotNullToBoolConverter`, `UpperCaseConverter`, `DateOnlyToDateTimeConverter`.
|
||||
|
||||
## Dialog Pattern
|
||||
|
||||
Editor dialogs use `TaskCompletionSource<bool>` — the dialog sets the result on save/cancel, and the caller awaits the TCS.
|
||||
Modals use `TaskCompletionSource` results behind the reusable `ModalShell` control — the dialog sets the result on save/cancel, and the caller awaits the TCS.
|
||||
|
||||
## Notes
|
||||
|
||||
- Context menus are on both list items and task items
|
||||
- Right-click selects the item before showing the context menu
|
||||
- Context menus exist on both list rows and task rows; right-click selects before opening the menu
|
||||
- "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).
|
||||
|
||||
@@ -7,11 +7,15 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="12.0.0" />
|
||||
<PackageReference Include="Avalonia" Version="12.0.4" />
|
||||
<PackageReference Include="Avalonia.AvaloniaEdit" Version="12.0.0" />
|
||||
<PackageReference Include="AvaloniaEdit.TextMate" Version="12.0.0" />
|
||||
<PackageReference Include="TextMateSharp.Grammars" Version="2.0.3" />
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" />
|
||||
<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" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
43
src/ClaudeDo.Ui/Converters/LogKindForegroundConverter.cs
Normal file
43
src/ClaudeDo.Ui/Converters/LogKindForegroundConverter.cs
Normal file
@@ -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 -->
|
||||
@@ -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>
|
||||
|
||||
@@ -229,6 +238,52 @@
|
||||
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- parked → slate-blue: an Idle task still holding its Active worktree -->
|
||||
<Style Selector="Border.chip.parked">
|
||||
<Setter Property="Background" Value="#22303A" />
|
||||
<Setter Property="BorderBrush" Value="#3A5060" />
|
||||
</Style>
|
||||
<Style Selector="Border.chip.parked > TextBlock">
|
||||
<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 -->
|
||||
<!-- ============================================================ -->
|
||||
@@ -355,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>
|
||||
@@ -362,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">
|
||||
@@ -467,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 -->
|
||||
@@ -871,14 +939,9 @@
|
||||
<Setter Property="Padding" Value="8,5" />
|
||||
<Setter Property="CornerRadius" Value="6" />
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="Transitions">
|
||||
<Transitions>
|
||||
<BrushTransition Property="Background" Duration="0:0:0.10"/>
|
||||
</Transitions>
|
||||
</Setter>
|
||||
</Style>
|
||||
<Style Selector="Border.subtask-row:pointerover">
|
||||
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
|
||||
<Setter Property="Background" Value="{StaticResource Surface3Brush}" />
|
||||
</Style>
|
||||
<Style Selector="Border.subtask-row.done TextBlock.subtask-title">
|
||||
<Setter Property="Opacity" Value="0.5" />
|
||||
@@ -1082,6 +1145,23 @@
|
||||
<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>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- DAY TOGGLE -->
|
||||
@@ -1102,4 +1182,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,17 @@
|
||||
<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) -->
|
||||
<SolidColorBrush x:Key="MergeTheirsTintBrush" Color="#1FD4A574" /> <!-- theirs side (amber) -->
|
||||
<SolidColorBrush x:Key="MergeConflictTintBrush" Color="#28C87060" /> <!-- unresolved conflict (blood) -->
|
||||
<SolidColorBrush x:Key="MergeConflictEdgeBrush" Color="#80C87060" /> <!-- unresolved conflict gutter edge / map tick -->
|
||||
<SolidColorBrush x:Key="MergeResolvedTintBrush" Color="#206FA86B" /> <!-- resolved conflict (green) -->
|
||||
<SolidColorBrush x:Key="MergeResolvedEdgeBrush" Color="#806FA86B" /> <!-- resolved conflict map tick -->
|
||||
<SolidColorBrush x:Key="AmberBrush" Color="#FFD4A574" /> <!-- solid amber (theirs label) -->
|
||||
|
||||
<!-- Window-body gradient layers (apply as LinearGradientBrush in the main content Border) -->
|
||||
<LinearGradientBrush x:Key="DesktopBackgroundBrush" StartPoint="0%,0%" EndPoint="0%,100%">
|
||||
|
||||
40
src/ClaudeDo.Ui/Services/IDialogService.cs
Normal file
40
src/ClaudeDo.Ui/Services/IDialogService.cs
Normal file
@@ -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);
|
||||
}
|
||||
29
src/ClaudeDo.Ui/Services/IMergeCoordinator.cs
Normal file
29
src/ClaudeDo.Ui/Services/IMergeCoordinator.cs
Normal file
@@ -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;
|
||||
}
|
||||
10
src/ClaudeDo.Ui/Services/Interfaces/IOnlineLoginService.cs
Normal file
10
src/ClaudeDo.Ui/Services/Interfaces/IOnlineLoginService.cs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace ClaudeDo.Ui.Services;
|
||||
|
||||
public sealed record OnlineLoginResult(bool Success, string? RefreshToken, string? Error, string? Warning = null);
|
||||
|
||||
public interface IOnlineLoginService
|
||||
{
|
||||
Task<OnlineLoginResult> LoginAsync(
|
||||
string authority, string clientId, string scope, string redirectUri,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ namespace ClaudeDo.Ui.Services;
|
||||
public interface IWorkerClient : INotifyPropertyChanged
|
||||
{
|
||||
bool IsConnected { get; }
|
||||
bool IsReconnecting { get; }
|
||||
|
||||
event Action<string, string, DateTime>? TaskStartedEvent;
|
||||
event Action<string, string, string, DateTime>? TaskFinishedEvent;
|
||||
@@ -17,6 +18,17 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
event Action<string>? WorktreeUpdatedEvent;
|
||||
event Action<string>? ListUpdatedEvent;
|
||||
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<string>? InteractiveSessionStartedEvent;
|
||||
event Action<string>? InteractiveSessionEndedEvent;
|
||||
event Action<string, IReadOnlyList<string>>? InteractiveQueueChangedEvent;
|
||||
event Action<string, string>? InteractiveMessageSentEvent;
|
||||
|
||||
event Action? PrepStartedEvent;
|
||||
event Action<string>? PrepLineEvent;
|
||||
@@ -28,12 +40,28 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
event Action<string>? PlanningMergeAbortedEvent;
|
||||
event Action<string>? PlanningCompletedEvent;
|
||||
|
||||
event Action<PrimeFiredEvent>? PrimeFired;
|
||||
|
||||
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);
|
||||
Task SendInteractiveMessageAsync(string taskId, string text);
|
||||
Task RemoveQueuedInteractiveMessageAsync(string taskId, string text);
|
||||
Task StopInteractiveSessionAsync(string taskId);
|
||||
Task InterruptInteractiveSessionAsync(string taskId);
|
||||
/// <summary>The question a running task is currently blocked on, if any (for re-attach).</summary>
|
||||
Task<PendingQuestionDto?> GetPendingQuestionAsync(string taskId);
|
||||
Task ResetTaskAsync(string taskId);
|
||||
Task CancelTaskAsync(string taskId);
|
||||
Task<List<AgentInfo>> GetAgentsAsync();
|
||||
Task RefreshAgentsAsync();
|
||||
Task<SeedResultDto?> RestoreDefaultAgentsAsync();
|
||||
Task<ListConfigDto?> GetListConfigAsync(string listId);
|
||||
Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto);
|
||||
Task SetTaskStatusAsync(string taskId, TaskStatus status);
|
||||
@@ -46,10 +74,10 @@ 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> ContinueMergeAsync(string taskId);
|
||||
Task AbortMergeAsync(string taskId);
|
||||
Task<MergeResultDto> ContinueConflictMergeAsync(string taskId);
|
||||
Task AbortConflictMergeAsync(string taskId);
|
||||
Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default);
|
||||
Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default);
|
||||
Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default);
|
||||
@@ -71,9 +99,29 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
event Action<string, bool, string?>? RefineFinishedEvent;
|
||||
Task ClearMyDayAsync();
|
||||
Task<AppSettingsDto?> GetAppSettingsAsync();
|
||||
Task UpdateAppSettingsAsync(AppSettingsDto dto);
|
||||
Task<List<DailyNoteDto>> GetDailyNotesAsync(DateOnly day);
|
||||
Task<DailyNoteDto?> AddDailyNoteAsync(DateOnly day, string text);
|
||||
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);
|
||||
Task DeletePrimeScheduleAsync(Guid id);
|
||||
|
||||
Task UpdateListAsync(UpdateListDto dto);
|
||||
Task UpdateListConfigAsync(UpdateListConfigDto dto);
|
||||
|
||||
Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync(string? listId = null);
|
||||
Task<WorktreeResetDto?> ResetAllWorktreesAsync();
|
||||
Task<List<WorktreeOverviewDto>> GetWorktreesOverviewAsync(string? listId);
|
||||
Task<(bool Ok, string? Error)> SetWorktreeStateAsync(string taskId, WorktreeState newState);
|
||||
Task<ForceRemoveResultDto?> ForceRemoveWorktreeAsync(string taskId);
|
||||
|
||||
Task<OnlineInboxStateDto?> GetOnlineInboxStateAsync();
|
||||
Task SetOnlineInboxConfigAsync(OnlineInboxConfigInputDto input);
|
||||
Task SetOnlineInboxAuthAsync(string refreshToken);
|
||||
Task ClearOnlineInboxAuthAsync();
|
||||
}
|
||||
|
||||
143
src/ClaudeDo.Ui/Services/OnlineLoginService.cs
Normal file
143
src/ClaudeDo.Ui/Services/OnlineLoginService.cs
Normal file
@@ -0,0 +1,143 @@
|
||||
using System.Diagnostics;
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using Duende.IdentityModel.OidcClient;
|
||||
using Duende.IdentityModel.OidcClient.Browser;
|
||||
|
||||
namespace ClaudeDo.Ui.Services;
|
||||
|
||||
public sealed class OnlineLoginService : IOnlineLoginService
|
||||
{
|
||||
public async Task<OnlineLoginResult> LoginAsync(
|
||||
string authority, string clientId, string scope, string redirectUri,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var browser = new LoopbackBrowser(redirectUri);
|
||||
var options = new OidcClientOptions
|
||||
{
|
||||
Authority = authority,
|
||||
ClientId = clientId,
|
||||
Scope = scope,
|
||||
RedirectUri = redirectUri,
|
||||
Browser = browser,
|
||||
};
|
||||
|
||||
var client = new OidcClient(options);
|
||||
var result = await client.LoginAsync(new LoginRequest(), ct);
|
||||
|
||||
if (result.IsError)
|
||||
return new OnlineLoginResult(false, null, result.Error);
|
||||
|
||||
if (string.IsNullOrEmpty(result.RefreshToken))
|
||||
return new OnlineLoginResult(false, null,
|
||||
"No refresh token returned. Ensure 'offline_access' is in scope and the client allows it.");
|
||||
|
||||
// Early heads-up: if the access token lacks the "user" project role the server will
|
||||
// reject sync with a 401. Login still succeeds; surface this as a warning, not an error.
|
||||
var warning = ZitadelTokenInspector.HasUserRole(result.AccessToken)
|
||||
? null
|
||||
: "missing-user-role";
|
||||
|
||||
return new OnlineLoginResult(true, result.RefreshToken, null, warning);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return new OnlineLoginResult(false, null, "Login cancelled.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new OnlineLoginResult(false, null, ex.Message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// IBrowser implementation: opens the system browser and captures the authorization
|
||||
/// response via a loopback HttpListener on the redirect URI's host/port.
|
||||
/// </summary>
|
||||
sealed class LoopbackBrowser : IBrowser
|
||||
{
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromMinutes(3);
|
||||
private readonly string _redirectUri;
|
||||
|
||||
public LoopbackBrowser(string redirectUri) => _redirectUri = redirectUri;
|
||||
|
||||
public async Task<BrowserResult> InvokeAsync(BrowserOptions options, CancellationToken ct = default)
|
||||
{
|
||||
// Derive the listener prefix from the redirect URI
|
||||
var uri = new Uri(_redirectUri);
|
||||
var prefix = $"{uri.Scheme}://{uri.Host}:{uri.Port}/";
|
||||
|
||||
using var listener = new HttpListener();
|
||||
listener.Prefixes.Add(prefix);
|
||||
|
||||
try
|
||||
{
|
||||
listener.Start();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new BrowserResult
|
||||
{
|
||||
ResultType = BrowserResultType.UnknownError,
|
||||
Error = $"Could not start loopback listener on {prefix}: {ex.Message}"
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Process.Start(new ProcessStartInfo(options.StartUrl) { UseShellExecute = true });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new BrowserResult
|
||||
{
|
||||
ResultType = BrowserResultType.UnknownError,
|
||||
Error = $"Could not open browser: {ex.Message}"
|
||||
};
|
||||
}
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(Timeout);
|
||||
|
||||
try
|
||||
{
|
||||
var context = await listener.GetContextAsync().WaitAsync(cts.Token);
|
||||
|
||||
var responseBody = Encoding.UTF8.GetBytes(
|
||||
"<html><body style=\"font-family:sans-serif;background:#0D1311;color:#E4EBE4;padding:40px\">" +
|
||||
"<h2>Login successful</h2><p>You may close this tab.</p></body></html>");
|
||||
|
||||
context.Response.ContentLength64 = responseBody.Length;
|
||||
context.Response.ContentType = "text/html; charset=utf-8";
|
||||
await context.Response.OutputStream.WriteAsync(responseBody, cts.Token);
|
||||
context.Response.OutputStream.Close();
|
||||
|
||||
// rawUrl already includes the redirect path (e.g. "/callback?code=..."),
|
||||
// so build the full URL from the scheme://host:port base — NOT the full
|
||||
// redirect URI, or the path would be doubled (".../callback/callback").
|
||||
var rawUrl = context.Request.RawUrl ?? "";
|
||||
var fullUri = prefix.TrimEnd('/') + rawUrl;
|
||||
|
||||
return new BrowserResult
|
||||
{
|
||||
ResultType = BrowserResultType.Success,
|
||||
Response = fullUri
|
||||
};
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return new BrowserResult
|
||||
{
|
||||
ResultType = BrowserResultType.Timeout,
|
||||
Error = "Login timed out waiting for browser callback."
|
||||
};
|
||||
}
|
||||
finally
|
||||
{
|
||||
listener.Stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,12 @@ 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<string>? InteractiveSessionStartedEvent;
|
||||
public event Action<string>? InteractiveSessionEndedEvent;
|
||||
public event Action<string, IReadOnlyList<string>>? InteractiveQueueChangedEvent;
|
||||
public event Action<string, string>? InteractiveMessageSentEvent;
|
||||
public event Action? ConnectionRestoredEvent;
|
||||
public event Action<string>? WorktreeUpdatedEvent;
|
||||
public event Action<string>? ListUpdatedEvent;
|
||||
@@ -68,6 +75,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 +142,36 @@ 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>("InteractiveSessionStarted", taskId =>
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => InteractiveSessionStartedEvent?.Invoke(taskId));
|
||||
});
|
||||
|
||||
_hub.On<string>("InteractiveSessionEnded", taskId =>
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => InteractiveSessionEndedEvent?.Invoke(taskId));
|
||||
});
|
||||
|
||||
_hub.On<string, IReadOnlyList<string>>("InteractiveQueueChanged", (taskId, pending) =>
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => InteractiveQueueChangedEvent?.Invoke(taskId, pending));
|
||||
});
|
||||
|
||||
_hub.On<string, string>("InteractiveMessageSent", (taskId, text) =>
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => InteractiveMessageSentEvent?.Invoke(taskId, text));
|
||||
});
|
||||
|
||||
_hub.On<string>("WorktreeUpdated", taskId =>
|
||||
{
|
||||
Dispatcher.UIThread.Post(() => WorktreeUpdatedEvent?.Invoke(taskId));
|
||||
@@ -258,6 +297,39 @@ 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 async Task SendInteractiveMessageAsync(string taskId, string text)
|
||||
{
|
||||
try { await _hub.InvokeAsync("SendInteractiveMessage", taskId, text); }
|
||||
catch { /* offline or session already ended */ }
|
||||
}
|
||||
|
||||
public async Task RemoveQueuedInteractiveMessageAsync(string taskId, string text)
|
||||
{
|
||||
try { await _hub.InvokeAsync("RemoveQueuedInteractiveMessage", taskId, text); }
|
||||
catch { /* offline or session already ended */ }
|
||||
}
|
||||
|
||||
public async Task StopInteractiveSessionAsync(string taskId)
|
||||
{
|
||||
try { await _hub.InvokeAsync("StopInteractiveSession", taskId); }
|
||||
catch { /* offline */ }
|
||||
}
|
||||
|
||||
public async Task InterruptInteractiveSessionAsync(string taskId)
|
||||
{
|
||||
try { await _hub.InvokeAsync("InterruptInteractiveSession", taskId); }
|
||||
catch { /* offline */ }
|
||||
}
|
||||
|
||||
public Task<PendingQuestionDto?> GetPendingQuestionAsync(string taskId)
|
||||
=> TryInvokeAsync<PendingQuestionDto>("GetPendingQuestion", taskId);
|
||||
|
||||
public async Task ResetTaskAsync(string taskId)
|
||||
{
|
||||
await _hub.InvokeAsync("ResetTask", taskId);
|
||||
@@ -272,17 +344,17 @@ 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);
|
||||
|
||||
public Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent)
|
||||
=> _hub.InvokeAsync("WriteConflictResolution", taskId, path, resolvedContent);
|
||||
|
||||
public Task<MergeResultDto> ContinueMergeAsync(string taskId)
|
||||
=> _hub.InvokeAsync<MergeResultDto>("ContinueMerge", taskId);
|
||||
public Task<MergeResultDto> ContinueConflictMergeAsync(string taskId)
|
||||
=> _hub.InvokeAsync<MergeResultDto>("ContinueConflictMerge", taskId);
|
||||
|
||||
public Task AbortMergeAsync(string taskId)
|
||||
=> _hub.InvokeAsync("AbortMerge", taskId);
|
||||
public Task AbortConflictMergeAsync(string taskId)
|
||||
=> _hub.InvokeAsync("AbortConflictMerge", taskId);
|
||||
|
||||
public Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId)
|
||||
=> TryInvokeAsync<MergeTargetsDto>("GetMergeTargets", taskId);
|
||||
@@ -388,6 +460,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);
|
||||
@@ -504,6 +579,18 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
await _hub.InvokeAsync("QueuePlanningSubtasksAsync", parentTaskId, ct);
|
||||
}
|
||||
|
||||
public Task<OnlineInboxStateDto?> GetOnlineInboxStateAsync()
|
||||
=> TryInvokeAsync<OnlineInboxStateDto>("GetOnlineInboxState");
|
||||
|
||||
public async Task SetOnlineInboxConfigAsync(OnlineInboxConfigInputDto input)
|
||||
=> await _hub.InvokeAsync("SetOnlineInboxConfig", input);
|
||||
|
||||
public async Task SetOnlineInboxAuthAsync(string refreshToken)
|
||||
=> await _hub.InvokeAsync("SetOnlineInboxAuth", refreshToken);
|
||||
|
||||
public async Task ClearOnlineInboxAuthAsync()
|
||||
=> await _hub.InvokeAsync("ClearOnlineInboxAuth");
|
||||
|
||||
// IWorkerClient explicit implementations (drop typed return values)
|
||||
async Task IWorkerClient.StartPlanningSessionAsync(string taskId, CancellationToken ct)
|
||||
=> await StartPlanningSessionAsync(taskId, ct);
|
||||
@@ -544,9 +631,9 @@ public sealed record WorktreeResetDto(int Removed, int TasksAffected, bool Block
|
||||
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);
|
||||
@@ -568,3 +655,23 @@ public sealed record WorktreeOverviewDto(
|
||||
bool PathExistsOnDisk);
|
||||
|
||||
public sealed record ForceRemoveResultDto(bool Removed, string? Reason);
|
||||
public sealed record PendingQuestionDto(string TaskId, string QuestionId, string Question);
|
||||
|
||||
public sealed record OnlineInboxStateDto(
|
||||
bool Enabled,
|
||||
string ApiBaseUrl,
|
||||
string Authority,
|
||||
string ClientId,
|
||||
string Scopes,
|
||||
string RedirectUri,
|
||||
bool SignedIn,
|
||||
int PollIntervalSeconds);
|
||||
|
||||
public sealed record OnlineInboxConfigInputDto(
|
||||
bool Enabled,
|
||||
string ApiBaseUrl,
|
||||
int PollIntervalSeconds,
|
||||
string Authority,
|
||||
string ClientId,
|
||||
string Scopes,
|
||||
string RedirectUri);
|
||||
|
||||
@@ -4,8 +4,8 @@ namespace ClaudeDo.Ui.Services;
|
||||
|
||||
public sealed class WorkerNotesApi : INotesApi
|
||||
{
|
||||
private readonly WorkerClient _client;
|
||||
public WorkerNotesApi(WorkerClient client) => _client = client;
|
||||
private readonly IWorkerClient _client;
|
||||
public WorkerNotesApi(IWorkerClient client) => _client = client;
|
||||
public Task<List<DailyNoteDto>> ListAsync(DateOnly day) => _client.GetDailyNotesAsync(day);
|
||||
public Task<DailyNoteDto?> AddAsync(DateOnly day, string text) => _client.AddDailyNoteAsync(day, text);
|
||||
public Task UpdateAsync(string id, string text) => _client.UpdateDailyNoteAsync(id, text);
|
||||
|
||||
@@ -2,8 +2,8 @@ namespace ClaudeDo.Ui.Services;
|
||||
|
||||
public sealed class WorkerPrimeScheduleApi : IPrimeScheduleApi
|
||||
{
|
||||
private readonly WorkerClient _client;
|
||||
public WorkerPrimeScheduleApi(WorkerClient client) => _client = client;
|
||||
private readonly IWorkerClient _client;
|
||||
public WorkerPrimeScheduleApi(IWorkerClient client) => _client = client;
|
||||
public Task<List<PrimeScheduleDto>> ListAsync() => _client.GetPrimeSchedulesAsync();
|
||||
public Task<PrimeScheduleDto?> UpsertAsync(PrimeScheduleDto dto) => _client.UpsertPrimeScheduleAsync(dto);
|
||||
public Task DeleteAsync(Guid id) => _client.DeletePrimeScheduleAsync(id);
|
||||
|
||||
64
src/ClaudeDo.Ui/Services/ZitadelTokenInspector.cs
Normal file
64
src/ClaudeDo.Ui/Services/ZitadelTokenInspector.cs
Normal file
@@ -0,0 +1,64 @@
|
||||
using System;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ClaudeDo.Ui.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Minimal, dependency-free inspection of a Zitadel JWT access token. Used to warn early when
|
||||
/// a freshly issued token lacks the "user" project role (the server otherwise rejects sync
|
||||
/// with a 401). The server remains the source of truth — this check fails open.
|
||||
/// </summary>
|
||||
public static class ZitadelTokenInspector
|
||||
{
|
||||
private const string ProjectRolesClaim = "urn:zitadel:iam:org:project:roles";
|
||||
private const string ProjectRolesClaimPrefix = "urn:zitadel:iam:org:project:";
|
||||
private const string ProjectRolesClaimSuffix = ":roles";
|
||||
private const string UserRole = "user";
|
||||
|
||||
/// <summary>
|
||||
/// Returns true if the access token carries the "user" role in either the generic or
|
||||
/// project-scoped Zitadel roles claim. Returns true (fail-open) if the token is absent or
|
||||
/// cannot be parsed — never block login on a decode hiccup.
|
||||
/// </summary>
|
||||
public static bool HasUserRole(string? accessToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(accessToken))
|
||||
return true;
|
||||
|
||||
var parts = accessToken.Split('.');
|
||||
if (parts.Length < 2)
|
||||
return true;
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(Base64UrlDecode(parts[1]));
|
||||
foreach (var claim in doc.RootElement.EnumerateObject())
|
||||
{
|
||||
if (claim.Name != ProjectRolesClaim &&
|
||||
!(claim.Name.StartsWith(ProjectRolesClaimPrefix, StringComparison.Ordinal) &&
|
||||
claim.Name.EndsWith(ProjectRolesClaimSuffix, StringComparison.Ordinal)))
|
||||
continue;
|
||||
|
||||
if (claim.Value.ValueKind == JsonValueKind.Object &&
|
||||
claim.Value.TryGetProperty(UserRole, out _))
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] Base64UrlDecode(string input)
|
||||
{
|
||||
var s = input.Replace('-', '+').Replace('_', '/');
|
||||
switch (s.Length % 4)
|
||||
{
|
||||
case 2: s += "=="; break;
|
||||
case 3: s += "="; break;
|
||||
}
|
||||
return Convert.FromBase64String(s);
|
||||
}
|
||||
}
|
||||
259
src/ClaudeDo.Ui/ViewModels/Agent/AgentConfigEditorViewModel.cs
Normal file
259
src/ClaudeDo.Ui/ViewModels/Agent/AgentConfigEditorViewModel.cs
Normal file
@@ -0,0 +1,259 @@
|
||||
using System.Collections.ObjectModel;
|
||||
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 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;
|
||||
}
|
||||
|
||||
public void Dispose() => Loc.LanguageChanged -= _langChangedHandler;
|
||||
|
||||
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;
|
||||
|
||||
if (_scope == AgentConfigScope.Task)
|
||||
await _worker.UpdateTaskAgentSettingsAsync(new UpdateTaskAgentSettingsDto(TargetId, model, sp, ap, turns));
|
||||
else
|
||||
await _worker.UpdateListConfigAsync(new UpdateListConfigDto(TargetId, model, sp, ap, turns));
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
_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);
|
||||
|
||||
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;
|
||||
}
|
||||
finally { _suppressSave = false; }
|
||||
EffectiveSystemPromptHint = "";
|
||||
TargetId = null;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -5,45 +5,89 @@ using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Conflicts;
|
||||
|
||||
public sealed partial class ConflictHunk : ObservableObject
|
||||
/// <summary>
|
||||
/// One conflict region in a file: the two competing versions (and the merge base when the
|
||||
/// merge used diff3 style), plus the chosen <see cref="Resolution"/> (null until resolved).
|
||||
/// </summary>
|
||||
public sealed partial class MergeConflictBlock : ObservableObject
|
||||
{
|
||||
public string Ours { get; }
|
||||
public string Theirs { get; }
|
||||
public string? Base { get; }
|
||||
public string Theirs { get; }
|
||||
|
||||
[ObservableProperty] private string? _resolution;
|
||||
|
||||
public bool IsResolved => Resolution is not null;
|
||||
public bool HasBase => Base is not null;
|
||||
|
||||
public ConflictHunk(string ours, string theirs, string? @base)
|
||||
public MergeConflictBlock(string ours, string? @base, string theirs)
|
||||
{
|
||||
Ours = ours;
|
||||
Theirs = theirs;
|
||||
Base = @base;
|
||||
Theirs = theirs;
|
||||
}
|
||||
|
||||
partial void OnResolutionChanged(string? value) => OnPropertyChanged(nameof(IsResolved));
|
||||
|
||||
[RelayCommand] private void AcceptCurrent() => Resolution = Ours;
|
||||
[RelayCommand] private void AcceptIncoming() => Resolution = Theirs;
|
||||
[RelayCommand] private void AcceptBoth() => Resolution = Ours + Theirs;
|
||||
[RelayCommand] private void EditManually() => Resolution ??= Ours;
|
||||
[RelayCommand] private void AcceptOurs() => Resolution = Ours;
|
||||
[RelayCommand] private void AcceptTheirs() => Resolution = Theirs;
|
||||
[RelayCommand] private void AcceptBoth() => Resolution = Ours + Theirs;
|
||||
[RelayCommand] private void AcceptBase() => Resolution = Base ?? "";
|
||||
}
|
||||
|
||||
public sealed class ConflictFile
|
||||
/// <summary>An ordered piece of a conflicted file: either stable common text or a conflict block.</summary>
|
||||
public sealed class MergeFileSegment
|
||||
{
|
||||
public string Path { get; }
|
||||
public IReadOnlyList<ConflictHunk> Hunks { get; }
|
||||
public bool IsConflict { get; }
|
||||
public string StableText { get; }
|
||||
public MergeConflictBlock? Conflict { get; }
|
||||
|
||||
public ConflictFile(string path, IReadOnlyList<ConflictHunk> hunks)
|
||||
private MergeFileSegment(bool isConflict, string stableText, MergeConflictBlock? conflict)
|
||||
{
|
||||
Path = path;
|
||||
Hunks = hunks;
|
||||
IsConflict = isConflict;
|
||||
StableText = stableText;
|
||||
Conflict = conflict;
|
||||
}
|
||||
|
||||
public bool AllHunksResolved => Hunks.Count > 0 && Hunks.All(h => h.IsResolved);
|
||||
|
||||
/// <summary>Merged file content: concatenation of each hunk's resolution
|
||||
/// (single whole-file hunk today; concatenation stays correct for multi-hunk later).</summary>
|
||||
public string ComposeResolvedContent() => string.Concat(Hunks.Select(h => h.Resolution));
|
||||
public static MergeFileSegment Stable(string text) => new(false, text, null);
|
||||
public static MergeFileSegment FromConflict(MergeConflictBlock block) => new(true, "", block);
|
||||
}
|
||||
|
||||
/// <summary>A conflicted file: its ordered segments (for reassembly) and just its conflict blocks.</summary>
|
||||
public sealed class MergeFile
|
||||
{
|
||||
public string Path { get; }
|
||||
public bool IsBinary { get; }
|
||||
public IReadOnlyList<MergeFileSegment> Segments { get; }
|
||||
public IReadOnlyList<MergeConflictBlock> Conflicts { get; }
|
||||
|
||||
public MergeFile(string path, bool isBinary, IReadOnlyList<MergeFileSegment> segments)
|
||||
{
|
||||
Path = path;
|
||||
IsBinary = isBinary;
|
||||
Segments = segments;
|
||||
Conflicts = segments.Where(s => s.IsConflict).Select(s => s.Conflict!).ToList();
|
||||
}
|
||||
|
||||
/// <summary>A binary file can't be resolved in-app; a text file is done once every block is resolved.</summary>
|
||||
public bool AllResolved => !IsBinary && Conflicts.All(c => c.IsResolved);
|
||||
|
||||
/// <summary>Reassemble the file: stable text verbatim, each conflict replaced by its resolution
|
||||
/// (empty when unresolved — the same "empty start" the editor shows; Continue is gated on
|
||||
/// <see cref="AllResolved"/> so an unresolved conflict never actually reaches here).</summary>
|
||||
public string Compose() => string.Concat(
|
||||
Segments.Select(s => s.IsConflict ? (s.Conflict!.Resolution ?? "") : s.StableText));
|
||||
|
||||
/// <summary>Left pane document: stable regions verbatim, conflict regions show Ours text.</summary>
|
||||
public string OursText => string.Concat(
|
||||
Segments.Select(s => s.IsConflict ? s.Conflict!.Ours : s.StableText));
|
||||
|
||||
/// <summary>Right pane document: stable regions verbatim, conflict regions show Theirs text.</summary>
|
||||
public string TheirsText => string.Concat(
|
||||
Segments.Select(s => s.IsConflict ? s.Conflict!.Theirs : s.StableText));
|
||||
|
||||
/// <summary>Middle (result) pane document: stable regions verbatim, conflict regions show the
|
||||
/// chosen Resolution, or empty when unresolved (the editor builds each conflict up from empty).</summary>
|
||||
public string ResultText => string.Concat(
|
||||
Segments.Select(s => s.IsConflict ? (s.Conflict!.Resolution ?? "") : s.StableText));
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
@@ -14,23 +15,115 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
|
||||
private readonly IWorkerClient _worker;
|
||||
private readonly string _taskId;
|
||||
|
||||
public ObservableCollection<ConflictFile> Files { get; } = new();
|
||||
// The task whose conflicted working tree is read/written. For a single-task merge this is
|
||||
// _taskId; for a planning unit-merge it's the subtask currently being merged.
|
||||
private string _conflictTaskId;
|
||||
|
||||
// When set, this is a planning unit-merge: continue/abort drive the orchestrator on the parent.
|
||||
private string? _planningParentId;
|
||||
|
||||
public ObservableCollection<MergeFile> Files { get; } = new();
|
||||
|
||||
// All text conflicts across all files, flattened for one-at-a-time navigation.
|
||||
private readonly List<(MergeFile File, MergeConflictBlock Block)> _flat = new();
|
||||
|
||||
[ObservableProperty] private bool _isBusy;
|
||||
[ObservableProperty] private string? _error;
|
||||
[ObservableProperty] private bool _canContinue;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ContinueHint))]
|
||||
private bool _canContinue;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(HasCurrent))]
|
||||
private MergeConflictBlock? _current;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(PositionText))]
|
||||
[NotifyPropertyChangedFor(nameof(CurrentPath))]
|
||||
[NotifyCanExecuteChangedFor(nameof(NextCommand))]
|
||||
[NotifyCanExecuteChangedFor(nameof(PreviousCommand))]
|
||||
private int _currentIndex = -1;
|
||||
|
||||
[ObservableProperty] private MergeFile? _activeFile;
|
||||
|
||||
/// <summary>Raised when the active file changes so the view can rebuild its three documents.</summary>
|
||||
public event Action? ActiveFileChanged;
|
||||
|
||||
partial void OnActiveFileChanged(MergeFile? value)
|
||||
{
|
||||
ActiveFileChanged?.Invoke();
|
||||
OnPropertyChanged(nameof(ActiveOursText));
|
||||
OnPropertyChanged(nameof(ActiveTheirsText));
|
||||
OnPropertyChanged(nameof(ActiveResultText));
|
||||
OnPropertyChanged(nameof(PositionText));
|
||||
// Keep the focused conflict inside the active file (e.g. when switched via the file picker).
|
||||
if (value is not null && (Current is null || !value.Conflicts.Contains(Current)))
|
||||
{
|
||||
var idx = _flat.FindIndex(x => x.File == value);
|
||||
if (idx >= 0) MoveTo(idx);
|
||||
}
|
||||
}
|
||||
|
||||
public string ActiveOursText => ActiveFile?.OursText ?? "";
|
||||
public string ActiveTheirsText => ActiveFile?.TheirsText ?? "";
|
||||
public string ActiveResultText => ActiveFile?.ResultText ?? "";
|
||||
|
||||
public bool HasCurrent => Current is not null;
|
||||
public int TotalConflicts => _flat.Count;
|
||||
public int ResolvedCount => _flat.Count(x => x.Block.IsResolved);
|
||||
public string? CurrentPath => InRange ? _flat[CurrentIndex].File.Path : null;
|
||||
|
||||
public string PositionText
|
||||
{
|
||||
get
|
||||
{
|
||||
if (ActiveFile is null || ActiveFile.Conflicts.Count == 0) return "No text conflicts";
|
||||
var count = ActiveFile.Conflicts.Count;
|
||||
var resolved = ActiveFile.Conflicts.Count(c => c.IsResolved);
|
||||
return $"{count} {(count == 1 ? "conflict" : "conflicts")} · {resolved} resolved";
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<string> BinaryFilePaths => Files.Where(f => f.IsBinary).Select(f => f.Path).ToList();
|
||||
public bool HasBinaryFiles => Files.Any(f => f.IsBinary);
|
||||
|
||||
public bool HasMultipleFiles => Files.Count > 1;
|
||||
|
||||
/// <summary>Cross-file progress shown in the editor: how many files still have unresolved
|
||||
/// (or binary) conflicts, so you can see how many more need attention.</summary>
|
||||
public string FilesSummary
|
||||
{
|
||||
get
|
||||
{
|
||||
var total = Files.Count;
|
||||
if (total == 0) return "";
|
||||
var unresolved = Files.Count(f => !f.AllResolved);
|
||||
return unresolved == 0 ? $"All {total} files resolved" : $"{unresolved} of {total} files unresolved";
|
||||
}
|
||||
}
|
||||
|
||||
public string ContinueHint => HasBinaryFiles
|
||||
? "Binary conflicts must be resolved externally — abort and resolve in your editor."
|
||||
: "";
|
||||
|
||||
private bool InRange => CurrentIndex >= 0 && CurrentIndex < _flat.Count;
|
||||
|
||||
public string TaskId => _taskId;
|
||||
public Action? CloseRequested { get; set; }
|
||||
|
||||
/// <summary>Raised when the current conflict changes so the view can reload its editors.</summary>
|
||||
public event Action? CurrentChanged;
|
||||
|
||||
public ConflictResolverViewModel(IWorkerClient worker, string taskId)
|
||||
{
|
||||
_worker = worker;
|
||||
_taskId = taskId;
|
||||
_conflictTaskId = taskId;
|
||||
}
|
||||
|
||||
/// <summary>Starts the conflict merge and loads ours/theirs/base per file.
|
||||
/// Returns true when there are conflicts to resolve (caller should show the dialog).</summary>
|
||||
/// <summary>Starts the conflict merge and loads the conflicted files as line-level segments.
|
||||
/// Returns true when there is something to resolve (caller should show the dialog).</summary>
|
||||
public async Task<bool> OpenAsync(string targetBranch)
|
||||
{
|
||||
IsBusy = true;
|
||||
@@ -44,21 +137,7 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
|
||||
Error = start.ErrorMessage;
|
||||
return false;
|
||||
}
|
||||
|
||||
var conflicts = await _worker.GetMergeConflictsAsync(_taskId);
|
||||
Files.Clear();
|
||||
foreach (var f in conflicts.Files)
|
||||
{
|
||||
var hunks = f.Hunks.Select(h =>
|
||||
{
|
||||
var hk = new ConflictHunk(h.Ours, h.Theirs, h.Base);
|
||||
hk.PropertyChanged += OnHunkChanged;
|
||||
return hk;
|
||||
}).ToList();
|
||||
Files.Add(new ConflictFile(f.Path, hunks));
|
||||
}
|
||||
RecomputeCanContinue();
|
||||
return Files.Count > 0;
|
||||
return await LoadDocumentsAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -68,14 +147,104 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
private void OnHunkChanged(object? sender, PropertyChangedEventArgs e)
|
||||
/// <summary>Resolves a planning unit-merge conflict for <paramref name="subtaskId"/>. The merge is
|
||||
/// already mid-conflict (driven by the orchestrator), so this only loads the conflicted files;
|
||||
/// continue/abort hand back to the orchestrator on <paramref name="planningParentId"/>.</summary>
|
||||
public async Task<bool> OpenForPlanningAsync(string planningParentId, string subtaskId)
|
||||
{
|
||||
if (e.PropertyName is nameof(ConflictHunk.IsResolved) or nameof(ConflictHunk.Resolution))
|
||||
RecomputeCanContinue();
|
||||
_planningParentId = planningParentId;
|
||||
_conflictTaskId = subtaskId;
|
||||
IsBusy = true;
|
||||
Error = null;
|
||||
try
|
||||
{
|
||||
return await LoadDocumentsAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Error = ex.Message;
|
||||
return false;
|
||||
}
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
private void RecomputeCanContinue()
|
||||
=> CanContinue = Files.Count > 0 && Files.All(f => f.AllHunksResolved);
|
||||
private async Task<bool> LoadDocumentsAsync()
|
||||
{
|
||||
var docs = await _worker.GetMergeConflictDocumentsAsync(_conflictTaskId);
|
||||
Files.Clear();
|
||||
_flat.Clear();
|
||||
foreach (var f in docs.Files)
|
||||
{
|
||||
var segments = f.Segments.Select(s => s.IsConflict
|
||||
? MergeFileSegment.FromConflict(Hook(new MergeConflictBlock(s.Ours, s.Base, s.Theirs)))
|
||||
: MergeFileSegment.Stable(s.Text)).ToList();
|
||||
var file = new MergeFile(f.Path, f.IsBinary, segments);
|
||||
Files.Add(file);
|
||||
foreach (var c in file.Conflicts) _flat.Add((file, c));
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(TotalConflicts));
|
||||
OnPropertyChanged(nameof(BinaryFilePaths));
|
||||
OnPropertyChanged(nameof(HasBinaryFiles));
|
||||
OnPropertyChanged(nameof(HasMultipleFiles));
|
||||
OnPropertyChanged(nameof(FilesSummary));
|
||||
RecomputeCanContinue();
|
||||
if (_flat.Count > 0)
|
||||
MoveTo(0); // also sets ActiveFile via MoveTo
|
||||
else if (Files.Count > 0)
|
||||
ActiveFile = Files[0];
|
||||
return Files.Count > 0;
|
||||
}
|
||||
|
||||
private MergeConflictBlock Hook(MergeConflictBlock block)
|
||||
{
|
||||
block.PropertyChanged += OnBlockChanged;
|
||||
return block;
|
||||
}
|
||||
|
||||
private void OnBlockChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName is nameof(MergeConflictBlock.IsResolved) or nameof(MergeConflictBlock.Resolution))
|
||||
{
|
||||
RecomputeCanContinue();
|
||||
OnPropertyChanged(nameof(ResolvedCount));
|
||||
OnPropertyChanged(nameof(PositionText));
|
||||
OnPropertyChanged(nameof(ActiveResultText));
|
||||
OnPropertyChanged(nameof(FilesSummary));
|
||||
}
|
||||
}
|
||||
|
||||
private void RecomputeCanContinue() =>
|
||||
CanContinue = Files.Count > 0 && Files.All(f => f.AllResolved);
|
||||
|
||||
private void MoveTo(int index)
|
||||
{
|
||||
CurrentIndex = index;
|
||||
Current = _flat[index].Block;
|
||||
ActiveFile = _flat[index].File;
|
||||
OnPropertyChanged(nameof(CurrentPath));
|
||||
CurrentChanged?.Invoke();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void SelectFile(MergeFile file)
|
||||
{
|
||||
// Jump to the first conflict in this file (if any); otherwise just switch the active file.
|
||||
var idx = _flat.FindIndex(x => x.File == file);
|
||||
if (idx >= 0)
|
||||
MoveTo(idx);
|
||||
else
|
||||
ActiveFile = file;
|
||||
}
|
||||
|
||||
private bool CanGoNext() => CurrentIndex >= 0 && CurrentIndex < _flat.Count - 1;
|
||||
private bool CanGoPrevious() => CurrentIndex > 0;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanGoNext))]
|
||||
private void Next() => MoveTo(CurrentIndex + 1);
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanGoPrevious))]
|
||||
private void Previous() => MoveTo(CurrentIndex - 1);
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ContinueAsync()
|
||||
@@ -85,10 +254,19 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
|
||||
Error = null;
|
||||
try
|
||||
{
|
||||
foreach (var file in Files)
|
||||
await _worker.WriteConflictResolutionAsync(_taskId, file.Path, file.ComposeResolvedContent());
|
||||
foreach (var file in Files.Where(f => !f.IsBinary))
|
||||
await _worker.WriteConflictResolutionAsync(_conflictTaskId, file.Path, file.Compose());
|
||||
|
||||
var result = await _worker.ContinueMergeAsync(_taskId);
|
||||
if (_planningParentId is not null)
|
||||
{
|
||||
// Hand back to the orchestrator: it commits this subtask and drains the rest.
|
||||
// A later subtask conflict re-opens this editor via the PlanningMergeConflict broadcast.
|
||||
await _worker.ContinuePlanningMergeAsync(_planningParentId);
|
||||
CloseRequested?.Invoke();
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await _worker.ContinueConflictMergeAsync(_taskId);
|
||||
if (string.Equals(result.Status, "merged", StringComparison.Ordinal))
|
||||
CloseRequested?.Invoke();
|
||||
else
|
||||
@@ -105,7 +283,13 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
|
||||
private async Task AbortAsync()
|
||||
{
|
||||
IsBusy = true;
|
||||
try { await _worker.AbortMergeAsync(_taskId); }
|
||||
try
|
||||
{
|
||||
if (_planningParentId is not null)
|
||||
await _worker.AbortPlanningMergeAsync(_planningParentId);
|
||||
else
|
||||
await _worker.AbortConflictMergeAsync(_taskId);
|
||||
}
|
||||
catch (Exception ex) { Error = ex.Message; }
|
||||
finally
|
||||
{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,35 +20,32 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly IServiceProvider? _services;
|
||||
private readonly WorkerClient? _worker;
|
||||
private readonly IWorkerClient? _worker;
|
||||
private static readonly TaskListFilterRegistry _filters = new();
|
||||
|
||||
public event EventHandler? SelectionChanged;
|
||||
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; }
|
||||
}
|
||||
@@ -143,7 +140,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
|
||||
private readonly EventHandler _langChangedHandler;
|
||||
|
||||
public ListsIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IServiceProvider? services = null, WorkerClient? worker = null)
|
||||
public ListsIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IServiceProvider? services = null, IWorkerClient? worker = null)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_services = services;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
34
src/ClaudeDo.Ui/ViewModels/Islands/LogLineViewModel.cs
Normal file
34
src/ClaudeDo.Ui/ViewModels/Islands/LogLineViewModel.cs
Normal file
@@ -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",
|
||||
_ => "",
|
||||
};
|
||||
}
|
||||
184
src/ClaudeDo.Ui/ViewModels/Islands/MergeSectionViewModel.cs
Normal file
184
src/ClaudeDo.Ui/ViewModels/Islands/MergeSectionViewModel.cs
Normal file
@@ -0,0 +1,184 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using System.Collections.ObjectModel;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.Modals;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||
|
||||
public sealed partial class MergeSectionViewModel : ViewModelBase
|
||||
{
|
||||
private readonly IWorkerClient _worker;
|
||||
private readonly IServiceProvider _services;
|
||||
|
||||
// Context mirrored from parent, updated via Sync* methods
|
||||
internal string? TaskId { get; private set; }
|
||||
internal string? TaskTitle { get; private set; }
|
||||
private string? _worktreePath;
|
||||
private string? _worktreeBaseCommit;
|
||||
private string? _worktreeHeadCommit;
|
||||
private string? _worktreeStateLabel;
|
||||
private string? _listWorkingDir;
|
||||
private bool _isPlanningParent;
|
||||
private int _subtaskCount;
|
||||
private bool _hasChildOutcomes;
|
||||
|
||||
[ObservableProperty] private ObservableCollection<string> _mergeTargetBranches = new();
|
||||
[ObservableProperty] private string? _selectedMergeTarget;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ShowMergePreviewMuted))]
|
||||
private string _mergePreviewText = "";
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ShowMergePreviewMuted))]
|
||||
private bool _mergeIsClean;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ShowMergePreviewMuted))]
|
||||
private bool _mergeIsConflict;
|
||||
|
||||
public bool ShowMergePreviewMuted =>
|
||||
!MergeIsClean && !MergeIsConflict && !string.IsNullOrEmpty(MergePreviewText);
|
||||
|
||||
public bool ShowMergeSection =>
|
||||
_worktreePath != null || _isPlanningParent || _hasChildOutcomes;
|
||||
|
||||
public Func<DiffViewerViewModel, System.Threading.Tasks.Task>? ShowDiffViewer { get; set; }
|
||||
public Func<MergeModalViewModel, System.Threading.Tasks.Task>? ShowMergeModal { get; set; }
|
||||
|
||||
public MergeSectionViewModel(IWorkerClient worker, IServiceProvider services)
|
||||
{
|
||||
_worker = worker;
|
||||
_services = services;
|
||||
}
|
||||
|
||||
partial void OnSelectedMergeTargetChanged(string? value) => _ = RefreshMergePreviewAsync();
|
||||
|
||||
internal void SyncWorktree(
|
||||
string? worktreePath,
|
||||
string? worktreeBase,
|
||||
string? worktreeHead,
|
||||
string? worktreeState,
|
||||
string? listWorkDir)
|
||||
{
|
||||
_worktreePath = worktreePath;
|
||||
_worktreeBaseCommit = worktreeBase;
|
||||
_worktreeHeadCommit = worktreeHead;
|
||||
_worktreeStateLabel = worktreeState;
|
||||
_listWorkingDir = listWorkDir;
|
||||
OnPropertyChanged(nameof(ShowMergeSection));
|
||||
OpenDiffCommand.NotifyCanExecuteChanged();
|
||||
OpenWorktreeCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
internal void SyncTaskContext(string? taskId, string? taskTitle, bool isPlanningParent)
|
||||
{
|
||||
TaskId = taskId;
|
||||
TaskTitle = taskTitle;
|
||||
_isPlanningParent = isPlanningParent;
|
||||
OnPropertyChanged(nameof(ShowMergeSection));
|
||||
}
|
||||
|
||||
internal void SyncChildOutcomes(bool hasChildOutcomes, int subtaskCount)
|
||||
{
|
||||
_hasChildOutcomes = hasChildOutcomes;
|
||||
_subtaskCount = subtaskCount;
|
||||
OnPropertyChanged(nameof(ShowMergeSection));
|
||||
ReviewCombinedDiffCommand.NotifyCanExecuteChanged();
|
||||
}
|
||||
|
||||
internal async System.Threading.Tasks.Task RefreshMergePreviewAsync()
|
||||
{
|
||||
if (TaskId is null || _worktreePath is null)
|
||||
{
|
||||
MergePreviewText = ""; MergeIsClean = false; MergeIsConflict = false;
|
||||
return;
|
||||
}
|
||||
if (_worktreeStateLabel is { } label && label != "Active")
|
||||
{
|
||||
MergePreviewText = label; MergeIsClean = false; MergeIsConflict = false;
|
||||
return;
|
||||
}
|
||||
var capturedTaskId = TaskId;
|
||||
var capturedTarget = SelectedMergeTarget;
|
||||
var dto = await _worker.PreviewMergeAsync(capturedTaskId, capturedTarget ?? "");
|
||||
if (TaskId != capturedTaskId || SelectedMergeTarget != capturedTarget) return;
|
||||
var (text, clean, conflict) = MergePreviewPresenter.Describe(dto);
|
||||
MergePreviewText = text; MergeIsClean = clean; MergeIsConflict = conflict;
|
||||
}
|
||||
|
||||
internal void Clear()
|
||||
{
|
||||
MergeTargetBranches.Clear();
|
||||
SelectedMergeTarget = null;
|
||||
MergePreviewText = "";
|
||||
MergeIsClean = false;
|
||||
MergeIsConflict = false;
|
||||
SyncWorktree(null, null, null, null, null);
|
||||
SyncTaskContext(null, null, false);
|
||||
SyncChildOutcomes(false, 0);
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanReviewDiff))]
|
||||
private async System.Threading.Tasks.Task ReviewCombinedDiffAsync()
|
||||
{
|
||||
if (TaskId is null || ShowDiffViewer is null) return;
|
||||
var vm = _services.GetRequiredService<DiffViewerViewModel>();
|
||||
vm.ConfigurePlanning(TaskId, SelectedMergeTarget ?? "main");
|
||||
await vm.LoadAsync();
|
||||
await ShowDiffViewer(vm);
|
||||
}
|
||||
|
||||
private bool CanReviewDiff() => (_isPlanningParent && _subtaskCount > 0) || _hasChildOutcomes;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanOpenDiff))]
|
||||
private async System.Threading.Tasks.Task OpenDiffAsync()
|
||||
{
|
||||
if (ShowDiffViewer is null) return;
|
||||
|
||||
var hasLiveWorktree =
|
||||
_worktreePath != null
|
||||
&& _worktreeStateLabel == "Active"
|
||||
&& System.IO.Directory.Exists(_worktreePath);
|
||||
|
||||
var vm = _services.GetRequiredService<DiffViewerViewModel>();
|
||||
if (hasLiveWorktree)
|
||||
{
|
||||
vm.ConfigureWorktree(_worktreePath!, _worktreeBaseCommit, TaskId, TaskTitle ?? "");
|
||||
vm.ShowMergeModal = ShowMergeModal;
|
||||
vm.ResolveMergeVm = () => _services.GetRequiredService<MergeModalViewModel>();
|
||||
}
|
||||
else if (CanDiffMergedRange)
|
||||
{
|
||||
vm.ConfigureCommitRange(_listWorkingDir!, _worktreeBaseCommit, _worktreeHeadCommit, TaskId, TaskTitle ?? "");
|
||||
}
|
||||
else return;
|
||||
|
||||
await vm.LoadAsync();
|
||||
await ShowDiffViewer(vm);
|
||||
}
|
||||
|
||||
private bool CanDiffMergedRange =>
|
||||
_worktreeBaseCommit != null && _worktreeHeadCommit != null && _listWorkingDir != null;
|
||||
|
||||
private bool CanOpenDiff() => _worktreePath != null || CanDiffMergedRange;
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanOpenWorktree))]
|
||||
private void OpenWorktree()
|
||||
{
|
||||
if (_worktreePath is null) return;
|
||||
try
|
||||
{
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = _worktreePath,
|
||||
UseShellExecute = true,
|
||||
});
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private bool CanOpenWorktree() => _worktreePath != null;
|
||||
}
|
||||
@@ -7,24 +7,15 @@ namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||
|
||||
public sealed partial class NoteBulletViewModel : ViewModelBase
|
||||
{
|
||||
private readonly Func<NoteBulletViewModel, Task> _save;
|
||||
private readonly Func<NoteBulletViewModel, Task> _delete;
|
||||
|
||||
public string Id { get; }
|
||||
|
||||
[ObservableProperty] private string _text;
|
||||
|
||||
public NoteBulletViewModel(string id, string text,
|
||||
Func<NoteBulletViewModel, Task> save, Func<NoteBulletViewModel, Task> delete)
|
||||
public NoteBulletViewModel(string id, string text)
|
||||
{
|
||||
Id = id;
|
||||
_text = text;
|
||||
_save = save;
|
||||
_delete = delete;
|
||||
}
|
||||
|
||||
[RelayCommand] private Task Save() => _save(this);
|
||||
[RelayCommand] private Task Delete() => _delete(this);
|
||||
}
|
||||
|
||||
public sealed partial class NotesEditorViewModel : ViewModelBase
|
||||
@@ -57,7 +48,7 @@ public sealed partial class NotesEditorViewModel : ViewModelBase
|
||||
}
|
||||
|
||||
private NoteBulletViewModel MakeBullet(string id, string text) =>
|
||||
new(id, text, SaveBulletAsync, DeleteBulletAsync);
|
||||
new(id, text);
|
||||
|
||||
[RelayCommand]
|
||||
private async Task AddBullet()
|
||||
@@ -73,11 +64,17 @@ public sealed partial class NotesEditorViewModel : ViewModelBase
|
||||
[RelayCommand] private Task NextDay() => LoadDayAsync(CurrentDay.AddDays(1));
|
||||
[RelayCommand] private Task Today() => LoadDayAsync(DateOnly.FromDateTime(DateTime.Today));
|
||||
|
||||
private Task SaveBulletAsync(NoteBulletViewModel b) => _api.UpdateAsync(b.Id, b.Text);
|
||||
|
||||
private async Task DeleteBulletAsync(NoteBulletViewModel b)
|
||||
[RelayCommand]
|
||||
private async Task CommitBullet(NoteBulletViewModel? b)
|
||||
{
|
||||
await _api.DeleteAsync(b.Id);
|
||||
Bullets.Remove(b);
|
||||
if (b is null) return;
|
||||
var text = b.Text?.Trim() ?? "";
|
||||
if (text.Length == 0)
|
||||
{
|
||||
await _api.DeleteAsync(b.Id);
|
||||
Bullets.Remove(b);
|
||||
return;
|
||||
}
|
||||
await _api.UpdateAsync(b.Id, text);
|
||||
}
|
||||
}
|
||||
|
||||
102
src/ClaudeDo.Ui/ViewModels/Islands/PrepPanelViewModel.cs
Normal file
102
src/ClaudeDo.Ui/ViewModels/Islands/PrepPanelViewModel.cs
Normal file
@@ -0,0 +1,102 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Text;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using ClaudeDo.Ui.Helpers;
|
||||
using ClaudeDo.Ui.Services;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||
|
||||
public sealed partial class PrepPanelViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
private readonly IWorkerClient _worker;
|
||||
private readonly StreamLineFormatter _formatter = new();
|
||||
private readonly StringBuilder _prepClaudeBuf = new();
|
||||
|
||||
private readonly Action _onPrepStartedHandler;
|
||||
private readonly Action<string> _onPrepLineHandler;
|
||||
private readonly Action<bool> _onPrepFinishedHandler;
|
||||
|
||||
[ObservableProperty] private bool _isPrepRunning;
|
||||
|
||||
public ObservableCollection<LogLineViewModel> PrepLog { get; } = new();
|
||||
|
||||
public bool ShowPrepEmptyState => !IsPrepRunning && PrepLog.Count == 0;
|
||||
|
||||
partial void OnIsPrepRunningChanged(bool value) => OnPropertyChanged(nameof(ShowPrepEmptyState));
|
||||
|
||||
public PrepPanelViewModel(IWorkerClient worker)
|
||||
{
|
||||
_worker = worker;
|
||||
_onPrepStartedHandler = OnPrepStarted;
|
||||
_onPrepLineHandler = OnPrepLine;
|
||||
_onPrepFinishedHandler = OnPrepFinished;
|
||||
|
||||
_worker.PrepStartedEvent += _onPrepStartedHandler;
|
||||
_worker.PrepLineEvent += _onPrepLineHandler;
|
||||
_worker.PrepFinishedEvent += _onPrepFinishedHandler;
|
||||
|
||||
PrepLog.CollectionChanged += (_, _) => OnPropertyChanged(nameof(ShowPrepEmptyState));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_worker.PrepStartedEvent -= _onPrepStartedHandler;
|
||||
_worker.PrepLineEvent -= _onPrepLineHandler;
|
||||
_worker.PrepFinishedEvent -= _onPrepFinishedHandler;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async System.Threading.Tasks.Task PlanDayAsync()
|
||||
{
|
||||
try { await _worker.RunDailyPrepNowAsync(); }
|
||||
catch { }
|
||||
}
|
||||
|
||||
public async System.Threading.Tasks.Task LoadLastPrepLogIfEmptyAsync()
|
||||
{
|
||||
if (IsPrepRunning || PrepLog.Count > 0) return;
|
||||
string text;
|
||||
try { text = await _worker.GetLastPrepLogAsync(); }
|
||||
catch { return; }
|
||||
if (IsPrepRunning || PrepLog.Count > 0) return;
|
||||
foreach (var line in text.Split('\n'))
|
||||
{
|
||||
var trimmed = line.TrimEnd('\r');
|
||||
if (trimmed.Length > 0) AppendStdoutLine(trimmed);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPrepStarted()
|
||||
{
|
||||
PrepLog.Clear();
|
||||
IsPrepRunning = true;
|
||||
}
|
||||
|
||||
private void OnPrepLine(string line) => AppendStdoutLine(line);
|
||||
|
||||
private void OnPrepFinished(bool success) => IsPrepRunning = false;
|
||||
|
||||
private void AppendStdoutLine(string line)
|
||||
{
|
||||
var formatted = _formatter.FormatLine(line);
|
||||
if (formatted is null) return;
|
||||
AppendClaudeText(formatted);
|
||||
}
|
||||
|
||||
private void AppendClaudeText(string chunk)
|
||||
{
|
||||
_prepClaudeBuf.Append(chunk);
|
||||
while (true)
|
||||
{
|
||||
var text = _prepClaudeBuf.ToString();
|
||||
var nl = text.IndexOf('\n');
|
||||
if (nl < 0) break;
|
||||
var piece = text[..nl].TrimEnd('\r');
|
||||
if (!string.IsNullOrWhiteSpace(piece))
|
||||
PrepLog.Add(new LogLineViewModel { Kind = LogKind.Claude, Text = piece });
|
||||
_prepClaudeBuf.Clear();
|
||||
_prepClaudeBuf.Append(text[(nl + 1)..]);
|
||||
}
|
||||
}
|
||||
}
|
||||
537
src/ClaudeDo.Ui/ViewModels/Islands/TaskMonitorViewModel.cs
Normal file
537
src/ClaudeDo.Ui/ViewModels/Islands/TaskMonitorViewModel.cs
Normal file
@@ -0,0 +1,537 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Text;
|
||||
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 Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||
|
||||
public sealed partial class TaskMonitorViewModel : ViewModelBase, 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:";
|
||||
|
||||
public ObservableCollection<QueuedMessageViewModel> QueuedMessages { get; } = new();
|
||||
public bool HasQueuedMessages => QueuedMessages.Count > 0;
|
||||
|
||||
// Captured handler delegates for disposal
|
||||
private readonly Action<string, string> _onTaskMessage;
|
||||
private readonly Action<string, string, DateTime> _onTaskStarted;
|
||||
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;
|
||||
private readonly Action<string> _onInteractiveStarted;
|
||||
private readonly Action<string> _onInteractiveEnded;
|
||||
private readonly Action<string, IReadOnlyList<string>> _onInteractiveQueueChanged;
|
||||
private readonly Action<string, string> _onInteractiveMessageSent;
|
||||
|
||||
// Interactive composer — active while the worker is in an interactive session.
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(SubmitComposerCommand))]
|
||||
private bool _isInteractiveLive;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(SubmitComposerCommand))]
|
||||
private string _composerDraft = string.Empty;
|
||||
|
||||
// A question the running task raised via AskUser and is blocking on, plus the answer
|
||||
// the user is typing. Ephemeral (in-memory + live events) — the task is still Running.
|
||||
[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;
|
||||
|
||||
_onInteractiveStarted = taskId =>
|
||||
{
|
||||
if (taskId == _subscribedTaskId) { IsInteractiveLive = true; AgentState = "running"; }
|
||||
};
|
||||
_worker.InteractiveSessionStartedEvent += _onInteractiveStarted;
|
||||
|
||||
_onInteractiveEnded = taskId =>
|
||||
{
|
||||
if (taskId != _subscribedTaskId) return;
|
||||
IsInteractiveLive = false;
|
||||
AgentState = "done";
|
||||
QueuedMessages.Clear();
|
||||
OnPropertyChanged(nameof(HasQueuedMessages));
|
||||
};
|
||||
_worker.InteractiveSessionEndedEvent += _onInteractiveEnded;
|
||||
|
||||
_onInteractiveQueueChanged = (taskId, pending) =>
|
||||
{
|
||||
if (taskId != _subscribedTaskId) return;
|
||||
QueuedMessages.Clear();
|
||||
foreach (var m in pending)
|
||||
{
|
||||
var text = m;
|
||||
QueuedMessages.Add(new QueuedMessageViewModel
|
||||
{
|
||||
Text = text,
|
||||
RemoveCommand = new CommunityToolkit.Mvvm.Input.RelayCommand(() => _ = RemoveQueuedAsync(text)),
|
||||
});
|
||||
}
|
||||
OnPropertyChanged(nameof(HasQueuedMessages));
|
||||
};
|
||||
_worker.InteractiveQueueChangedEvent += _onInteractiveQueueChanged;
|
||||
|
||||
_onInteractiveMessageSent = (taskId, text) =>
|
||||
{
|
||||
if (taskId == _subscribedTaskId)
|
||||
Log.Add(new LogLineViewModel { Kind = LogKind.User, Text = text });
|
||||
};
|
||||
_worker.InteractiveMessageSentEvent += _onInteractiveMessageSent;
|
||||
}
|
||||
|
||||
// Surface a pending question (used by live event + re-attach hydration).
|
||||
public void SetPendingQuestion(string questionId, string question)
|
||||
{
|
||||
PendingQuestionId = questionId;
|
||||
PendingQuestion = question;
|
||||
}
|
||||
|
||||
// Used by Mission Control when it creates the monitor after the started event already fired.
|
||||
public void SetInteractiveLive(bool live)
|
||||
{
|
||||
IsInteractiveLive = live;
|
||||
if (live) AgentState = "running";
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanSubmitComposer))]
|
||||
private async System.Threading.Tasks.Task SubmitComposer()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_subscribedTaskId)) return;
|
||||
var text = ComposerDraft;
|
||||
if (string.IsNullOrWhiteSpace(text)) return;
|
||||
ComposerDraft = string.Empty;
|
||||
await _worker.SendInteractiveMessageAsync(_subscribedTaskId, text);
|
||||
}
|
||||
|
||||
private bool CanSubmitComposer() => IsInteractiveLive && !string.IsNullOrWhiteSpace(ComposerDraft);
|
||||
|
||||
[RelayCommand]
|
||||
private async System.Threading.Tasks.Task StopInteractive()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_subscribedTaskId) && IsInteractiveLive)
|
||||
await _worker.StopInteractiveSessionAsync(_subscribedTaskId);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async System.Threading.Tasks.Task InterruptInteractive()
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_subscribedTaskId) && IsInteractiveLive)
|
||||
await _worker.InterruptInteractiveSessionAsync(_subscribedTaskId);
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task RemoveQueuedAsync(string text)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_subscribedTaskId))
|
||||
await _worker.RemoveQueuedInteractiveMessageAsync(_subscribedTaskId, text);
|
||||
}
|
||||
|
||||
private void ClearPendingQuestion()
|
||||
{
|
||||
PendingQuestionId = null;
|
||||
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();
|
||||
IsInteractiveLive = false;
|
||||
ComposerDraft = string.Empty;
|
||||
QueuedMessages.Clear();
|
||||
OnPropertyChanged(nameof(HasQueuedMessages));
|
||||
}
|
||||
|
||||
[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 = result;
|
||||
Roadblocks = null;
|
||||
return;
|
||||
}
|
||||
|
||||
var summary = result[..idx].TrimEnd().TrimEnd('⚠').TrimEnd();
|
||||
SessionOutcome = string.IsNullOrWhiteSpace(summary) ? null : summary;
|
||||
Roadblocks = result[(idx + RoadblockMarker.Length)..].Trim();
|
||||
}
|
||||
|
||||
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;
|
||||
_worker.InteractiveSessionStartedEvent -= _onInteractiveStarted;
|
||||
_worker.InteractiveSessionEndedEvent -= _onInteractiveEnded;
|
||||
_worker.InteractiveQueueChangedEvent -= _onInteractiveQueueChanged;
|
||||
_worker.InteractiveMessageSentEvent -= _onInteractiveMessageSent;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class QueuedMessageViewModel
|
||||
{
|
||||
public required string Text { get; init; }
|
||||
public required System.Windows.Input.ICommand RemoveCommand { get; init; }
|
||||
}
|
||||
@@ -18,6 +18,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
[ObservableProperty] private PlanningPhase _planningPhase;
|
||||
[ObservableProperty] private string? _branch;
|
||||
[ObservableProperty] private string? _diffStat;
|
||||
[ObservableProperty] private ClaudeDo.Data.Models.WorktreeState? _worktreeState;
|
||||
[ObservableProperty] private DateTime? _scheduledFor;
|
||||
[ObservableProperty] private int _diffAdditions;
|
||||
[ObservableProperty] private int _diffDeletions;
|
||||
@@ -31,8 +32,11 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
[ObservableProperty] private bool _hasQueuedSubtasks;
|
||||
[ObservableProperty] private bool _showListChip = true;
|
||||
[ObservableProperty] private bool _parentFinalized;
|
||||
[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;
|
||||
|
||||
@@ -46,9 +50,13 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
public bool IsAgentSuggested => IsChild && !string.IsNullOrEmpty(CreatedBy) && CreatedBy == ParentTaskId;
|
||||
public bool IsPlanningParent => PlanningPhase != PlanningPhase.None
|
||||
|| HasPlanningChildren;
|
||||
// A child only reads as a child while its parent shares the current view. When the parent is
|
||||
// absent (removed from My Day, or daily-prep placed a lone child there), the row renders as a
|
||||
// normal top-level task instead of an orphaned, indented Draft.
|
||||
public bool ShowAsChild => IsChild && ParentInView;
|
||||
// A subtask is Draft until its planning parent is finalized, then Planned (queueable).
|
||||
public bool IsDraft => IsChild && Status == TaskStatus.Idle && !ParentFinalized;
|
||||
public bool IsPlanned => IsChild && Status == TaskStatus.Idle && ParentFinalized;
|
||||
public bool IsDraft => ShowAsChild && Status == TaskStatus.Idle && !ParentFinalized;
|
||||
public bool IsPlanned => ShowAsChild && Status == TaskStatus.Idle && ParentFinalized;
|
||||
|
||||
public bool CanOpenPlanningSession => Status == TaskStatus.Idle
|
||||
&& PlanningPhase == PlanningPhase.None
|
||||
@@ -71,16 +79,28 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
public bool IsOverdue => ScheduledFor is { } d && d.Date < DateTime.Today && !Done;
|
||||
public bool IsRunning => Status == TaskStatus.Running;
|
||||
public bool IsWaitingForReview => Status == TaskStatus.WaitingForReview;
|
||||
// Parked = set aside from review: Idle but still holding its Active worktree (vs a plain Idle task).
|
||||
public bool IsParked => Status == TaskStatus.Idle && WorktreeState == ClaudeDo.Data.Models.WorktreeState.Active;
|
||||
public bool IsQueued => Status == TaskStatus.Queued && string.IsNullOrEmpty(BlockedByTaskId);
|
||||
public bool IsWaiting => Status == TaskStatus.Queued && !string.IsNullOrEmpty(BlockedByTaskId);
|
||||
public bool CanRemoveFromQueue => IsQueued || HasQueuedSubtasks;
|
||||
// "Send to queue" is the single queue entry. On a finalized planning parent it queues the
|
||||
// plan (children) via CanQueuePlan; an Active (not-yet-finalized) planning parent is hidden —
|
||||
// it must be finalized first.
|
||||
public bool CanSendToQueue => !IsRunning && !IsQueued && !IsWaitingForReview && !HasQueuedSubtasks
|
||||
&& (!IsChild || ParentFinalized);
|
||||
&& (!IsChild || ParentFinalized)
|
||||
&& PlanningPhase != PlanningPhase.Active;
|
||||
// Parent-level "send plan to queue" — only once the plan is finalized (children Planned).
|
||||
// Drives the routing inside SendToQueue, not a separate menu entry.
|
||||
public bool CanQueuePlan => !IsChild && HasPlanningChildren
|
||||
&& PlanningPhase == PlanningPhase.Finalized
|
||||
&& !HasQueuedSubtasks;
|
||||
// User-triggered finalize for a planning parent whose session was closed before finalizing.
|
||||
public bool CanFinalizePlanning => PlanningPhase == PlanningPhase.Active && !IsChild;
|
||||
public bool HasSchedule => ScheduledFor.HasValue;
|
||||
// "Add to My Day" — shown on any task not already in My Day; a Done task has no place in
|
||||
// today's focus list. The mirror of "Remove from My Day" (gated on IsMyDay).
|
||||
public bool CanAddToMyDay => !IsMyDay && !Done;
|
||||
public bool HasRoadblock => RoadblockCount > 0;
|
||||
public string RoadblockTooltip => RoadblockCount == 1
|
||||
? "1 roadblock reported during the run — see details"
|
||||
@@ -90,7 +110,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
public string DiffDeletionsText => $"−{DiffDeletions}";
|
||||
public string StepsText => Loc.T("vm.taskRow.stepsText", StepsCompleted, StepsCount);
|
||||
|
||||
public string StatusLabel => Status switch
|
||||
public string StatusLabel => IsParked ? Loc.T("vm.taskStatus.parked") : Status switch
|
||||
{
|
||||
TaskStatus.Idle => Loc.T("vm.taskStatus.idle"),
|
||||
TaskStatus.Queued => Loc.T("vm.taskStatus.queued"),
|
||||
@@ -121,6 +141,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
OnPropertyChanged(nameof(StatusLabel));
|
||||
OnPropertyChanged(nameof(IsRunning));
|
||||
OnPropertyChanged(nameof(IsWaitingForReview));
|
||||
OnPropertyChanged(nameof(IsParked));
|
||||
OnPropertyChanged(nameof(IsQueued));
|
||||
OnPropertyChanged(nameof(IsWaiting));
|
||||
OnPropertyChanged(nameof(IsDraft));
|
||||
@@ -135,12 +156,20 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
{
|
||||
OnPropertyChanged(nameof(IsChild));
|
||||
OnPropertyChanged(nameof(IsAgentSuggested));
|
||||
OnPropertyChanged(nameof(ShowAsChild));
|
||||
OnPropertyChanged(nameof(IsDraft));
|
||||
OnPropertyChanged(nameof(IsPlanned));
|
||||
OnPropertyChanged(nameof(CanSendToQueue));
|
||||
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
||||
}
|
||||
|
||||
partial void OnParentInViewChanged(bool value)
|
||||
{
|
||||
OnPropertyChanged(nameof(ShowAsChild));
|
||||
OnPropertyChanged(nameof(IsDraft));
|
||||
OnPropertyChanged(nameof(IsPlanned));
|
||||
}
|
||||
|
||||
partial void OnCreatedByChanged(string? value) => OnPropertyChanged(nameof(IsAgentSuggested));
|
||||
|
||||
partial void OnParentFinalizedChanged(bool value)
|
||||
@@ -159,6 +188,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
||||
OnPropertyChanged(nameof(CanResumeOrDiscardPlanning));
|
||||
OnPropertyChanged(nameof(CanQueuePlan));
|
||||
OnPropertyChanged(nameof(CanSendToQueue));
|
||||
OnPropertyChanged(nameof(CanFinalizePlanning));
|
||||
OnPropertyChanged(nameof(CanRefine));
|
||||
}
|
||||
|
||||
@@ -185,7 +216,17 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
}
|
||||
|
||||
partial void OnBranchChanged(string? value) => OnPropertyChanged(nameof(HasBranch));
|
||||
partial void OnDoneChanged(bool value) => OnPropertyChanged(nameof(IsOverdue));
|
||||
partial void OnWorktreeStateChanged(ClaudeDo.Data.Models.WorktreeState? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsParked));
|
||||
OnPropertyChanged(nameof(StatusLabel));
|
||||
}
|
||||
partial void OnDoneChanged(bool value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsOverdue));
|
||||
OnPropertyChanged(nameof(CanAddToMyDay));
|
||||
}
|
||||
partial void OnIsMyDayChanged(bool value) => OnPropertyChanged(nameof(CanAddToMyDay));
|
||||
partial void OnScheduledForChanged(DateTime? value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsOverdue));
|
||||
@@ -222,6 +263,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
PlanningPhase = t.PlanningPhase;
|
||||
Branch = t.Worktree?.BranchName;
|
||||
DiffStat = t.Worktree?.DiffStat;
|
||||
WorktreeState = t.Worktree?.State;
|
||||
ScheduledFor = t.ScheduledFor;
|
||||
DiffAdditions = add;
|
||||
DiffDeletions = del;
|
||||
|
||||
@@ -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;
|
||||
@@ -219,14 +222,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 +303,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
|
||||
@@ -334,6 +327,10 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
// Items is already ordered by SortOrder from the DB query.
|
||||
// Treat rows whose ParentTaskId is not in the current view as orphans -> top-level.
|
||||
var visibleIds = Items.Select(r => r.Id).ToHashSet();
|
||||
// A child reads as a child only while its parent is in the view. Flag orphans so they
|
||||
// render flat (no indent, no Draft/Planned badge) instead of breaking the layout.
|
||||
foreach (var r in Items)
|
||||
r.ParentInView = string.IsNullOrEmpty(r.ParentTaskId) || visibleIds.Contains(r.ParentTaskId!);
|
||||
bool IsTopLevel(TaskRowViewModel r) =>
|
||||
!r.IsChild
|
||||
|| string.IsNullOrEmpty(r.ParentTaskId)
|
||||
@@ -357,19 +354,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;
|
||||
@@ -404,20 +411,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);
|
||||
@@ -500,6 +502,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;
|
||||
@@ -571,6 +595,52 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
TasksChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task AddToMyDayAsync(TaskRowViewModel? row)
|
||||
{
|
||||
if (row is null || row.IsMyDay) return;
|
||||
row.IsMyDay = true;
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
|
||||
if (entity != null)
|
||||
{
|
||||
entity.IsMyDay = true;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
Regroup();
|
||||
UpdateSubtitle();
|
||||
TasksChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RemoveFromMyDayAsync(TaskRowViewModel? row)
|
||||
{
|
||||
if (row is null) return;
|
||||
row.IsMyDay = false;
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
// Removing a parent takes its whole plan off My Day: clear the task and every child, so no
|
||||
// orphaned child is left behind (independently-IsMyDay children included). A leaf child has
|
||||
// no children of its own, so this collapses to just clearing the row itself.
|
||||
var affected = await db.Tasks
|
||||
.Where(t => t.Id == row.Id || t.ParentTaskId == row.Id)
|
||||
.ToListAsync();
|
||||
foreach (var t in affected)
|
||||
t.IsMyDay = false;
|
||||
if (affected.Count > 0)
|
||||
await db.SaveChangesAsync();
|
||||
if (_currentList?.Id == "smart:my-day")
|
||||
{
|
||||
var drop = Items
|
||||
.Where(r => r.Id == row.Id || r.ParentTaskId == row.Id)
|
||||
.ToList();
|
||||
foreach (var r in drop)
|
||||
Items.Remove(r);
|
||||
}
|
||||
Regroup();
|
||||
UpdateSubtitle();
|
||||
TasksChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
public async Task SetStatusOnRowAsync(TaskRowViewModel row, TaskStatus status)
|
||||
{
|
||||
if (_worker is null) return;
|
||||
@@ -582,6 +652,12 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
private async Task SendToQueueAsync(TaskRowViewModel? row)
|
||||
{
|
||||
if (row is null || row.IsRunning) return;
|
||||
// A finalized planning parent queues its plan (children sequentially), not itself.
|
||||
if (row.CanQueuePlan)
|
||||
{
|
||||
await QueuePlanningSubtasksAsync(row);
|
||||
return;
|
||||
}
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
|
||||
if (entity is null) return;
|
||||
@@ -704,6 +780,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;
|
||||
|
||||
@@ -719,7 +807,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
if (row.Status != TaskStatus.Idle || row.PlanningPhase != PlanningPhase.None) return;
|
||||
ForegroundHelper.AllowAny();
|
||||
try { await _worker!.StartPlanningSessionAsync(row.Id); }
|
||||
catch { }
|
||||
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.planningOpenFailed", ex.Message)); }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
@@ -728,7 +816,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
if (row is null || _worker is null) return;
|
||||
ForegroundHelper.AllowAny();
|
||||
try { await _worker.OpenInteractiveTerminalAsync(row.Id); }
|
||||
catch { }
|
||||
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.runInteractiveFailed", 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;
|
||||
@@ -20,7 +19,8 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
public ListsIslandViewModel? Lists { get; }
|
||||
public TasksIslandViewModel? Tasks { get; }
|
||||
public DetailsIslandViewModel? Details { get; }
|
||||
public WorkerClient? Worker { get; }
|
||||
public IWorkerClient? Worker { get; }
|
||||
public MissionControlViewModel? MissionControl { get; }
|
||||
public UpdateCheckService UpdateCheck => _updateCheck;
|
||||
|
||||
public string ConnectionText =>
|
||||
@@ -41,38 +41,60 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
|
||||
public Func<MergeModalViewModel> ResolveMergeVm => _mergeVmFactory;
|
||||
|
||||
// Set by MainWindow to open the conflict resolution dialog.
|
||||
public Func<ConflictResolutionViewModel, Task>? ShowConflictDialog { get; set; }
|
||||
|
||||
// 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;
|
||||
@@ -133,6 +155,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");
|
||||
@@ -146,44 +179,17 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
private void OnPlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList<string> conflictedFiles)
|
||||
{
|
||||
// Already on UI thread (WorkerClient dispatches via Dispatcher.UIThread.Post).
|
||||
_ = OpenConflictDialogAsync(planningTaskId, subtaskId, conflictedFiles);
|
||||
// A unit-merge conflict resolves in the same in-app 3-way editor as a single-task merge.
|
||||
_ = OpenPlanningConflictAsync(planningTaskId, subtaskId);
|
||||
}
|
||||
|
||||
private async Task OpenConflictDialogAsync(string planningTaskId, string subtaskId, IReadOnlyList<string> conflictedFiles)
|
||||
private async Task OpenPlanningConflictAsync(string planningTaskId, string subtaskId)
|
||||
{
|
||||
if (ShowConflictDialog == null || _dbFactory == null) return;
|
||||
|
||||
string subtaskTitle = subtaskId;
|
||||
// The conflict lives in the list's working dir (the repo being merged into),
|
||||
// not the subtask worktree. VS Code must open this folder to show the merge UI.
|
||||
string repoDirectory = System.Environment.CurrentDirectory;
|
||||
string targetBranch = Worker?.LastApproveTarget ?? "main";
|
||||
|
||||
try
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
var entity = await ctx.Tasks
|
||||
.Include(t => t.List)
|
||||
.AsNoTracking()
|
||||
.FirstOrDefaultAsync(t => t.Id == subtaskId);
|
||||
if (entity != null)
|
||||
{
|
||||
subtaskTitle = entity.Title;
|
||||
if (entity.List?.WorkingDir is { } dir && !string.IsNullOrWhiteSpace(dir))
|
||||
repoDirectory = dir;
|
||||
}
|
||||
}
|
||||
catch { /* Non-fatal: fall back to subtaskId and cwd */ }
|
||||
|
||||
var vm = new ConflictResolutionViewModel(
|
||||
Worker!,
|
||||
planningTaskId,
|
||||
subtaskTitle,
|
||||
targetBranch,
|
||||
conflictedFiles,
|
||||
repoDirectory);
|
||||
|
||||
await ShowConflictDialog(vm);
|
||||
if (ConflictResolverFactory is null || Dialogs is null) return;
|
||||
var vm = ConflictResolverFactory(subtaskId);
|
||||
var hasConflicts = await vm.OpenForPlanningAsync(planningTaskId, subtaskId);
|
||||
if (hasConflicts)
|
||||
await Dialogs.ShowConflictResolverAsync(vm);
|
||||
}
|
||||
|
||||
// For tests only — does NOT wire up events.
|
||||
@@ -193,7 +199,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
ListsIslandViewModel lists,
|
||||
TasksIslandViewModel tasks,
|
||||
DetailsIslandViewModel details,
|
||||
WorkerClient worker,
|
||||
IWorkerClient worker,
|
||||
UpdateCheckService updateCheck,
|
||||
InstallerLocator installerLocator,
|
||||
WorkerLocator workerLocator,
|
||||
@@ -201,9 +207,14 @@ 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);
|
||||
_updateCheck = updateCheck;
|
||||
_installerLocator = installerLocator;
|
||||
_workerLocator = workerLocator;
|
||||
@@ -216,6 +227,7 @@ 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.TasksChanged += (_, _) => _ = Lists.RefreshCountsAsync();
|
||||
Tasks.OpenListSettingsRequested += (_, _) =>
|
||||
{
|
||||
@@ -229,10 +241,9 @@ 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(WorkerClient.IsConnected) or nameof(WorkerClient.IsReconnecting))
|
||||
if (e.PropertyName is nameof(IWorkerClient.IsConnected) or nameof(IWorkerClient.IsReconnecting))
|
||||
{
|
||||
OnPropertyChanged(nameof(ConnectionText));
|
||||
OnPropertyChanged(nameof(IsOffline));
|
||||
@@ -307,11 +318,27 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
if (InlineUpdateStatus == text) InlineUpdateStatus = null;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenMissionControl()
|
||||
{
|
||||
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;
|
||||
@@ -327,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]
|
||||
@@ -336,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();
|
||||
}
|
||||
|
||||
@@ -348,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; }
|
||||
}
|
||||
@@ -365,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; }
|
||||
}
|
||||
|
||||
234
src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs
Normal file
234
src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs
Normal file
@@ -0,0 +1,234 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Specialized;
|
||||
using System.Linq;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
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;
|
||||
private readonly Action<string> _onInteractiveStarted;
|
||||
|
||||
public ObservableCollection<TaskMonitorViewModel> Monitors { get; } = new();
|
||||
|
||||
[ObservableProperty] private int _columnCount = 1;
|
||||
|
||||
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;
|
||||
|
||||
// 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;
|
||||
|
||||
_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;
|
||||
|
||||
_onInteractiveStarted = taskId =>
|
||||
{
|
||||
EnsureMonitor(taskId);
|
||||
var m = Monitors.FirstOrDefault(x => x.SubscribedTaskId == taskId);
|
||||
m?.SetInteractiveLive(true);
|
||||
};
|
||||
_worker.InteractiveSessionStartedEvent += _onInteractiveStarted;
|
||||
|
||||
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();
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
private void OnMonitorsChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
ColumnCount = Monitors.Count switch
|
||||
{
|
||||
<= 1 => 1,
|
||||
<= 4 => 2,
|
||||
_ => 3,
|
||||
};
|
||||
OnPropertyChanged(nameof(HasMonitors));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_worker.TaskStartedEvent -= _onTaskStarted;
|
||||
_worker.TaskFinishedEvent -= _onTaskFinished;
|
||||
_worker.TaskUpdatedEvent -= _onTaskUpdated;
|
||||
_worker.ConnectionRestoredEvent -= _onConnectionRestored;
|
||||
_worker.InteractiveSessionStartedEvent -= _onInteractiveStarted;
|
||||
Monitors.CollectionChanged -= OnMonitorsChanged;
|
||||
foreach (var m in Monitors) m.Dispose();
|
||||
Monitors.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,140 +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 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();
|
||||
await vm.InitializeAsync(TaskId, TaskTitle);
|
||||
await ShowMergeModal(vm);
|
||||
// The diff is stale once the worktree has been merged away — close it too.
|
||||
if (vm.Merged) CloseAction?.Invoke();
|
||||
}
|
||||
|
||||
public async Task LoadAsync(CancellationToken ct = default)
|
||||
{
|
||||
Files.Clear();
|
||||
StatusMessage = null;
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
131
src/ClaudeDo.Ui/ViewModels/Modals/DiffModels.cs
Normal file
131
src/ClaudeDo.Ui/ViewModels/Modals/DiffModels.cs
Normal file
@@ -0,0 +1,131 @@
|
||||
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;
|
||||
}
|
||||
|
||||
/// 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 };
|
||||
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;
|
||||
}
|
||||
}
|
||||
243
src/ClaudeDo.Ui/ViewModels/Modals/DiffViewerViewModel.cs
Normal file
243
src/ClaudeDo.Ui/ViewModels/Modals/DiffViewerViewModel.cs
Normal file
@@ -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;
|
||||
@@ -12,7 +13,7 @@ namespace ClaudeDo.Ui.ViewModels.Modals;
|
||||
|
||||
public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
{
|
||||
private readonly WorkerClient _worker;
|
||||
private readonly IWorkerClient _worker;
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
|
||||
public string ListId { get; set; } = "";
|
||||
@@ -28,60 +29,19 @@ 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; }
|
||||
|
||||
public ListSettingsModalViewModel(WorkerClient worker, IDbContextFactory<ClaudeDoDbContext> dbFactory)
|
||||
public ListSettingsModalViewModel(IWorkerClient worker, IDbContextFactory<ClaudeDoDbContext> dbFactory)
|
||||
{
|
||||
_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;
|
||||
}
|
||||
}
|
||||
|
||||
58
src/ClaudeDo.Ui/ViewModels/Modals/LogVisualizerViewModel.cs
Normal file
58
src/ClaudeDo.Ui/ViewModels/Modals/LogVisualizerViewModel.cs
Normal file
@@ -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);
|
||||
@@ -8,7 +8,8 @@ namespace ClaudeDo.Ui.ViewModels.Modals;
|
||||
|
||||
public sealed partial class MergeModalViewModel : ViewModelBase
|
||||
{
|
||||
private readonly WorkerClient _worker;
|
||||
private readonly IWorkerClient _worker;
|
||||
private readonly IMergeCoordinator _merge;
|
||||
|
||||
public string TaskId { get; set; } = "";
|
||||
public string TaskTitle { get; set; } = "";
|
||||
@@ -32,9 +33,13 @@ public sealed partial class MergeModalViewModel : ViewModelBase
|
||||
/// close itself after this modal closes.
|
||||
public bool Merged { get; private set; }
|
||||
|
||||
public MergeModalViewModel(WorkerClient worker)
|
||||
/// 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, IMergeCoordinator merge)
|
||||
{
|
||||
_worker = worker;
|
||||
_merge = merge;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync(string taskId, string taskTitle)
|
||||
@@ -96,9 +101,11 @@ public sealed partial class MergeModalViewModel : ViewModelBase
|
||||
});
|
||||
break;
|
||||
case "conflict":
|
||||
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 ?? "");
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace ClaudeDo.Ui.ViewModels.Modals.Settings;
|
||||
|
||||
public sealed partial class FilesSettingsTabViewModel : ViewModelBase
|
||||
{
|
||||
private readonly WorkerClient _worker;
|
||||
private readonly IWorkerClient _worker;
|
||||
|
||||
[ObservableProperty] private string _statusMessage = "";
|
||||
[ObservableProperty] private bool _isBusy;
|
||||
@@ -21,7 +21,7 @@ public sealed partial class FilesSettingsTabViewModel : ViewModelBase
|
||||
public string DailyPrepPromptPath { get; } = PromptFiles.PathFor(PromptKind.DailyPrep);
|
||||
public string WeeklyReportPromptPath { get; } = PromptFiles.PathFor(PromptKind.WeeklyReport);
|
||||
|
||||
public FilesSettingsTabViewModel(WorkerClient worker) => _worker = worker;
|
||||
public FilesSettingsTabViewModel(IWorkerClient worker) => _worker = worker;
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RestoreDefaultAgents()
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Modals.Settings;
|
||||
|
||||
public sealed partial class OnlineInboxSettingsViewModel : ViewModelBase
|
||||
{
|
||||
private readonly IWorkerClient _worker;
|
||||
private readonly IOnlineLoginService _loginService;
|
||||
|
||||
[ObservableProperty] private bool _enabled;
|
||||
[ObservableProperty] private string _apiBaseUrl = "";
|
||||
[ObservableProperty] private string _authority = "";
|
||||
[ObservableProperty] private string _clientId = "";
|
||||
[ObservableProperty] private string _scopes = "openid offline_access";
|
||||
[ObservableProperty] private string _redirectUri = "http://localhost:8765/callback";
|
||||
[ObservableProperty] private int _pollIntervalSeconds = 60;
|
||||
[ObservableProperty] private bool _signedIn;
|
||||
[ObservableProperty] private bool _isBusy;
|
||||
[ObservableProperty] private string _statusMessage = "";
|
||||
|
||||
public OnlineInboxSettingsViewModel(IWorkerClient worker, IOnlineLoginService loginService)
|
||||
{
|
||||
_worker = worker;
|
||||
_loginService = loginService;
|
||||
}
|
||||
|
||||
public async Task LoadAsync()
|
||||
{
|
||||
IsBusy = true;
|
||||
StatusMessage = "";
|
||||
try
|
||||
{
|
||||
var dto = await _worker.GetOnlineInboxStateAsync();
|
||||
if (dto is null)
|
||||
{
|
||||
StatusMessage = Loc.T("vm.onlineInbox.workerOffline");
|
||||
return;
|
||||
}
|
||||
|
||||
Enabled = dto.Enabled;
|
||||
ApiBaseUrl = dto.ApiBaseUrl;
|
||||
Authority = dto.Authority;
|
||||
ClientId = dto.ClientId;
|
||||
Scopes = dto.Scopes;
|
||||
RedirectUri = dto.RedirectUri;
|
||||
SignedIn = dto.SignedIn;
|
||||
PollIntervalSeconds = dto.PollIntervalSeconds;
|
||||
}
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
// Persists the Online Inbox config. Exceptions propagate so callers (the modal's Apply)
|
||||
// can surface and halt; the per-tab Save button wraps this and shows its own message.
|
||||
public async Task SaveAsync()
|
||||
{
|
||||
IsBusy = true;
|
||||
StatusMessage = "";
|
||||
try
|
||||
{
|
||||
await _worker.SetOnlineInboxConfigAsync(new OnlineInboxConfigInputDto(
|
||||
Enabled,
|
||||
ApiBaseUrl,
|
||||
PollIntervalSeconds,
|
||||
Authority,
|
||||
ClientId,
|
||||
Scopes,
|
||||
RedirectUri));
|
||||
StatusMessage = Loc.T("vm.onlineInbox.saved");
|
||||
}
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task Save()
|
||||
{
|
||||
try { await SaveAsync(); }
|
||||
catch (Exception ex) { StatusMessage = Loc.T("vm.onlineInbox.saveFailed", ex.Message); }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task SignIn()
|
||||
{
|
||||
IsBusy = true;
|
||||
StatusMessage = "";
|
||||
try
|
||||
{
|
||||
var result = await _loginService.LoginAsync(Authority, ClientId, Scopes, RedirectUri);
|
||||
if (!result.Success)
|
||||
{
|
||||
StatusMessage = Loc.T("vm.onlineInbox.signInFailed", result.Error ?? "Unknown error");
|
||||
return;
|
||||
}
|
||||
|
||||
await _worker.SetOnlineInboxAuthAsync(result.RefreshToken!);
|
||||
SignedIn = true;
|
||||
StatusMessage = result.Warning == "missing-user-role"
|
||||
? Loc.T("vm.onlineInbox.signedInNoRole")
|
||||
: Loc.T("vm.onlineInbox.signedIn");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = Loc.T("vm.onlineInbox.signInFailed", ex.Message);
|
||||
}
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task SignOut()
|
||||
{
|
||||
IsBusy = true;
|
||||
StatusMessage = "";
|
||||
try
|
||||
{
|
||||
await _worker.ClearOnlineInboxAuthAsync();
|
||||
SignedIn = false;
|
||||
StatusMessage = Loc.T("vm.onlineInbox.signedOut");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = Loc.T("vm.onlineInbox.signOutFailed", ex.Message);
|
||||
}
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ namespace ClaudeDo.Ui.ViewModels.Modals.Settings;
|
||||
|
||||
public sealed partial class WorktreesSettingsTabViewModel : ViewModelBase
|
||||
{
|
||||
private readonly WorkerClient _worker;
|
||||
private readonly IWorkerClient _worker;
|
||||
|
||||
[ObservableProperty] private string _worktreeStrategy = "sibling";
|
||||
[ObservableProperty] private string? _centralWorktreeRoot;
|
||||
@@ -21,7 +21,7 @@ public sealed partial class WorktreesSettingsTabViewModel : ViewModelBase
|
||||
|
||||
public IReadOnlyList<string> WorktreeStrategies { get; } = new[] { "sibling", "central" };
|
||||
|
||||
public WorktreesSettingsTabViewModel(WorkerClient worker) => _worker = worker;
|
||||
public WorktreesSettingsTabViewModel(IWorkerClient worker) => _worker = worker;
|
||||
|
||||
public string? Validate()
|
||||
{
|
||||
|
||||
@@ -11,12 +11,13 @@ namespace ClaudeDo.Ui.ViewModels.Modals;
|
||||
|
||||
public sealed partial class SettingsModalViewModel : ViewModelBase
|
||||
{
|
||||
private readonly WorkerClient _worker;
|
||||
private readonly IWorkerClient _worker;
|
||||
|
||||
public GeneralSettingsTabViewModel General { get; }
|
||||
public WorktreesSettingsTabViewModel Worktrees { get; }
|
||||
public FilesSettingsTabViewModel Files { get; }
|
||||
public PrimeClaudeTabViewModel Prime { get; }
|
||||
public OnlineInboxSettingsViewModel OnlineInbox { get; }
|
||||
|
||||
[ObservableProperty] private string _validationError = "";
|
||||
[ObservableProperty] private bool _isBusy;
|
||||
@@ -24,7 +25,8 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
||||
|
||||
public Action? CloseAction { get; set; }
|
||||
|
||||
public SettingsModalViewModel(WorkerClient worker, PrimeClaudeTabViewModel prime,
|
||||
public SettingsModalViewModel(IWorkerClient worker, PrimeClaudeTabViewModel prime,
|
||||
IOnlineLoginService onlineLoginService,
|
||||
ILocalizer localizer, AppSettings appSettings)
|
||||
{
|
||||
_worker = worker;
|
||||
@@ -36,6 +38,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
||||
Worktrees = new WorktreesSettingsTabViewModel(worker);
|
||||
Files = new FilesSettingsTabViewModel(worker);
|
||||
Prime = prime;
|
||||
OnlineInbox = new OnlineInboxSettingsViewModel(worker, onlineLoginService);
|
||||
}
|
||||
|
||||
public async Task LoadAsync()
|
||||
@@ -65,6 +68,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
||||
else StatusMessage = Loc.T("vm.settingsModal.workerOffline");
|
||||
|
||||
await Prime.LoadAsync();
|
||||
await OnlineInbox.LoadAsync();
|
||||
}
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
@@ -96,6 +100,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
||||
Prime.DailyPrepMaxTasks);
|
||||
await _worker.UpdateAppSettingsAsync(dto);
|
||||
await Prime.SaveAsync();
|
||||
await OnlineInbox.SaveAsync();
|
||||
CloseAction?.Invoke();
|
||||
}
|
||||
catch (Exception ex) { StatusMessage = Loc.T("vm.settingsModal.saveFailed", ex.Message); }
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using ClaudeDo.Data.Git;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Modals;
|
||||
|
||||
public sealed partial class WorktreeNodeViewModel : ViewModelBase
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public string? Status { get; init; }
|
||||
public bool IsDirectory { get; init; }
|
||||
public string RelativePath { get; init; } = "";
|
||||
public ObservableCollection<WorktreeNodeViewModel> Children { get; } = new();
|
||||
[ObservableProperty] private bool _isExpanded = true;
|
||||
}
|
||||
|
||||
public sealed partial class WorktreeModalViewModel : ViewModelBase
|
||||
{
|
||||
private readonly GitService _git;
|
||||
|
||||
public ObservableCollection<WorktreeNodeViewModel> Root { get; } = new();
|
||||
public ObservableCollection<DiffLineViewModel> SelectedFileDiffLines { get; } = new();
|
||||
|
||||
[ObservableProperty] private string _worktreePath = "";
|
||||
[ObservableProperty] private string? _baseCommit;
|
||||
[ObservableProperty] private WorktreeNodeViewModel? _selectedNode;
|
||||
|
||||
// Set by the view (same pattern as DiffModalViewModel.CloseAction)
|
||||
public Action? CloseAction { get; set; }
|
||||
|
||||
public WorktreeModalViewModel(GitService git)
|
||||
{
|
||||
_git = git;
|
||||
}
|
||||
|
||||
partial void OnSelectedNodeChanged(WorktreeNodeViewModel? value)
|
||||
{
|
||||
_ = LoadFileDiffAsync(value);
|
||||
}
|
||||
|
||||
private async Task LoadFileDiffAsync(WorktreeNodeViewModel? node)
|
||||
{
|
||||
SelectedFileDiffLines.Clear();
|
||||
|
||||
if (node is null || node.IsDirectory || string.IsNullOrEmpty(node.RelativePath))
|
||||
return;
|
||||
|
||||
string diff;
|
||||
try
|
||||
{
|
||||
diff = await _git.GetFileDiffAsync(WorktreePath, BaseCommit, node.RelativePath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var line in UnifiedDiffParser.Flatten(UnifiedDiffParser.Parse(diff)))
|
||||
SelectedFileDiffLines.Add(line);
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Close() => CloseAction?.Invoke();
|
||||
|
||||
public async Task LoadAsync(CancellationToken ct = default)
|
||||
{
|
||||
Root.Clear();
|
||||
|
||||
string stdout;
|
||||
bool committedMode = !string.IsNullOrEmpty(BaseCommit);
|
||||
try
|
||||
{
|
||||
stdout = committedMode
|
||||
? await _git.GetCommittedFilesAsync(WorktreePath, BaseCommit!, ct)
|
||||
: await _git.GetStatusPorcelainAsync(WorktreePath, ct);
|
||||
}
|
||||
catch { return; }
|
||||
|
||||
if (string.IsNullOrWhiteSpace(stdout)) return;
|
||||
|
||||
var dirs = new Dictionary<string, WorktreeNodeViewModel>(StringComparer.Ordinal);
|
||||
|
||||
foreach (var line in stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
string? path;
|
||||
string? status;
|
||||
|
||||
if (committedMode)
|
||||
{
|
||||
// diff --name-status format: <status>\t<path>
|
||||
var tab = line.IndexOf('\t');
|
||||
if (tab < 0) continue;
|
||||
var statusChar = line[0];
|
||||
status = statusChar != ' ' ? statusChar.ToString() : null;
|
||||
path = line[(tab + 1)..].Trim().Replace('\\', '/');
|
||||
}
|
||||
else
|
||||
{
|
||||
// porcelain format: XY<space>path
|
||||
if (line.Length < 4) continue;
|
||||
var xy = line[..2];
|
||||
var statusChar = xy[0] != ' ' ? xy[0] : xy[1];
|
||||
status = statusChar != ' ' ? statusChar.ToString() : null;
|
||||
path = line[3..].Trim().Replace('\\', '/');
|
||||
}
|
||||
|
||||
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (segments.Length == 0) continue;
|
||||
|
||||
WorktreeNodeViewModel? 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 WorktreeNodeViewModel { Name = segments[i], IsDirectory = true };
|
||||
dirs[accumulated] = dir;
|
||||
if (parent == null) Root.Add(dir);
|
||||
else parent.Children.Add(dir);
|
||||
}
|
||||
parent = dir;
|
||||
}
|
||||
|
||||
var leaf = new WorktreeNodeViewModel
|
||||
{
|
||||
Name = segments[^1],
|
||||
Status = status,
|
||||
IsDirectory = false,
|
||||
RelativePath = path
|
||||
};
|
||||
if (parent == null) Root.Add(leaf);
|
||||
else parent.Children.Add(leaf);
|
||||
}
|
||||
|
||||
SelectedNode = FindFirstLeaf(Root);
|
||||
}
|
||||
|
||||
private static WorktreeNodeViewModel? FindFirstLeaf(IEnumerable<WorktreeNodeViewModel> nodes)
|
||||
{
|
||||
foreach (var n in nodes)
|
||||
{
|
||||
if (!n.IsDirectory) return n;
|
||||
var nested = FindFirstLeaf(n.Children);
|
||||
if (nested is not null) return nested;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,12 @@ public sealed partial class WorktreeOverviewRowViewModel : ViewModelBase
|
||||
[ObservableProperty] private string _path = "";
|
||||
[ObservableProperty] private string _branchName = "";
|
||||
[ObservableProperty] private string _baseCommit = "";
|
||||
[ObservableProperty][NotifyPropertyChangedFor(nameof(IsActive))] private WorktreeState _state;
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsActive))]
|
||||
[NotifyPropertyChangedFor(nameof(IsMerged))]
|
||||
[NotifyPropertyChangedFor(nameof(IsDiscarded))]
|
||||
[NotifyPropertyChangedFor(nameof(IsKept))]
|
||||
private WorktreeState _state;
|
||||
[ObservableProperty] private string? _diffStat;
|
||||
[ObservableProperty][NotifyPropertyChangedFor(nameof(AgeText))] private DateTime _createdAt;
|
||||
[ObservableProperty] private bool _pathExistsOnDisk;
|
||||
@@ -40,6 +45,9 @@ public sealed partial class WorktreeOverviewRowViewModel : ViewModelBase
|
||||
|
||||
public string AgeText => FormatAge(DateTime.UtcNow - CreatedAt);
|
||||
public bool IsActive => State == WorktreeState.Active;
|
||||
public bool IsMerged => State == WorktreeState.Merged;
|
||||
public bool IsDiscarded => State == WorktreeState.Discarded;
|
||||
public bool IsKept => State == WorktreeState.Kept;
|
||||
public bool IsRunning => TaskStatus == TaskStatus.Running;
|
||||
|
||||
private static string FormatAge(TimeSpan ts)
|
||||
@@ -60,8 +68,9 @@ public sealed partial class WorktreesGroupViewModel : ViewModelBase
|
||||
|
||||
public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
|
||||
{
|
||||
private readonly WorkerClient _worker;
|
||||
private readonly Func<WorktreeModalViewModel> _diffVmFactory;
|
||||
private readonly IWorkerClient _worker;
|
||||
private readonly Func<DiffViewerViewModel> _diffVmFactory;
|
||||
private readonly IMergeCoordinator _merge;
|
||||
|
||||
[ObservableProperty] private string? _listIdFilter;
|
||||
[ObservableProperty] private string _title = "Worktrees";
|
||||
@@ -79,20 +88,18 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
|
||||
public ObservableCollection<string> MergeTargets { get; } = new();
|
||||
public ObservableCollection<WorktreeOverviewRowViewModel> ConflictRows { get; } = new();
|
||||
|
||||
/// Inert seam wired by the integrator to Layer C's resolver at merge time. (taskId, targetBranch)
|
||||
public Func<string, string, Task>? RequestConflictResolution { get; set; }
|
||||
|
||||
public Action? CloseAction { get; set; }
|
||||
public Action<WorktreeModalViewModel>? ShowDiffAction { get; set; }
|
||||
public Action<DiffViewerViewModel>? ShowDiffAction { get; set; }
|
||||
public Action<string, string>? JumpToTaskAction { get; set; }
|
||||
public Func<string, Task<bool>>? ConfirmAction { get; set; }
|
||||
public Func<MergeModalViewModel>? ResolveMergeVm { get; set; }
|
||||
public Func<MergeModalViewModel, Task>? ShowMergeAction { get; set; }
|
||||
|
||||
public WorktreesOverviewModalViewModel(WorkerClient worker, Func<WorktreeModalViewModel> diffVmFactory)
|
||||
public WorktreesOverviewModalViewModel(IWorkerClient worker, Func<DiffViewerViewModel> diffVmFactory, IMergeCoordinator merge)
|
||||
{
|
||||
_worker = worker;
|
||||
_diffVmFactory = diffVmFactory;
|
||||
_merge = merge;
|
||||
}
|
||||
|
||||
public void SelectRow(WorktreeOverviewRowViewModel row)
|
||||
@@ -178,8 +185,7 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
|
||||
{
|
||||
if (row is null) return;
|
||||
var diffVm = _diffVmFactory();
|
||||
diffVm.WorktreePath = row.Path;
|
||||
diffVm.BaseCommit = string.IsNullOrEmpty(row.BaseCommit) ? null : row.BaseCommit;
|
||||
diffVm.ConfigureWorktree(row.Path, row.BaseCommit);
|
||||
ShowDiffAction?.Invoke(diffVm);
|
||||
}
|
||||
|
||||
@@ -328,7 +334,7 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
|
||||
private void ResolveConflict(WorktreeOverviewRowViewModel? row)
|
||||
{
|
||||
if (row is null) return;
|
||||
RequestConflictResolution?.Invoke(row.TaskId, SelectedTarget ?? "");
|
||||
_ = _merge.ResolveConflictAsync(row.TaskId, SelectedTarget ?? "");
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
using System.Diagnostics;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using ClaudeDo.Ui.Services;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Planning;
|
||||
|
||||
public sealed partial class ConflictResolutionViewModel : ObservableObject
|
||||
{
|
||||
private readonly IWorkerClient _worker;
|
||||
private readonly string _planningTaskId;
|
||||
// The repository directory that is currently mid-merge (the list's working dir),
|
||||
// NOT the subtask worktree. Opening this folder is what makes VS Code show its
|
||||
// merge-conflict resolution UI.
|
||||
private readonly string _repoDirectory;
|
||||
|
||||
public string SubtaskTitle { get; }
|
||||
public string TargetBranch { get; }
|
||||
public IReadOnlyList<string> ConflictedFiles { get; }
|
||||
public string SubtaskLabel => Loc.T("vm.conflictResolution.subtaskPrefix", SubtaskTitle);
|
||||
public string TargetLabel => Loc.T("vm.conflictResolution.targetPrefix", TargetBranch);
|
||||
|
||||
[ObservableProperty] private string? _vsCodeError;
|
||||
[ObservableProperty] private string? _actionError;
|
||||
|
||||
public Action? CloseRequested { get; set; }
|
||||
|
||||
public ConflictResolutionViewModel(
|
||||
IWorkerClient worker,
|
||||
string planningTaskId,
|
||||
string subtaskTitle,
|
||||
string targetBranch,
|
||||
IReadOnlyList<string> conflictedFiles,
|
||||
string repoDirectory)
|
||||
{
|
||||
_worker = worker;
|
||||
_planningTaskId = planningTaskId;
|
||||
_repoDirectory = repoDirectory;
|
||||
SubtaskTitle = subtaskTitle;
|
||||
TargetBranch = targetBranch;
|
||||
ConflictedFiles = conflictedFiles;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenInVsCode()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Open the folder that is mid-merge so VS Code shows the Source Control
|
||||
// merge-conflict UI for every conflicted file. Opening individual files
|
||||
// gives only a plain editor with no conflict resolution affordances.
|
||||
Process.Start(new ProcessStartInfo
|
||||
{
|
||||
FileName = "code",
|
||||
Arguments = $"\"{_repoDirectory}\"",
|
||||
UseShellExecute = true,
|
||||
});
|
||||
VsCodeError = null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
VsCodeError = Loc.T("vm.conflictResolution.vsCodeError", ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task ContinueAsync()
|
||||
{
|
||||
ActionError = null;
|
||||
try
|
||||
{
|
||||
await _worker.ContinuePlanningMergeAsync(_planningTaskId);
|
||||
CloseRequested?.Invoke();
|
||||
}
|
||||
catch (Exception ex) { ActionError = ex.Message; }
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task AbortAsync()
|
||||
{
|
||||
ActionError = null;
|
||||
try
|
||||
{
|
||||
await _worker.AbortPlanningMergeAsync(_planningTaskId);
|
||||
CloseRequested?.Invoke();
|
||||
}
|
||||
catch (Exception ex) { ActionError = ex.Message; }
|
||||
}
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.Modals;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Planning;
|
||||
|
||||
public sealed partial class PlanningDiffViewModel : ObservableObject
|
||||
{
|
||||
private readonly IWorkerClient _worker;
|
||||
private readonly string _planningTaskId;
|
||||
private readonly string _targetBranch;
|
||||
|
||||
public ObservableCollection<SubtaskDiffRow> Subtasks { get; } = new();
|
||||
public ObservableCollection<DiffLineViewModel> DiffLines { get; } = new();
|
||||
|
||||
[ObservableProperty] private SubtaskDiffRow? _selectedSubtask;
|
||||
[ObservableProperty] private string _displayedDiff = "";
|
||||
[ObservableProperty] private bool _isCombinedMode;
|
||||
[ObservableProperty] private string? _combinedWarning;
|
||||
[ObservableProperty] private bool _isLoadingCombined;
|
||||
|
||||
public Action? CloseAction { get; set; }
|
||||
|
||||
public PlanningDiffViewModel(IWorkerClient worker, string planningTaskId, string targetBranch)
|
||||
{
|
||||
_worker = worker;
|
||||
_planningTaskId = planningTaskId;
|
||||
_targetBranch = targetBranch;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void Close() => CloseAction?.Invoke();
|
||||
|
||||
public async Task InitializeAsync()
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed record SubtaskDiffRow(string Id, string Title, string? DiffStat, string UnifiedDiff);
|
||||
@@ -2,11 +2,12 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Conflicts"
|
||||
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
|
||||
xmlns:ae="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
|
||||
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
||||
x:DataType="vm:ConflictResolverViewModel"
|
||||
x:Class="ClaudeDo.Ui.Views.Conflicts.ConflictResolverView"
|
||||
Title="{loc:Tr conflictResolver.windowTitle}"
|
||||
Width="760" Height="640" MinWidth="560" MinHeight="420"
|
||||
Width="1280" Height="820" MinWidth="960" MinHeight="560"
|
||||
CanResize="True"
|
||||
WindowDecorations="BorderOnly"
|
||||
ExtendClientAreaToDecorationsHint="True"
|
||||
@@ -16,67 +17,157 @@
|
||||
|
||||
<Window.KeyBindings>
|
||||
<KeyBinding Gesture="Escape" Command="{Binding AbortCommand}"/>
|
||||
<KeyBinding Gesture="F8" Command="{Binding NextCommand}"/>
|
||||
<KeyBinding Gesture="Shift+F8" Command="{Binding PreviousCommand}"/>
|
||||
</Window.KeyBindings>
|
||||
|
||||
<Window.Styles>
|
||||
<Style Selector="ae|TextEditor">
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="{StaticResource FontSizeMono}" />
|
||||
<Setter Property="Background" Value="{DynamicResource Surface2Brush}" />
|
||||
<Setter Property="Padding" Value="4,2" />
|
||||
<Setter Property="WordWrap" Value="False" />
|
||||
</Style>
|
||||
<Style Selector="Border.col-head">
|
||||
<Setter Property="Padding" Value="8,4" />
|
||||
<Setter Property="BorderThickness" Value="0,0,0,1" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource LineBrush}" />
|
||||
<Setter Property="Background" Value="{DynamicResource Surface2Brush}" />
|
||||
</Style>
|
||||
<Style Selector="Border.pane">
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource LineBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="6" />
|
||||
<Setter Property="ClipToBounds" Value="True" />
|
||||
</Style>
|
||||
<!-- Inline accept controls in the between-pane gutters -->
|
||||
<Style Selector="Button.accept-gutter">
|
||||
<Setter Property="Width" Value="22" />
|
||||
<Setter Property="Height" Value="20" />
|
||||
<Setter Property="Padding" Value="0" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
<Setter Property="FontWeight" Value="Bold" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Background" Value="{DynamicResource Surface2Brush}" />
|
||||
<Setter Property="BorderBrush" Value="{DynamicResource MergeConflictEdgeBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="CornerRadius" Value="4" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Button.accept-gutter:pointerover /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{DynamicResource MergeConflictTintBrush}" />
|
||||
</Style>
|
||||
</Window.Styles>
|
||||
|
||||
<ctl:ModalShell Title="{loc:Tr conflictResolver.modalTitle}" CloseCommand="{Binding AbortCommand}">
|
||||
<ctl:ModalShell.Footer>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8"
|
||||
HorizontalAlignment="Right" VerticalAlignment="Center">
|
||||
<Button Classes="btn" Content="{loc:Tr conflictResolver.continue}"
|
||||
Command="{Binding ContinueCommand}" IsEnabled="{Binding CanContinue}"/>
|
||||
<Button Classes="btn" Content="{loc:Tr conflictResolver.abort}" Command="{Binding AbortCommand}"/>
|
||||
</StackPanel>
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Grid.Column="0" Classes="meta" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource BloodBrush}"
|
||||
Text="{Binding ContinueHint}"
|
||||
IsVisible="{Binding HasBinaryFiles}"/>
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="btn accent" Content="{loc:Tr conflictResolver.continue}"
|
||||
Command="{Binding ContinueCommand}" IsEnabled="{Binding CanContinue}"/>
|
||||
<Button Classes="btn" Content="{loc:Tr conflictResolver.abort}" Command="{Binding AbortCommand}"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</ctl:ModalShell.Footer>
|
||||
|
||||
<Grid RowDefinitions="Auto,*" Margin="16,12">
|
||||
<TextBlock Grid.Row="0" Classes="meta" Margin="0,0,0,8"
|
||||
Text="{loc:Tr conflictResolver.loading}"
|
||||
IsVisible="{Binding IsBusy}"/>
|
||||
<Grid Margin="14,10" RowDefinitions="Auto,Auto,Auto,*">
|
||||
|
||||
<!-- Busy / error -->
|
||||
<TextBlock Grid.Row="0" Classes="meta" Margin="0,0,0,6"
|
||||
Text="{loc:Tr conflictResolver.loading}" IsVisible="{Binding IsBusy}"/>
|
||||
<TextBlock Grid.Row="0" Classes="meta" Foreground="{DynamicResource BloodBrush}"
|
||||
Text="{Binding Error}" TextWrapping="Wrap"
|
||||
IsVisible="{Binding Error, Converter={x:Static ObjectConverters.IsNotNull}}"/>
|
||||
|
||||
<ScrollViewer Grid.Row="1">
|
||||
<ItemsControl ItemsSource="{Binding Files}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ConflictFile">
|
||||
<StackPanel Spacing="8" Margin="0,0,0,16">
|
||||
<TextBlock Classes="path-mono heading" Text="{Binding Path}"/>
|
||||
<ItemsControl ItemsSource="{Binding Hunks}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ConflictHunk">
|
||||
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="1"
|
||||
CornerRadius="6" Padding="10" Margin="0,0,0,8">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="meta" Text="{loc:Tr conflictResolver.current}"/>
|
||||
<TextBox Text="{Binding Ours, Mode=OneWay}" IsReadOnly="True"
|
||||
AcceptsReturn="True" MaxHeight="120"/>
|
||||
<TextBlock Classes="meta" Text="{loc:Tr conflictResolver.incoming}"/>
|
||||
<TextBox Text="{Binding Theirs, Mode=OneWay}" IsReadOnly="True"
|
||||
AcceptsReturn="True" MaxHeight="120"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<Button Classes="btn" Content="{loc:Tr conflictResolver.acceptCurrent}"
|
||||
Command="{Binding AcceptCurrentCommand}"/>
|
||||
<Button Classes="btn" Content="{loc:Tr conflictResolver.acceptIncoming}"
|
||||
Command="{Binding AcceptIncomingCommand}"/>
|
||||
<Button Classes="btn" Content="{loc:Tr conflictResolver.acceptBoth}"
|
||||
Command="{Binding AcceptBothCommand}"/>
|
||||
<Button Classes="btn" Content="{loc:Tr conflictResolver.editManually}"
|
||||
Command="{Binding EditManuallyCommand}"/>
|
||||
</StackPanel>
|
||||
<TextBlock Classes="meta" Text="{loc:Tr conflictResolver.mergedResult}"/>
|
||||
<TextBox Text="{Binding Resolution, Mode=TwoWay}"
|
||||
AcceptsReturn="True" MinHeight="80" MaxHeight="200"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
<!-- Binary-conflict banner -->
|
||||
<Border Grid.Row="1" Margin="0,0,0,8" Padding="10,7" CornerRadius="6"
|
||||
Background="{DynamicResource ErrorTintBrush}"
|
||||
BorderBrush="{DynamicResource BloodBrush}" BorderThickness="1"
|
||||
IsVisible="{Binding HasBinaryFiles}">
|
||||
<StackPanel Spacing="3">
|
||||
<TextBlock Classes="meta" Foreground="{DynamicResource BloodBrush}"
|
||||
Text="{loc:Tr conflictResolver.binaryHint}"/>
|
||||
<ItemsControl ItemsSource="{Binding BinaryFilePaths}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="x:String">
|
||||
<TextBlock Classes="path-mono" Text="{Binding}"/>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Toolbar: change nav · file switcher · readout -->
|
||||
<Grid Grid.Row="2" ColumnDefinitions="Auto,Auto,Auto,*,Auto,Auto" Margin="0,0,0,8"
|
||||
IsVisible="{Binding HasCurrent}">
|
||||
<Button Grid.Column="0" Classes="btn" Content="↑" Margin="0,0,4,0" Padding="10,4"
|
||||
ToolTip.Tip="{loc:Tr conflictResolver.prevConflict}"
|
||||
Command="{Binding PreviousCommand}"/>
|
||||
<Button Grid.Column="1" Classes="btn" Content="↓" Margin="0,0,12,0" Padding="10,4"
|
||||
ToolTip.Tip="{loc:Tr conflictResolver.nextConflict}"
|
||||
Command="{Binding NextCommand}"/>
|
||||
<ComboBox Grid.Column="2" MinWidth="240" MaxWidth="520"
|
||||
ItemsSource="{Binding Files}"
|
||||
SelectedItem="{Binding ActiveFile, Mode=TwoWay}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:MergeFile">
|
||||
<TextBlock Classes="path-mono" Text="{Binding Path}" TextTrimming="CharacterEllipsis"/>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<TextBlock Grid.Column="4" Classes="meta" VerticalAlignment="Center" Margin="0,0,14,0"
|
||||
Foreground="{DynamicResource AmberBrush}"
|
||||
IsVisible="{Binding HasMultipleFiles}"
|
||||
Text="{Binding FilesSummary}"/>
|
||||
<TextBlock Grid.Column="5" Classes="meta" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource TextDimBrush}"
|
||||
Text="{Binding PositionText}"/>
|
||||
</Grid>
|
||||
|
||||
<!-- Three panes: Ours | (gutter) | Result | (gutter) | Theirs -->
|
||||
<Grid Grid.Row="3" ColumnDefinitions="*,26,*,26,*" IsVisible="{Binding HasCurrent}">
|
||||
<Border Grid.Column="0" Classes="pane">
|
||||
<DockPanel>
|
||||
<Border Classes="col-head" DockPanel.Dock="Top">
|
||||
<TextBlock Classes="eyebrow" Text="{loc:Tr conflictResolver.ours}"
|
||||
Foreground="{DynamicResource MossBrush}"/>
|
||||
</Border>
|
||||
<ae:TextEditor Name="OursEditor" IsReadOnly="True" ShowLineNumbers="True"/>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<Canvas Grid.Column="1" Name="LeftGutter" Background="Transparent"/>
|
||||
|
||||
<Border Grid.Column="2" Classes="pane">
|
||||
<DockPanel>
|
||||
<Border Classes="col-head" DockPanel.Dock="Top">
|
||||
<TextBlock Classes="eyebrow" Text="{loc:Tr conflictResolver.result}"/>
|
||||
</Border>
|
||||
<Canvas Name="ConflictMap" DockPanel.Dock="Right" Width="13"
|
||||
Background="{DynamicResource Surface2Brush}"
|
||||
ToolTip.Tip="{loc:Tr conflictResolver.conflictMap}"/>
|
||||
<ae:TextEditor Name="ResultEditor" ShowLineNumbers="True"/>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<Canvas Grid.Column="3" Name="RightGutter" Background="Transparent"/>
|
||||
|
||||
<Border Grid.Column="4" Classes="pane">
|
||||
<DockPanel>
|
||||
<Border Classes="col-head" DockPanel.Dock="Top">
|
||||
<TextBlock Classes="eyebrow" Text="{loc:Tr conflictResolver.theirs}"
|
||||
Foreground="{DynamicResource AmberBrush}"/>
|
||||
</Border>
|
||||
<ae:TextEditor Name="TheirsEditor" IsReadOnly="True" ShowLineNumbers="True"/>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</ctl:ModalShell>
|
||||
</Window>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user