Compare commits
54 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
984
CHANGELOG.md
984
CHANGELOG.md
@@ -1,36 +1,970 @@
|
|||||||
# Changelog
|
# 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/),
|
### Features
|
||||||
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.
|
|
||||||
|
|
||||||
## [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
|
- unresolved conflicts compose to empty, not Ours (+ review nits) (23a93ce)
|
||||||
matches tasks in `WaitingForReview` (it previously matched `Done` + active
|
- harden 3-pane editor + document the new conflict resolver (869dd25)
|
||||||
worktree, a state successful runs never land in).
|
- invalidate cached access token when the signed-in user changes (cfe23cd)
|
||||||
- UI ViewModels (`Details`, `Tasks`, `Lists` islands and the shell) now dispose
|
- preserve API base path in Online Inbox client (8b347de)
|
||||||
their `Loc.LanguageChanged`, worker-event, and timer subscriptions, fixing
|
- queue dispatches skip the StartRunning re-claim (74ca2e0)
|
||||||
long-lived subscription leaks.
|
- document and test Queued→Failed guard in FailAsync (fe73f45)
|
||||||
- Stopping a task while the worker is offline no longer throws: `StopAsync`
|
- stateless AbortPlanningMerge after worker restart mid-merge (fb1d799)
|
||||||
guards on task/running/connection state and handles hub errors.
|
- route FinalizeParentDoneAsync through TaskStateService (e9e4ad8)
|
||||||
|
|
||||||
### Hardening
|
### Refactoring
|
||||||
|
|
||||||
- Release-readiness audit pass across Worker, Data, UI, MCP, App and Installer
|
- bring IWorkerClient to parity with WorkerClient (b5417f6)
|
||||||
(see `docs/open.md` backlog for tracked follow-ups).
|
|
||||||
|
|
||||||
## [1.7.0]
|
### Documentation
|
||||||
|
|
||||||
- Agent roadblock + run-outcome surfacing in the task detail pane.
|
- spec + plan for Rider-style 3-pane merge editor (983c177)
|
||||||
- i18n: localized task-header, task-row and prime-schedule tooltips.
|
- KunsZitadel is server-side only; desktop uses an OIDC client flow (96da9fb)
|
||||||
- CI: dependency-audit and changelog Gitea workflows.
|
- API contract, desktop design spec, and implementation plan (8cbe1ad)
|
||||||
- Layer A/B/C git merge & conflict-resolution cockpit (multi-worktree
|
- close out the review round in open.md, sync CLAUDE.md with merges (23ff391)
|
||||||
batch-merge, inline conflict resolver).
|
- 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.Ui** — Views, ViewModels, SignalR client (MVVM with CommunityToolkit.Mvvm)
|
||||||
- **ClaudeDo.Data** — SQLite data layer, repositories, models, GitService
|
- **ClaudeDo.Data** — SQLite data layer, repositories, models, GitService
|
||||||
- **ClaudeDo.Worker** — ASP.NET Core hosted service, task queue, Claude CLI runner
|
- **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
|
## Tech Stack
|
||||||
|
|
||||||
@@ -75,6 +79,8 @@ dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
|
|||||||
|
|
||||||
## Docs
|
## Docs
|
||||||
|
|
||||||
- `docs/plan.md` — full architecture and design spec
|
- `docs/open.md` — open verification items and remaining code TODOs (the only doc kept current besides the CLAUDE.md files)
|
||||||
- `docs/open.md` — verification checklist and improvement backlog
|
- `docs/plan.md` — original design spec (historical; tag-queue/schema.sql parts are outdated)
|
||||||
- `docs/improvement-plan.md` — prioritized improvement items
|
- `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)
|
# 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.
|
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.
|
||||||
28
docs/open.md
28
docs/open.md
@@ -1,6 +1,6 @@
|
|||||||
# ClaudeDo — Offene Punkte
|
# 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,37 @@ 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`.
|
- 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.
|
- 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").
|
- **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`.
|
- **Worker-Autostart am Gerät:** Logoff/Logon-Autostart, Update-Pfad, Uninstall entfernt die Startup-`.lnk`.
|
||||||
|
|
||||||
## Offene Code-Punkte
|
## 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.
|
- **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
|
# 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
|
## 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.
|
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.
|
||||||
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.
|
||||||
@@ -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.)
|
||||||
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.
|
||||||
@@ -31,6 +31,7 @@
|
|||||||
|
|
||||||
<Application.Styles>
|
<Application.Styles>
|
||||||
<FluentTheme />
|
<FluentTheme />
|
||||||
|
<StyleInclude Source="avares://AvaloniaEdit/Themes/Fluent/AvaloniaEdit.xaml" />
|
||||||
<StyleInclude Source="avares://ClaudeDo.Ui/Design/IslandStyles.axaml" />
|
<StyleInclude Source="avares://ClaudeDo.Ui/Design/IslandStyles.axaml" />
|
||||||
<!-- Global defaults: every Window inherits Inter Tight + body size.
|
<!-- Global defaults: every Window inherits Inter Tight + body size.
|
||||||
Controls that need mono opt in via their own class/style. -->
|
Controls that need mono opt in via their own class/style. -->
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ Desktop entry point for the ClaudeDo application. Configures DI, initializes the
|
|||||||
|
|
||||||
## DI Registration Pattern
|
## DI Registration Pattern
|
||||||
|
|
||||||
- **Singletons**: `IDbContextFactory`, all Repositories, GitService, WorkerClient, `IReleaseClient`, `InstallerLocator` / `WorkerLocator`, the island VMs (`ListsIslandViewModel`, `TasksIslandViewModel`, `DetailsIslandViewModel`) and `IslandsShellViewModel` (the window's DataContext)
|
- **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`, `WorktreeModalViewModel`, `WorktreesOverviewModalViewModel`, `PrimeClaudeTabViewModel`), several exposed as `Func<T>` factories for on-demand dialog creation
|
- **Transients**: modal VMs (`SettingsModalViewModel`, `MergeModalViewModel`, `ListSettingsModalViewModel`, `RepoImportModalViewModel`, `WeeklyReportModalViewModel`, `WorktreeModalViewModel`, `WorktreesOverviewModalViewModel`, `PrimeClaudeTabViewModel`), several exposed as `Func<T>` factories for on-demand dialog creation; `ConflictResolverViewModel` via a `Func<string, ConflictResolverViewModel>` factory keyed by taskId (singleton factory, handed to `IslandsShellViewModel.ConflictResolverFactory`)
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
|||||||
@@ -14,10 +14,12 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Avalonia" Version="12.0.0" />
|
<PackageReference Include="Avalonia" Version="12.0.4" />
|
||||||
<PackageReference Include="Avalonia.Desktop" Version="12.0.0" />
|
<PackageReference Include="Avalonia.Desktop" Version="12.0.4" />
|
||||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.0" />
|
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.4" />
|
||||||
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.0" />
|
<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">
|
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.0">
|
||||||
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
|
||||||
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ sealed class Program
|
|||||||
sc.AddTransient<Func<WorktreesOverviewModalViewModel>>(sp => () => sp.GetRequiredService<WorktreesOverviewModalViewModel>());
|
sc.AddTransient<Func<WorktreesOverviewModalViewModel>>(sp => () => sp.GetRequiredService<WorktreesOverviewModalViewModel>());
|
||||||
sc.AddSingleton<IPrimeScheduleApi, WorkerPrimeScheduleApi>();
|
sc.AddSingleton<IPrimeScheduleApi, WorkerPrimeScheduleApi>();
|
||||||
sc.AddSingleton<INotesApi, WorkerNotesApi>();
|
sc.AddSingleton<INotesApi, WorkerNotesApi>();
|
||||||
|
sc.AddSingleton<IOnlineLoginService, OnlineLoginService>();
|
||||||
sc.AddTransient<PrimeClaudeTabViewModel>();
|
sc.AddTransient<PrimeClaudeTabViewModel>();
|
||||||
sc.AddTransient<SettingsModalViewModel>();
|
sc.AddTransient<SettingsModalViewModel>();
|
||||||
sc.AddTransient<MergeModalViewModel>();
|
sc.AddTransient<MergeModalViewModel>();
|
||||||
@@ -134,22 +135,22 @@ sealed class Program
|
|||||||
sc.AddTransient<Func<WeeklyReportModalViewModel>>(sp => () => sp.GetRequiredService<WeeklyReportModalViewModel>());
|
sc.AddTransient<Func<WeeklyReportModalViewModel>>(sp => () => sp.GetRequiredService<WeeklyReportModalViewModel>());
|
||||||
sc.AddSingleton<Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>>(sp =>
|
sc.AddSingleton<Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>>(sp =>
|
||||||
taskId => new ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel(
|
taskId => new ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel(
|
||||||
sp.GetRequiredService<WorkerClient>(), taskId));
|
sp.GetRequiredService<IWorkerClient>(), taskId));
|
||||||
|
|
||||||
// Islands shell VMs
|
// Islands shell VMs
|
||||||
sc.AddSingleton<ListsIslandViewModel>(sp =>
|
sc.AddSingleton<ListsIslandViewModel>(sp =>
|
||||||
new ListsIslandViewModel(
|
new ListsIslandViewModel(
|
||||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||||
sp,
|
sp,
|
||||||
sp.GetRequiredService<WorkerClient>()));
|
sp.GetRequiredService<IWorkerClient>()));
|
||||||
sc.AddSingleton<TasksIslandViewModel>(sp =>
|
sc.AddSingleton<TasksIslandViewModel>(sp =>
|
||||||
new TasksIslandViewModel(
|
new TasksIslandViewModel(
|
||||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||||
sp.GetRequiredService<WorkerClient>()));
|
sp.GetRequiredService<IWorkerClient>()));
|
||||||
sc.AddSingleton<DetailsIslandViewModel>(sp =>
|
sc.AddSingleton<DetailsIslandViewModel>(sp =>
|
||||||
new DetailsIslandViewModel(
|
new DetailsIslandViewModel(
|
||||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||||
sp.GetRequiredService<WorkerClient>(),
|
sp.GetRequiredService<IWorkerClient>(),
|
||||||
sp,
|
sp,
|
||||||
sp.GetRequiredService<INotesApi>()));
|
sp.GetRequiredService<INotesApi>()));
|
||||||
sc.AddSingleton<IslandsShellViewModel>(sp =>
|
sc.AddSingleton<IslandsShellViewModel>(sp =>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. The atomic `Q
|
|||||||
|
|
||||||
## Git
|
## 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, show-stage for conflict hunks), `PreviewMergeAsync` (non-destructive mergeability check via `git merge-tree --write-tree`), `CountChangedFilesAsync`, rev-parse, is-git-repo
|
||||||
|
|
||||||
## Schema
|
## Schema
|
||||||
|
|
||||||
|
|||||||
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(
|
public async Task<(int ExitCode, string Stderr)> MergeNoFfAsync(
|
||||||
string repoDir, string sourceBranch, string message, CancellationToken ct = default)
|
string repoDir, string sourceBranch, string message, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
// 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,
|
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);
|
return (exitCode, stderr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,26 @@ public static class ModelRegistry
|
|||||||
{
|
{
|
||||||
public static readonly IReadOnlyList<string> Aliases = new[] { "sonnet", "opus", "haiku" };
|
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 DefaultAlias = "sonnet";
|
||||||
public const string PlanningAlias = "opus";
|
public const string PlanningAlias = "opus";
|
||||||
|
|
||||||
public const string ListDefaultSentinel = "(default)";
|
public const string ListDefaultSentinel = "(default)";
|
||||||
public const string TaskInheritSentinel = "(inherit)";
|
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)}.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,7 +82,10 @@ public static class PromptFiles
|
|||||||
## Out-of-scope improvements
|
## Out-of-scope improvements
|
||||||
If you notice worthwhile work that is genuinely outside this task's scope
|
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
|
(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
|
## Working in the repo
|
||||||
- Read a file before editing it. Match the conventions already in this codebase —
|
- Read a file before editing it. Match the conventions already in this codebase —
|
||||||
@@ -122,8 +125,8 @@ public static class PromptFiles
|
|||||||
# Out-of-scope follow-up
|
# Out-of-scope follow-up
|
||||||
|
|
||||||
You are an improvement follow-up that another task filed via SuggestImprovement.
|
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
|
It was deliberately scoped narrow, and is intentionally a small, cheap unit of
|
||||||
description ask — nothing more.
|
work. Do EXACTLY what this task's title and description ask — nothing more.
|
||||||
|
|
||||||
- Make the smallest change that satisfies the task. No opportunistic refactors,
|
- Make the smallest change that satisfies the task. No opportunistic refactors,
|
||||||
renames, reformatting, or "while I'm here" cleanup beyond what is asked.
|
renames, reformatting, or "while I'm here" cleanup beyond what is asked.
|
||||||
@@ -150,6 +153,14 @@ public static class PromptFiles
|
|||||||
Once the design is approved, create the child tasks with CreateChildTask, then
|
Once the design is approved, create the child tasks with CreateChildTask, then
|
||||||
call Finalize. Keep each subtask concrete and self-contained with a clear
|
call Finalize. Keep each subtask concrete and self-contained with a clear
|
||||||
done-state, ordered so dependencies come first.
|
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 = """
|
private const string PlanningInitialDefault = """
|
||||||
|
|||||||
@@ -87,6 +87,22 @@ public sealed class TaskRepository
|
|||||||
.ToListAsync(ct);
|
.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
|
#endregion
|
||||||
|
|
||||||
#region Status transitions
|
#region Status transitions
|
||||||
@@ -197,6 +213,7 @@ public sealed class TaskRepository
|
|||||||
string? description,
|
string? description,
|
||||||
string? commitType,
|
string? commitType,
|
||||||
string? createdBy = null,
|
string? createdBy = null,
|
||||||
|
string? model = null,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
// AsNoTracking: SetPlanningStartedAsync mutates via ExecuteUpdate which
|
// AsNoTracking: SetPlanningStartedAsync mutates via ExecuteUpdate which
|
||||||
@@ -223,6 +240,7 @@ public sealed class TaskRepository
|
|||||||
ParentTaskId = parentId,
|
ParentTaskId = parentId,
|
||||||
SortOrder = (maxSort ?? -1) + 1,
|
SortOrder = (maxSort ?? -1) + 1,
|
||||||
CreatedBy = createdBy,
|
CreatedBy = createdBy,
|
||||||
|
Model = ModelRegistry.NormalizeAlias(model),
|
||||||
};
|
};
|
||||||
_context.Tasks.Add(child);
|
_context.Tasks.Add(child);
|
||||||
await _context.SaveChangesAsync(ct);
|
await _context.SaveChangesAsync(ct);
|
||||||
|
|||||||
@@ -63,6 +63,26 @@
|
|||||||
"daySa": "Sa",
|
"daySa": "Sa",
|
||||||
"daySu": "So"
|
"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": {
|
"inherit": {
|
||||||
"inheritedFromList": "geerbt · Liste",
|
"inheritedFromList": "geerbt · Liste",
|
||||||
"inheritedFromGlobal": "geerbt · Global",
|
"inheritedFromGlobal": "geerbt · Global",
|
||||||
@@ -90,10 +110,12 @@
|
|||||||
"ctxRunInteractively": "Interaktiv ausführen",
|
"ctxRunInteractively": "Interaktiv ausführen",
|
||||||
"ctxOpenPlanningSession": "Planungssitzung öffnen",
|
"ctxOpenPlanningSession": "Planungssitzung öffnen",
|
||||||
"ctxResumePlanningSession": "Planungssitzung fortsetzen",
|
"ctxResumePlanningSession": "Planungssitzung fortsetzen",
|
||||||
|
"ctxFinalizePlanningSession": "Plan finalisieren",
|
||||||
"ctxDiscardPlanningSession": "Planungssitzung verwerfen",
|
"ctxDiscardPlanningSession": "Planungssitzung verwerfen",
|
||||||
"ctxQueueSubtasks": "Teilaufgaben nacheinander einreihen",
|
|
||||||
"ctxScheduleFor": "Planen für...",
|
"ctxScheduleFor": "Planen für...",
|
||||||
"ctxClearSchedule": "Zeitplan entfernen",
|
"ctxClearSchedule": "Zeitplan entfernen",
|
||||||
|
"ctxRemoveFromMyDay": "Aus Mein Tag entfernen",
|
||||||
|
"ctxAddToMyDay": "Zu Mein Tag hinzufügen",
|
||||||
"badgeDraft": "ENTWURF",
|
"badgeDraft": "ENTWURF",
|
||||||
"badgePlanned": "GEPLANT",
|
"badgePlanned": "GEPLANT",
|
||||||
"approve": "Genehmigen",
|
"approve": "Genehmigen",
|
||||||
@@ -378,13 +400,17 @@
|
|||||||
"windowTitle": "Merge-Konflikte lösen",
|
"windowTitle": "Merge-Konflikte lösen",
|
||||||
"modalTitle": "KONFLIKTE LÖSEN",
|
"modalTitle": "KONFLIKTE LÖSEN",
|
||||||
"loading": "Konflikte werden geladen…",
|
"loading": "Konflikte werden geladen…",
|
||||||
"current": "Aktuell (unsere)",
|
"ours": "MAIN · Ziel-Branch",
|
||||||
"incoming": "Eingehend (ihre)",
|
"result": "ERGEBNIS",
|
||||||
"mergedResult": "Zusammengeführtes Ergebnis",
|
"theirs": "INCOMING · Task-Branch",
|
||||||
"acceptCurrent": "Aktuelle übernehmen",
|
"binaryHint": "Binärdateien können hier nicht zusammengeführt werden — brich ab und löse sie in deinem Editor:",
|
||||||
"acceptIncoming": "Eingehende übernehmen",
|
"prevConflict": "Vorheriger Konflikt (Umschalt+F8)",
|
||||||
"acceptBoth": "Beide übernehmen",
|
"nextConflict": "Nächster Konflikt (F8)",
|
||||||
"editManually": "Manuell bearbeiten",
|
"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",
|
"continue": "Lösen & fortfahren",
|
||||||
"abort": "Merge abbrechen"
|
"abort": "Merge abbrechen"
|
||||||
},
|
},
|
||||||
@@ -401,6 +427,8 @@
|
|||||||
"shell": {
|
"shell": {
|
||||||
"menu": {
|
"menu": {
|
||||||
"help": "Hilfe",
|
"help": "Hilfe",
|
||||||
|
"worker": "Worker",
|
||||||
|
"repositories": "Repositories",
|
||||||
"checkForUpdates": "Nach Updates suchen",
|
"checkForUpdates": "Nach Updates suchen",
|
||||||
"restartWorker": "Worker neu starten",
|
"restartWorker": "Worker neu starten",
|
||||||
"worktrees": "Worktrees…",
|
"worktrees": "Worktrees…",
|
||||||
@@ -418,15 +446,16 @@
|
|||||||
"connection": { "online": "Online", "connecting": "Verbinden…", "offline": "Offline" },
|
"connection": { "online": "Online", "connecting": "Verbinden…", "offline": "Offline" },
|
||||||
"shell": { "restartingWorker": "Worker wird neu gestartet…" },
|
"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" },
|
"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" },
|
"planningBadge": { "active": "PLANUNG", "finalized": "GEPLANT" },
|
||||||
"taskRow": { "createdPrefix": "Erstellt {0}", "stepsText": "{0}/{1} Schritte" },
|
"taskRow": { "createdPrefix": "Erstellt {0}", "stepsText": "{0}/{1} Schritte" },
|
||||||
"tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}" },
|
"tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}" },
|
||||||
"diff": { "loadFailed": "Diff konnte nicht geladen werden: {0}", "noChanges": "Keine Änderungen anzuzeigen." },
|
"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)." },
|
"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}" },
|
"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}" },
|
"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}" },
|
"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}" },
|
"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}" },
|
"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." },
|
"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,6 +63,26 @@
|
|||||||
"daySa": "Sa",
|
"daySa": "Sa",
|
||||||
"daySu": "Su"
|
"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": {
|
"inherit": {
|
||||||
"inheritedFromList": "inherited · List",
|
"inheritedFromList": "inherited · List",
|
||||||
"inheritedFromGlobal": "inherited · Global",
|
"inheritedFromGlobal": "inherited · Global",
|
||||||
@@ -90,10 +110,12 @@
|
|||||||
"ctxRunInteractively": "Run interactively",
|
"ctxRunInteractively": "Run interactively",
|
||||||
"ctxOpenPlanningSession": "Open planning Session",
|
"ctxOpenPlanningSession": "Open planning Session",
|
||||||
"ctxResumePlanningSession": "Resume planning Session",
|
"ctxResumePlanningSession": "Resume planning Session",
|
||||||
|
"ctxFinalizePlanningSession": "Finalize plan",
|
||||||
"ctxDiscardPlanningSession": "Discard planning session",
|
"ctxDiscardPlanningSession": "Discard planning session",
|
||||||
"ctxQueueSubtasks": "Queue subtasks sequentially",
|
|
||||||
"ctxScheduleFor": "Schedule for...",
|
"ctxScheduleFor": "Schedule for...",
|
||||||
"ctxClearSchedule": "Clear schedule",
|
"ctxClearSchedule": "Clear schedule",
|
||||||
|
"ctxRemoveFromMyDay": "Remove from My Day",
|
||||||
|
"ctxAddToMyDay": "Add to My Day",
|
||||||
"badgeDraft": "DRAFT",
|
"badgeDraft": "DRAFT",
|
||||||
"badgePlanned": "PLANNED",
|
"badgePlanned": "PLANNED",
|
||||||
"approve": "Approve",
|
"approve": "Approve",
|
||||||
@@ -378,13 +400,17 @@
|
|||||||
"windowTitle": "Resolve merge conflicts",
|
"windowTitle": "Resolve merge conflicts",
|
||||||
"modalTitle": "RESOLVE CONFLICTS",
|
"modalTitle": "RESOLVE CONFLICTS",
|
||||||
"loading": "Loading conflicts…",
|
"loading": "Loading conflicts…",
|
||||||
"current": "Current (ours)",
|
"ours": "MAIN · merge target",
|
||||||
"incoming": "Incoming (theirs)",
|
"result": "RESULT",
|
||||||
"mergedResult": "Merged result",
|
"theirs": "INCOMING · task branch",
|
||||||
"acceptCurrent": "Accept Current",
|
"binaryHint": "Binary files can't be merged here — abort and resolve them in your editor:",
|
||||||
"acceptIncoming": "Accept Incoming",
|
"prevConflict": "Previous conflict (Shift+F8)",
|
||||||
"acceptBoth": "Accept Both",
|
"nextConflict": "Next conflict (F8)",
|
||||||
"editManually": "Edit manually",
|
"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",
|
"continue": "Resolve & continue",
|
||||||
"abort": "Abort merge"
|
"abort": "Abort merge"
|
||||||
},
|
},
|
||||||
@@ -401,6 +427,8 @@
|
|||||||
"shell": {
|
"shell": {
|
||||||
"menu": {
|
"menu": {
|
||||||
"help": "Help",
|
"help": "Help",
|
||||||
|
"worker": "Worker",
|
||||||
|
"repositories": "Repositories",
|
||||||
"checkForUpdates": "Check for updates",
|
"checkForUpdates": "Check for updates",
|
||||||
"restartWorker": "Restart worker",
|
"restartWorker": "Restart worker",
|
||||||
"worktrees": "Worktrees…",
|
"worktrees": "Worktrees…",
|
||||||
@@ -418,15 +446,16 @@
|
|||||||
"connection": { "online": "Online", "connecting": "Connecting…", "offline": "Offline" },
|
"connection": { "online": "Online", "connecting": "Connecting…", "offline": "Offline" },
|
||||||
"shell": { "restartingWorker": "Restarting worker…" },
|
"shell": { "restartingWorker": "Restarting worker…" },
|
||||||
"agentStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "review": "Review", "children": "Waiting for Improvements", "done": "Done", "failed": "Failed", "cancelled": "Cancelled" },
|
"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" },
|
"planningBadge": { "active": "PLANNING", "finalized": "PLANNED" },
|
||||||
"taskRow": { "createdPrefix": "Created {0}", "stepsText": "{0}/{1} steps" },
|
"taskRow": { "createdPrefix": "Created {0}", "stepsText": "{0}/{1} steps" },
|
||||||
"tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}" },
|
"tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}" },
|
||||||
"diff": { "loadFailed": "Failed to load diff: {0}", "noChanges": "No changes to show." },
|
"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)." },
|
"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}" },
|
"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}" },
|
"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}" },
|
"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}" },
|
"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}" },
|
"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)." },
|
"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)." },
|
||||||
|
|||||||
@@ -8,56 +8,61 @@ MVVM with CommunityToolkit.Mvvm source generators:
|
|||||||
- `[ObservableProperty]` for bindable properties
|
- `[ObservableProperty]` for bindable properties
|
||||||
- `[RelayCommand]` for commands (supports async and CanExecute)
|
- `[RelayCommand]` for commands (supports async and CanExecute)
|
||||||
- All ViewModels inherit `ViewModelBase` (extends `ObservableObject`)
|
- 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
|
`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.
|
||||||
- **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`.
|
|
||||||
|
|
||||||
All views use compiled bindings (`x:DataType`).
|
```
|
||||||
|
ViewModels/
|
||||||
|
IslandsShellViewModel.cs — root coordinator
|
||||||
|
Islands/ — ListsIsland, TasksIsland, DetailsIsland, TaskRow, ListNavItem,
|
||||||
|
NotesEditor, MergePreviewPresenter
|
||||||
|
Modals/ — About, Diff, ListSettings, Merge, RepoImport, Settings (+ Settings/ tab VMs),
|
||||||
|
UnfinishedPlanning, WeeklyReport, WorkerConnection, Worktree,
|
||||||
|
WorktreesOverview, UnifiedDiffParser
|
||||||
|
Planning/ — PlanningDiffViewModel
|
||||||
|
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
|
||||||
|
Design/ — Tokens.axaml (design tokens; merged before styles) + IslandStyles.axaml
|
||||||
|
(component styles + the filled icon geometry library)
|
||||||
|
```
|
||||||
|
|
||||||
## ViewModels
|
## ViewModels
|
||||||
|
|
||||||
- **MainWindowViewModel** — root coordinator; manages list collection, selected list, dialog creation via `Func<T>` factories
|
- **IslandsShellViewModel** — root coordinator; owns the three island VMs and the `WorkerClient`, wires cross-island events (selection, notes/prep mode, conflict resolution), owns connection state, the update banner, the inline worker-log strip, responsive-layout flags (`ShowLists`/`ShowDetails` by window width), `PrimeStatus` flash, and the modal openers (About, RepoImport, WeeklyReport, WorktreesOverview, WorkerConnection help) plus `RestartWorkerAsync`/`CheckForUpdatesAsync`. Hosts `UpdateCheckService`.
|
||||||
- **TaskListViewModel** — manages task collection for selected list; handles CRUD, "Run Now"
|
- **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`.
|
||||||
- **TaskDetailViewModel** — displays task details, streams live log, controls worktree operations
|
- **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.
|
||||||
- **TaskItemViewModel** / **ListItemViewModel** — lightweight display VMs
|
- **DetailsIslandViewModel** — the detail pane for a bound `TaskRowViewModel`. Owns live-log streaming (`Log` via `StreamLineFormatter`), debounced title/description editing, subtasks, session-outcome/roadblock split (splits `Result` at the roadblock marker into two cards), the three-tab work console (`output`/`git`/`session`), child surfacing (`ChildOutcomes` rows plus `ChildrenNeedingAttention`/`HasChildrenNeedingAttention` — children that failed, were cancelled, await review, or reported roadblocks — drive an attention band on the Session tab, which is only visible when `HasChildOutcomes`), and the modes: `IsNotesMode` (hosts `NotesEditorViewModel`), `IsPrepMode`, computed `IsTaskDetailVisible = !IsNotesMode && !IsPrepMode`. Three concerns are extracted into section VMs exposed as properties: **AgentSettingsSectionViewModel** (per-task Model/MaxTurns/AgentPath overrides with `InheritedBadge` + `InheritanceResolver`, additive SystemPrompt, debounced save), **MergeSectionViewModel** (merge-target selection, mergeability indicator via `MergePreviewPresenter` over `PreviewMergeAsync`, `OpenDiffAsync` — live worktree or commit range after merge —, `ReviewCombinedDiffCommand` → `PlanningDiffViewModel`), **PrepPanelViewModel** (daily-prep panel: `PrepLog`, `PlanDayCommand` → `RunDailyPrepNowAsync`, persisted last run via `GetLastPrepLogAsync`). Helper rows (`ChildOutcomeRowViewModel`, `SubtaskRowViewModel`, `LogLineViewModel`) live in the same file.
|
||||||
- **TaskEditorViewModel** / **ListEditorViewModel** — dialog VMs with validation
|
- **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).
|
||||||
- **StatusBarViewModel** — connection state and active tasks
|
- **NotesEditorViewModel** — day navigator + bullet CRUD for daily notes via `INotesApi`.
|
||||||
- **WeeklyReportModalViewModel** — drives the weekly report modal
|
- **Modal VMs** — `SettingsModalViewModel` (four tabs: General, Worktrees, Files prompt-paths, Prime Claude incl. `DailyPrepMaxTasks` + prime-schedule rows), `ListSettingsModalViewModel` (name, working dir, commit type, per-list Model/SystemPrompt/AgentPath/MaxTurns with inherited-badge + reset, delete list), `RepoImportModalViewModel` (bulk-create lists from git repos found under chosen parents; already-wired repos disabled), `WeeklyReportModalViewModel` (range pickers default "since last standup weekday → today", cached per range, markdown via MarkdownView), `MergeModalViewModel` (single-task merge form, called from the diff modal), `WorktreesOverviewModalViewModel` (global/per-list worktree rows, batch merge + state ops), `UnfinishedPlanningModalViewModel` (Resume/FinalizeNow/Discard for a draft planning session), `WorkerConnectionModalViewModel` (offline help), `AboutModalViewModel`.
|
||||||
- **NotesEditorViewModel** — manages daily note bullet CRUD for the selected day
|
- **Diff stack** — `UnifiedDiffParser` (static; parses `git diff` output into `DiffFileViewModel`s, detecting added/deleted/renamed/binary files and per-line numbers; `Flatten` injects file-header rows for a combined single-pane view). `DiffModalViewModel` has two modes: live worktree (branch diff vs base, with a Merge action) and commit-range `base..head` (`FromCommitRange = true` — shows a merged task's diff after its worktree is gone; no merge action). The view renders a file list with binary/empty placeholders via `DiffLinesView`.
|
||||||
- **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.
|
- **Planning/Conflicts** — `PlanningDiffViewModel` (per-subtask diffs via `GetPlanningAggregateAsync`, toggle to combined integration-branch diff, conflict warnings), `ConflictResolverViewModel` (in-app **Rider-style 3-pane merge editor** for both single-task and planning unit-merge conflicts: single-task starts the conflict merge, parses each conflicted file into stable/conflict `MergeFileSegment`s via the worker's `GetMergeConflictDocuments`; exposes the active file's three reconstructed documents — `ActiveOursText` / `ActiveResultText` / `ActiveTheirsText` (from `MergeFile.OursText/ResultText/TheirsText`; Result seeds unresolved conflicts with Ours) — plus `ActiveFile`/`SelectFileCommand` (multi-file switcher), `Current`/`Next`/`Previous` (focused-conflict nav), a per-active-file `PositionText` readout, per-block `AcceptOurs/Theirs/Both/Base` + `MergeFile.Compose`, and `CanContinue` gated on every file resolved + no binary; writes each file via `WriteConflictResolution`, continue/abort; **planning mode** via `OpenForPlanningAsync(parentId, subtaskId)` loads the current subtask's mid-merge conflicts without re-starting the merge and routes continue/abort to `ContinuePlanningMerge`/`AbortPlanningMerge`, so a unit-merge conflict re-opens the editor per subtask via the `PlanningMergeConflict` broadcast). The view (`Views/Conflicts/ConflictResolverView`) shows the whole file in three **AvaloniaEdit** panes — MAIN/ours (read-only) | editable Result | INCOMING/theirs (read-only) — with TextMate highlighting by extension (theme `StyleInclude` in `App.axaml`); a code-behind `IBackgroundRenderer` tints each conflict block (unresolved/resolved) across panes, an `IReadOnlySectionProvider` + `TextAnchor` regions keep only conflict spans editable in Result (edits flow back to the block); each unresolved conflict starts EMPTY (a thin marker bar); the between-pane gutter controls **toggle** each side in/out of the result — `›`/`‹` add MAIN/INCOMING in click order (first pick on top), clicking again removes that side — so a conflict can take main, incoming, both, or neither; a `FilesSummary` readout shows how many files still have conflicts, and the three panes share a proportional synced vertical scroll. A conflict overview ruler right of the Result pane (`ConflictMap`) maps every conflict in the file proportionally (click a tick to jump) — handy for long files. Conflict block tints live in `Tokens.axaml` (`Merge*TintBrush`). The editor is reached from review **Approve** on conflict and from the **Merge** button in the Diff window (a conflicting `MergeTask` hands off to the resolver via `RequestConflictResolution`).
|
||||||
- **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`).
|
|
||||||
|
|
||||||
## Services
|
## 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`
|
- **WorkerClient** / **IWorkerClient** — SignalR client connecting to `http://127.0.0.1:47821/hub`, auto-reconnect with exponential backoff. The surface tracks `WorkerHub` (see `src/ClaudeDo.Worker/CLAUDE.md` for the canonical method/event list); groups: task execution (RunNow/Cancel/Continue/Reset/SetTaskStatus), review (`ApproveReviewAsync(taskId, targetBranch) -> MergeResultDto`, reject-to-queue/idle, cancel review, `PreviewMergeAsync -> MergePreviewDto`), planning sessions (start/resume/discard/finalize, queue subtasks, pending draft count, interactive terminal, refine), planning aggregate/integration-branch diffs, unit-merge continue/abort, single-task conflict resolving (start/get-conflicts/write-resolution/continue/abort), worktrees (overview, set state, force remove, cleanup, reset all), agents, app settings, lists/config, weekly report, daily notes, daily prep (`RunDailyPrepNowAsync`, `ClearMyDayAsync`, `GetLastPrepLogAsync`), prime schedules. Events mirror `HubBroadcaster` (task/worktree/list/run updates, prep events, planning-merge events, refine events, worker log). Lifecycle (`StartAsync`/`StopAsync`) and a few admin methods live only on the concrete `WorkerClient`.
|
||||||
- **INotesApi** / **WorkerNotesApi** — thin wrapper over the daily-note WorkerClient methods (mirrors `IPrimeScheduleApi`). UI DTO: `DailyNoteDto(Id, Date, Text, SortOrder)`.
|
- **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
|
## Converters
|
||||||
|
|
||||||
- **StatusColorConverter** — task status string -> color (Queued=Blue, Running=Orange, Done=Green, Failed=Red, Manual=Gray)
|
`StatusColorConverter` (+ `ConnectionColorConverter` in the same file), `WorktreeStateColorConverter`, `WorkerLogLevelToBrushConverter`, `DotBrushConverter`, `EqStatusConverter`, `IconKeyConverter`, `CheckboxBorderConverter`, `StrikeIfTrueConverter`, `BoolToItalicConverter`, `BoolToDraftOpacityConverter`, `NotNullToBoolConverter`, `UpperCaseConverter`, `DateOnlyToDateTimeConverter`.
|
||||||
- **ConnectionColorConverter** — connection state -> color (Online=Green, Offline=Red)
|
|
||||||
|
|
||||||
## Dialog Pattern
|
## 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
|
## Notes
|
||||||
|
|
||||||
- Context menus are on both list items and task items
|
- Context menus exist on both list rows and task rows; right-click selects before opening the menu
|
||||||
- Right-click selects the item before showing the context menu
|
|
||||||
- "Run Now" CanExecute re-evaluates when worker connection state changes
|
- "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.
|
- 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`.
|
||||||
|
|||||||
@@ -7,11 +7,15 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<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="CommunityToolkit.Mvvm" Version="8.4.1" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" />
|
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
|
||||||
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
|
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
|
||||||
|
<PackageReference Include="Duende.IdentityModel.OidcClient" Version="7.1.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -229,6 +229,15 @@
|
|||||||
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
|
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
|
||||||
</Style>
|
</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>
|
||||||
|
|
||||||
<!-- ============================================================ -->
|
<!-- ============================================================ -->
|
||||||
<!-- BUTTONS -->
|
<!-- BUTTONS -->
|
||||||
<!-- ============================================================ -->
|
<!-- ============================================================ -->
|
||||||
@@ -871,14 +880,9 @@
|
|||||||
<Setter Property="Padding" Value="8,5" />
|
<Setter Property="Padding" Value="8,5" />
|
||||||
<Setter Property="CornerRadius" Value="6" />
|
<Setter Property="CornerRadius" Value="6" />
|
||||||
<Setter Property="Background" Value="Transparent" />
|
<Setter Property="Background" Value="Transparent" />
|
||||||
<Setter Property="Transitions">
|
|
||||||
<Transitions>
|
|
||||||
<BrushTransition Property="Background" Duration="0:0:0.10"/>
|
|
||||||
</Transitions>
|
|
||||||
</Setter>
|
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Border.subtask-row:pointerover">
|
<Style Selector="Border.subtask-row:pointerover">
|
||||||
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
|
<Setter Property="Background" Value="{StaticResource Surface3Brush}" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Border.subtask-row.done TextBlock.subtask-title">
|
<Style Selector="Border.subtask-row.done TextBlock.subtask-title">
|
||||||
<Setter Property="Opacity" Value="0.5" />
|
<Setter Property="Opacity" Value="0.5" />
|
||||||
|
|||||||
@@ -100,6 +100,15 @@
|
|||||||
<SolidColorBrush x:Key="DoneTintBrush" Color="#1F6FA86B" />
|
<SolidColorBrush x:Key="DoneTintBrush" Color="#1F6FA86B" />
|
||||||
<SolidColorBrush x:Key="DoneTintBorderBrush" Color="#4C6FA86B" />
|
<SolidColorBrush x:Key="DoneTintBorderBrush" Color="#4C6FA86B" />
|
||||||
|
|
||||||
|
<!-- 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) -->
|
<!-- Window-body gradient layers (apply as LinearGradientBrush in the main content Border) -->
|
||||||
<LinearGradientBrush x:Key="DesktopBackgroundBrush" StartPoint="0%,0%" EndPoint="0%,100%">
|
<LinearGradientBrush x:Key="DesktopBackgroundBrush" StartPoint="0%,0%" EndPoint="0%,100%">
|
||||||
<GradientStop Offset="0" Color="#FF05070A" />
|
<GradientStop Offset="0" Color="#FF05070A" />
|
||||||
|
|||||||
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
|
public interface IWorkerClient : INotifyPropertyChanged
|
||||||
{
|
{
|
||||||
bool IsConnected { get; }
|
bool IsConnected { get; }
|
||||||
|
bool IsReconnecting { get; }
|
||||||
|
|
||||||
event Action<string, string, DateTime>? TaskStartedEvent;
|
event Action<string, string, DateTime>? TaskStartedEvent;
|
||||||
event Action<string, string, string, DateTime>? TaskFinishedEvent;
|
event Action<string, string, string, DateTime>? TaskFinishedEvent;
|
||||||
@@ -17,6 +18,7 @@ public interface IWorkerClient : INotifyPropertyChanged
|
|||||||
event Action<string>? WorktreeUpdatedEvent;
|
event Action<string>? WorktreeUpdatedEvent;
|
||||||
event Action<string>? ListUpdatedEvent;
|
event Action<string>? ListUpdatedEvent;
|
||||||
event Action<string, string>? TaskMessageEvent;
|
event Action<string, string>? TaskMessageEvent;
|
||||||
|
event Action<WorkerLogEntry>? WorkerLogReceivedEvent;
|
||||||
|
|
||||||
event Action? PrepStartedEvent;
|
event Action? PrepStartedEvent;
|
||||||
event Action<string>? PrepLineEvent;
|
event Action<string>? PrepLineEvent;
|
||||||
@@ -28,12 +30,18 @@ public interface IWorkerClient : INotifyPropertyChanged
|
|||||||
event Action<string>? PlanningMergeAbortedEvent;
|
event Action<string>? PlanningMergeAbortedEvent;
|
||||||
event Action<string>? PlanningCompletedEvent;
|
event Action<string>? PlanningCompletedEvent;
|
||||||
|
|
||||||
|
event Action<PrimeFiredEvent>? PrimeFired;
|
||||||
|
|
||||||
|
string? LastApproveTarget { get; }
|
||||||
|
|
||||||
Task WakeQueueAsync();
|
Task WakeQueueAsync();
|
||||||
Task RunNowAsync(string taskId);
|
Task RunNowAsync(string taskId);
|
||||||
Task ContinueTaskAsync(string taskId, string followUpPrompt);
|
Task ContinueTaskAsync(string taskId, string followUpPrompt);
|
||||||
Task ResetTaskAsync(string taskId);
|
Task ResetTaskAsync(string taskId);
|
||||||
Task CancelTaskAsync(string taskId);
|
Task CancelTaskAsync(string taskId);
|
||||||
Task<List<AgentInfo>> GetAgentsAsync();
|
Task<List<AgentInfo>> GetAgentsAsync();
|
||||||
|
Task RefreshAgentsAsync();
|
||||||
|
Task<SeedResultDto?> RestoreDefaultAgentsAsync();
|
||||||
Task<ListConfigDto?> GetListConfigAsync(string listId);
|
Task<ListConfigDto?> GetListConfigAsync(string listId);
|
||||||
Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto);
|
Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto);
|
||||||
Task SetTaskStatusAsync(string taskId, TaskStatus status);
|
Task SetTaskStatusAsync(string taskId, TaskStatus status);
|
||||||
@@ -47,9 +55,10 @@ public interface IWorkerClient : INotifyPropertyChanged
|
|||||||
// ── Conflict resolution (worker hub side implemented by Layer C) ──
|
// ── Conflict resolution (worker hub side implemented by Layer C) ──
|
||||||
Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch);
|
Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch);
|
||||||
Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId);
|
Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId);
|
||||||
|
Task<MergeConflictDocumentsDto> GetMergeConflictDocumentsAsync(string taskId);
|
||||||
Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent);
|
Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent);
|
||||||
Task<MergeResultDto> ContinueMergeAsync(string taskId);
|
Task<MergeResultDto> ContinueConflictMergeAsync(string taskId);
|
||||||
Task AbortMergeAsync(string taskId);
|
Task AbortConflictMergeAsync(string taskId);
|
||||||
Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default);
|
Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default);
|
||||||
Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default);
|
Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default);
|
||||||
Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default);
|
Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default);
|
||||||
@@ -71,9 +80,28 @@ public interface IWorkerClient : INotifyPropertyChanged
|
|||||||
event Action<string, bool, string?>? RefineFinishedEvent;
|
event Action<string, bool, string?>? RefineFinishedEvent;
|
||||||
Task ClearMyDayAsync();
|
Task ClearMyDayAsync();
|
||||||
Task<AppSettingsDto?> GetAppSettingsAsync();
|
Task<AppSettingsDto?> GetAppSettingsAsync();
|
||||||
|
Task UpdateAppSettingsAsync(AppSettingsDto dto);
|
||||||
Task<List<DailyNoteDto>> GetDailyNotesAsync(DateOnly day);
|
Task<List<DailyNoteDto>> GetDailyNotesAsync(DateOnly day);
|
||||||
Task<DailyNoteDto?> AddDailyNoteAsync(DateOnly day, string text);
|
Task<DailyNoteDto?> AddDailyNoteAsync(DateOnly day, string text);
|
||||||
Task UpdateDailyNoteAsync(string id, string text);
|
Task UpdateDailyNoteAsync(string id, string text);
|
||||||
Task DeleteDailyNoteAsync(string id);
|
Task DeleteDailyNoteAsync(string id);
|
||||||
Task<string> GetLastPrepLogAsync();
|
Task<string> GetLastPrepLogAsync();
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -275,14 +275,17 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
public Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId)
|
public Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId)
|
||||||
=> _hub.InvokeAsync<MergeConflictsDto>("GetMergeConflicts", 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)
|
public Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent)
|
||||||
=> _hub.InvokeAsync("WriteConflictResolution", taskId, path, resolvedContent);
|
=> _hub.InvokeAsync("WriteConflictResolution", taskId, path, resolvedContent);
|
||||||
|
|
||||||
public Task<MergeResultDto> ContinueMergeAsync(string taskId)
|
public Task<MergeResultDto> ContinueConflictMergeAsync(string taskId)
|
||||||
=> _hub.InvokeAsync<MergeResultDto>("ContinueMerge", taskId);
|
=> _hub.InvokeAsync<MergeResultDto>("ContinueConflictMerge", taskId);
|
||||||
|
|
||||||
public Task AbortMergeAsync(string taskId)
|
public Task AbortConflictMergeAsync(string taskId)
|
||||||
=> _hub.InvokeAsync("AbortMerge", taskId);
|
=> _hub.InvokeAsync("AbortConflictMerge", taskId);
|
||||||
|
|
||||||
public Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId)
|
public Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId)
|
||||||
=> TryInvokeAsync<MergeTargetsDto>("GetMergeTargets", taskId);
|
=> TryInvokeAsync<MergeTargetsDto>("GetMergeTargets", taskId);
|
||||||
@@ -504,6 +507,18 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
await _hub.InvokeAsync("QueuePlanningSubtasksAsync", parentTaskId, ct);
|
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)
|
// IWorkerClient explicit implementations (drop typed return values)
|
||||||
async Task IWorkerClient.StartPlanningSessionAsync(string taskId, CancellationToken ct)
|
async Task IWorkerClient.StartPlanningSessionAsync(string taskId, CancellationToken ct)
|
||||||
=> await StartPlanningSessionAsync(taskId, ct);
|
=> await StartPlanningSessionAsync(taskId, ct);
|
||||||
@@ -547,6 +562,9 @@ public record MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalB
|
|||||||
public record MergeConflictsDto(string TaskId, IReadOnlyList<ConflictFileDto> Files);
|
public record MergeConflictsDto(string TaskId, IReadOnlyList<ConflictFileDto> Files);
|
||||||
public record ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks);
|
public record ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks);
|
||||||
public record ConflictHunkDto(string Ours, string Theirs, string? Base);
|
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 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 UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
|
||||||
public sealed record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
|
public sealed record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
|
||||||
@@ -568,3 +586,22 @@ public sealed record WorktreeOverviewDto(
|
|||||||
bool PathExistsOnDisk);
|
bool PathExistsOnDisk);
|
||||||
|
|
||||||
public sealed record ForceRemoveResultDto(bool Removed, string? Reason);
|
public sealed record ForceRemoveResultDto(bool Removed, string? Reason);
|
||||||
|
|
||||||
|
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
|
public sealed class WorkerNotesApi : INotesApi
|
||||||
{
|
{
|
||||||
private readonly WorkerClient _client;
|
private readonly IWorkerClient _client;
|
||||||
public WorkerNotesApi(WorkerClient client) => _client = client;
|
public WorkerNotesApi(IWorkerClient client) => _client = client;
|
||||||
public Task<List<DailyNoteDto>> ListAsync(DateOnly day) => _client.GetDailyNotesAsync(day);
|
public Task<List<DailyNoteDto>> ListAsync(DateOnly day) => _client.GetDailyNotesAsync(day);
|
||||||
public Task<DailyNoteDto?> AddAsync(DateOnly day, string text) => _client.AddDailyNoteAsync(day, text);
|
public Task<DailyNoteDto?> AddAsync(DateOnly day, string text) => _client.AddDailyNoteAsync(day, text);
|
||||||
public Task UpdateAsync(string id, string text) => _client.UpdateDailyNoteAsync(id, 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
|
public sealed class WorkerPrimeScheduleApi : IPrimeScheduleApi
|
||||||
{
|
{
|
||||||
private readonly WorkerClient _client;
|
private readonly IWorkerClient _client;
|
||||||
public WorkerPrimeScheduleApi(WorkerClient client) => _client = client;
|
public WorkerPrimeScheduleApi(IWorkerClient client) => _client = client;
|
||||||
public Task<List<PrimeScheduleDto>> ListAsync() => _client.GetPrimeSchedulesAsync();
|
public Task<List<PrimeScheduleDto>> ListAsync() => _client.GetPrimeSchedulesAsync();
|
||||||
public Task<PrimeScheduleDto?> UpsertAsync(PrimeScheduleDto dto) => _client.UpsertPrimeScheduleAsync(dto);
|
public Task<PrimeScheduleDto?> UpsertAsync(PrimeScheduleDto dto) => _client.UpsertPrimeScheduleAsync(dto);
|
||||||
public Task DeleteAsync(Guid id) => _client.DeletePrimeScheduleAsync(id);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,45 +5,89 @@ using CommunityToolkit.Mvvm.Input;
|
|||||||
|
|
||||||
namespace ClaudeDo.Ui.ViewModels.Conflicts;
|
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 Ours { get; }
|
||||||
public string Theirs { get; }
|
|
||||||
public string? Base { get; }
|
public string? Base { get; }
|
||||||
|
public string Theirs { get; }
|
||||||
|
|
||||||
[ObservableProperty] private string? _resolution;
|
[ObservableProperty] private string? _resolution;
|
||||||
|
|
||||||
public bool IsResolved => Resolution is not null;
|
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;
|
Ours = ours;
|
||||||
Theirs = theirs;
|
|
||||||
Base = @base;
|
Base = @base;
|
||||||
|
Theirs = theirs;
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnResolutionChanged(string? value) => OnPropertyChanged(nameof(IsResolved));
|
partial void OnResolutionChanged(string? value) => OnPropertyChanged(nameof(IsResolved));
|
||||||
|
|
||||||
[RelayCommand] private void AcceptCurrent() => Resolution = Ours;
|
[RelayCommand] private void AcceptOurs() => Resolution = Ours;
|
||||||
[RelayCommand] private void AcceptIncoming() => Resolution = Theirs;
|
[RelayCommand] private void AcceptTheirs() => Resolution = Theirs;
|
||||||
[RelayCommand] private void AcceptBoth() => Resolution = Ours + Theirs;
|
[RelayCommand] private void AcceptBoth() => Resolution = Ours + Theirs;
|
||||||
[RelayCommand] private void EditManually() => Resolution ??= Ours;
|
[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 bool IsConflict { get; }
|
||||||
|
public string StableText { get; }
|
||||||
|
public MergeConflictBlock? Conflict { get; }
|
||||||
|
|
||||||
|
private MergeFileSegment(bool isConflict, string stableText, MergeConflictBlock? conflict)
|
||||||
|
{
|
||||||
|
IsConflict = isConflict;
|
||||||
|
StableText = stableText;
|
||||||
|
Conflict = conflict;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 string Path { get; }
|
||||||
public IReadOnlyList<ConflictHunk> Hunks { get; }
|
public bool IsBinary { get; }
|
||||||
|
public IReadOnlyList<MergeFileSegment> Segments { get; }
|
||||||
|
public IReadOnlyList<MergeConflictBlock> Conflicts { get; }
|
||||||
|
|
||||||
public ConflictFile(string path, IReadOnlyList<ConflictHunk> hunks)
|
public MergeFile(string path, bool isBinary, IReadOnlyList<MergeFileSegment> segments)
|
||||||
{
|
{
|
||||||
Path = path;
|
Path = path;
|
||||||
Hunks = hunks;
|
IsBinary = isBinary;
|
||||||
|
Segments = segments;
|
||||||
|
Conflicts = segments.Where(s => s.IsConflict).Select(s => s.Conflict!).ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
public bool AllHunksResolved => Hunks.Count > 0 && Hunks.All(h => h.IsResolved);
|
/// <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>Merged file content: concatenation of each hunk's resolution
|
/// <summary>Reassemble the file: stable text verbatim, each conflict replaced by its resolution
|
||||||
/// (single whole-file hunk today; concatenation stays correct for multi-hunk later).</summary>
|
/// (empty when unresolved — the same "empty start" the editor shows; Continue is gated on
|
||||||
public string ComposeResolvedContent() => string.Concat(Hunks.Select(h => h.Resolution));
|
/// <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;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
@@ -14,23 +15,115 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
|
|||||||
private readonly IWorkerClient _worker;
|
private readonly IWorkerClient _worker;
|
||||||
private readonly string _taskId;
|
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 bool _isBusy;
|
||||||
[ObservableProperty] private string? _error;
|
[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 string TaskId => _taskId;
|
||||||
public Action? CloseRequested { get; set; }
|
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)
|
public ConflictResolverViewModel(IWorkerClient worker, string taskId)
|
||||||
{
|
{
|
||||||
_worker = worker;
|
_worker = worker;
|
||||||
_taskId = taskId;
|
_taskId = taskId;
|
||||||
|
_conflictTaskId = taskId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Starts the conflict merge and loads ours/theirs/base per file.
|
/// <summary>Starts the conflict merge and loads the conflicted files as line-level segments.
|
||||||
/// Returns true when there are conflicts to resolve (caller should show the dialog).</summary>
|
/// Returns true when there is something to resolve (caller should show the dialog).</summary>
|
||||||
public async Task<bool> OpenAsync(string targetBranch)
|
public async Task<bool> OpenAsync(string targetBranch)
|
||||||
{
|
{
|
||||||
IsBusy = true;
|
IsBusy = true;
|
||||||
@@ -44,21 +137,7 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
|
|||||||
Error = start.ErrorMessage;
|
Error = start.ErrorMessage;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
return await LoadDocumentsAsync();
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -68,14 +147,104 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
|
|||||||
finally { IsBusy = false; }
|
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))
|
_planningParentId = planningParentId;
|
||||||
RecomputeCanContinue();
|
_conflictTaskId = subtaskId;
|
||||||
|
IsBusy = true;
|
||||||
|
Error = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return await LoadDocumentsAsync();
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Error = ex.Message;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
finally { IsBusy = false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RecomputeCanContinue()
|
private async Task<bool> LoadDocumentsAsync()
|
||||||
=> CanContinue = Files.Count > 0 && Files.All(f => f.AllHunksResolved);
|
{
|
||||||
|
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]
|
[RelayCommand]
|
||||||
private async Task ContinueAsync()
|
private async Task ContinueAsync()
|
||||||
@@ -85,10 +254,19 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
|
|||||||
Error = null;
|
Error = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
foreach (var file in Files)
|
foreach (var file in Files.Where(f => !f.IsBinary))
|
||||||
await _worker.WriteConflictResolutionAsync(_taskId, file.Path, file.ComposeResolvedContent());
|
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))
|
if (string.Equals(result.Status, "merged", StringComparison.Ordinal))
|
||||||
CloseRequested?.Invoke();
|
CloseRequested?.Invoke();
|
||||||
else
|
else
|
||||||
@@ -105,7 +283,13 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
|
|||||||
private async Task AbortAsync()
|
private async Task AbortAsync()
|
||||||
{
|
{
|
||||||
IsBusy = true;
|
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; }
|
catch (Exception ex) { Error = ex.Message; }
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Ui.Helpers;
|
||||||
|
using ClaudeDo.Ui.Localization;
|
||||||
|
using ClaudeDo.Ui.Services;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
|
||||||
|
public sealed partial class AgentSettingsSectionViewModel : ViewModelBase, IDisposable
|
||||||
|
{
|
||||||
|
private readonly IWorkerClient _worker;
|
||||||
|
private readonly EventHandler _langChangedHandler;
|
||||||
|
|
||||||
|
internal string? TaskId { get; set; }
|
||||||
|
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(IsAgentSectionEnabled))]
|
||||||
|
private bool _isRunning;
|
||||||
|
|
||||||
|
public bool IsAgentSectionEnabled => !IsRunning;
|
||||||
|
|
||||||
|
[ObservableProperty] private string? _taskModelSelection;
|
||||||
|
[ObservableProperty] private string _taskSystemPrompt = "";
|
||||||
|
[ObservableProperty] private AgentInfo? _taskSelectedAgent;
|
||||||
|
[ObservableProperty] private decimal? _taskMaxTurns;
|
||||||
|
[ObservableProperty] private string _modelBadge = "";
|
||||||
|
[ObservableProperty] private string _modelInheritedHint = "";
|
||||||
|
[ObservableProperty] private string _turnsBadge = "";
|
||||||
|
[ObservableProperty] private string _turnsInheritedHint = "";
|
||||||
|
[ObservableProperty] private string _agentBadge = "";
|
||||||
|
[ObservableProperty] private string _effectiveSystemPromptHint = "";
|
||||||
|
|
||||||
|
private string _globalModel = ModelRegistry.DefaultAlias;
|
||||||
|
private int _globalMaxTurns = 100;
|
||||||
|
private string? _listModel;
|
||||||
|
private int? _listMaxTurns;
|
||||||
|
private string? _listAgentName;
|
||||||
|
|
||||||
|
private bool _suppressAgentSave;
|
||||||
|
private CancellationTokenSource? _agentSaveCts;
|
||||||
|
|
||||||
|
public int EffectiveMaxTurns =>
|
||||||
|
TaskMaxTurns is decimal t ? (int)t : (_listMaxTurns ?? _globalMaxTurns);
|
||||||
|
|
||||||
|
public ObservableCollection<string> TaskModelOptions { get; } = new(ModelRegistry.Aliases);
|
||||||
|
public ObservableCollection<AgentInfo> TaskAgentOptions { get; } = new();
|
||||||
|
|
||||||
|
public AgentSettingsSectionViewModel(IWorkerClient worker)
|
||||||
|
{
|
||||||
|
_worker = worker;
|
||||||
|
_langChangedHandler = (_, _) =>
|
||||||
|
{
|
||||||
|
RecomputeModelBadge();
|
||||||
|
RecomputeTurnsBadge();
|
||||||
|
RecomputeAgentBadge();
|
||||||
|
};
|
||||||
|
Loc.LanguageChanged += _langChangedHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => Loc.LanguageChanged -= _langChangedHandler;
|
||||||
|
|
||||||
|
partial void OnTaskModelSelectionChanged(string? value) { RecomputeModelBadge(); QueueAgentSave(); }
|
||||||
|
|
||||||
|
partial void OnTaskMaxTurnsChanged(decimal? value)
|
||||||
|
{
|
||||||
|
RecomputeTurnsBadge();
|
||||||
|
OnPropertyChanged(nameof(EffectiveMaxTurns));
|
||||||
|
QueueAgentSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnTaskSystemPromptChanged(string value) => QueueAgentSave();
|
||||||
|
partial void OnTaskSelectedAgentChanged(AgentInfo? value) { RecomputeAgentBadge(); QueueAgentSave(); }
|
||||||
|
|
||||||
|
private void RecomputeModelBadge()
|
||||||
|
{
|
||||||
|
var (value, source) = InheritanceResolver.Resolve(TaskModelSelection, _listModel, _globalModel);
|
||||||
|
ModelInheritedHint = value;
|
||||||
|
ModelBadge = BadgeFor(source, !string.IsNullOrWhiteSpace(TaskModelSelection));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RecomputeTurnsBadge()
|
||||||
|
{
|
||||||
|
var (value, source) = InheritanceResolver.Resolve(
|
||||||
|
TaskMaxTurns?.ToString(), _listMaxTurns?.ToString(), _globalMaxTurns.ToString());
|
||||||
|
TurnsInheritedHint = value;
|
||||||
|
TurnsBadge = BadgeFor(source, TaskMaxTurns is not null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RecomputeAgentBadge()
|
||||||
|
{
|
||||||
|
var taskSet = TaskSelectedAgent is not null && !string.IsNullOrWhiteSpace(TaskSelectedAgent.Path);
|
||||||
|
var (_, source) = InheritanceResolver.Resolve(
|
||||||
|
taskSet ? TaskSelectedAgent!.Path : null, _listAgentName, null);
|
||||||
|
AgentBadge = BadgeFor(source, taskSet);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BadgeFor(InheritSource source, bool taskSet) => taskSet
|
||||||
|
? Loc.T("settings.inherit.overrideBadge")
|
||||||
|
: source == InheritSource.List
|
||||||
|
? Loc.T("settings.inherit.inheritedFromList")
|
||||||
|
: Loc.T("settings.inherit.inheritedFromGlobal");
|
||||||
|
|
||||||
|
private void QueueAgentSave()
|
||||||
|
{
|
||||||
|
if (_suppressAgentSave || TaskId is null) return;
|
||||||
|
_agentSaveCts?.Cancel();
|
||||||
|
_agentSaveCts = new CancellationTokenSource();
|
||||||
|
_ = SaveAgentSettingsAsync(_agentSaveCts.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async System.Threading.Tasks.Task SaveAgentSettingsAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await System.Threading.Tasks.Task.Delay(300, ct);
|
||||||
|
if (TaskId is null) return;
|
||||||
|
|
||||||
|
var model = string.IsNullOrWhiteSpace(TaskModelSelection) ? null : TaskModelSelection;
|
||||||
|
var sp = string.IsNullOrWhiteSpace(TaskSystemPrompt) ? null : TaskSystemPrompt;
|
||||||
|
var ap = TaskSelectedAgent is null || string.IsNullOrWhiteSpace(TaskSelectedAgent.Path)
|
||||||
|
? null : TaskSelectedAgent.Path;
|
||||||
|
var turns = TaskMaxTurns is decimal d ? (int?)d : null;
|
||||||
|
|
||||||
|
await _worker.UpdateTaskAgentSettingsAsync(
|
||||||
|
new UpdateTaskAgentSettingsDto(TaskId, model, sp, ap, turns));
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { }
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
internal async System.Threading.Tasks.Task LoadAsync(
|
||||||
|
ClaudeDo.Data.Models.TaskEntity entity, CancellationToken ct)
|
||||||
|
{
|
||||||
|
_suppressAgentSave = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
TaskAgentOptions.Clear();
|
||||||
|
TaskAgentOptions.Add(new AgentInfo("(inherited)", "", ""));
|
||||||
|
var agents = await _worker.GetAgentsAsync();
|
||||||
|
foreach (var a in agents) TaskAgentOptions.Add(a);
|
||||||
|
|
||||||
|
TaskModelSelection = string.IsNullOrWhiteSpace(entity.Model) ? null : entity.Model!;
|
||||||
|
TaskMaxTurns = entity.MaxTurns is int tmt ? tmt : (decimal?)null;
|
||||||
|
TaskSystemPrompt = entity.SystemPrompt ?? "";
|
||||||
|
TaskSelectedAgent = string.IsNullOrWhiteSpace(entity.AgentPath)
|
||||||
|
? TaskAgentOptions[0]
|
||||||
|
: (TaskAgentOptions.FirstOrDefault(a => a.Path == entity.AgentPath) ?? TaskAgentOptions[0]);
|
||||||
|
|
||||||
|
var listCfg = await _worker.GetListConfigAsync(entity.ListId);
|
||||||
|
var app = await _worker.GetAppSettingsAsync();
|
||||||
|
_globalModel = app?.DefaultModel ?? ModelRegistry.DefaultAlias;
|
||||||
|
_globalMaxTurns = app?.DefaultMaxTurns ?? 100;
|
||||||
|
_listModel = listCfg?.Model;
|
||||||
|
_listMaxTurns = listCfg?.MaxTurns;
|
||||||
|
_listAgentName = string.IsNullOrWhiteSpace(listCfg?.AgentPath)
|
||||||
|
? null : System.IO.Path.GetFileName(listCfg!.AgentPath!);
|
||||||
|
|
||||||
|
EffectiveSystemPromptHint = string.IsNullOrWhiteSpace(listCfg?.SystemPrompt)
|
||||||
|
? "" : listCfg!.SystemPrompt!;
|
||||||
|
|
||||||
|
RecomputeModelBadge();
|
||||||
|
RecomputeTurnsBadge();
|
||||||
|
RecomputeAgentBadge();
|
||||||
|
OnPropertyChanged(nameof(EffectiveMaxTurns));
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_suppressAgentSave = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void Clear()
|
||||||
|
{
|
||||||
|
_suppressAgentSave = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
TaskModelSelection = null;
|
||||||
|
TaskMaxTurns = null;
|
||||||
|
TaskSystemPrompt = "";
|
||||||
|
TaskSelectedAgent = null;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_suppressAgentSave = false;
|
||||||
|
}
|
||||||
|
EffectiveSystemPromptHint = "";
|
||||||
|
TaskId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand] private void ResetTaskModel() => TaskModelSelection = null;
|
||||||
|
[RelayCommand] private void ResetTaskTurns() => TaskMaxTurns = null;
|
||||||
|
[RelayCommand] private void ResetTaskAgent() =>
|
||||||
|
TaskSelectedAgent = TaskAgentOptions.Count > 0 ? TaskAgentOptions[0] : null;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
{
|
{
|
||||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
private readonly IServiceProvider? _services;
|
private readonly IServiceProvider? _services;
|
||||||
private readonly WorkerClient? _worker;
|
private readonly IWorkerClient? _worker;
|
||||||
private static readonly TaskListFilterRegistry _filters = new();
|
private static readonly TaskListFilterRegistry _filters = new();
|
||||||
|
|
||||||
public event EventHandler? SelectionChanged;
|
public event EventHandler? SelectionChanged;
|
||||||
@@ -143,7 +143,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
|
|
||||||
private readonly EventHandler _langChangedHandler;
|
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;
|
_dbFactory = dbFactory;
|
||||||
_services = services;
|
_services = services;
|
||||||
|
|||||||
201
src/ClaudeDo.Ui/ViewModels/Islands/MergeSectionViewModel.cs
Normal file
201
src/ClaudeDo.Ui/ViewModels/Islands/MergeSectionViewModel.cs
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
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<DiffModalViewModel, System.Threading.Tasks.Task>? ShowDiffModal { get; set; }
|
||||||
|
public Func<MergeModalViewModel, System.Threading.Tasks.Task>? ShowMergeModal { get; set; }
|
||||||
|
public Func<ViewModels.Planning.PlanningDiffViewModel, System.Threading.Tasks.Task>? ShowPlanningDiffModal { get; set; }
|
||||||
|
public Func<string, string, System.Threading.Tasks.Task>? RequestConflictResolution { get; set; }
|
||||||
|
|
||||||
|
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 || ShowPlanningDiffModal is null) return;
|
||||||
|
var vm = new ViewModels.Planning.PlanningDiffViewModel(_worker, TaskId, SelectedMergeTarget ?? "main");
|
||||||
|
await vm.InitializeAsync();
|
||||||
|
await ShowPlanningDiffModal(vm);
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CanReviewDiff() => (_isPlanningParent && _subtaskCount > 0) || _hasChildOutcomes;
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanOpenDiff))]
|
||||||
|
private async System.Threading.Tasks.Task OpenDiffAsync()
|
||||||
|
{
|
||||||
|
if (ShowDiffModal is null) return;
|
||||||
|
var git = _services.GetRequiredService<ClaudeDo.Data.Git.GitService>();
|
||||||
|
|
||||||
|
var hasLiveWorktree =
|
||||||
|
_worktreePath != null
|
||||||
|
&& _worktreeStateLabel == "Active"
|
||||||
|
&& System.IO.Directory.Exists(_worktreePath);
|
||||||
|
|
||||||
|
DiffModalViewModel diffVm;
|
||||||
|
if (hasLiveWorktree)
|
||||||
|
{
|
||||||
|
diffVm = new DiffModalViewModel(git)
|
||||||
|
{
|
||||||
|
WorktreePath = _worktreePath!,
|
||||||
|
BaseRef = _worktreeBaseCommit,
|
||||||
|
TaskId = TaskId,
|
||||||
|
TaskTitle = TaskTitle ?? "",
|
||||||
|
ShowMergeModal = ShowMergeModal,
|
||||||
|
ResolveMergeVm = () => _services.GetRequiredService<MergeModalViewModel>(),
|
||||||
|
RequestConflictResolution = RequestConflictResolution,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else if (CanDiffMergedRange)
|
||||||
|
{
|
||||||
|
diffVm = new DiffModalViewModel(git)
|
||||||
|
{
|
||||||
|
WorktreePath = _listWorkingDir!,
|
||||||
|
BaseRef = _worktreeBaseCommit,
|
||||||
|
HeadCommit = _worktreeHeadCommit,
|
||||||
|
FromCommitRange = true,
|
||||||
|
TaskId = TaskId,
|
||||||
|
TaskTitle = TaskTitle ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else return;
|
||||||
|
|
||||||
|
await diffVm.LoadAsync();
|
||||||
|
await ShowDiffModal(diffVm);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
public sealed partial class NoteBulletViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
private readonly Func<NoteBulletViewModel, Task> _save;
|
|
||||||
private readonly Func<NoteBulletViewModel, Task> _delete;
|
|
||||||
|
|
||||||
public string Id { get; }
|
public string Id { get; }
|
||||||
|
|
||||||
[ObservableProperty] private string _text;
|
[ObservableProperty] private string _text;
|
||||||
|
|
||||||
public NoteBulletViewModel(string id, string text,
|
public NoteBulletViewModel(string id, string text)
|
||||||
Func<NoteBulletViewModel, Task> save, Func<NoteBulletViewModel, Task> delete)
|
|
||||||
{
|
{
|
||||||
Id = id;
|
Id = id;
|
||||||
_text = text;
|
_text = text;
|
||||||
_save = save;
|
|
||||||
_delete = delete;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand] private Task Save() => _save(this);
|
|
||||||
[RelayCommand] private Task Delete() => _delete(this);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed partial class NotesEditorViewModel : ViewModelBase
|
public sealed partial class NotesEditorViewModel : ViewModelBase
|
||||||
@@ -57,7 +48,7 @@ public sealed partial class NotesEditorViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
private NoteBulletViewModel MakeBullet(string id, string text) =>
|
private NoteBulletViewModel MakeBullet(string id, string text) =>
|
||||||
new(id, text, SaveBulletAsync, DeleteBulletAsync);
|
new(id, text);
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task AddBullet()
|
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 NextDay() => LoadDayAsync(CurrentDay.AddDays(1));
|
||||||
[RelayCommand] private Task Today() => LoadDayAsync(DateOnly.FromDateTime(DateTime.Today));
|
[RelayCommand] private Task Today() => LoadDayAsync(DateOnly.FromDateTime(DateTime.Today));
|
||||||
|
|
||||||
private Task SaveBulletAsync(NoteBulletViewModel b) => _api.UpdateAsync(b.Id, b.Text);
|
[RelayCommand]
|
||||||
|
private async Task CommitBullet(NoteBulletViewModel? b)
|
||||||
private async Task DeleteBulletAsync(NoteBulletViewModel b)
|
{
|
||||||
|
if (b is null) return;
|
||||||
|
var text = b.Text?.Trim() ?? "";
|
||||||
|
if (text.Length == 0)
|
||||||
{
|
{
|
||||||
await _api.DeleteAsync(b.Id);
|
await _api.DeleteAsync(b.Id);
|
||||||
Bullets.Remove(b);
|
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)..]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
[ObservableProperty] private PlanningPhase _planningPhase;
|
[ObservableProperty] private PlanningPhase _planningPhase;
|
||||||
[ObservableProperty] private string? _branch;
|
[ObservableProperty] private string? _branch;
|
||||||
[ObservableProperty] private string? _diffStat;
|
[ObservableProperty] private string? _diffStat;
|
||||||
|
[ObservableProperty] private ClaudeDo.Data.Models.WorktreeState? _worktreeState;
|
||||||
[ObservableProperty] private DateTime? _scheduledFor;
|
[ObservableProperty] private DateTime? _scheduledFor;
|
||||||
[ObservableProperty] private int _diffAdditions;
|
[ObservableProperty] private int _diffAdditions;
|
||||||
[ObservableProperty] private int _diffDeletions;
|
[ObservableProperty] private int _diffDeletions;
|
||||||
@@ -31,6 +32,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
[ObservableProperty] private bool _hasQueuedSubtasks;
|
[ObservableProperty] private bool _hasQueuedSubtasks;
|
||||||
[ObservableProperty] private bool _showListChip = true;
|
[ObservableProperty] private bool _showListChip = true;
|
||||||
[ObservableProperty] private bool _parentFinalized;
|
[ObservableProperty] private bool _parentFinalized;
|
||||||
|
[ObservableProperty] private bool _parentInView = true;
|
||||||
[ObservableProperty] private int _roadblockCount;
|
[ObservableProperty] private int _roadblockCount;
|
||||||
[ObservableProperty] private bool _isRefining;
|
[ObservableProperty] private bool _isRefining;
|
||||||
|
|
||||||
@@ -46,9 +48,13 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
public bool IsAgentSuggested => IsChild && !string.IsNullOrEmpty(CreatedBy) && CreatedBy == ParentTaskId;
|
public bool IsAgentSuggested => IsChild && !string.IsNullOrEmpty(CreatedBy) && CreatedBy == ParentTaskId;
|
||||||
public bool IsPlanningParent => PlanningPhase != PlanningPhase.None
|
public bool IsPlanningParent => PlanningPhase != PlanningPhase.None
|
||||||
|| HasPlanningChildren;
|
|| 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).
|
// A subtask is Draft until its planning parent is finalized, then Planned (queueable).
|
||||||
public bool IsDraft => IsChild && Status == TaskStatus.Idle && !ParentFinalized;
|
public bool IsDraft => ShowAsChild && Status == TaskStatus.Idle && !ParentFinalized;
|
||||||
public bool IsPlanned => IsChild && Status == TaskStatus.Idle && ParentFinalized;
|
public bool IsPlanned => ShowAsChild && Status == TaskStatus.Idle && ParentFinalized;
|
||||||
|
|
||||||
public bool CanOpenPlanningSession => Status == TaskStatus.Idle
|
public bool CanOpenPlanningSession => Status == TaskStatus.Idle
|
||||||
&& PlanningPhase == PlanningPhase.None
|
&& PlanningPhase == PlanningPhase.None
|
||||||
@@ -71,16 +77,28 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
public bool IsOverdue => ScheduledFor is { } d && d.Date < DateTime.Today && !Done;
|
public bool IsOverdue => ScheduledFor is { } d && d.Date < DateTime.Today && !Done;
|
||||||
public bool IsRunning => Status == TaskStatus.Running;
|
public bool IsRunning => Status == TaskStatus.Running;
|
||||||
public bool IsWaitingForReview => Status == TaskStatus.WaitingForReview;
|
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 IsQueued => Status == TaskStatus.Queued && string.IsNullOrEmpty(BlockedByTaskId);
|
||||||
public bool IsWaiting => Status == TaskStatus.Queued && !string.IsNullOrEmpty(BlockedByTaskId);
|
public bool IsWaiting => Status == TaskStatus.Queued && !string.IsNullOrEmpty(BlockedByTaskId);
|
||||||
public bool CanRemoveFromQueue => IsQueued || HasQueuedSubtasks;
|
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
|
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).
|
// 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
|
public bool CanQueuePlan => !IsChild && HasPlanningChildren
|
||||||
&& PlanningPhase == PlanningPhase.Finalized
|
&& PlanningPhase == PlanningPhase.Finalized
|
||||||
&& !HasQueuedSubtasks;
|
&& !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;
|
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 bool HasRoadblock => RoadblockCount > 0;
|
||||||
public string RoadblockTooltip => RoadblockCount == 1
|
public string RoadblockTooltip => RoadblockCount == 1
|
||||||
? "1 roadblock reported during the run — see details"
|
? "1 roadblock reported during the run — see details"
|
||||||
@@ -90,7 +108,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
public string DiffDeletionsText => $"−{DiffDeletions}";
|
public string DiffDeletionsText => $"−{DiffDeletions}";
|
||||||
public string StepsText => Loc.T("vm.taskRow.stepsText", StepsCompleted, StepsCount);
|
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.Idle => Loc.T("vm.taskStatus.idle"),
|
||||||
TaskStatus.Queued => Loc.T("vm.taskStatus.queued"),
|
TaskStatus.Queued => Loc.T("vm.taskStatus.queued"),
|
||||||
@@ -121,6 +139,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
OnPropertyChanged(nameof(StatusLabel));
|
OnPropertyChanged(nameof(StatusLabel));
|
||||||
OnPropertyChanged(nameof(IsRunning));
|
OnPropertyChanged(nameof(IsRunning));
|
||||||
OnPropertyChanged(nameof(IsWaitingForReview));
|
OnPropertyChanged(nameof(IsWaitingForReview));
|
||||||
|
OnPropertyChanged(nameof(IsParked));
|
||||||
OnPropertyChanged(nameof(IsQueued));
|
OnPropertyChanged(nameof(IsQueued));
|
||||||
OnPropertyChanged(nameof(IsWaiting));
|
OnPropertyChanged(nameof(IsWaiting));
|
||||||
OnPropertyChanged(nameof(IsDraft));
|
OnPropertyChanged(nameof(IsDraft));
|
||||||
@@ -135,12 +154,20 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(IsChild));
|
OnPropertyChanged(nameof(IsChild));
|
||||||
OnPropertyChanged(nameof(IsAgentSuggested));
|
OnPropertyChanged(nameof(IsAgentSuggested));
|
||||||
|
OnPropertyChanged(nameof(ShowAsChild));
|
||||||
OnPropertyChanged(nameof(IsDraft));
|
OnPropertyChanged(nameof(IsDraft));
|
||||||
OnPropertyChanged(nameof(IsPlanned));
|
OnPropertyChanged(nameof(IsPlanned));
|
||||||
OnPropertyChanged(nameof(CanSendToQueue));
|
OnPropertyChanged(nameof(CanSendToQueue));
|
||||||
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
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 OnCreatedByChanged(string? value) => OnPropertyChanged(nameof(IsAgentSuggested));
|
||||||
|
|
||||||
partial void OnParentFinalizedChanged(bool value)
|
partial void OnParentFinalizedChanged(bool value)
|
||||||
@@ -159,6 +186,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
||||||
OnPropertyChanged(nameof(CanResumeOrDiscardPlanning));
|
OnPropertyChanged(nameof(CanResumeOrDiscardPlanning));
|
||||||
OnPropertyChanged(nameof(CanQueuePlan));
|
OnPropertyChanged(nameof(CanQueuePlan));
|
||||||
|
OnPropertyChanged(nameof(CanSendToQueue));
|
||||||
|
OnPropertyChanged(nameof(CanFinalizePlanning));
|
||||||
OnPropertyChanged(nameof(CanRefine));
|
OnPropertyChanged(nameof(CanRefine));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,7 +214,17 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
partial void OnBranchChanged(string? value) => OnPropertyChanged(nameof(HasBranch));
|
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)
|
partial void OnScheduledForChanged(DateTime? value)
|
||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(IsOverdue));
|
OnPropertyChanged(nameof(IsOverdue));
|
||||||
@@ -222,6 +261,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
PlanningPhase = t.PlanningPhase;
|
PlanningPhase = t.PlanningPhase;
|
||||||
Branch = t.Worktree?.BranchName;
|
Branch = t.Worktree?.BranchName;
|
||||||
DiffStat = t.Worktree?.DiffStat;
|
DiffStat = t.Worktree?.DiffStat;
|
||||||
|
WorktreeState = t.Worktree?.State;
|
||||||
ScheduledFor = t.ScheduledFor;
|
ScheduledFor = t.ScheduledFor;
|
||||||
DiffAdditions = add;
|
DiffAdditions = add;
|
||||||
DiffDeletions = del;
|
DiffDeletions = del;
|
||||||
|
|||||||
@@ -334,6 +334,10 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
// Items is already ordered by SortOrder from the DB query.
|
// Items is already ordered by SortOrder from the DB query.
|
||||||
// Treat rows whose ParentTaskId is not in the current view as orphans -> top-level.
|
// Treat rows whose ParentTaskId is not in the current view as orphans -> top-level.
|
||||||
var visibleIds = Items.Select(r => r.Id).ToHashSet();
|
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) =>
|
bool IsTopLevel(TaskRowViewModel r) =>
|
||||||
!r.IsChild
|
!r.IsChild
|
||||||
|| string.IsNullOrEmpty(r.ParentTaskId)
|
|| string.IsNullOrEmpty(r.ParentTaskId)
|
||||||
@@ -571,6 +575,52 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
TasksChanged?.Invoke(this, EventArgs.Empty);
|
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)
|
public async Task SetStatusOnRowAsync(TaskRowViewModel row, TaskStatus status)
|
||||||
{
|
{
|
||||||
if (_worker is null) return;
|
if (_worker is null) return;
|
||||||
@@ -582,6 +632,12 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
private async Task SendToQueueAsync(TaskRowViewModel? row)
|
private async Task SendToQueueAsync(TaskRowViewModel? row)
|
||||||
{
|
{
|
||||||
if (row is null || row.IsRunning) return;
|
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();
|
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||||
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
|
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
|
||||||
if (entity is null) return;
|
if (entity is null) return;
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
|||||||
public ListsIslandViewModel? Lists { get; }
|
public ListsIslandViewModel? Lists { get; }
|
||||||
public TasksIslandViewModel? Tasks { get; }
|
public TasksIslandViewModel? Tasks { get; }
|
||||||
public DetailsIslandViewModel? Details { get; }
|
public DetailsIslandViewModel? Details { get; }
|
||||||
public WorkerClient? Worker { get; }
|
public IWorkerClient? Worker { get; }
|
||||||
public UpdateCheckService UpdateCheck => _updateCheck;
|
public UpdateCheckService UpdateCheck => _updateCheck;
|
||||||
|
|
||||||
public string ConnectionText =>
|
public string ConnectionText =>
|
||||||
@@ -41,9 +41,6 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
|||||||
|
|
||||||
public Func<MergeModalViewModel> ResolveMergeVm => _mergeVmFactory;
|
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.
|
// 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.
|
// The integrator connects Layer A/B's RequestConflictResolution(taskId, target) to this method.
|
||||||
public Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>? ConflictResolverFactory { get; set; }
|
public Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>? ConflictResolverFactory { get; set; }
|
||||||
@@ -146,44 +143,17 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
|||||||
private void OnPlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList<string> conflictedFiles)
|
private void OnPlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList<string> conflictedFiles)
|
||||||
{
|
{
|
||||||
// Already on UI thread (WorkerClient dispatches via Dispatcher.UIThread.Post).
|
// 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;
|
if (ConflictResolverFactory is null || ShowConflictResolver is null) return;
|
||||||
|
var vm = ConflictResolverFactory(subtaskId);
|
||||||
string subtaskTitle = subtaskId;
|
var hasConflicts = await vm.OpenForPlanningAsync(planningTaskId, subtaskId);
|
||||||
// The conflict lives in the list's working dir (the repo being merged into),
|
if (hasConflicts)
|
||||||
// not the subtask worktree. VS Code must open this folder to show the merge UI.
|
await ShowConflictResolver(vm);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// For tests only — does NOT wire up events.
|
// For tests only — does NOT wire up events.
|
||||||
@@ -193,7 +163,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
|||||||
ListsIslandViewModel lists,
|
ListsIslandViewModel lists,
|
||||||
TasksIslandViewModel tasks,
|
TasksIslandViewModel tasks,
|
||||||
DetailsIslandViewModel details,
|
DetailsIslandViewModel details,
|
||||||
WorkerClient worker,
|
IWorkerClient worker,
|
||||||
UpdateCheckService updateCheck,
|
UpdateCheckService updateCheck,
|
||||||
InstallerLocator installerLocator,
|
InstallerLocator installerLocator,
|
||||||
WorkerLocator workerLocator,
|
WorkerLocator workerLocator,
|
||||||
@@ -232,7 +202,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
|||||||
Details.RequestConflictResolution = RequestConflictResolutionAsync;
|
Details.RequestConflictResolution = RequestConflictResolutionAsync;
|
||||||
Worker.PropertyChanged += (_, e) =>
|
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(ConnectionText));
|
||||||
OnPropertyChanged(nameof(IsOffline));
|
OnPropertyChanged(nameof(IsOffline));
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ public sealed partial class DiffModalViewModel : ViewModelBase
|
|||||||
public string TaskTitle { get; init; } = "";
|
public string TaskTitle { get; init; } = "";
|
||||||
public Func<MergeModalViewModel, Task>? ShowMergeModal { get; set; }
|
public Func<MergeModalViewModel, Task>? ShowMergeModal { get; set; }
|
||||||
public Func<MergeModalViewModel>? ResolveMergeVm { get; set; }
|
public Func<MergeModalViewModel>? ResolveMergeVm { get; set; }
|
||||||
|
public Func<string, string, Task>? RequestConflictResolution { get; set; }
|
||||||
|
|
||||||
public ObservableCollection<DiffFileViewModel> Files { get; } = new();
|
public ObservableCollection<DiffFileViewModel> Files { get; } = new();
|
||||||
|
|
||||||
@@ -99,10 +100,11 @@ public sealed partial class DiffModalViewModel : ViewModelBase
|
|||||||
{
|
{
|
||||||
if (TaskId is null || ShowMergeModal is null || ResolveMergeVm is null) return;
|
if (TaskId is null || ShowMergeModal is null || ResolveMergeVm is null) return;
|
||||||
var vm = ResolveMergeVm();
|
var vm = ResolveMergeVm();
|
||||||
|
vm.RequestConflictResolution = RequestConflictResolution;
|
||||||
await vm.InitializeAsync(TaskId, TaskTitle);
|
await vm.InitializeAsync(TaskId, TaskTitle);
|
||||||
await ShowMergeModal(vm);
|
await ShowMergeModal(vm);
|
||||||
// The diff is stale once the worktree has been merged away — close it too.
|
// The diff is stale once the worktree merged away or a conflict opened the editor.
|
||||||
if (vm.Merged) CloseAction?.Invoke();
|
if (vm.Merged || vm.RoutedToResolver) CloseAction?.Invoke();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task LoadAsync(CancellationToken ct = default)
|
public async Task LoadAsync(CancellationToken ct = default)
|
||||||
@@ -110,6 +112,12 @@ public sealed partial class DiffModalViewModel : ViewModelBase
|
|||||||
Files.Clear();
|
Files.Clear();
|
||||||
StatusMessage = null;
|
StatusMessage = null;
|
||||||
|
|
||||||
|
if (FromCommitRange && (BaseRef is null || HeadCommit is null))
|
||||||
|
{
|
||||||
|
StatusMessage = Loc.T("vm.diff.unavailable");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
string raw;
|
string raw;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ namespace ClaudeDo.Ui.ViewModels.Modals;
|
|||||||
|
|
||||||
public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
private readonly WorkerClient _worker;
|
private readonly IWorkerClient _worker;
|
||||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
|
|
||||||
public string ListId { get; set; } = "";
|
public string ListId { get; set; } = "";
|
||||||
@@ -50,7 +50,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
|||||||
|
|
||||||
public Action? CloseAction { get; set; }
|
public Action? CloseAction { get; set; }
|
||||||
|
|
||||||
public ListSettingsModalViewModel(WorkerClient worker, IDbContextFactory<ClaudeDoDbContext> dbFactory)
|
public ListSettingsModalViewModel(IWorkerClient worker, IDbContextFactory<ClaudeDoDbContext> dbFactory)
|
||||||
{
|
{
|
||||||
_worker = worker;
|
_worker = worker;
|
||||||
_dbFactory = dbFactory;
|
_dbFactory = dbFactory;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ namespace ClaudeDo.Ui.ViewModels.Modals;
|
|||||||
|
|
||||||
public sealed partial class MergeModalViewModel : ViewModelBase
|
public sealed partial class MergeModalViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
private readonly WorkerClient _worker;
|
private readonly IWorkerClient _worker;
|
||||||
|
|
||||||
public string TaskId { get; set; } = "";
|
public string TaskId { get; set; } = "";
|
||||||
public string TaskTitle { get; set; } = "";
|
public string TaskTitle { get; set; } = "";
|
||||||
@@ -28,11 +28,18 @@ public sealed partial class MergeModalViewModel : ViewModelBase
|
|||||||
|
|
||||||
public Action? CloseAction { get; set; }
|
public Action? CloseAction { get; set; }
|
||||||
|
|
||||||
|
/// Set by the caller to hand a conflicting merge off to the in-app 3-pane editor
|
||||||
|
/// instead of dead-ending on the conflict message.
|
||||||
|
public Func<string, string, Task>? RequestConflictResolution { get; set; }
|
||||||
|
|
||||||
/// True once a merge has succeeded — lets the caller (e.g. the diff window)
|
/// True once a merge has succeeded — lets the caller (e.g. the diff window)
|
||||||
/// close itself after this modal closes.
|
/// close itself after this modal closes.
|
||||||
public bool Merged { get; private set; }
|
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)
|
||||||
{
|
{
|
||||||
_worker = worker;
|
_worker = worker;
|
||||||
}
|
}
|
||||||
@@ -96,9 +103,21 @@ public sealed partial class MergeModalViewModel : ViewModelBase
|
|||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
case "conflict":
|
case "conflict":
|
||||||
|
// Hand off to the in-app 3-pane merge editor when wired (MergeTask aborted
|
||||||
|
// cleanly, so the resolver re-starts the merge leaving conflicts in the tree).
|
||||||
|
if (RequestConflictResolution is not null)
|
||||||
|
{
|
||||||
|
var branch = SelectedBranch!;
|
||||||
|
RoutedToResolver = true;
|
||||||
|
CloseAction?.Invoke();
|
||||||
|
await RequestConflictResolution(TaskId, branch);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
HasConflict = true;
|
HasConflict = true;
|
||||||
ConflictFiles = result.ConflictFiles;
|
ConflictFiles = result.ConflictFiles;
|
||||||
ErrorMessage = Loc.T("vm.merge.conflict");
|
ErrorMessage = Loc.T("vm.merge.conflict");
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case "blocked":
|
case "blocked":
|
||||||
ErrorMessage = Loc.T("vm.merge.blocked", result.ErrorMessage ?? "");
|
ErrorMessage = Loc.T("vm.merge.blocked", result.ErrorMessage ?? "");
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ namespace ClaudeDo.Ui.ViewModels.Modals.Settings;
|
|||||||
|
|
||||||
public sealed partial class FilesSettingsTabViewModel : ViewModelBase
|
public sealed partial class FilesSettingsTabViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
private readonly WorkerClient _worker;
|
private readonly IWorkerClient _worker;
|
||||||
|
|
||||||
[ObservableProperty] private string _statusMessage = "";
|
[ObservableProperty] private string _statusMessage = "";
|
||||||
[ObservableProperty] private bool _isBusy;
|
[ObservableProperty] private bool _isBusy;
|
||||||
@@ -21,7 +21,7 @@ public sealed partial class FilesSettingsTabViewModel : ViewModelBase
|
|||||||
public string DailyPrepPromptPath { get; } = PromptFiles.PathFor(PromptKind.DailyPrep);
|
public string DailyPrepPromptPath { get; } = PromptFiles.PathFor(PromptKind.DailyPrep);
|
||||||
public string WeeklyReportPromptPath { get; } = PromptFiles.PathFor(PromptKind.WeeklyReport);
|
public string WeeklyReportPromptPath { get; } = PromptFiles.PathFor(PromptKind.WeeklyReport);
|
||||||
|
|
||||||
public FilesSettingsTabViewModel(WorkerClient worker) => _worker = worker;
|
public FilesSettingsTabViewModel(IWorkerClient worker) => _worker = worker;
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async Task RestoreDefaultAgents()
|
private async Task RestoreDefaultAgents()
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
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; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task Save()
|
||||||
|
{
|
||||||
|
IsBusy = true;
|
||||||
|
StatusMessage = "";
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _worker.SetOnlineInboxConfigAsync(new OnlineInboxConfigInputDto(
|
||||||
|
Enabled,
|
||||||
|
ApiBaseUrl,
|
||||||
|
PollIntervalSeconds,
|
||||||
|
Authority,
|
||||||
|
ClientId,
|
||||||
|
Scopes,
|
||||||
|
RedirectUri));
|
||||||
|
StatusMessage = Loc.T("vm.onlineInbox.saved");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
StatusMessage = Loc.T("vm.onlineInbox.saveFailed", ex.Message);
|
||||||
|
}
|
||||||
|
finally { IsBusy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[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
|
public sealed partial class WorktreesSettingsTabViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
private readonly WorkerClient _worker;
|
private readonly IWorkerClient _worker;
|
||||||
|
|
||||||
[ObservableProperty] private string _worktreeStrategy = "sibling";
|
[ObservableProperty] private string _worktreeStrategy = "sibling";
|
||||||
[ObservableProperty] private string? _centralWorktreeRoot;
|
[ObservableProperty] private string? _centralWorktreeRoot;
|
||||||
@@ -21,7 +21,7 @@ public sealed partial class WorktreesSettingsTabViewModel : ViewModelBase
|
|||||||
|
|
||||||
public IReadOnlyList<string> WorktreeStrategies { get; } = new[] { "sibling", "central" };
|
public IReadOnlyList<string> WorktreeStrategies { get; } = new[] { "sibling", "central" };
|
||||||
|
|
||||||
public WorktreesSettingsTabViewModel(WorkerClient worker) => _worker = worker;
|
public WorktreesSettingsTabViewModel(IWorkerClient worker) => _worker = worker;
|
||||||
|
|
||||||
public string? Validate()
|
public string? Validate()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -11,12 +11,13 @@ namespace ClaudeDo.Ui.ViewModels.Modals;
|
|||||||
|
|
||||||
public sealed partial class SettingsModalViewModel : ViewModelBase
|
public sealed partial class SettingsModalViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
private readonly WorkerClient _worker;
|
private readonly IWorkerClient _worker;
|
||||||
|
|
||||||
public GeneralSettingsTabViewModel General { get; }
|
public GeneralSettingsTabViewModel General { get; }
|
||||||
public WorktreesSettingsTabViewModel Worktrees { get; }
|
public WorktreesSettingsTabViewModel Worktrees { get; }
|
||||||
public FilesSettingsTabViewModel Files { get; }
|
public FilesSettingsTabViewModel Files { get; }
|
||||||
public PrimeClaudeTabViewModel Prime { get; }
|
public PrimeClaudeTabViewModel Prime { get; }
|
||||||
|
public OnlineInboxSettingsViewModel OnlineInbox { get; }
|
||||||
|
|
||||||
[ObservableProperty] private string _validationError = "";
|
[ObservableProperty] private string _validationError = "";
|
||||||
[ObservableProperty] private bool _isBusy;
|
[ObservableProperty] private bool _isBusy;
|
||||||
@@ -24,7 +25,8 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
|||||||
|
|
||||||
public Action? CloseAction { get; set; }
|
public Action? CloseAction { get; set; }
|
||||||
|
|
||||||
public SettingsModalViewModel(WorkerClient worker, PrimeClaudeTabViewModel prime,
|
public SettingsModalViewModel(IWorkerClient worker, PrimeClaudeTabViewModel prime,
|
||||||
|
IOnlineLoginService onlineLoginService,
|
||||||
ILocalizer localizer, AppSettings appSettings)
|
ILocalizer localizer, AppSettings appSettings)
|
||||||
{
|
{
|
||||||
_worker = worker;
|
_worker = worker;
|
||||||
@@ -36,6 +38,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
|||||||
Worktrees = new WorktreesSettingsTabViewModel(worker);
|
Worktrees = new WorktreesSettingsTabViewModel(worker);
|
||||||
Files = new FilesSettingsTabViewModel(worker);
|
Files = new FilesSettingsTabViewModel(worker);
|
||||||
Prime = prime;
|
Prime = prime;
|
||||||
|
OnlineInbox = new OnlineInboxSettingsViewModel(worker, onlineLoginService);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task LoadAsync()
|
public async Task LoadAsync()
|
||||||
@@ -65,6 +68,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
|||||||
else StatusMessage = Loc.T("vm.settingsModal.workerOffline");
|
else StatusMessage = Loc.T("vm.settingsModal.workerOffline");
|
||||||
|
|
||||||
await Prime.LoadAsync();
|
await Prime.LoadAsync();
|
||||||
|
await OnlineInbox.LoadAsync();
|
||||||
}
|
}
|
||||||
finally { IsBusy = false; }
|
finally { IsBusy = false; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ public sealed partial class WorktreesGroupViewModel : ViewModelBase
|
|||||||
|
|
||||||
public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
|
public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
private readonly WorkerClient _worker;
|
private readonly IWorkerClient _worker;
|
||||||
private readonly Func<WorktreeModalViewModel> _diffVmFactory;
|
private readonly Func<WorktreeModalViewModel> _diffVmFactory;
|
||||||
|
|
||||||
[ObservableProperty] private string? _listIdFilter;
|
[ObservableProperty] private string? _listIdFilter;
|
||||||
@@ -89,7 +89,7 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
|
|||||||
public Func<MergeModalViewModel>? ResolveMergeVm { get; set; }
|
public Func<MergeModalViewModel>? ResolveMergeVm { get; set; }
|
||||||
public Func<MergeModalViewModel, Task>? ShowMergeAction { get; set; }
|
public Func<MergeModalViewModel, Task>? ShowMergeAction { get; set; }
|
||||||
|
|
||||||
public WorktreesOverviewModalViewModel(WorkerClient worker, Func<WorktreeModalViewModel> diffVmFactory)
|
public WorktreesOverviewModalViewModel(IWorkerClient worker, Func<WorktreeModalViewModel> diffVmFactory)
|
||||||
{
|
{
|
||||||
_worker = worker;
|
_worker = worker;
|
||||||
_diffVmFactory = diffVmFactory;
|
_diffVmFactory = diffVmFactory;
|
||||||
|
|||||||
@@ -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; }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,11 +2,12 @@
|
|||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Conflicts"
|
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Conflicts"
|
||||||
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
|
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
|
||||||
|
xmlns:ae="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
|
||||||
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
||||||
x:DataType="vm:ConflictResolverViewModel"
|
x:DataType="vm:ConflictResolverViewModel"
|
||||||
x:Class="ClaudeDo.Ui.Views.Conflicts.ConflictResolverView"
|
x:Class="ClaudeDo.Ui.Views.Conflicts.ConflictResolverView"
|
||||||
Title="{loc:Tr conflictResolver.windowTitle}"
|
Title="{loc:Tr conflictResolver.windowTitle}"
|
||||||
Width="760" Height="640" MinWidth="560" MinHeight="420"
|
Width="1280" Height="820" MinWidth="960" MinHeight="560"
|
||||||
CanResize="True"
|
CanResize="True"
|
||||||
WindowDecorations="BorderOnly"
|
WindowDecorations="BorderOnly"
|
||||||
ExtendClientAreaToDecorationsHint="True"
|
ExtendClientAreaToDecorationsHint="True"
|
||||||
@@ -16,67 +17,157 @@
|
|||||||
|
|
||||||
<Window.KeyBindings>
|
<Window.KeyBindings>
|
||||||
<KeyBinding Gesture="Escape" Command="{Binding AbortCommand}"/>
|
<KeyBinding Gesture="Escape" Command="{Binding AbortCommand}"/>
|
||||||
|
<KeyBinding Gesture="F8" Command="{Binding NextCommand}"/>
|
||||||
|
<KeyBinding Gesture="Shift+F8" Command="{Binding PreviousCommand}"/>
|
||||||
</Window.KeyBindings>
|
</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 Title="{loc:Tr conflictResolver.modalTitle}" CloseCommand="{Binding AbortCommand}">
|
||||||
<ctl:ModalShell.Footer>
|
<ctl:ModalShell.Footer>
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8"
|
<Grid ColumnDefinitions="*,Auto">
|
||||||
HorizontalAlignment="Right" VerticalAlignment="Center">
|
<TextBlock Grid.Column="0" Classes="meta" VerticalAlignment="Center"
|
||||||
<Button Classes="btn" Content="{loc:Tr conflictResolver.continue}"
|
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}"/>
|
Command="{Binding ContinueCommand}" IsEnabled="{Binding CanContinue}"/>
|
||||||
<Button Classes="btn" Content="{loc:Tr conflictResolver.abort}" Command="{Binding AbortCommand}"/>
|
<Button Classes="btn" Content="{loc:Tr conflictResolver.abort}" Command="{Binding AbortCommand}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
</ctl:ModalShell.Footer>
|
</ctl:ModalShell.Footer>
|
||||||
|
|
||||||
<Grid RowDefinitions="Auto,*" Margin="16,12">
|
<Grid Margin="14,10" RowDefinitions="Auto,Auto,Auto,*">
|
||||||
<TextBlock Grid.Row="0" Classes="meta" Margin="0,0,0,8"
|
|
||||||
Text="{loc:Tr conflictResolver.loading}"
|
<!-- Busy / error -->
|
||||||
IsVisible="{Binding IsBusy}"/>
|
<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}"
|
<TextBlock Grid.Row="0" Classes="meta" Foreground="{DynamicResource BloodBrush}"
|
||||||
Text="{Binding Error}" TextWrapping="Wrap"
|
Text="{Binding Error}" TextWrapping="Wrap"
|
||||||
IsVisible="{Binding Error, Converter={x:Static ObjectConverters.IsNotNull}}"/>
|
IsVisible="{Binding Error, Converter={x:Static ObjectConverters.IsNotNull}}"/>
|
||||||
|
|
||||||
<ScrollViewer Grid.Row="1">
|
<!-- Binary-conflict banner -->
|
||||||
<ItemsControl ItemsSource="{Binding Files}">
|
<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>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:ConflictFile">
|
<DataTemplate x:DataType="x:String">
|
||||||
<StackPanel Spacing="8" Margin="0,0,0,16">
|
<TextBlock Classes="path-mono" Text="{Binding}"/>
|
||||||
<TextBlock Classes="path-mono heading" Text="{Binding Path}"/>
|
</DataTemplate>
|
||||||
<ItemsControl ItemsSource="{Binding Hunks}">
|
</ItemsControl.ItemTemplate>
|
||||||
<ItemsControl.ItemTemplate>
|
</ItemsControl>
|
||||||
<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>
|
</StackPanel>
|
||||||
</Border>
|
</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>
|
</DataTemplate>
|
||||||
</ItemsControl.ItemTemplate>
|
</ComboBox.ItemTemplate>
|
||||||
</ItemsControl>
|
</ComboBox>
|
||||||
</StackPanel>
|
<TextBlock Grid.Column="4" Classes="meta" VerticalAlignment="Center" Margin="0,0,14,0"
|
||||||
</DataTemplate>
|
Foreground="{DynamicResource AmberBrush}"
|
||||||
</ItemsControl.ItemTemplate>
|
IsVisible="{Binding HasMultipleFiles}"
|
||||||
</ItemsControl>
|
Text="{Binding FilesSummary}"/>
|
||||||
</ScrollViewer>
|
<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>
|
</Grid>
|
||||||
</ctl:ModalShell>
|
</ctl:ModalShell>
|
||||||
</Window>
|
</Window>
|
||||||
|
|||||||
@@ -1,19 +1,488 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.IO;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Controls.Shapes;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using Avalonia.Threading;
|
||||||
|
using Avalonia.VisualTree;
|
||||||
|
using AvaloniaEdit;
|
||||||
|
using AvaloniaEdit.Document;
|
||||||
|
using AvaloniaEdit.Editing;
|
||||||
|
using AvaloniaEdit.Rendering;
|
||||||
|
using AvaloniaEdit.TextMate;
|
||||||
using ClaudeDo.Ui.ViewModels.Conflicts;
|
using ClaudeDo.Ui.ViewModels.Conflicts;
|
||||||
|
using TextMateSharp.Grammars;
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.Views.Conflicts;
|
namespace ClaudeDo.Ui.Views.Conflicts;
|
||||||
|
|
||||||
public partial class ConflictResolverView : Window
|
public partial class ConflictResolverView : Window
|
||||||
{
|
{
|
||||||
|
private ConflictResolverViewModel? _vm;
|
||||||
|
private RegistryOptions? _registry;
|
||||||
|
private TextMate.Installation? _oursTm, _resultTm, _theirsTm;
|
||||||
|
|
||||||
|
// Fixed conflict spans for the read-only side panes (recomputed each rebuild).
|
||||||
|
private List<(int Offset, int Length, MergeConflictBlock Block)> _oursSpans = new();
|
||||||
|
private List<(int Offset, int Length, MergeConflictBlock Block)> _theirsSpans = new();
|
||||||
|
|
||||||
|
// Live, edit-tracked conflict regions in the editable result document.
|
||||||
|
private readonly List<ResultRegion> _resultRegions = new();
|
||||||
|
private readonly List<MergeConflictBlock> _hookedBlocks = new();
|
||||||
|
|
||||||
|
private ScrollViewer?[] _scrollViewers = Array.Empty<ScrollViewer?>();
|
||||||
|
private bool _wired;
|
||||||
|
private bool _rebuilding;
|
||||||
|
private bool _applyingAccept;
|
||||||
|
private bool _syncing;
|
||||||
|
private bool _gutterPending;
|
||||||
|
private int _gutterRetries;
|
||||||
|
|
||||||
public ConflictResolverView()
|
public ConflictResolverView()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override void OnDataContextChanged(System.EventArgs e)
|
protected override void OnDataContextChanged(EventArgs e)
|
||||||
{
|
{
|
||||||
base.OnDataContextChanged(e);
|
base.OnDataContextChanged(e);
|
||||||
if (DataContext is ConflictResolverViewModel vm)
|
|
||||||
vm.CloseRequested = Close;
|
if (_vm is not null)
|
||||||
|
{
|
||||||
|
_vm.ActiveFileChanged -= Rebuild;
|
||||||
|
_vm.CurrentChanged -= ScrollToCurrent;
|
||||||
|
}
|
||||||
|
// The editors persist across a DataContext swap, so drop stale scroll-sync hooks first.
|
||||||
|
foreach (var sv in _scrollViewers)
|
||||||
|
if (sv is not null) sv.ScrollChanged -= OnPaneScroll;
|
||||||
|
_scrollViewers = Array.Empty<ScrollViewer?>();
|
||||||
|
_wired = false;
|
||||||
|
|
||||||
|
_vm = DataContext as ConflictResolverViewModel;
|
||||||
|
if (_vm is null) return;
|
||||||
|
|
||||||
|
_vm.CloseRequested = Close;
|
||||||
|
EnsureEditors();
|
||||||
|
_vm.ActiveFileChanged += Rebuild;
|
||||||
|
_vm.CurrentChanged += ScrollToCurrent;
|
||||||
|
Rebuild();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── One-time editor setup ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private void EnsureEditors()
|
||||||
|
{
|
||||||
|
if (_registry is not null) return;
|
||||||
|
_registry = new RegistryOptions(ThemeName.DarkPlus);
|
||||||
|
_oursTm = OursEditor.InstallTextMate(_registry);
|
||||||
|
_resultTm = ResultEditor.InstallTextMate(_registry);
|
||||||
|
_theirsTm = TheirsEditor.InstallTextMate(_registry);
|
||||||
|
|
||||||
|
ResultEditor.Document ??= new TextDocument();
|
||||||
|
ResultEditor.Document.Changed += OnResultDocumentChanged;
|
||||||
|
ResultEditor.TextArea.ReadOnlySectionProvider =
|
||||||
|
new ConflictReadOnlyProvider(() => _resultRegions.Select(r => (r.Start.Offset, r.End.Offset)));
|
||||||
|
|
||||||
|
var conflict = BrushRes("MergeConflictTintBrush", Color.Parse("#28C87060"));
|
||||||
|
var resolved = BrushRes("MergeResolvedTintBrush", Color.Parse("#206FA86B"));
|
||||||
|
OursEditor.TextArea.TextView.BackgroundRenderers.Add(new MergeBlockRenderer(
|
||||||
|
() => _oursSpans.Select(s => (s.Offset, s.Length, s.Block.IsResolved)), conflict, resolved));
|
||||||
|
ResultEditor.TextArea.TextView.BackgroundRenderers.Add(new MergeBlockRenderer(
|
||||||
|
() => _resultRegions.Select(r => (r.Start.Offset, r.End.Offset - r.Start.Offset, r.Block.IsResolved)), conflict, resolved));
|
||||||
|
TheirsEditor.TextArea.TextView.BackgroundRenderers.Add(new MergeBlockRenderer(
|
||||||
|
() => _theirsSpans.Select(s => (s.Offset, s.Length, s.Block.IsResolved)), conflict, resolved));
|
||||||
|
}
|
||||||
|
|
||||||
|
private IBrush BrushRes(string key, Color fallback)
|
||||||
|
{
|
||||||
|
if (this.TryGetResource(key, null, out var v) && v is IBrush b)
|
||||||
|
return b;
|
||||||
|
return new SolidColorBrush(fallback);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Rebuild the three documents for the active file ───────────────────────
|
||||||
|
|
||||||
|
private void Rebuild()
|
||||||
|
{
|
||||||
|
if (_vm is null) return;
|
||||||
|
_rebuilding = true;
|
||||||
|
_gutterRetries = 0; // fresh retry budget for this file's gutter layout
|
||||||
|
try
|
||||||
|
{
|
||||||
|
ClearGutters();
|
||||||
|
UnhookBlocks();
|
||||||
|
_resultRegions.Clear();
|
||||||
|
|
||||||
|
var file = _vm.ActiveFile;
|
||||||
|
if (file is null || file.IsBinary)
|
||||||
|
{
|
||||||
|
OursEditor.Text = TheirsEditor.Text = "";
|
||||||
|
if (ResultEditor.Document is { } d0) d0.Text = "";
|
||||||
|
_oursSpans = new(); _theirsSpans = new();
|
||||||
|
InvalidateRenderers();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var (oursText, oursSpans) = BuildSide(file, b => b.Ours);
|
||||||
|
var (theirsText, theirsSpans) = BuildSide(file, b => b.Theirs);
|
||||||
|
// Unresolved conflicts start EMPTY — the user builds the result by appending sides.
|
||||||
|
var (resultText, resultSpans) = BuildSide(file, b => b.Resolution ?? "");
|
||||||
|
_oursSpans = oursSpans;
|
||||||
|
_theirsSpans = theirsSpans;
|
||||||
|
|
||||||
|
OursEditor.Text = oursText;
|
||||||
|
TheirsEditor.Text = theirsText;
|
||||||
|
ResultEditor.Document ??= new TextDocument();
|
||||||
|
ResultEditor.Document.Text = resultText;
|
||||||
|
|
||||||
|
var doc = ResultEditor.Document;
|
||||||
|
foreach (var (offset, length, block) in resultSpans)
|
||||||
|
{
|
||||||
|
var start = doc.CreateAnchor(offset);
|
||||||
|
start.MovementType = AnchorMovementType.BeforeInsertion;
|
||||||
|
var end = doc.CreateAnchor(offset + length);
|
||||||
|
end.MovementType = AnchorMovementType.AfterInsertion;
|
||||||
|
_resultRegions.Add(new ResultRegion(block, start, end));
|
||||||
|
block.PropertyChanged += OnBlockChanged;
|
||||||
|
_hookedBlocks.Add(block);
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplyGrammar(file.Path);
|
||||||
|
InvalidateRenderers();
|
||||||
|
}
|
||||||
|
finally { _rebuilding = false; }
|
||||||
|
|
||||||
|
if (!_wired)
|
||||||
|
{
|
||||||
|
_wired = true;
|
||||||
|
Dispatcher.UIThread.Post(HookScrollSync, DispatcherPriority.Loaded);
|
||||||
|
}
|
||||||
|
QueueGutters();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (string Text, List<(int Offset, int Length, MergeConflictBlock Block)> Spans) BuildSide(
|
||||||
|
MergeFile file, Func<MergeConflictBlock, string> pick)
|
||||||
|
{
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
var spans = new List<(int, int, MergeConflictBlock)>();
|
||||||
|
foreach (var seg in file.Segments)
|
||||||
|
{
|
||||||
|
if (seg.IsConflict)
|
||||||
|
{
|
||||||
|
var text = pick(seg.Conflict!);
|
||||||
|
spans.Add((sb.Length, text.Length, seg.Conflict!));
|
||||||
|
sb.Append(text);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sb.Append(seg.StableText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (sb.ToString(), spans);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UnhookBlocks()
|
||||||
|
{
|
||||||
|
foreach (var b in _hookedBlocks) b.PropertyChanged -= OnBlockChanged;
|
||||||
|
_hookedBlocks.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnBlockChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.PropertyName is nameof(MergeConflictBlock.IsResolved) or nameof(MergeConflictBlock.Resolution))
|
||||||
|
{
|
||||||
|
InvalidateRenderers();
|
||||||
|
QueueGutters();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── User edits in the result document flow back to the owning conflict ────
|
||||||
|
|
||||||
|
private void OnResultDocumentChanged(object? sender, DocumentChangeEventArgs e)
|
||||||
|
{
|
||||||
|
if (_rebuilding || _applyingAccept) return;
|
||||||
|
foreach (var r in _resultRegions)
|
||||||
|
{
|
||||||
|
if (e.Offset >= r.Start.Offset && e.Offset <= r.End.Offset)
|
||||||
|
{
|
||||||
|
r.Block.Resolution = ResultEditor.Document.GetText(r.Start.Offset, Math.Max(0, r.End.Offset - r.Start.Offset));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
QueueGutters();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Toggle a side in/out of the result region ────────────────────────────
|
||||||
|
|
||||||
|
// Each side can be included at most once. Clicking adds it (in click order, first on
|
||||||
|
// top); clicking again removes it. The region content is rebuilt from the included set.
|
||||||
|
private void ToggleSide(ResultRegion region, char side)
|
||||||
|
{
|
||||||
|
if (region.Order.Contains(side)) region.Order.Remove(side);
|
||||||
|
else region.Order.Add(side);
|
||||||
|
|
||||||
|
var text = string.Concat(region.Order.Select(c => c == 'o' ? region.Block.Ours : region.Block.Theirs));
|
||||||
|
_applyingAccept = true;
|
||||||
|
try { ResultEditor.Document.Replace(region.Start.Offset, region.End.Offset - region.Start.Offset, text); }
|
||||||
|
finally { _applyingAccept = false; }
|
||||||
|
|
||||||
|
region.Block.Resolution = region.Order.Count == 0 ? null : text;
|
||||||
|
InvalidateRenderers();
|
||||||
|
PositionGutters();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Inline accept controls in the between-pane gutters ────────────────────
|
||||||
|
|
||||||
|
private void ClearGutters()
|
||||||
|
{
|
||||||
|
LeftGutter.Children.Clear();
|
||||||
|
RightGutter.Children.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Coalesce gutter re-layouts so repeated change/scroll events can't flood the dispatcher.
|
||||||
|
private void QueueGutters()
|
||||||
|
{
|
||||||
|
if (_gutterPending) return;
|
||||||
|
_gutterPending = true;
|
||||||
|
Dispatcher.UIThread.Post(() => { _gutterPending = false; PositionGutters(); }, DispatcherPriority.Background);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void PositionGutters()
|
||||||
|
{
|
||||||
|
ClearGutters();
|
||||||
|
PopulateConflictMap();
|
||||||
|
if (_vm?.ActiveFile is null) return;
|
||||||
|
var tv = ResultEditor.TextArea.TextView;
|
||||||
|
if (!tv.VisualLinesValid)
|
||||||
|
{
|
||||||
|
// Retry until the editor is laid out, but bounded so a never-laid-out editor
|
||||||
|
// (e.g. minimized window) can't busy-loop the dispatcher.
|
||||||
|
if (_gutterRetries++ < 40) QueueGutters();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_gutterRetries = 0;
|
||||||
|
|
||||||
|
var doc = ResultEditor.Document;
|
||||||
|
foreach (var region in _resultRegions)
|
||||||
|
{
|
||||||
|
// Controls stay visible whether or not a side is included, so either can be toggled.
|
||||||
|
var len = region.End.Offset - region.Start.Offset;
|
||||||
|
ISegment probe = len > 0
|
||||||
|
? new Seg(region.Start.Offset, len)
|
||||||
|
: new Seg(region.Start.Offset, region.Start.Offset < doc.TextLength ? 1 : 0);
|
||||||
|
var rects = BackgroundGeometryBuilder.GetRectsForSegment(tv, probe).ToList();
|
||||||
|
if (rects.Count == 0) continue;
|
||||||
|
var y = rects[0].Top;
|
||||||
|
|
||||||
|
var r = region;
|
||||||
|
var oursIn = region.Order.Contains('o');
|
||||||
|
var theirsIn = region.Order.Contains('t');
|
||||||
|
|
||||||
|
if (tv.TranslatePoint(new Point(0, y), LeftGutter) is { } pl &&
|
||||||
|
pl.Y > -24 && pl.Y < LeftGutter.Bounds.Height + 24)
|
||||||
|
AddAcceptButton(LeftGutter, pl.Y, oursIn ? "−" : "›", () => ToggleSide(r, 'o'),
|
||||||
|
Tr(oursIn ? "conflictResolver.removeOurs" : "conflictResolver.acceptOurs"));
|
||||||
|
|
||||||
|
if (tv.TranslatePoint(new Point(0, y), RightGutter) is { } pr &&
|
||||||
|
pr.Y > -24 && pr.Y < RightGutter.Bounds.Height + 24)
|
||||||
|
AddAcceptButton(RightGutter, pr.Y, theirsIn ? "−" : "‹", () => ToggleSide(r, 't'),
|
||||||
|
Tr(theirsIn ? "conflictResolver.removeTheirs" : "conflictResolver.acceptTheirs"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void AddAcceptButton(Canvas canvas, double y, string glyph, Action onClick, string tip)
|
||||||
|
{
|
||||||
|
var b = new Button { Content = glyph };
|
||||||
|
b.Classes.Add("accept-gutter");
|
||||||
|
ToolTip.SetTip(b, tip);
|
||||||
|
b.Click += (_, _) => onClick();
|
||||||
|
Canvas.SetLeft(b, 1);
|
||||||
|
Canvas.SetTop(b, Math.Max(0, y));
|
||||||
|
canvas.Children.Add(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Conflict overview ruler (right of the result pane) ───────────────────
|
||||||
|
|
||||||
|
// A proportional map of every conflict in the active file so they're findable in
|
||||||
|
// long files without scrolling; ticks recolor by resolved state and jump on click.
|
||||||
|
private void PopulateConflictMap()
|
||||||
|
{
|
||||||
|
ConflictMap.Children.Clear();
|
||||||
|
if (_vm?.ActiveFile is null || _resultRegions.Count == 0) return;
|
||||||
|
var h = ConflictMap.Bounds.Height;
|
||||||
|
if (h <= 1) return;
|
||||||
|
var doc = ResultEditor.Document;
|
||||||
|
var totalLines = Math.Max(1, doc.LineCount);
|
||||||
|
var unresolved = BrushRes("MergeConflictEdgeBrush", Color.Parse("#80C87060"));
|
||||||
|
var resolved = BrushRes("MergeResolvedEdgeBrush", Color.Parse("#806FA86B"));
|
||||||
|
|
||||||
|
foreach (var region in _resultRegions)
|
||||||
|
{
|
||||||
|
var line = doc.GetLineByOffset(region.Start.Offset).LineNumber;
|
||||||
|
var y = (line - 1) / (double)totalLines * h;
|
||||||
|
var tick = new Rectangle
|
||||||
|
{
|
||||||
|
Width = 9,
|
||||||
|
Height = 4,
|
||||||
|
Fill = region.Block.IsResolved ? resolved : unresolved,
|
||||||
|
Cursor = new Avalonia.Input.Cursor(Avalonia.Input.StandardCursorType.Hand),
|
||||||
|
};
|
||||||
|
Canvas.SetLeft(tick, 2);
|
||||||
|
Canvas.SetTop(tick, Math.Min(h - 4, Math.Max(0, y)));
|
||||||
|
var r = region;
|
||||||
|
tick.PointerPressed += (_, _) => JumpToRegion(r);
|
||||||
|
ConflictMap.Children.Add(tick);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void JumpToRegion(ResultRegion region)
|
||||||
|
{
|
||||||
|
var line = ResultEditor.Document.GetLineByOffset(region.Start.Offset).LineNumber;
|
||||||
|
ResultEditor.ScrollToLine(line);
|
||||||
|
QueueGutters();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Tr(string key) => ClaudeDo.Ui.Localization.Loc.T(key);
|
||||||
|
|
||||||
|
// ── Synced vertical scroll across the three panes ─────────────────────────
|
||||||
|
|
||||||
|
private void HookScrollSync()
|
||||||
|
{
|
||||||
|
_scrollViewers = new[] { OursEditor, ResultEditor, TheirsEditor }
|
||||||
|
.Select(ed => ed.FindDescendantOfType<ScrollViewer>())
|
||||||
|
.ToArray();
|
||||||
|
foreach (var sv in _scrollViewers)
|
||||||
|
if (sv is not null) sv.ScrollChanged += OnPaneScroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPaneScroll(object? sender, ScrollChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_syncing || sender is not ScrollViewer src) return;
|
||||||
|
_syncing = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var sv in _scrollViewers)
|
||||||
|
if (sv is not null && !ReferenceEquals(sv, src) && Math.Abs(sv.Offset.Y - src.Offset.Y) > 0.5)
|
||||||
|
sv.Offset = new Vector(sv.Offset.X, src.Offset.Y);
|
||||||
|
}
|
||||||
|
finally { _syncing = false; }
|
||||||
|
PositionGutters();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ScrollToCurrent()
|
||||||
|
{
|
||||||
|
if (_vm?.Current is not { } block) return;
|
||||||
|
var region = _resultRegions.FirstOrDefault(r => ReferenceEquals(r.Block, block));
|
||||||
|
if (region is null) return;
|
||||||
|
var line = ResultEditor.Document.GetLineByOffset(region.Start.Offset).LineNumber;
|
||||||
|
ResultEditor.ScrollToLine(line);
|
||||||
|
QueueGutters();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void InvalidateRenderers()
|
||||||
|
{
|
||||||
|
OursEditor.TextArea.TextView.InvalidateVisual();
|
||||||
|
ResultEditor.TextArea.TextView.InvalidateVisual();
|
||||||
|
TheirsEditor.TextArea.TextView.InvalidateVisual();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyGrammar(string? path)
|
||||||
|
{
|
||||||
|
if (_registry is null || string.IsNullOrEmpty(path)) return;
|
||||||
|
var ext = System.IO.Path.GetExtension(path);
|
||||||
|
if (string.IsNullOrEmpty(ext)) return;
|
||||||
|
var language = _registry.GetLanguageByExtension(ext);
|
||||||
|
if (language is null) return;
|
||||||
|
var scope = _registry.GetScopeByLanguageId(language.Id);
|
||||||
|
_oursTm?.SetGrammar(scope);
|
||||||
|
_resultTm?.SetGrammar(scope);
|
||||||
|
_theirsTm?.SetGrammar(scope);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helper types (single-consumer; live with their consumer per repo style) ─
|
||||||
|
|
||||||
|
/// <summary>A minimal <see cref="ISegment"/> for geometry/read-only queries.</summary>
|
||||||
|
private readonly struct Seg : ISegment
|
||||||
|
{
|
||||||
|
public Seg(int offset, int length) { Offset = offset; Length = length; }
|
||||||
|
public int Offset { get; }
|
||||||
|
public int Length { get; }
|
||||||
|
public int EndOffset => Offset + Length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>An editable conflict region in the result document, tracking which sides are
|
||||||
|
/// currently included (in click order — <c>'o'</c> = ours/main, <c>'t'</c> = theirs/incoming).</summary>
|
||||||
|
private sealed class ResultRegion
|
||||||
|
{
|
||||||
|
public ResultRegion(MergeConflictBlock block, TextAnchor start, TextAnchor end)
|
||||||
|
{
|
||||||
|
Block = block; Start = start; End = end;
|
||||||
|
}
|
||||||
|
public MergeConflictBlock Block { get; }
|
||||||
|
public TextAnchor Start { get; }
|
||||||
|
public TextAnchor End { get; }
|
||||||
|
public List<char> Order { get; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Paints each conflict block with the unresolved/resolved tint across a pane.</summary>
|
||||||
|
private sealed class MergeBlockRenderer : IBackgroundRenderer
|
||||||
|
{
|
||||||
|
private readonly Func<IEnumerable<(int Offset, int Length, bool Resolved)>> _spans;
|
||||||
|
private readonly IBrush _conflict;
|
||||||
|
private readonly IBrush _resolved;
|
||||||
|
|
||||||
|
public MergeBlockRenderer(Func<IEnumerable<(int, int, bool)>> spans, IBrush conflict, IBrush resolved)
|
||||||
|
{
|
||||||
|
_spans = spans; _conflict = conflict; _resolved = resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
public KnownLayer Layer => KnownLayer.Background;
|
||||||
|
|
||||||
|
public void Draw(TextView textView, DrawingContext drawingContext)
|
||||||
|
{
|
||||||
|
if (!textView.VisualLinesValid) return;
|
||||||
|
foreach (var (offset, length, resolved) in _spans())
|
||||||
|
{
|
||||||
|
var brush = resolved ? _resolved : _conflict;
|
||||||
|
if (length > 0)
|
||||||
|
{
|
||||||
|
var builder = new BackgroundGeometryBuilder { AlignToWholePixels = true, CornerRadius = 2 };
|
||||||
|
builder.AddSegment(textView, new Seg(offset, length));
|
||||||
|
var geo = builder.CreateGeometry();
|
||||||
|
if (geo is not null) drawingContext.DrawGeometry(brush, null, geo);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Empty region (nothing accepted yet): a thin marker bar marks the spot.
|
||||||
|
var at = offset < textView.Document.TextLength ? offset : Math.Max(0, offset - 1);
|
||||||
|
var rects = BackgroundGeometryBuilder.GetRectsForSegment(textView, new Seg(at, 1)).ToList();
|
||||||
|
if (rects.Count > 0)
|
||||||
|
drawingContext.FillRectangle(brush, new Rect(0, rects[0].Top, textView.Bounds.Width, 3));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Makes everything read-only except the live conflict regions in the result document.</summary>
|
||||||
|
private sealed class ConflictReadOnlyProvider : IReadOnlySectionProvider
|
||||||
|
{
|
||||||
|
private readonly Func<IEnumerable<(int Start, int End)>> _regions;
|
||||||
|
public ConflictReadOnlyProvider(Func<IEnumerable<(int, int)>> regions) => _regions = regions;
|
||||||
|
|
||||||
|
public bool CanInsert(int offset) => _regions().Any(r => offset >= r.Start && offset <= r.End);
|
||||||
|
|
||||||
|
public IEnumerable<ISegment> GetDeletableSegments(ISegment segment)
|
||||||
|
{
|
||||||
|
foreach (var (start, end) in _regions())
|
||||||
|
{
|
||||||
|
var s = Math.Max(segment.Offset, start);
|
||||||
|
var e = Math.Min(segment.EndOffset, end);
|
||||||
|
if (e > s) yield return new Seg(s, e - s);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,8 +138,8 @@
|
|||||||
|
|
||||||
<!-- Action buttons -->
|
<!-- Action buttons -->
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,4,0,0">
|
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,4,0,0">
|
||||||
<Button Classes="btn" Content="{loc:Tr agent.openDiff}" Command="{Binding OpenDiffCommand}"/>
|
<Button Classes="btn" Content="{loc:Tr agent.openDiff}" Command="{Binding Merge.OpenDiffCommand}"/>
|
||||||
<Button Classes="btn" Command="{Binding OpenWorktreeCommand}"
|
<Button Classes="btn" Command="{Binding Merge.OpenWorktreeCommand}"
|
||||||
ToolTip.Tip="{loc:Tr agent.openWorktreeTip}">
|
ToolTip.Tip="{loc:Tr agent.openWorktreeTip}">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
|
<StackPanel Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
|
||||||
<PathIcon Data="{StaticResource Icon.ArrowOut}"
|
<PathIcon Data="{StaticResource Icon.ArrowOut}"
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ public partial class DescriptionStepsCard : UserControl
|
|||||||
|
|
||||||
private void OnSubtaskEditLostFocus(object? sender, RoutedEventArgs e)
|
private void OnSubtaskEditLostFocus(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (sender is TextBox { DataContext: SubtaskRowViewModel row })
|
if (sender is TextBox { DataContext: SubtaskRowViewModel row }
|
||||||
row.IsEditing = false;
|
&& DataContext is DetailsIslandViewModel vm
|
||||||
|
&& vm.CommitSubtaskEditCommand.CanExecute(row))
|
||||||
|
vm.CommitSubtaskEditCommand.Execute(row);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@
|
|||||||
<!-- Column 2: gear button with agent settings flyout -->
|
<!-- Column 2: gear button with agent settings flyout -->
|
||||||
<Button Grid.Column="2" Classes="icon-btn"
|
<Button Grid.Column="2" Classes="icon-btn"
|
||||||
ToolTip.Tip="{loc:Tr details.agentSettingsTip}"
|
ToolTip.Tip="{loc:Tr details.agentSettingsTip}"
|
||||||
IsEnabled="{Binding IsAgentSectionEnabled}"
|
IsEnabled="{Binding AgentSettings.IsAgentSectionEnabled}"
|
||||||
VerticalAlignment="Top"
|
VerticalAlignment="Top"
|
||||||
Margin="6,0,0,0">
|
Margin="6,0,0,0">
|
||||||
<TextBlock Text="⚙" FontSize="{StaticResource FontSizeTaskTitle}"/>
|
<TextBlock Text="⚙" FontSize="{StaticResource FontSizeTaskTitle}"/>
|
||||||
@@ -64,50 +64,50 @@
|
|||||||
<StackPanel Spacing="2">
|
<StackPanel Spacing="2">
|
||||||
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
|
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
|
||||||
<TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr details.modelLabel}" VerticalAlignment="Center"/>
|
<TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr details.modelLabel}" VerticalAlignment="Center"/>
|
||||||
<ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding ModelBadge}"/>
|
<ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding AgentSettings.ModelBadge}"/>
|
||||||
<Button Grid.Column="3" Classes="icon-btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
|
<Button Grid.Column="3" Classes="icon-btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
|
||||||
Command="{Binding ResetTaskModelCommand}"/>
|
Command="{Binding AgentSettings.ResetTaskModelCommand}"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<ComboBox ItemsSource="{Binding TaskModelOptions}"
|
<ComboBox ItemsSource="{Binding AgentSettings.TaskModelOptions}"
|
||||||
SelectedItem="{Binding TaskModelSelection, Mode=TwoWay}"
|
SelectedItem="{Binding AgentSettings.TaskModelSelection, Mode=TwoWay}"
|
||||||
PlaceholderText="{Binding ModelInheritedHint}"
|
PlaceholderText="{Binding AgentSettings.ModelInheritedHint}"
|
||||||
HorizontalAlignment="Stretch"/>
|
HorizontalAlignment="Stretch"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel Spacing="2">
|
<StackPanel Spacing="2">
|
||||||
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
|
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
|
||||||
<TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr details.maxTurnsLabel}" VerticalAlignment="Center"/>
|
<TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr details.maxTurnsLabel}" VerticalAlignment="Center"/>
|
||||||
<ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding TurnsBadge}"/>
|
<ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding AgentSettings.TurnsBadge}"/>
|
||||||
<Button Grid.Column="3" Classes="icon-btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
|
<Button Grid.Column="3" Classes="icon-btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
|
||||||
Command="{Binding ResetTaskTurnsCommand}"/>
|
Command="{Binding AgentSettings.ResetTaskTurnsCommand}"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<NumericUpDown Value="{Binding TaskMaxTurns, Mode=TwoWay}"
|
<NumericUpDown Value="{Binding AgentSettings.TaskMaxTurns, Mode=TwoWay}"
|
||||||
PlaceholderText="{Binding TurnsInheritedHint}"
|
PlaceholderText="{Binding AgentSettings.TurnsInheritedHint}"
|
||||||
Minimum="1" Maximum="200" Increment="1" FormatString="0"
|
Minimum="1" Maximum="200" Increment="1" FormatString="0"
|
||||||
HorizontalAlignment="Stretch"/>
|
HorizontalAlignment="Stretch"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel Spacing="2">
|
<StackPanel Spacing="2">
|
||||||
<TextBlock Classes="field-label" Text="{loc:Tr details.systemPromptLabel}"/>
|
<TextBlock Classes="field-label" Text="{loc:Tr details.systemPromptLabel}"/>
|
||||||
<TextBox Text="{Binding TaskSystemPrompt, Mode=TwoWay}"
|
<TextBox Text="{Binding AgentSettings.TaskSystemPrompt, Mode=TwoWay}"
|
||||||
AcceptsReturn="True" TextWrapping="Wrap" MinHeight="70"/>
|
AcceptsReturn="True" TextWrapping="Wrap" MinHeight="70"/>
|
||||||
<TextBlock Classes="meta" Opacity="0.6"
|
<TextBlock Classes="meta" Opacity="0.6"
|
||||||
Text="{loc:Tr details.systemPromptPrepended}"
|
Text="{loc:Tr details.systemPromptPrepended}"
|
||||||
IsVisible="{Binding EffectiveSystemPromptHint, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
IsVisible="{Binding AgentSettings.EffectiveSystemPromptHint, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
<TextBlock Classes="meta" Opacity="0.6" TextWrapping="Wrap"
|
<TextBlock Classes="meta" Opacity="0.6" TextWrapping="Wrap"
|
||||||
Text="{Binding EffectiveSystemPromptHint}"
|
Text="{Binding AgentSettings.EffectiveSystemPromptHint}"
|
||||||
IsVisible="{Binding EffectiveSystemPromptHint, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
IsVisible="{Binding AgentSettings.EffectiveSystemPromptHint, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel Spacing="2">
|
<StackPanel Spacing="2">
|
||||||
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
|
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
|
||||||
<TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr details.agentFileLabel}" VerticalAlignment="Center"/>
|
<TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr details.agentFileLabel}" VerticalAlignment="Center"/>
|
||||||
<ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding AgentBadge}"/>
|
<ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding AgentSettings.AgentBadge}"/>
|
||||||
<Button Grid.Column="3" Classes="icon-btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
|
<Button Grid.Column="3" Classes="icon-btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
|
||||||
Command="{Binding ResetTaskAgentCommand}"/>
|
Command="{Binding AgentSettings.ResetTaskAgentCommand}"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<ComboBox ItemsSource="{Binding TaskAgentOptions}"
|
<ComboBox ItemsSource="{Binding AgentSettings.TaskAgentOptions}"
|
||||||
SelectedItem="{Binding TaskSelectedAgent, Mode=TwoWay}"
|
SelectedItem="{Binding AgentSettings.TaskSelectedAgent, Mode=TwoWay}"
|
||||||
HorizontalAlignment="Stretch">
|
HorizontalAlignment="Stretch">
|
||||||
<ComboBox.ItemTemplate>
|
<ComboBox.ItemTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
|
|||||||
@@ -167,9 +167,16 @@
|
|||||||
CommandParameter="output" />
|
CommandParameter="output" />
|
||||||
<Button Classes="tab-btn"
|
<Button Classes="tab-btn"
|
||||||
Classes.active="{Binding IsGitTab}"
|
Classes.active="{Binding IsGitTab}"
|
||||||
Content="Git"
|
|
||||||
Command="{Binding SelectTabCommand}"
|
Command="{Binding SelectTabCommand}"
|
||||||
CommandParameter="git" />
|
CommandParameter="git">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
|
<TextBlock Text="Git" VerticalAlignment="Center" />
|
||||||
|
<!-- Review-pending dot: where to act when a task awaits review -->
|
||||||
|
<Ellipse Width="6" Height="6" VerticalAlignment="Center"
|
||||||
|
Fill="{DynamicResource AccentBrush}"
|
||||||
|
IsVisible="{Binding IsWaitingForReview}" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
<Button Classes="tab-btn"
|
<Button Classes="tab-btn"
|
||||||
Classes.active="{Binding IsSessionTab}"
|
Classes.active="{Binding IsSessionTab}"
|
||||||
Content="Session"
|
Content="Session"
|
||||||
@@ -205,41 +212,27 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- Review prompt — sits directly on the terminal, like a shell input line;
|
<!-- Review footer: feedback + Resume session, shown while awaiting review.
|
||||||
only while awaiting review. No border/fill so it reads as part of the log. -->
|
Lives here (with the live log) rather than the Git tab. -->
|
||||||
<Grid DockPanel.Dock="Bottom"
|
<Border DockPanel.Dock="Bottom"
|
||||||
IsVisible="{Binding IsWaitingForReview}"
|
IsVisible="{Binding IsWaitingForReview}"
|
||||||
ColumnDefinitions="Auto,*,Auto"
|
Margin="12,6,12,2">
|
||||||
Margin="12,2,12,8">
|
<StackPanel Spacing="8">
|
||||||
<TextBlock Grid.Column="0" Text="❯"
|
<TextBox Name="ReviewInput"
|
||||||
FontFamily="{StaticResource MonoFont}"
|
|
||||||
FontSize="{StaticResource FontSizeMono}"
|
|
||||||
Foreground="{DynamicResource AccentBrush}"
|
|
||||||
VerticalAlignment="Top" Margin="0,2,8,0" />
|
|
||||||
<TextBox Grid.Column="1"
|
|
||||||
Name="ReviewInput"
|
|
||||||
KeyDown="OnReviewInputKeyDown"
|
KeyDown="OnReviewInputKeyDown"
|
||||||
Text="{Binding ReviewFeedback, Mode=TwoWay}"
|
Text="{Binding ReviewFeedback, Mode=TwoWay}"
|
||||||
AcceptsReturn="True"
|
AcceptsReturn="True"
|
||||||
TextWrapping="Wrap"
|
TextWrapping="Wrap"
|
||||||
MaxHeight="160"
|
MaxHeight="120"
|
||||||
PlaceholderText="Feedback for the next run…"
|
PlaceholderText="Feedback for a re-run…"
|
||||||
Background="Transparent"
|
|
||||||
BorderThickness="0"
|
|
||||||
Padding="0"
|
|
||||||
VerticalContentAlignment="Center"
|
|
||||||
FontFamily="{StaticResource MonoFont}"
|
FontFamily="{StaticResource MonoFont}"
|
||||||
FontSize="{StaticResource FontSizeMono}" />
|
FontSize="{StaticResource FontSizeMono}" />
|
||||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="10"
|
<Button Classes="btn" Content="Resume session"
|
||||||
VerticalAlignment="Top" Margin="12,2,0,0">
|
HorizontalAlignment="Left"
|
||||||
<Button Classes="prompt-action accent" Content="[Continue]"
|
|
||||||
ToolTip.Tip="{loc:Tr session.reviewContinueTip}"
|
ToolTip.Tip="{loc:Tr session.reviewContinueTip}"
|
||||||
Command="{Binding RejectReviewCommand}" />
|
Command="{Binding RejectReviewCommand}" />
|
||||||
<Button Classes="prompt-action" Content="[Reset]"
|
|
||||||
ToolTip.Tip="{loc:Tr session.reviewResetTip}"
|
|
||||||
Command="{Binding ResetReviewCommand}" />
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Border>
|
||||||
|
|
||||||
<ScrollViewer Name="LogScroll"
|
<ScrollViewer Name="LogScroll"
|
||||||
VerticalScrollBarVisibility="Visible"
|
VerticalScrollBarVisibility="Visible"
|
||||||
@@ -264,49 +257,83 @@
|
|||||||
|
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|
||||||
<!-- Git: one Approve + merge cockpit -->
|
<!-- Git: the review + merge cockpit -->
|
||||||
<ScrollViewer IsVisible="{Binding IsGitTab}" Padding="14,10">
|
<ScrollViewer IsVisible="{Binding IsGitTab}" Padding="14,10">
|
||||||
<StackPanel Spacing="12" IsVisible="{Binding ShowMergeSection}">
|
<StackPanel Spacing="14">
|
||||||
<TextBlock Classes="section-label" Text="MERGE" />
|
|
||||||
|
|
||||||
|
<!-- Merge controls — shown whenever there's a worktree / unit to merge.
|
||||||
|
Header reads REVIEW while a decision is pending, otherwise MERGE. -->
|
||||||
|
<StackPanel Spacing="14" IsVisible="{Binding Merge.ShowMergeSection}">
|
||||||
|
<TextBlock Classes="section-label" Text="REVIEW"
|
||||||
|
IsVisible="{Binding IsWaitingForReview}" />
|
||||||
|
<TextBlock Classes="section-label" Text="MERGE"
|
||||||
|
IsVisible="{Binding !IsWaitingForReview}" />
|
||||||
|
|
||||||
|
<!-- Change summary (review only) -->
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6"
|
||||||
|
IsVisible="{Binding IsWaitingForReview}">
|
||||||
|
<TextBlock Classes="diff-add" Text="{Binding DiffAddText}" />
|
||||||
|
<TextBlock Classes="diff-del" Text="{Binding DiffDelText}" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Target branch + pre-flight mergeability -->
|
||||||
<StackPanel Spacing="4">
|
<StackPanel Spacing="4">
|
||||||
<TextBlock Classes="field-label" Text="Target branch" />
|
<TextBlock Classes="field-label" Text="Target branch" />
|
||||||
<ComboBox ItemsSource="{Binding MergeTargetBranches}"
|
<ComboBox ItemsSource="{Binding Merge.MergeTargetBranches}"
|
||||||
SelectedItem="{Binding SelectedMergeTarget, Mode=TwoWay}"
|
SelectedItem="{Binding Merge.SelectedMergeTarget, Mode=TwoWay}"
|
||||||
HorizontalAlignment="Stretch" />
|
HorizontalAlignment="Stretch" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<StackPanel Spacing="0">
|
<StackPanel Spacing="0">
|
||||||
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
|
<TextBlock Classes="meta" Text="{Binding Merge.MergePreviewText}" TextWrapping="Wrap"
|
||||||
Foreground="{DynamicResource MossBrush}"
|
Foreground="{DynamicResource MossBrush}"
|
||||||
IsVisible="{Binding MergeIsClean}" />
|
IsVisible="{Binding Merge.MergeIsClean}" />
|
||||||
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
|
<TextBlock Classes="meta" Text="{Binding Merge.MergePreviewText}" TextWrapping="Wrap"
|
||||||
Foreground="{DynamicResource BloodBrush}"
|
Foreground="{DynamicResource BloodBrush}"
|
||||||
IsVisible="{Binding MergeIsConflict}" />
|
IsVisible="{Binding Merge.MergeIsConflict}" />
|
||||||
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
|
<TextBlock Classes="meta" Text="{Binding Merge.MergePreviewText}" TextWrapping="Wrap"
|
||||||
Foreground="{DynamicResource TextMuteBrush}"
|
Foreground="{DynamicResource TextMuteBrush}"
|
||||||
IsVisible="{Binding ShowMergePreviewMuted}" />
|
IsVisible="{Binding Merge.ShowMergePreviewMuted}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- Primary action: Approve flows straight into the merge. -->
|
<!-- Inspect: diff / worktree / combined diff -->
|
||||||
<WrapPanel Orientation="Horizontal">
|
<WrapPanel Orientation="Horizontal">
|
||||||
<Button Classes="btn accent" Content="Approve & Merge" Margin="0,0,8,8"
|
|
||||||
Command="{Binding ApproveReviewCommand}"
|
|
||||||
IsVisible="{Binding IsWaitingForReview}" />
|
|
||||||
<Button Classes="btn" Content="Open Diff" Margin="0,0,8,8"
|
<Button Classes="btn" Content="Open Diff" Margin="0,0,8,8"
|
||||||
Command="{Binding OpenDiffCommand}" />
|
Command="{Binding Merge.OpenDiffCommand}" />
|
||||||
<Button Classes="btn" Margin="0,0,8,8"
|
<Button Classes="btn" Margin="0,0,8,8"
|
||||||
ToolTip.Tip="{loc:Tr agent.openWorktreeTip}"
|
ToolTip.Tip="{loc:Tr agent.openWorktreeTip}"
|
||||||
Command="{Binding OpenWorktreeCommand}">
|
Command="{Binding Merge.OpenWorktreeCommand}">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="5">
|
<StackPanel Orientation="Horizontal" Spacing="5">
|
||||||
<TextBlock Text="Worktree" />
|
<TextBlock Text="Worktree" />
|
||||||
<PathIcon Data="{StaticResource Icon.ArrowOut}" Width="11" Height="11" />
|
<PathIcon Data="{StaticResource Icon.ArrowOut}" Width="11" Height="11" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Button>
|
</Button>
|
||||||
<Button Classes="btn" Content="Review Combined Diff" Margin="0,0,8,8"
|
<Button Classes="btn" Content="Review Combined Diff" Margin="0,0,8,8"
|
||||||
Command="{Binding ReviewCombinedDiffCommand}" />
|
Command="{Binding Merge.ReviewCombinedDiffCommand}" />
|
||||||
</WrapPanel>
|
</WrapPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Review decision — the merge verbs. Feedback + Resume session moved to the
|
||||||
|
Output tab. Present while awaiting review, even for sandbox runs. -->
|
||||||
|
<StackPanel Spacing="10" IsVisible="{Binding IsWaitingForReview}">
|
||||||
|
<Border Height="1" Background="{DynamicResource LineBrush}"
|
||||||
|
IsVisible="{Binding Merge.ShowMergeSection}" />
|
||||||
|
|
||||||
|
<WrapPanel Orientation="Horizontal">
|
||||||
|
<Button Classes="btn accent" Content="Approve & Merge" Margin="0,0,8,8"
|
||||||
|
Command="{Binding ApproveReviewCommand}" />
|
||||||
|
<Button Classes="btn" Content="Park" Margin="0,0,8,8"
|
||||||
|
ToolTip.Tip="Set aside — back to Idle, keeps the worktree"
|
||||||
|
Command="{Binding ParkReviewCommand}" />
|
||||||
|
<Button Classes="btn" Content="Cancel" Margin="0,0,8,8"
|
||||||
|
Command="{Binding CancelReviewCommand}" />
|
||||||
|
</WrapPanel>
|
||||||
|
|
||||||
|
<Button Classes="prompt-action" Content="Reset (discard branch)…"
|
||||||
|
ToolTip.Tip="{loc:Tr session.reviewResetTip}"
|
||||||
|
Command="{Binding ResetReviewCommand}" />
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
|
|
||||||
<!-- Session: subtask outcomes (review lives in Output, merge in Git) -->
|
<!-- Session: subtask outcomes (review lives in Output, merge in Git) -->
|
||||||
|
|||||||
@@ -104,16 +104,16 @@
|
|||||||
<DockPanel>
|
<DockPanel>
|
||||||
<Border DockPanel.Dock="Top" Padding="12,8">
|
<Border DockPanel.Dock="Top" Padding="12,8">
|
||||||
<Button Classes="btn primary"
|
<Button Classes="btn primary"
|
||||||
Command="{Binding PlanDayCommand}"
|
Command="{Binding Prep.PlanDayCommand}"
|
||||||
IsEnabled="{Binding !IsPrepRunning}"
|
IsEnabled="{Binding !Prep.IsPrepRunning}"
|
||||||
Content="{loc:Tr details.planDay}"/>
|
Content="{loc:Tr details.planDay}"/>
|
||||||
</Border>
|
</Border>
|
||||||
<Panel>
|
<Panel>
|
||||||
<islands:SessionTerminalView
|
<islands:SessionTerminalView
|
||||||
Margin="18,8,18,0"
|
Margin="18,8,18,0"
|
||||||
Entries="{Binding PrepLog}" Label="daily-prep"
|
Entries="{Binding Prep.PrepLog}" Label="daily-prep"
|
||||||
IsRunning="{Binding IsPrepRunning}"/>
|
IsRunning="{Binding Prep.IsPrepRunning}"/>
|
||||||
<TextBlock IsVisible="{Binding ShowPrepEmptyState}"
|
<TextBlock IsVisible="{Binding Prep.ShowPrepEmptyState}"
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||||
Foreground="{DynamicResource TextMuteBrush}"
|
Foreground="{DynamicResource TextMuteBrush}"
|
||||||
Text="{loc:Tr details.prepEmpty}"/>
|
Text="{loc:Tr details.prepEmpty}"/>
|
||||||
|
|||||||
@@ -35,6 +35,13 @@ public partial class DetailsIslandView : UserControl
|
|||||||
if (h <= 0) return;
|
if (h <= 0) return;
|
||||||
DetailBodyGrid.RowDefinitions[0].MaxHeight = h * 2.0 / 3.0;
|
DetailBodyGrid.RowDefinitions[0].MaxHeight = h * 2.0 / 3.0;
|
||||||
DetailBodyGrid.RowDefinitions[1].MinHeight = h / 3.0;
|
DetailBodyGrid.RowDefinitions[1].MinHeight = h / 3.0;
|
||||||
|
// The description sits in an Auto row, which measures its cell with
|
||||||
|
// infinite height — so the card's inner ScrollViewer thinks everything
|
||||||
|
// fits and never scrolls. Bounding the card itself gives that
|
||||||
|
// ScrollViewer a finite measure constraint so it engages once the
|
||||||
|
// content exceeds 2/3 of the island. (RowDefinition.MaxHeight above only
|
||||||
|
// clamps the drag and the final row height, not the measure constraint.)
|
||||||
|
DescriptionCard.MaxHeight = h * 2.0 / 3.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnDataContextChanged(object? sender, EventArgs e)
|
private void OnDataContextChanged(object? sender, EventArgs e)
|
||||||
@@ -48,7 +55,7 @@ public partial class DetailsIslandView : UserControl
|
|||||||
vm.PropertyChanged += OnViewModelPropertyChanged;
|
vm.PropertyChanged += OnViewModelPropertyChanged;
|
||||||
ApplyResizeStateForCurrentTask();
|
ApplyResizeStateForCurrentTask();
|
||||||
|
|
||||||
vm.ShowDiffModal = async (diffVm) =>
|
vm.Merge.ShowDiffModal = async (diffVm) =>
|
||||||
{
|
{
|
||||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||||
if (owner == null) return;
|
if (owner == null) return;
|
||||||
@@ -56,7 +63,7 @@ public partial class DetailsIslandView : UserControl
|
|||||||
await modal.ShowDialog(owner);
|
await modal.ShowDialog(owner);
|
||||||
};
|
};
|
||||||
|
|
||||||
vm.ShowMergeModal = async (mergeVm) =>
|
vm.Merge.ShowMergeModal = async (mergeVm) =>
|
||||||
{
|
{
|
||||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||||
if (owner == null) return;
|
if (owner == null) return;
|
||||||
@@ -64,7 +71,7 @@ public partial class DetailsIslandView : UserControl
|
|||||||
await modal.ShowDialog(owner);
|
await modal.ShowDialog(owner);
|
||||||
};
|
};
|
||||||
|
|
||||||
vm.ShowPlanningDiffModal = async (planningDiffVm) =>
|
vm.Merge.ShowPlanningDiffModal = async (planningDiffVm) =>
|
||||||
{
|
{
|
||||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||||
if (owner == null) return;
|
if (owner == null) return;
|
||||||
|
|||||||
@@ -28,11 +28,8 @@
|
|||||||
<ItemsControl ItemsSource="{Binding Bullets}">
|
<ItemsControl ItemsSource="{Binding Bullets}">
|
||||||
<ItemsControl.ItemTemplate>
|
<ItemsControl.ItemTemplate>
|
||||||
<DataTemplate x:DataType="vm:NoteBulletViewModel">
|
<DataTemplate x:DataType="vm:NoteBulletViewModel">
|
||||||
<Grid ColumnDefinitions="*,Auto,Auto" Margin="0,2" ColumnSpacing="6">
|
<TextBox Text="{Binding Text}" Margin="0,2"
|
||||||
<TextBox Grid.Column="0" Text="{Binding Text}"/>
|
LostFocus="OnBulletLostFocus"/>
|
||||||
<Button Grid.Column="1" Classes="btn" Content="{loc:Tr notes.save}" Command="{Binding SaveCommand}"/>
|
|
||||||
<Button Grid.Column="2" Classes="btn" Content="{loc:Tr notes.delete}" Command="{Binding DeleteCommand}"/>
|
|
||||||
</Grid>
|
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
</ItemsControl.ItemTemplate>
|
</ItemsControl.ItemTemplate>
|
||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.Views.Islands;
|
namespace ClaudeDo.Ui.Views.Islands;
|
||||||
|
|
||||||
public partial class NotesEditorView : UserControl
|
public partial class NotesEditorView : UserControl
|
||||||
{
|
{
|
||||||
public NotesEditorView() => InitializeComponent();
|
public NotesEditorView() => InitializeComponent();
|
||||||
|
|
||||||
|
private void OnBulletLostFocus(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is TextBox { DataContext: NoteBulletViewModel bullet }
|
||||||
|
&& DataContext is NotesEditorViewModel vm
|
||||||
|
&& vm.CommitBulletCommand.CanExecute(bullet))
|
||||||
|
vm.CommitBulletCommand.Execute(bullet);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,8 @@
|
|||||||
<!-- Indent wrapper: col 0 = 24px child indent track, col 1 = content -->
|
<!-- Indent wrapper: col 0 = 24px child indent track, col 1 = content -->
|
||||||
<Grid Grid.Row="1" ColumnDefinitions="Auto,*">
|
<Grid Grid.Row="1" ColumnDefinitions="Auto,*">
|
||||||
|
|
||||||
<!-- Indent track (only visible for child tasks) -->
|
<!-- Indent track (only while the parent shares this view; orphaned children render flat) -->
|
||||||
<Border Grid.Column="0" Width="24" IsVisible="{Binding IsChild}" VerticalAlignment="Stretch">
|
<Border Grid.Column="0" Width="24" IsVisible="{Binding ShowAsChild}" VerticalAlignment="Stretch">
|
||||||
<Rectangle Width="1" Fill="{DynamicResource LineBrush}"
|
<Rectangle Width="1" Fill="{DynamicResource LineBrush}"
|
||||||
HorizontalAlignment="Right" Margin="0,4"/>
|
HorizontalAlignment="Right" Margin="0,4"/>
|
||||||
</Border>
|
</Border>
|
||||||
@@ -56,17 +56,23 @@
|
|||||||
<MenuItem Header="{loc:Tr tasks.ctxResumePlanningSession}"
|
<MenuItem Header="{loc:Tr tasks.ctxResumePlanningSession}"
|
||||||
Click="OnResumePlanningSessionClick"
|
Click="OnResumePlanningSessionClick"
|
||||||
IsVisible="{Binding CanResumeOrDiscardPlanning}"/>
|
IsVisible="{Binding CanResumeOrDiscardPlanning}"/>
|
||||||
|
<MenuItem Header="{loc:Tr tasks.ctxFinalizePlanningSession}"
|
||||||
|
Click="OnFinalizePlanningSessionClick"
|
||||||
|
IsVisible="{Binding CanFinalizePlanning}"/>
|
||||||
<MenuItem Header="{loc:Tr tasks.ctxDiscardPlanningSession}"
|
<MenuItem Header="{loc:Tr tasks.ctxDiscardPlanningSession}"
|
||||||
Click="OnDiscardPlanningSessionClick"
|
Click="OnDiscardPlanningSessionClick"
|
||||||
IsVisible="{Binding CanResumeOrDiscardPlanning}"/>
|
IsVisible="{Binding CanResumeOrDiscardPlanning}"/>
|
||||||
<MenuItem Header="{loc:Tr tasks.ctxQueueSubtasks}"
|
|
||||||
Click="OnQueuePlanningSubtasksClick"
|
|
||||||
IsVisible="{Binding CanQueuePlan}"/>
|
|
||||||
<Separator/>
|
<Separator/>
|
||||||
<MenuItem Header="{loc:Tr tasks.ctxScheduleFor}" Click="OnScheduleForClick"/>
|
<MenuItem Header="{loc:Tr tasks.ctxScheduleFor}" Click="OnScheduleForClick"/>
|
||||||
<MenuItem Header="{loc:Tr tasks.ctxClearSchedule}"
|
<MenuItem Header="{loc:Tr tasks.ctxClearSchedule}"
|
||||||
IsVisible="{Binding HasSchedule}"
|
IsVisible="{Binding HasSchedule}"
|
||||||
Click="OnClearScheduleClick"/>
|
Click="OnClearScheduleClick"/>
|
||||||
|
<MenuItem Header="{loc:Tr tasks.ctxAddToMyDay}"
|
||||||
|
IsVisible="{Binding CanAddToMyDay}"
|
||||||
|
Click="OnAddToMyDayClick"/>
|
||||||
|
<MenuItem Header="{loc:Tr tasks.ctxRemoveFromMyDay}"
|
||||||
|
IsVisible="{Binding IsMyDay}"
|
||||||
|
Click="OnRemoveFromMyDayClick"/>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
</Border.ContextMenu>
|
</Border.ContextMenu>
|
||||||
<Grid ColumnDefinitions="0,18,32,*,Auto,Auto,32" Margin="6,8,10,8">
|
<Grid ColumnDefinitions="0,18,32,*,Auto,Auto,32" Margin="6,8,10,8">
|
||||||
@@ -146,6 +152,7 @@
|
|||||||
|
|
||||||
<!-- Status chip -->
|
<!-- Status chip -->
|
||||||
<Border Classes="chip"
|
<Border Classes="chip"
|
||||||
|
Classes.parked="{Binding IsParked}"
|
||||||
Classes.running="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Running}"
|
Classes.running="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Running}"
|
||||||
Classes.review="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=WaitingForReview}"
|
Classes.review="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=WaitingForReview}"
|
||||||
Classes.children="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=WaitingForChildren}"
|
Classes.children="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=WaitingForChildren}"
|
||||||
|
|||||||
@@ -48,6 +48,18 @@ public partial class TaskRowView : UserControl
|
|||||||
await vm.ClearScheduleCommand.ExecuteAsync(row);
|
await vm.ClearScheduleCommand.ExecuteAsync(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async void OnAddToMyDayClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
|
||||||
|
await vm.AddToMyDayCommand.ExecuteAsync(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnRemoveFromMyDayClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
|
||||||
|
await vm.RemoveFromMyDayCommand.ExecuteAsync(row);
|
||||||
|
}
|
||||||
|
|
||||||
private async void OnOpenPlanningSessionClick(object? sender, RoutedEventArgs e)
|
private async void OnOpenPlanningSessionClick(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
|
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
|
||||||
@@ -72,10 +84,10 @@ public partial class TaskRowView : UserControl
|
|||||||
await vm.DiscardPlanningSessionCommand.ExecuteAsync(row);
|
await vm.DiscardPlanningSessionCommand.ExecuteAsync(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void OnQueuePlanningSubtasksClick(object? sender, RoutedEventArgs e)
|
private async void OnFinalizePlanningSessionClick(object? sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
|
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
|
||||||
await vm.QueuePlanningSubtasksCommand.ExecuteAsync(row);
|
await vm.FinalizePlanningSessionCommand.ExecuteAsync(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void OnSetStatusClick(object? sender, RoutedEventArgs e)
|
private async void OnSetStatusClick(object? sender, RoutedEventArgs e)
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
<KeyBinding Gesture="Shift+OemQuestion" Command="{Binding FocusSearchCommand}"/>
|
<KeyBinding Gesture="Shift+OemQuestion" Command="{Binding FocusSearchCommand}"/>
|
||||||
<KeyBinding Gesture="Ctrl+N" Command="{Binding FocusAddTaskCommand}"/>
|
<KeyBinding Gesture="Ctrl+N" Command="{Binding FocusAddTaskCommand}"/>
|
||||||
</Window.KeyBindings>
|
</Window.KeyBindings>
|
||||||
<Grid RowDefinitions="36,Auto,*,22">
|
<Grid x:Name="RootGrid" RowDefinitions="36,Auto,*,22">
|
||||||
<!-- Custom title bar -->
|
<!-- Custom title bar -->
|
||||||
<Border Grid.Row="0"
|
<Border Grid.Row="0"
|
||||||
Background="{DynamicResource DeepBrush}"
|
Background="{DynamicResource DeepBrush}"
|
||||||
@@ -57,18 +57,26 @@
|
|||||||
<Menu Margin="12,0,0,0"
|
<Menu Margin="12,0,0,0"
|
||||||
Background="Transparent"
|
Background="Transparent"
|
||||||
VerticalAlignment="Center">
|
VerticalAlignment="Center">
|
||||||
|
<MenuItem Header="{loc:Tr shell.menu.worker}"
|
||||||
|
FontSize="{StaticResource FontSizeMono}"
|
||||||
|
Foreground="{DynamicResource TextDimBrush}">
|
||||||
|
<MenuItem Header="{loc:Tr shell.menu.restartWorker}"
|
||||||
|
Command="{Binding RestartWorkerCommand}"/>
|
||||||
|
<MenuItem Header="{loc:Tr shell.menu.checkForUpdates}"
|
||||||
|
Command="{Binding CheckForUpdatesCommand}"/>
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem Header="{loc:Tr shell.menu.repositories}"
|
||||||
|
FontSize="{StaticResource FontSizeMono}"
|
||||||
|
Foreground="{DynamicResource TextDimBrush}">
|
||||||
|
<MenuItem Header="{loc:Tr shell.menu.addRepos}" Command="{Binding OpenRepoImportCommand}"/>
|
||||||
|
<MenuItem Header="{loc:Tr shell.menu.worktrees}"
|
||||||
|
Command="{Binding OpenWorktreesOverviewGlobalCommand}"/>
|
||||||
|
</MenuItem>
|
||||||
<MenuItem Header="{loc:Tr shell.menu.help}"
|
<MenuItem Header="{loc:Tr shell.menu.help}"
|
||||||
FontSize="{StaticResource FontSizeMono}"
|
FontSize="{StaticResource FontSizeMono}"
|
||||||
Foreground="{DynamicResource TextDimBrush}">
|
Foreground="{DynamicResource TextDimBrush}">
|
||||||
<MenuItem Header="{loc:Tr shell.menu.checkForUpdates}"
|
|
||||||
Command="{Binding CheckForUpdatesCommand}"/>
|
|
||||||
<MenuItem Header="{loc:Tr shell.menu.restartWorker}"
|
|
||||||
Command="{Binding RestartWorkerCommand}"/>
|
|
||||||
<MenuItem Header="{loc:Tr shell.menu.worktrees}"
|
|
||||||
Command="{Binding OpenWorktreesOverviewGlobalCommand}"/>
|
|
||||||
<MenuItem Header="{loc:Tr shell.menu.weeklyReport}" Command="{Binding OpenWeeklyReportCommand}"/>
|
<MenuItem Header="{loc:Tr shell.menu.weeklyReport}" Command="{Binding OpenWeeklyReportCommand}"/>
|
||||||
<MenuItem Header="{loc:Tr shell.menu.about}" Command="{Binding OpenAboutCommand}"/>
|
<MenuItem Header="{loc:Tr shell.menu.about}" Command="{Binding OpenAboutCommand}"/>
|
||||||
<MenuItem Header="{loc:Tr shell.menu.addRepos}" Command="{Binding OpenRepoImportCommand}"/>
|
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
</Menu>
|
</Menu>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ public partial class MainWindow : Window
|
|||||||
base.OnPropertyChanged(change);
|
base.OnPropertyChanged(change);
|
||||||
if (change.Property == WindowStateProperty)
|
if (change.Property == WindowStateProperty)
|
||||||
UpdateMaxIcon();
|
UpdateMaxIcon();
|
||||||
|
if (change.Property == OffScreenMarginProperty)
|
||||||
|
RootGrid.Margin = OffScreenMargin;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpdateMaxIcon()
|
private void UpdateMaxIcon()
|
||||||
@@ -40,11 +42,6 @@ public partial class MainWindow : Window
|
|||||||
{
|
{
|
||||||
if (DataContext is IslandsShellViewModel vm)
|
if (DataContext is IslandsShellViewModel vm)
|
||||||
{
|
{
|
||||||
vm.ShowConflictDialog = async (conflictVm) =>
|
|
||||||
{
|
|
||||||
var modal = new ConflictResolutionView { DataContext = conflictVm };
|
|
||||||
await modal.ShowDialog(this);
|
|
||||||
};
|
|
||||||
vm.ShowAboutModal = async (aboutVm) =>
|
vm.ShowAboutModal = async (aboutVm) =>
|
||||||
{
|
{
|
||||||
var dlg = new AboutModalView { DataContext = aboutVm };
|
var dlg = new AboutModalView { DataContext = aboutVm };
|
||||||
|
|||||||
@@ -261,6 +261,99 @@
|
|||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</TabItem>
|
</TabItem>
|
||||||
|
|
||||||
|
<TabItem Header="{loc:Tr settings.onlineInbox.tabHeader}">
|
||||||
|
<ScrollViewer>
|
||||||
|
<StackPanel Spacing="14" Margin="0,8,0,0">
|
||||||
|
|
||||||
|
<!-- Enable toggle + restart hint -->
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<CheckBox IsChecked="{Binding OnlineInbox.Enabled, Mode=TwoWay}"
|
||||||
|
Content="{loc:Tr settings.onlineInbox.enabledLabel}"/>
|
||||||
|
<TextBlock Classes="meta" Text="{loc:Tr settings.onlineInbox.restartHint}"
|
||||||
|
TextWrapping="Wrap" Opacity="0.6"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="0,1,0,0" Margin="0,2,0,0"/>
|
||||||
|
|
||||||
|
<!-- Auth status section -->
|
||||||
|
<StackPanel Spacing="8">
|
||||||
|
<TextBlock Classes="section-label" Text="{loc:Tr settings.onlineInbox.statusSection}"/>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10" VerticalAlignment="Center"
|
||||||
|
IsVisible="{Binding OnlineInbox.SignedIn}">
|
||||||
|
<Border Width="8" Height="8" CornerRadius="4"
|
||||||
|
Background="{DynamicResource StatusRunningBrush}"/>
|
||||||
|
<TextBlock Classes="body" VerticalAlignment="Center"
|
||||||
|
Text="{loc:Tr settings.onlineInbox.signedInStatus}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="10" VerticalAlignment="Center"
|
||||||
|
IsVisible="{Binding !OnlineInbox.SignedIn}">
|
||||||
|
<Border Width="8" Height="8" CornerRadius="4"
|
||||||
|
Background="{DynamicResource StatusIdleBrush}"/>
|
||||||
|
<TextBlock Classes="body" VerticalAlignment="Center"
|
||||||
|
Text="{loc:Tr settings.onlineInbox.signedOutStatus}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<Button Classes="btn"
|
||||||
|
Content="{loc:Tr settings.onlineInbox.signInButton}"
|
||||||
|
Command="{Binding OnlineInbox.SignInCommand}"
|
||||||
|
IsEnabled="{Binding !OnlineInbox.IsBusy}"
|
||||||
|
IsVisible="{Binding !OnlineInbox.SignedIn}"/>
|
||||||
|
<Button Classes="btn danger"
|
||||||
|
Content="{loc:Tr settings.onlineInbox.signOutButton}"
|
||||||
|
Command="{Binding OnlineInbox.SignOutCommand}"
|
||||||
|
IsEnabled="{Binding !OnlineInbox.IsBusy}"
|
||||||
|
IsVisible="{Binding OnlineInbox.SignedIn}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="0,1,0,0" Margin="0,2,0,0"/>
|
||||||
|
|
||||||
|
<!-- Config fields -->
|
||||||
|
<StackPanel Spacing="10">
|
||||||
|
<TextBlock Classes="section-label" Text="{loc:Tr settings.onlineInbox.configSection}"/>
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<TextBlock Classes="field-label" Text="{loc:Tr settings.onlineInbox.apiBaseUrlLabel}"/>
|
||||||
|
<TextBox Text="{Binding OnlineInbox.ApiBaseUrl, Mode=TwoWay}"
|
||||||
|
PlaceholderText="{loc:Tr settings.onlineInbox.apiBaseUrlPlaceholder}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<TextBlock Classes="field-label" Text="{loc:Tr settings.onlineInbox.authorityLabel}"/>
|
||||||
|
<TextBox Text="{Binding OnlineInbox.Authority, Mode=TwoWay}"
|
||||||
|
PlaceholderText="{loc:Tr settings.onlineInbox.authorityPlaceholder}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<Grid ColumnDefinitions="*,12,*">
|
||||||
|
<StackPanel Grid.Column="0" Spacing="4">
|
||||||
|
<TextBlock Classes="field-label" Text="{loc:Tr settings.onlineInbox.clientIdLabel}"/>
|
||||||
|
<TextBox Text="{Binding OnlineInbox.ClientId, Mode=TwoWay}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Column="2" Spacing="4">
|
||||||
|
<TextBlock Classes="field-label" Text="{loc:Tr settings.onlineInbox.scopesLabel}"/>
|
||||||
|
<TextBox Text="{Binding OnlineInbox.Scopes, Mode=TwoWay}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Grid>
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<TextBlock Classes="field-label" Text="{loc:Tr settings.onlineInbox.redirectUriLabel}"/>
|
||||||
|
<TextBox Text="{Binding OnlineInbox.RedirectUri, Mode=TwoWay}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<TextBlock Classes="field-label" Text="{loc:Tr settings.onlineInbox.pollIntervalLabel}"/>
|
||||||
|
<NumericUpDown Value="{Binding OnlineInbox.PollIntervalSeconds, Mode=TwoWay}"
|
||||||
|
Minimum="10" Maximum="3600" Increment="10" FormatString="0"
|
||||||
|
HorizontalAlignment="Left" Width="140"/>
|
||||||
|
</StackPanel>
|
||||||
|
<Button Classes="btn"
|
||||||
|
Content="{loc:Tr settings.onlineInbox.saveButton}"
|
||||||
|
Command="{Binding OnlineInbox.SaveCommand}"
|
||||||
|
IsEnabled="{Binding !OnlineInbox.IsBusy}"
|
||||||
|
HorizontalAlignment="Left"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<TextBlock Classes="meta" Text="{Binding OnlineInbox.StatusMessage}"
|
||||||
|
IsVisible="{Binding OnlineInbox.StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
</TabItem>
|
||||||
|
|
||||||
</TabControl>
|
</TabControl>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|
||||||
|
|||||||
@@ -81,7 +81,7 @@
|
|||||||
IsVisible="{Binding HasOutcome}"/>
|
IsVisible="{Binding HasOutcome}"/>
|
||||||
<Border Grid.Column="3" CornerRadius="3" Padding="6,2" VerticalAlignment="Center"
|
<Border Grid.Column="3" CornerRadius="3" Padding="6,2" VerticalAlignment="Center"
|
||||||
Background="{Binding State, Converter={StaticResource WorktreeStateColor}}">
|
Background="{Binding State, Converter={StaticResource WorktreeStateColor}}">
|
||||||
<TextBlock Classes="meta" Text="{Binding State}" Foreground="{DynamicResource TextBrush}"
|
<TextBlock Classes="meta" Text="{Binding State}" Foreground="{DynamicResource DeepBrush}"
|
||||||
HorizontalAlignment="Center"/>
|
HorizontalAlignment="Center"/>
|
||||||
</Border>
|
</Border>
|
||||||
<TextBlock Grid.Column="4" Classes="meta" Text="{Binding DiffStat}" VerticalAlignment="Center"/>
|
<TextBlock Grid.Column="4" Classes="meta" Text="{Binding DiffStat}" VerticalAlignment="Center"/>
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
<Window xmlns="https://github.com/avaloniaui"
|
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
||||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Planning"
|
|
||||||
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
|
|
||||||
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
|
||||||
x:DataType="vm:ConflictResolutionViewModel"
|
|
||||||
x:Class="ClaudeDo.Ui.Views.Planning.ConflictResolutionView"
|
|
||||||
Title="{loc:Tr planning.conflict.windowTitle}"
|
|
||||||
Width="560" SizeToContent="Height" MinWidth="460"
|
|
||||||
CanResize="True"
|
|
||||||
WindowDecorations="BorderOnly"
|
|
||||||
ExtendClientAreaToDecorationsHint="True"
|
|
||||||
ExtendClientAreaTitleBarHeightHint="-1"
|
|
||||||
WindowStartupLocation="CenterOwner"
|
|
||||||
Background="{DynamicResource SurfaceBrush}">
|
|
||||||
|
|
||||||
<Window.KeyBindings>
|
|
||||||
<KeyBinding Gesture="Escape" Command="{Binding AbortCommand}"/>
|
|
||||||
</Window.KeyBindings>
|
|
||||||
|
|
||||||
<ctl:ModalShell Title="{loc:Tr planning.conflict.modalTitle}" CloseCommand="{Binding AbortCommand}">
|
|
||||||
<ctl:ModalShell.Footer>
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8"
|
|
||||||
HorizontalAlignment="Right" VerticalAlignment="Center">
|
|
||||||
<Button Classes="btn" Content="{loc:Tr planning.conflict.openInVsCode}" Command="{Binding OpenInVsCodeCommand}"/>
|
|
||||||
<Button Classes="btn" Content="{loc:Tr planning.conflict.resolved}" Command="{Binding ContinueCommand}"/>
|
|
||||||
<Button Classes="btn" Content="{loc:Tr planning.conflict.abort}" Command="{Binding AbortCommand}"/>
|
|
||||||
</StackPanel>
|
|
||||||
</ctl:ModalShell.Footer>
|
|
||||||
|
|
||||||
<!-- Content -->
|
|
||||||
<StackPanel Spacing="12" Margin="20,16" MinWidth="520">
|
|
||||||
<TextBlock Classes="heading"
|
|
||||||
Text="{Binding SubtaskLabel}"/>
|
|
||||||
<TextBlock Classes="body" Text="{Binding TargetLabel}"/>
|
|
||||||
<ItemsControl ItemsSource="{Binding ConflictedFiles}">
|
|
||||||
<ItemsControl.ItemTemplate>
|
|
||||||
<DataTemplate>
|
|
||||||
<TextBlock Classes="path-mono" Text="{Binding}"/>
|
|
||||||
</DataTemplate>
|
|
||||||
</ItemsControl.ItemTemplate>
|
|
||||||
</ItemsControl>
|
|
||||||
<TextBlock Classes="meta" Text="{Binding VsCodeError}" Foreground="{DynamicResource BloodBrush}"
|
|
||||||
IsVisible="{Binding VsCodeError, Converter={x:Static ObjectConverters.IsNotNull}}"
|
|
||||||
TextWrapping="Wrap"/>
|
|
||||||
<TextBlock Classes="meta" Text="{Binding ActionError}" Foreground="{DynamicResource BloodBrush}"
|
|
||||||
IsVisible="{Binding ActionError, Converter={x:Static ObjectConverters.IsNotNull}}"
|
|
||||||
TextWrapping="Wrap"/>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
</ctl:ModalShell>
|
|
||||||
</Window>
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
using Avalonia.Controls;
|
|
||||||
using ClaudeDo.Ui.ViewModels.Planning;
|
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.Views.Planning;
|
|
||||||
|
|
||||||
public partial class ConflictResolutionView : Window
|
|
||||||
{
|
|
||||||
public ConflictResolutionView()
|
|
||||||
{
|
|
||||||
InitializeComponent();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void OnDataContextChanged(EventArgs e)
|
|
||||||
{
|
|
||||||
base.OnDataContextChanged(e);
|
|
||||||
if (DataContext is ConflictResolutionViewModel vm)
|
|
||||||
vm.CloseRequested = Close;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,15 +8,18 @@ ASP.NET Core hosted service that executes tasks via Claude CLI in isolated envir
|
|||||||
Worker/
|
Worker/
|
||||||
State/ — TaskStateService + TransitionResult (sole owner of Status/PlanningPhase/BlockedBy writes)
|
State/ — TaskStateService + TransitionResult (sole owner of Status/PlanningPhase/BlockedBy writes)
|
||||||
Queue/ — IQueueWaker, IQueuePicker, QueueService (BackgroundService), OverrideSlotService
|
Queue/ — IQueueWaker, IQueuePicker, QueueService (BackgroundService), OverrideSlotService
|
||||||
Lifecycle/ — StaleTaskRecovery, TaskResetService, TaskMergeService
|
Lifecycle/ — StaleTaskRecovery, TaskResetService, TaskMergeService, ClaudeCliPreflight, OrphanRecovery, PlanningLineageRecovery
|
||||||
Worktrees/ — WorktreeMaintenanceService
|
Worktrees/ — WorktreeMaintenanceService
|
||||||
Agents/ — AgentFileService, DefaultAgentSeeder
|
Agents/ — AgentFileService, DefaultAgentSeeder
|
||||||
Runner/ — TaskRunner + Claude CLI integration
|
Runner/ — TaskRunner + Claude CLI integration; TaskRunMcpService/TaskRunMcpContext/TaskRunTokenRegistry (in-task MCP wired during execution)
|
||||||
Planning/ — PlanningSessionManager, PlanningChainCoordinator, PlanningMcpService
|
Planning/ — PlanningSessionManager, PlanningChainCoordinator, PlanningMcpService, PlanningMergeOrchestrator, PlanningAggregator, PlanningSessionContext/PlanningTokenAuth/PlanningMcpContextAccessor, WindowsTerminalPlanningLauncher (IPlanningTerminalLauncher)
|
||||||
External/ — ExternalMcpService
|
Refine/ — RefineRunner + RefinePrompt (hub `RefineTask`; broadcasts RefineStarted/RefineFinished)
|
||||||
|
External/ — ExternalMcpService + sibling tool classes
|
||||||
|
Config/ — WorkerConfig
|
||||||
Hub/ — WorkerHub, HubBroadcaster
|
Hub/ — WorkerHub, HubBroadcaster
|
||||||
Report/ — ClaudeHistoryReader, WeekReportPromptBuilder, WeekReportService; interfaces in Report/Interfaces/
|
Report/ — ClaudeHistoryReader, WeekReportPromptBuilder, WeekReportService; interfaces in Report/Interfaces/
|
||||||
Prime/ — daily-prep ("Prime Claude"): PrimeScheduler (BackgroundService), PrimeRunner (runs the daily prep), DailyPrepPrompt (fixed prompt + CLI args + LogPath() helper), NextDueCalculator, PrimeScheduleSignal; interfaces in Prime/Interfaces/ (IPrimeRunner, IPrimeClock, IPrimeScheduleSignal, IPrimeBroadcaster)
|
Prime/ — daily-prep ("Prime Claude"): PrimeScheduler (BackgroundService), PrimeRunner (runs the daily prep), DailyPrepPrompt (fixed prompt + CLI args + LogPath() helper), NextDueCalculator, PrimeScheduleSignal; interfaces in Prime/Interfaces/ (IPrimeRunner, IPrimeClock, IPrimeScheduleSignal, IPrimeBroadcaster)
|
||||||
|
Online/ — optional Online Inbox sync: OnlineInboxConfig (config record), Dtos (RemoteList/RemoteTask/MirrorTask), IOnlineInboxApi, OnlineInboxApiClient (typed HttpClient, bearer auth, HTTPS guard), OnlineTokenStore (DPAPI refresh-token store, Windows-only), StaticTokenAuthProvider (default/test IOnlineAuthProvider), ZitadelAuthProvider (stub — TODO(online-inbox) Phase 2), OnlineSyncService (BackgroundService: reconcile loop), OnlineBacklog (Idle-backlog filter/query); interface in Online/Interfaces/ (IOnlineAuthProvider)
|
||||||
```
|
```
|
||||||
|
|
||||||
Interfaces (e.g. `IQueueWaker`, `IPrimeClock`, `ITaskStateService`) live in an `Interfaces/` subfolder within their area; the namespace stays the area namespace.
|
Interfaces (e.g. `IQueueWaker`, `IPrimeClock`, `ITaskStateService`) live in an `Interfaces/` subfolder within their area; the namespace stays the area namespace.
|
||||||
@@ -29,11 +32,11 @@ Interfaces (e.g. `IQueueWaker`, `IPrimeClock`, `ITaskStateService`) live in an `
|
|||||||
- **OverrideSlotService** — owns `RunNow` / `ContinueTask`; goes through `TaskStateService.StartRunningAsync` (caller-driven, serialized by slot lock).
|
- **OverrideSlotService** — owns `RunNow` / `ContinueTask`; goes through `TaskStateService.StartRunningAsync` (caller-driven, serialized by slot lock).
|
||||||
- **StaleTaskRecovery** — startup-only service; calls `TaskStateService.RecoverStaleRunningAsync` to flip orphaned `Running` rows to `Failed`.
|
- **StaleTaskRecovery** — startup-only service; calls `TaskStateService.RecoverStaleRunningAsync` to flip orphaned `Running` rows to `Failed`.
|
||||||
- **External/*** — always-on MCP tools for general Claude sessions, scoped to *starting* and *observing* sessions (no worktree/merge, multi-turn, planning, or app-settings writes). Auth via optional `X-ClaudeDo-Key` header. Registered explicitly in `Program.cs`'s external app via `.WithTools<T>()`. Organized by concern:
|
- **External/*** — always-on MCP tools for general Claude sessions, scoped to *starting* and *observing* sessions (no worktree/merge, multi-turn, planning, or app-settings writes). Auth via optional `X-ClaudeDo-Key` header. Registered explicitly in `Program.cs`'s external app via `.WithTools<T>()`. Organized by concern:
|
||||||
- `ExternalMcpService` — task CRUD + execution: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `UpdateTask`, `UpdateTaskStatus` (`Idle` / `Queued`), `ReviewTask` (`approve` / `reject_rerun` / `reject_park` / `cancel` for a WaitingForReview task), `RunTaskNow`, `CancelTask`, `DeleteTask`
|
- `ExternalMcpService` — task CRUD + execution: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `AddSubtask`, `UpdateTask`, `UpdateTaskStatus` (`Idle` / `Queued`), `GetTaskStatusValues`, `ReviewTask` (`approve` / `reject_rerun` / `reject_park` / `cancel` for a WaitingForReview task), `RunTaskNow`, `ContinueTask`, `CancelTask`, `DeleteTask`; worktree/git: `GetTaskWorktree`, `GetTaskDiff`, `MergeTask`, `ListWorktrees`, `CleanupTaskWorktree`
|
||||||
- `ListMcpTools` — `CreateList`, `UpdateList`, `DeleteList`
|
- `ListMcpTools` — `CreateList`, `UpdateList`, `DeleteList`
|
||||||
- `ConfigMcpTools` — `GetListConfig`, `SetListConfig`, `SetTaskConfig`
|
- `ConfigMcpTools` — `GetListConfig`, `SetListConfig`, `GetTaskConfig`, `SetTaskConfig`
|
||||||
- `RunHistoryMcpTools` — `ListRuns`, `GetRun`, `GetTaskLog` (latest run's log, tail-capped at 256 KB)
|
- `RunHistoryMcpTools` — `ListRuns`, `GetRun`, `GetTaskLog` (latest run's log, tail-capped at 256 KB)
|
||||||
- `AgentMcpTools` — `ListAgents`
|
- `AgentMcpTools` — `ListAgents` (class lives in `LifecycleMcpTools.cs`)
|
||||||
- `LifecycleMcpTools` — `ResetFailedTask`
|
- `LifecycleMcpTools` — `ResetFailedTask`
|
||||||
- `AppSettingsMcpTools` — `GetAppSettings` (read-only)
|
- `AppSettingsMcpTools` — `GetAppSettings` (read-only)
|
||||||
- `ExternalMcpService` also exposes two daily-prep tools:
|
- `ExternalMcpService` also exposes two daily-prep tools:
|
||||||
@@ -67,7 +70,7 @@ Allowed transitions (enforced by `TaskStateService`):
|
|||||||
|
|
||||||
```
|
```
|
||||||
Idle → Queued | Running (RunNow)
|
Idle → Queued | Running (RunNow)
|
||||||
Queued → Running | Cancelled | Idle
|
Queued → Running | Cancelled | Idle | Failed (OverrideSlotService preflight gap: RunAsync can fail before StartRunningAsync is called)
|
||||||
Running → WaitingForReview (standalone success, no children)
|
Running → WaitingForReview (standalone success, no children)
|
||||||
| WaitingForChildren (parent with pending children)
|
| WaitingForChildren (parent with pending children)
|
||||||
| Done (planning/improvement child success) | Failed | Cancelled
|
| Done (planning/improvement child success) | Failed | Cancelled
|
||||||
@@ -143,9 +146,17 @@ Each CLI invocation is recorded in the `task_runs` table via `TaskRunRepository`
|
|||||||
|
|
||||||
## SignalR Hub
|
## SignalR Hub
|
||||||
|
|
||||||
**WorkerHub** methods: `Ping`, `GetActive`, `RunNow`, `CancelTask`, `WakeQueue`, `ContinueTask`, `ResetTask`, `ApproveReview(taskId, targetBranch) -> MergeResultDto` (childless task: merges its worktree then Done, conflict stays WaitingForReview; task with children: drives `PlanningMergeOrchestrator` to merge the whole unit), `ContinuePlanningMerge` / `AbortPlanningMerge` (resolve a unit-merge conflict), `PreviewMerge(taskId, targetBranch) -> MergePreviewDto` (non-destructive mergeability check), `RejectReviewToQueue`, `RejectReviewToIdle`, `CancelReview`, `GetAgents`, `RefreshAgents`, `GetAppSettings`, `UpdateAppSettings`, `CleanupFinishedWorktrees`, `ResetAllWorktrees`, `MergeTask`, `GetMergeTargets`, `UpdateList`, `UpdateListConfig`, `GetListConfig`, `UpdateTaskAgentSettings`, `GetWeekReport`, `GenerateWeekReport`, `GetDailyNotes`, `AddDailyNote`, `UpdateDailyNote`, `DeleteDailyNote`, `RunDailyPrepNow`, `ClearMyDay`, `GetLastPrepLog`
|
**WorkerHub** methods, grouped:
|
||||||
|
|
||||||
**HubBroadcaster** events: `TaskStarted`, `TaskFinished`, `TaskMessage`, `WorktreeUpdated`, `TaskUpdated`, `RunCreated`, `ListUpdated`, `PrimeFired`, `PrepStarted`, `PrepLine`, `PrepFinished`
|
- Execution: `Ping`, `GetActive`, `RunNow`, `CancelTask`, `WakeQueue`, `ContinueTask`, `ResetTask`, `SetTaskStatus`, `RefineTask`
|
||||||
|
- Review/merge: `ApproveReview(taskId, targetBranch) -> MergeResultDto` (childless task: merges its worktree then Done, conflict stays WaitingForReview; task with children: drives `PlanningMergeOrchestrator` to merge the whole unit), `ContinuePlanningMerge` / `AbortPlanningMerge` (resolve a unit-merge conflict), `PreviewMerge(taskId, targetBranch) -> MergePreviewDto` (non-destructive mergeability check), `RejectReviewToQueue`, `RejectReviewToIdle`, `CancelReview`, `MergeTask`, `GetMergeTargets`
|
||||||
|
- Single-task conflict resolver (Layer C): `StartConflictMerge`, `GetMergeConflicts` (hunks), `WriteConflictResolution`, `ContinueConflictMerge`, `AbortConflictMerge` (service-level `TaskMergeService.ContinueMergeAsync`/`AbortMergeAsync` keep their names)
|
||||||
|
- Planning sessions: `StartPlanningSession`, `ResumePlanningSession`, `DiscardPlanningSession`, `FinalizePlanningSession`, `QueuePlanningSubtasks`, `GetPendingDraftCount`, `OpenInteractiveTerminal`, `GetPlanningAggregate` (per-subtask diffs), `BuildPlanningIntegrationBranch` (combined diff)
|
||||||
|
- Worktrees: `CleanupFinishedWorktrees`, `ResetAllWorktrees`, `GetWorktreesOverview`, `SetWorktreeState`, `ForceRemoveWorktree`
|
||||||
|
- Agents/settings/lists: `GetAgents`, `RefreshAgents`, `RestoreDefaultAgents`, `GetAppSettings`, `UpdateAppSettings`, `UpdateList`, `UpdateListConfig`, `GetListConfig`, `UpdateTaskAgentSettings`
|
||||||
|
- Reports/notes/prep: `GetWeekReport`, `GenerateWeekReport`, `GetDailyNotes`, `AddDailyNote`, `UpdateDailyNote`, `DeleteDailyNote`, `RunDailyPrepNow`, `ClearMyDay`, `GetLastPrepLog`, `ListPrimeSchedules`, `UpsertPrimeSchedule`, `DeletePrimeSchedule`
|
||||||
|
|
||||||
|
**HubBroadcaster** events: `TaskStarted`, `TaskFinished`, `TaskMessage`, `WorktreeUpdated`, `TaskUpdated`, `RunCreated`, `ListUpdated`, `WorkerLog`, `PrimeFired`, `PrepStarted`, `PrepLine`, `PrepFinished`, `PlanningMergeStarted`, `PlanningSubtaskMerged`, `PlanningMergeConflict`, `PlanningMergeAborted`, `PlanningCompleted`, `RefineStarted`, `RefineFinished`
|
||||||
|
|
||||||
## Config
|
## Config
|
||||||
|
|
||||||
@@ -155,8 +166,14 @@ Loaded from `~/.todo-app/worker.config.json`:
|
|||||||
- `queue_backstop_interval_ms` (default 30000)
|
- `queue_backstop_interval_ms` (default 30000)
|
||||||
- `signalr_port` (default 47821)
|
- `signalr_port` (default 47821)
|
||||||
- `claude_bin` (path to claude CLI)
|
- `claude_bin` (path to claude CLI)
|
||||||
|
- `online_inbox` — Online Inbox config (default: `enabled=false`, zero network when disabled):
|
||||||
|
- `enabled` (bool, default false) — when false the entire `Online/` stack is not registered
|
||||||
|
- `api_base_url` (string) — must be HTTPS or loopback; validated at startup when enabled
|
||||||
|
- `poll_interval_seconds` (int, default 60)
|
||||||
|
- `zitadel.authority`, `zitadel.client_id`, `zitadel.scopes` (Phase 2; not used until ZitadelAuthProvider is wired)
|
||||||
|
- The refresh token is NOT in this file — stored encrypted via DPAPI at `~/.todo-app/online-inbox.token`
|
||||||
|
|
||||||
Per-list config (`list_config` in DB) provides defaults for `model`, `system_prompt`, `agent_path`; tasks can override each individually.
|
Per-list config (`list_config` in DB) provides defaults for `model`, `system_prompt`, `agent_path`; tasks can override each individually. Task-generating MCP tools (`AddTask`, planning `CreateChildTask`, `SuggestImprovement`) accept an optional `model` (alias-validated via `ModelRegistry.NormalizeAlias` — `haiku`/`sonnet`/`opus`, blank = inherit) so Claude assigns the cheapest capable model at creation time; the planning/system/improvement prompts instruct it to do so (`ModelRegistry.ByCostAscending` = the cost order).
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||||
<PackageReference Include="ModelContextProtocol" Version="1.2.0" />
|
<PackageReference Include="ModelContextProtocol" Version="1.2.0" />
|
||||||
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.2.0" />
|
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.2.0" />
|
||||||
|
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using ClaudeDo.Data;
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Worker.Online;
|
||||||
|
|
||||||
namespace ClaudeDo.Worker.Config;
|
namespace ClaudeDo.Worker.Config;
|
||||||
|
|
||||||
@@ -39,6 +41,9 @@ public sealed class WorkerConfig
|
|||||||
[JsonPropertyName("external_mcp_api_key")]
|
[JsonPropertyName("external_mcp_api_key")]
|
||||||
public string? ExternalMcpApiKey { get; set; }
|
public string? ExternalMcpApiKey { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("online_inbox")]
|
||||||
|
public OnlineInboxConfig OnlineInbox { get; set; } = new();
|
||||||
|
|
||||||
public static string DefaultConfigPath =>
|
public static string DefaultConfigPath =>
|
||||||
Path.Combine(Paths.AppDataRoot(), "worker.config.json");
|
Path.Combine(Paths.AppDataRoot(), "worker.config.json");
|
||||||
|
|
||||||
@@ -70,9 +75,38 @@ public sealed class WorkerConfig
|
|||||||
return cfg;
|
return cfg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persists ONLY the <c>online_inbox</c> section back to <paramref name="path"/>
|
||||||
|
/// (defaults to <see cref="DefaultConfigPath"/>) without rewriting any other fields.
|
||||||
|
/// Reads the existing JSON, replaces the <c>online_inbox</c> node, and writes back indented.
|
||||||
|
/// </summary>
|
||||||
|
public void SaveOnlineInbox(string? path = null)
|
||||||
|
{
|
||||||
|
path ??= DefaultConfigPath;
|
||||||
|
|
||||||
|
var root = File.Exists(path)
|
||||||
|
? JsonNode.Parse(File.ReadAllText(path)) as JsonObject ?? new JsonObject()
|
||||||
|
: new JsonObject();
|
||||||
|
|
||||||
|
root["online_inbox"] = JsonSerializer.SerializeToNode(OnlineInbox, InboxSerializerOpts);
|
||||||
|
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
|
||||||
|
File.WriteAllText(path, root.ToJsonString(WriteOpts));
|
||||||
|
}
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions JsonOpts = new()
|
private static readonly JsonSerializerOptions JsonOpts = new()
|
||||||
{
|
{
|
||||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||||
AllowTrailingCommas = true,
|
AllowTrailingCommas = true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions InboxSerializerOpts = new()
|
||||||
|
{
|
||||||
|
WriteIndented = false,
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly JsonSerializerOptions WriteOpts = new()
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,13 +142,18 @@ public sealed class ExternalMcpService
|
|||||||
return ToDto(task);
|
return ToDto(task);
|
||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description("Create a new task in the given list. Set queueImmediately=true to enqueue it for agent execution.")]
|
[McpServerTool, Description(
|
||||||
|
"Create a new task in the given list. Set queueImmediately=true to enqueue it for agent execution. " +
|
||||||
|
"Set model to the cheapest model that can do the task well — 'haiku' for trivial/mechanical work, " +
|
||||||
|
"'sonnet' for normal coding (the default), 'opus' only for complex or cross-cutting work. " +
|
||||||
|
"Leave model null to inherit the list/global default.")]
|
||||||
public async Task<TaskDto> AddTask(
|
public async Task<TaskDto> AddTask(
|
||||||
string listId,
|
string listId,
|
||||||
string title,
|
string title,
|
||||||
string? description = null,
|
string? description = null,
|
||||||
string? createdBy = null,
|
string? createdBy = null,
|
||||||
bool queueImmediately = false,
|
bool queueImmediately = false,
|
||||||
|
string? model = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(listId))
|
if (string.IsNullOrWhiteSpace(listId))
|
||||||
@@ -169,6 +174,7 @@ public sealed class ExternalMcpService
|
|||||||
CreatedAt = DateTime.UtcNow,
|
CreatedAt = DateTime.UtcNow,
|
||||||
CommitType = list.DefaultCommitType,
|
CommitType = list.DefaultCommitType,
|
||||||
CreatedBy = createdBy.NullIfBlank() ?? "mcp",
|
CreatedBy = createdBy.NullIfBlank() ?? "mcp",
|
||||||
|
Model = ModelRegistry.NormalizeAlias(model),
|
||||||
};
|
};
|
||||||
await _tasks.AddAsync(entity, cancellationToken);
|
await _tasks.AddAsync(entity, cancellationToken);
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ using ClaudeDo.Data;
|
|||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using ClaudeDo.Data.Repositories;
|
using ClaudeDo.Data.Repositories;
|
||||||
using ClaudeDo.Worker.Agents;
|
using ClaudeDo.Worker.Agents;
|
||||||
|
using ClaudeDo.Worker.Config;
|
||||||
using ClaudeDo.Worker.Lifecycle;
|
using ClaudeDo.Worker.Lifecycle;
|
||||||
|
using ClaudeDo.Worker.Online;
|
||||||
using ClaudeDo.Worker.Planning;
|
using ClaudeDo.Worker.Planning;
|
||||||
using ClaudeDo.Worker.Prime;
|
using ClaudeDo.Worker.Prime;
|
||||||
using ClaudeDo.Worker.Queue;
|
using ClaudeDo.Worker.Queue;
|
||||||
@@ -59,12 +61,34 @@ public record MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalB
|
|||||||
public record MergeConflictsDto(string TaskId, IReadOnlyList<ConflictFileDto> Files);
|
public record MergeConflictsDto(string TaskId, IReadOnlyList<ConflictFileDto> Files);
|
||||||
public record ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks);
|
public record ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks);
|
||||||
public record ConflictHunkDto(string Ours, string Theirs, string? Base);
|
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 record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType);
|
public record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType);
|
||||||
public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
|
public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
|
||||||
public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
|
public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
|
||||||
public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
|
public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
|
||||||
public record SeedResultDto(int Copied, int Skipped);
|
public record SeedResultDto(int Copied, int Skipped);
|
||||||
|
|
||||||
|
public record OnlineInboxStateDto(
|
||||||
|
bool Enabled,
|
||||||
|
string ApiBaseUrl,
|
||||||
|
string Authority,
|
||||||
|
string ClientId,
|
||||||
|
string Scopes,
|
||||||
|
string RedirectUri,
|
||||||
|
bool SignedIn,
|
||||||
|
int PollIntervalSeconds);
|
||||||
|
|
||||||
|
public record OnlineInboxConfigInput(
|
||||||
|
bool Enabled,
|
||||||
|
string ApiBaseUrl,
|
||||||
|
int PollIntervalSeconds,
|
||||||
|
string Authority,
|
||||||
|
string ClientId,
|
||||||
|
string Scopes,
|
||||||
|
string RedirectUri);
|
||||||
|
|
||||||
public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||||
{
|
{
|
||||||
private static readonly string Version =
|
private static readonly string Version =
|
||||||
@@ -89,6 +113,9 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
private readonly ITaskStateService _state;
|
private readonly ITaskStateService _state;
|
||||||
private readonly IWeekReportService _report;
|
private readonly IWeekReportService _report;
|
||||||
private readonly IRefineRunner _refineRunner;
|
private readonly IRefineRunner _refineRunner;
|
||||||
|
private readonly WorkerConfig _cfg;
|
||||||
|
private readonly OnlineInboxConfig _onlineInboxConfig;
|
||||||
|
private readonly OnlineTokenStore _onlineTokenStore;
|
||||||
|
|
||||||
public WorkerHub(
|
public WorkerHub(
|
||||||
QueueService queue,
|
QueueService queue,
|
||||||
@@ -109,7 +136,10 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
IPrimeRunner primeRunner,
|
IPrimeRunner primeRunner,
|
||||||
ITaskStateService state,
|
ITaskStateService state,
|
||||||
IWeekReportService report,
|
IWeekReportService report,
|
||||||
IRefineRunner refineRunner)
|
IRefineRunner refineRunner,
|
||||||
|
WorkerConfig cfg,
|
||||||
|
OnlineInboxConfig onlineInboxConfig,
|
||||||
|
OnlineTokenStore onlineTokenStore)
|
||||||
{
|
{
|
||||||
_queue = queue;
|
_queue = queue;
|
||||||
_waker = waker;
|
_waker = waker;
|
||||||
@@ -130,6 +160,9 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
_state = state;
|
_state = state;
|
||||||
_report = report;
|
_report = report;
|
||||||
_refineRunner = refineRunner;
|
_refineRunner = refineRunner;
|
||||||
|
_cfg = cfg;
|
||||||
|
_onlineInboxConfig = onlineInboxConfig;
|
||||||
|
_onlineTokenStore = onlineTokenStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Maps the two exceptions service methods throw into client-facing HubExceptions:
|
// Maps the two exceptions service methods throw into client-facing HubExceptions:
|
||||||
@@ -353,11 +386,23 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
new[] { new ConflictHunkDto(f.Ours, f.Theirs, f.Base) })).ToList());
|
new[] { new ConflictHunkDto(f.Ours, f.Theirs, f.Base) })).ToList());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
public Task<MergeConflictDocumentsDto> GetMergeConflictDocuments(string taskId)
|
||||||
|
=> HubGuard(async () =>
|
||||||
|
{
|
||||||
|
var c = await _mergeService.GetConflictDocumentsAsync(taskId, CancellationToken.None);
|
||||||
|
return new MergeConflictDocumentsDto(
|
||||||
|
c.TaskId,
|
||||||
|
c.Files.Select(f => new ConflictDocumentDto(
|
||||||
|
f.Path, f.IsBinary,
|
||||||
|
f.Segments.Select(s => new MergeSegmentDto(
|
||||||
|
s.IsConflict, s.Text, s.Ours, s.Base, s.Theirs)).ToList())).ToList());
|
||||||
|
});
|
||||||
|
|
||||||
public Task WriteConflictResolution(string taskId, string path, string resolvedContent)
|
public Task WriteConflictResolution(string taskId, string path, string resolvedContent)
|
||||||
=> HubGuard(() => _mergeService.WriteResolutionAsync(
|
=> HubGuard(() => _mergeService.WriteResolutionAsync(
|
||||||
taskId, path, resolvedContent ?? "", CancellationToken.None));
|
taskId, path, resolvedContent ?? "", CancellationToken.None));
|
||||||
|
|
||||||
public Task<MergeResultDto> ContinueMerge(string taskId)
|
public Task<MergeResultDto> ContinueConflictMerge(string taskId)
|
||||||
=> HubGuard(async () =>
|
=> HubGuard(async () =>
|
||||||
{
|
{
|
||||||
var r = await _mergeService.ContinueMergeAsync(taskId, CancellationToken.None);
|
var r = await _mergeService.ContinueMergeAsync(taskId, CancellationToken.None);
|
||||||
@@ -366,7 +411,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
|
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
|
||||||
});
|
});
|
||||||
|
|
||||||
public Task AbortMerge(string taskId)
|
public Task AbortConflictMerge(string taskId)
|
||||||
=> HubGuard(async () =>
|
=> HubGuard(async () =>
|
||||||
{
|
{
|
||||||
var r = await _mergeService.AbortMergeAsync(taskId, CancellationToken.None);
|
var r = await _mergeService.AbortMergeAsync(taskId, CancellationToken.None);
|
||||||
@@ -684,4 +729,42 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
|
|
||||||
return ids.Count;
|
return ids.Count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#pragma warning disable CA1416 // ClaudeDo.Worker is Windows-only; DPAPI calls are safe here.
|
||||||
|
public OnlineInboxStateDto GetOnlineInboxState()
|
||||||
|
{
|
||||||
|
var signedIn = _onlineTokenStore.Read() is not null;
|
||||||
|
return new OnlineInboxStateDto(
|
||||||
|
_onlineInboxConfig.Enabled,
|
||||||
|
_onlineInboxConfig.ApiBaseUrl,
|
||||||
|
_onlineInboxConfig.Zitadel.Authority,
|
||||||
|
_onlineInboxConfig.Zitadel.ClientId,
|
||||||
|
_onlineInboxConfig.Zitadel.Scopes,
|
||||||
|
_onlineInboxConfig.RedirectUri,
|
||||||
|
signedIn,
|
||||||
|
_onlineInboxConfig.PollIntervalSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetOnlineInboxConfig(OnlineInboxConfigInput input)
|
||||||
|
{
|
||||||
|
_onlineInboxConfig.Enabled = input.Enabled;
|
||||||
|
_onlineInboxConfig.ApiBaseUrl = input.ApiBaseUrl ?? "";
|
||||||
|
_onlineInboxConfig.PollIntervalSeconds = input.PollIntervalSeconds;
|
||||||
|
_onlineInboxConfig.RedirectUri = input.RedirectUri ?? "http://localhost:8765/callback";
|
||||||
|
_onlineInboxConfig.Zitadel.Authority = input.Authority ?? "";
|
||||||
|
_onlineInboxConfig.Zitadel.ClientId = input.ClientId ?? "";
|
||||||
|
_onlineInboxConfig.Zitadel.Scopes = input.Scopes ?? "openid offline_access";
|
||||||
|
_cfg.SaveOnlineInbox();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void SetOnlineInboxAuth(string refreshToken)
|
||||||
|
{
|
||||||
|
_onlineTokenStore.Save(refreshToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ClearOnlineInboxAuth()
|
||||||
|
{
|
||||||
|
_onlineTokenStore.Clear();
|
||||||
|
}
|
||||||
|
#pragma warning restore CA1416
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,15 @@ public sealed record ConflictFileContent(
|
|||||||
string Theirs,
|
string Theirs,
|
||||||
string? Base);
|
string? Base);
|
||||||
|
|
||||||
|
public sealed record ConflictDocuments(
|
||||||
|
string TaskId,
|
||||||
|
IReadOnlyList<ConflictDocumentContent> Files);
|
||||||
|
|
||||||
|
public sealed record ConflictDocumentContent(
|
||||||
|
string Path,
|
||||||
|
bool IsBinary,
|
||||||
|
IReadOnlyList<MergeSegment> Segments);
|
||||||
|
|
||||||
public sealed class TaskMergeService
|
public sealed class TaskMergeService
|
||||||
{
|
{
|
||||||
public const string StatusMerged = "merged";
|
public const string StatusMerged = "merged";
|
||||||
@@ -256,6 +265,45 @@ public sealed class TaskMergeService
|
|||||||
return new MergeConflicts(taskId, result);
|
return new MergeConflicts(taskId, result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads each conflicted working-tree file and parses its conflict markers into line-level
|
||||||
|
/// segments (with the diff3 merge base when present). Binary files are flagged and skipped.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<ConflictDocuments> GetConflictDocumentsAsync(string taskId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var (_, list, _) = await LoadMergeContextAsync(taskId, ct);
|
||||||
|
if (string.IsNullOrWhiteSpace(list.WorkingDir))
|
||||||
|
throw new InvalidOperationException("list has no working directory");
|
||||||
|
|
||||||
|
var files = await _git.ListConflictedFilesAsync(list.WorkingDir, ct);
|
||||||
|
var result = new List<ConflictDocumentContent>(files.Count);
|
||||||
|
foreach (var path in files)
|
||||||
|
{
|
||||||
|
var full = Path.Combine(list.WorkingDir, path.Replace('/', Path.DirectorySeparatorChar));
|
||||||
|
string text;
|
||||||
|
try { text = await File.ReadAllTextAsync(full, ct); }
|
||||||
|
catch { text = ""; }
|
||||||
|
|
||||||
|
if (LooksBinary(text))
|
||||||
|
{
|
||||||
|
result.Add(new ConflictDocumentContent(path, true, Array.Empty<MergeSegment>()));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Add(new ConflictDocumentContent(path, false, ConflictMarkerParser.Parse(text)));
|
||||||
|
}
|
||||||
|
return new ConflictDocuments(taskId, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A NUL byte in the head of the file is the conventional binary sniff.
|
||||||
|
private static bool LooksBinary(string text)
|
||||||
|
{
|
||||||
|
var n = Math.Min(text.Length, 8000);
|
||||||
|
for (var i = 0; i < n; i++)
|
||||||
|
if (text[i] == '\0') return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task WriteResolutionAsync(string taskId, string path, string content, CancellationToken ct)
|
public async Task WriteResolutionAsync(string taskId, string path, string content, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var (_, list, _) = await LoadMergeContextAsync(taskId, ct);
|
var (_, list, _) = await LoadMergeContextAsync(taskId, ct);
|
||||||
|
|||||||
27
src/ClaudeDo.Worker/Online/Dtos.cs
Normal file
27
src/ClaudeDo.Worker/Online/Dtos.cs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Online;
|
||||||
|
|
||||||
|
// OwnerId carries the resource owner's Zitadel subject (sub). It is nullable and optional so
|
||||||
|
// the contract stays multi-user-ready without changing single-user behavior: today the desktop
|
||||||
|
// stamps it on push and defensively ignores pulled tasks owned by a different user, while the
|
||||||
|
// server remains the authority that scopes data by the token's sub.
|
||||||
|
public sealed record RemoteList(
|
||||||
|
[property: JsonPropertyName("id")] string Id,
|
||||||
|
[property: JsonPropertyName("name")] string Name,
|
||||||
|
[property: JsonPropertyName("ownerId")] string? OwnerId = null);
|
||||||
|
|
||||||
|
public sealed record RemoteTask(
|
||||||
|
[property: JsonPropertyName("id")] string Id,
|
||||||
|
[property: JsonPropertyName("listId")] string ListId,
|
||||||
|
[property: JsonPropertyName("title")] string Title,
|
||||||
|
[property: JsonPropertyName("description")] string? Description,
|
||||||
|
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt,
|
||||||
|
[property: JsonPropertyName("ownerId")] string? OwnerId = null);
|
||||||
|
|
||||||
|
public sealed record MirrorTask(
|
||||||
|
[property: JsonPropertyName("id")] string Id,
|
||||||
|
[property: JsonPropertyName("listId")] string ListId,
|
||||||
|
[property: JsonPropertyName("title")] string Title,
|
||||||
|
[property: JsonPropertyName("description")] string? Description,
|
||||||
|
[property: JsonPropertyName("ownerId")] string? OwnerId = null);
|
||||||
9
src/ClaudeDo.Worker/Online/IOnlineInboxApi.cs
Normal file
9
src/ClaudeDo.Worker/Online/IOnlineInboxApi.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
namespace ClaudeDo.Worker.Online;
|
||||||
|
|
||||||
|
public interface IOnlineInboxApi
|
||||||
|
{
|
||||||
|
Task PutListsAsync(IReadOnlyList<RemoteList> lists, CancellationToken ct = default);
|
||||||
|
Task<IReadOnlyList<RemoteTask>> GetUnimportedTasksAsync(CancellationToken ct = default);
|
||||||
|
Task MarkImportedAsync(string id, CancellationToken ct = default);
|
||||||
|
Task PutMirrorAsync(IReadOnlyList<MirrorTask> tasks, CancellationToken ct = default);
|
||||||
|
}
|
||||||
13
src/ClaudeDo.Worker/Online/Interfaces/IOnlineAuthProvider.cs
Normal file
13
src/ClaudeDo.Worker/Online/Interfaces/IOnlineAuthProvider.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
namespace ClaudeDo.Worker.Online.Interfaces;
|
||||||
|
|
||||||
|
public interface IOnlineAuthProvider
|
||||||
|
{
|
||||||
|
Task<string?> GetAccessTokenAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets an access token, optionally dropping any cached token first so a fresh
|
||||||
|
/// (role-bearing) token is minted via the refresh-token grant. Used to recover from a
|
||||||
|
/// 401 caused by a stale token issued before role assertion was enabled.
|
||||||
|
/// </summary>
|
||||||
|
Task<string?> GetAccessTokenAsync(bool forceRefresh, CancellationToken ct = default);
|
||||||
|
}
|
||||||
49
src/ClaudeDo.Worker/Online/JwtClaims.cs
Normal file
49
src/ClaudeDo.Worker/Online/JwtClaims.cs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Online;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimal, dependency-free reader for a JWT access token's payload claims. Used to resolve the
|
||||||
|
/// current user's Zitadel subject (<c>sub</c>) so sync payloads can be stamped with an owner.
|
||||||
|
/// Never throws — returns null when the token is absent or cannot be parsed.
|
||||||
|
/// </summary>
|
||||||
|
public static class JwtClaims
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the <c>sub</c> claim of the JWT, or null if the token is absent/unparseable or
|
||||||
|
/// carries no subject.
|
||||||
|
/// </summary>
|
||||||
|
public static string? GetSubject(string? jwt)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(jwt))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
var parts = jwt.Split('.');
|
||||||
|
if (parts.Length < 2)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(Base64UrlDecode(parts[1]));
|
||||||
|
if (doc.RootElement.TryGetProperty("sub", out var sub) &&
|
||||||
|
sub.ValueKind == JsonValueKind.String)
|
||||||
|
return sub.GetString();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/ClaudeDo.Worker/Online/OnlineBacklog.cs
Normal file
27
src/ClaudeDo.Worker/Online/OnlineBacklog.cs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Online;
|
||||||
|
|
||||||
|
public static class OnlineBacklog
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the current Idle backlog: Status==Idle, no parent, PlanningPhase==None, not blocked.
|
||||||
|
/// These are the tasks mirrored to the online store (§2 of the contract).
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<List<MirrorTask>> CurrentAsync(
|
||||||
|
TaskRepository tasks,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var all = await tasks.GetAllIdleBacklogAsync(ct);
|
||||||
|
return all.Select(t => new MirrorTask(t.Id, t.ListId, t.Title, t.Description)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static bool IsBacklogItem(TaskEntity t) =>
|
||||||
|
t.Status == TaskStatus.Idle
|
||||||
|
&& t.ParentTaskId == null
|
||||||
|
&& t.PlanningPhase == PlanningPhase.None
|
||||||
|
&& t.BlockedByTaskId == null;
|
||||||
|
}
|
||||||
104
src/ClaudeDo.Worker/Online/OnlineInboxApiClient.cs
Normal file
104
src/ClaudeDo.Worker/Online/OnlineInboxApiClient.cs
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using ClaudeDo.Worker.Online.Interfaces;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Online;
|
||||||
|
|
||||||
|
public sealed class OnlineInboxApiClient : IOnlineInboxApi
|
||||||
|
{
|
||||||
|
internal const string MissingRoleMessage =
|
||||||
|
"Account has no access (missing 'user' role in Zitadel). " +
|
||||||
|
"Grant the 'user' role for this account in the ClaudeDo project, then sign in again.";
|
||||||
|
|
||||||
|
private readonly HttpClient _http;
|
||||||
|
private readonly IOnlineAuthProvider _auth;
|
||||||
|
|
||||||
|
public OnlineInboxApiClient(HttpClient http, IOnlineAuthProvider auth)
|
||||||
|
{
|
||||||
|
_http = http;
|
||||||
|
_auth = auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates that <paramref name="baseUrl"/> is HTTPS or a loopback address.
|
||||||
|
/// Throws <see cref="InvalidOperationException"/> for non-HTTPS non-loopback URLs.
|
||||||
|
/// </summary>
|
||||||
|
public static void ValidateBaseUrl(string baseUrl)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||||
|
throw new InvalidOperationException("online_inbox.api_base_url is not configured.");
|
||||||
|
|
||||||
|
var uri = new Uri(baseUrl, UriKind.Absolute);
|
||||||
|
if (uri.Scheme != "https" && !uri.IsLoopback)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"online_inbox.api_base_url must be HTTPS or loopback. Got: {baseUrl}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task PutListsAsync(IReadOnlyList<RemoteList> lists, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var resp = await SendAsync(HttpMethod.Put, "lists", lists, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<RemoteTask>> GetUnimportedTasksAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var resp = await SendAsync(HttpMethod.Get, "tasks?imported=false", null, ct);
|
||||||
|
var result = await resp.Content.ReadFromJsonAsync<List<RemoteTask>>(ct);
|
||||||
|
return result ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task MarkImportedAsync(string id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var resp = await SendAsync(HttpMethod.Post, $"tasks/{Uri.EscapeDataString(id)}/imported", null, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task PutMirrorAsync(IReadOnlyList<MirrorTask> tasks, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var resp = await SendAsync(HttpMethod.Put, "tasks/mirror", tasks, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends an authenticated request. On a 401, forces a fresh (role-bearing) token via the
|
||||||
|
/// refresh-token grant and retries once; if a fresh token is still rejected, throws an
|
||||||
|
/// <see cref="OnlineInboxException"/> with <see cref="MissingRoleMessage"/>.
|
||||||
|
/// </summary>
|
||||||
|
private async Task<HttpResponseMessage> SendAsync(
|
||||||
|
HttpMethod method, string path, object? body, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var resp = await SendOnceAsync(method, path, body, forceRefresh: false, ct);
|
||||||
|
|
||||||
|
if (resp.StatusCode == HttpStatusCode.Unauthorized)
|
||||||
|
{
|
||||||
|
resp.Dispose();
|
||||||
|
resp = await SendOnceAsync(method, path, body, forceRefresh: true, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resp.StatusCode == HttpStatusCode.Unauthorized)
|
||||||
|
{
|
||||||
|
resp.Dispose();
|
||||||
|
throw new OnlineInboxException(401, MissingRoleMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resp.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var status = (int)resp.StatusCode;
|
||||||
|
var errBody = await resp.Content.ReadAsStringAsync(ct);
|
||||||
|
resp.Dispose();
|
||||||
|
throw new OnlineInboxException(status, $"Online Inbox API error {status}: {errBody}");
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<HttpResponseMessage> SendOnceAsync(
|
||||||
|
HttpMethod method, string path, object? body, bool forceRefresh, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var token = await _auth.GetAccessTokenAsync(forceRefresh, ct);
|
||||||
|
using var req = new HttpRequestMessage(method, path);
|
||||||
|
if (token is not null)
|
||||||
|
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
if (body is not null)
|
||||||
|
req.Content = JsonContent.Create(body);
|
||||||
|
return await _http.SendAsync(req, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/ClaudeDo.Worker/Online/OnlineInboxConfig.cs
Normal file
33
src/ClaudeDo.Worker/Online/OnlineInboxConfig.cs
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Online;
|
||||||
|
|
||||||
|
public sealed class ZitadelClientConfig
|
||||||
|
{
|
||||||
|
[JsonPropertyName("authority")]
|
||||||
|
public string Authority { get; set; } = "";
|
||||||
|
|
||||||
|
[JsonPropertyName("client_id")]
|
||||||
|
public string ClientId { get; set; } = "";
|
||||||
|
|
||||||
|
[JsonPropertyName("scopes")]
|
||||||
|
public string Scopes { get; set; } = "openid offline_access";
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class OnlineInboxConfig
|
||||||
|
{
|
||||||
|
[JsonPropertyName("enabled")]
|
||||||
|
public bool Enabled { get; set; } = false;
|
||||||
|
|
||||||
|
[JsonPropertyName("api_base_url")]
|
||||||
|
public string ApiBaseUrl { get; set; } = "";
|
||||||
|
|
||||||
|
[JsonPropertyName("poll_interval_seconds")]
|
||||||
|
public int PollIntervalSeconds { get; set; } = 60;
|
||||||
|
|
||||||
|
[JsonPropertyName("redirect_uri")]
|
||||||
|
public string RedirectUri { get; set; } = "http://localhost:8765/callback";
|
||||||
|
|
||||||
|
[JsonPropertyName("zitadel")]
|
||||||
|
public ZitadelClientConfig Zitadel { get; set; } = new();
|
||||||
|
}
|
||||||
12
src/ClaudeDo.Worker/Online/OnlineInboxException.cs
Normal file
12
src/ClaudeDo.Worker/Online/OnlineInboxException.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
namespace ClaudeDo.Worker.Online;
|
||||||
|
|
||||||
|
public sealed class OnlineInboxException : Exception
|
||||||
|
{
|
||||||
|
public int StatusCode { get; }
|
||||||
|
|
||||||
|
public OnlineInboxException(int statusCode, string message)
|
||||||
|
: base(message)
|
||||||
|
{
|
||||||
|
StatusCode = statusCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
142
src/ClaudeDo.Worker/Online/OnlineSyncService.cs
Normal file
142
src/ClaudeDo.Worker/Online/OnlineSyncService.cs
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Worker.Online.Interfaces;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Online;
|
||||||
|
|
||||||
|
public sealed class OnlineSyncService : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
|
private readonly IOnlineInboxApi _api;
|
||||||
|
private readonly IOnlineAuthProvider _auth;
|
||||||
|
private readonly OnlineInboxConfig _config;
|
||||||
|
private readonly ILogger<OnlineSyncService> _logger;
|
||||||
|
|
||||||
|
public OnlineSyncService(
|
||||||
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||||
|
IOnlineInboxApi api,
|
||||||
|
IOnlineAuthProvider auth,
|
||||||
|
OnlineInboxConfig config,
|
||||||
|
ILogger<OnlineSyncService> logger)
|
||||||
|
{
|
||||||
|
_dbFactory = dbFactory;
|
||||||
|
_api = api;
|
||||||
|
_auth = auth;
|
||||||
|
_config = config;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await TickAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (OnlineInboxException ex) when (ex.StatusCode == 401)
|
||||||
|
{
|
||||||
|
_logger.LogError(
|
||||||
|
"OnlineSyncService: {Message} Sync is paused until you sign in again with an authorized account.",
|
||||||
|
ex.Message);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "OnlineSyncService cycle failed; backing off to next interval");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(_config.PollIntervalSeconds), stoppingToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal async Task TickAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var token = await _auth.GetAccessTokenAsync(ct);
|
||||||
|
if (token is null)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("OnlineSyncService: no access token, skipping cycle");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the current user's Zitadel subject so sync payloads carry an owner and pulls
|
||||||
|
// can be guarded. Null today (single user / server derives it from the token).
|
||||||
|
var ownerId = JwtClaims.GetSubject(token);
|
||||||
|
|
||||||
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||||
|
var tasks = new TaskRepository(ctx);
|
||||||
|
var lists = new ListRepository(ctx);
|
||||||
|
|
||||||
|
// Step 1: pull unimported tasks, import them locally, mark each imported.
|
||||||
|
var unimported = await _api.GetUnimportedTasksAsync(ct);
|
||||||
|
foreach (var remote in unimported)
|
||||||
|
{
|
||||||
|
// Multi-user guard: never import a task explicitly owned by a different user.
|
||||||
|
// Unowned tasks (ownerId == null) stay importable so single-user behavior is intact.
|
||||||
|
if (ownerId is not null && remote.OwnerId is not null && remote.OwnerId != ownerId)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"OnlineSyncService: remote task {Id} is owned by another user; skipping", remote.Id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var existing = await tasks.GetByIdAsync(remote.Id, ct);
|
||||||
|
if (existing is not null)
|
||||||
|
{
|
||||||
|
// Already imported locally; just mark it on the server.
|
||||||
|
await _api.MarkImportedAsync(remote.Id, ct);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var list = await lists.GetByIdAsync(remote.ListId, ct);
|
||||||
|
if (list is null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"OnlineSyncService: remote task {Id} references unknown list {ListId}; skipping",
|
||||||
|
remote.Id, remote.ListId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var entity = new TaskEntity
|
||||||
|
{
|
||||||
|
Id = remote.Id,
|
||||||
|
ListId = remote.ListId,
|
||||||
|
Title = remote.Title,
|
||||||
|
Description = remote.Description,
|
||||||
|
Status = TaskStatus.Idle,
|
||||||
|
CreatedBy = "online",
|
||||||
|
CreatedAt = remote.CreatedAt.UtcDateTime,
|
||||||
|
CommitType = CommitTypeRegistry.DefaultType,
|
||||||
|
};
|
||||||
|
await tasks.AddAsync(entity, ct);
|
||||||
|
await _api.MarkImportedAsync(remote.Id, ct);
|
||||||
|
|
||||||
|
_logger.LogInformation("OnlineSyncService: imported task {Id} ('{Title}')", remote.Id, remote.Title);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: push full list catalog, stamped with the owner.
|
||||||
|
var allLists = await lists.GetAllAsync(ct);
|
||||||
|
var remoteLists = allLists.Select(l => new RemoteList(l.Id, l.Name, ownerId)).ToList();
|
||||||
|
await _api.PutListsAsync(remoteLists, ct);
|
||||||
|
|
||||||
|
// Step 3: push current Idle backlog mirror, stamped with the owner.
|
||||||
|
var mirror = (await OnlineBacklog.CurrentAsync(tasks, ct))
|
||||||
|
.Select(m => m with { OwnerId = ownerId })
|
||||||
|
.ToList();
|
||||||
|
await _api.PutMirrorAsync(mirror, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/ClaudeDo.Worker/Online/OnlineTokenStore.cs
Normal file
54
src/ClaudeDo.Worker/Online/OnlineTokenStore.cs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
using System.Runtime.Versioning;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using ClaudeDo.Data;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Online;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persists the Zitadel refresh token encrypted with DPAPI (CurrentUser scope).
|
||||||
|
/// Windows-only; the file lives at ~/.todo-app/online-inbox.token.
|
||||||
|
/// </summary>
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
public sealed class OnlineTokenStore
|
||||||
|
{
|
||||||
|
private readonly string _tokenPath;
|
||||||
|
|
||||||
|
public OnlineTokenStore()
|
||||||
|
: this(Path.Combine(Paths.AppDataRoot(), "online-inbox.token")) { }
|
||||||
|
|
||||||
|
internal OnlineTokenStore(string tokenPath)
|
||||||
|
{
|
||||||
|
_tokenPath = tokenPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Save(string refreshToken)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(refreshToken);
|
||||||
|
var plain = Encoding.UTF8.GetBytes(refreshToken);
|
||||||
|
var cipher = ProtectedData.Protect(plain, null, DataProtectionScope.CurrentUser);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(_tokenPath)!);
|
||||||
|
File.WriteAllBytes(_tokenPath, cipher);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? Read()
|
||||||
|
{
|
||||||
|
if (!File.Exists(_tokenPath)) return null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cipher = File.ReadAllBytes(_tokenPath);
|
||||||
|
var plain = ProtectedData.Unprotect(cipher, null, DataProtectionScope.CurrentUser);
|
||||||
|
return Encoding.UTF8.GetString(plain);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
if (File.Exists(_tokenPath))
|
||||||
|
File.Delete(_tokenPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
24
src/ClaudeDo.Worker/Online/StaticTokenAuthProvider.cs
Normal file
24
src/ClaudeDo.Worker/Online/StaticTokenAuthProvider.cs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
using ClaudeDo.Worker.Online.Interfaces;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Online;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Simple <see cref="IOnlineAuthProvider"/> that returns a fixed token supplied at construction.
|
||||||
|
/// Used as the default DI registration until <c>ZitadelAuthProvider</c> is wired (Phase 2).
|
||||||
|
/// Also serves as the test double.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class StaticTokenAuthProvider : IOnlineAuthProvider
|
||||||
|
{
|
||||||
|
private readonly string? _token;
|
||||||
|
|
||||||
|
public StaticTokenAuthProvider(string? token = null)
|
||||||
|
{
|
||||||
|
_token = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<string?> GetAccessTokenAsync(CancellationToken ct = default)
|
||||||
|
=> Task.FromResult(_token);
|
||||||
|
|
||||||
|
public Task<string?> GetAccessTokenAsync(bool forceRefresh, CancellationToken ct = default)
|
||||||
|
=> Task.FromResult(_token);
|
||||||
|
}
|
||||||
195
src/ClaudeDo.Worker/Online/ZitadelAuthProvider.cs
Normal file
195
src/ClaudeDo.Worker/Online/ZitadelAuthProvider.cs
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Runtime.Versioning;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using ClaudeDo.Worker.Online.Interfaces;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Online;
|
||||||
|
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
public sealed class ZitadelAuthProvider : IOnlineAuthProvider
|
||||||
|
{
|
||||||
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
|
private readonly OnlineTokenStore _tokenStore;
|
||||||
|
private readonly OnlineInboxConfig _config;
|
||||||
|
private readonly ILogger<ZitadelAuthProvider> _logger;
|
||||||
|
|
||||||
|
private readonly SemaphoreSlim _lock = new(1, 1);
|
||||||
|
|
||||||
|
// Cached access token state.
|
||||||
|
private string? _cachedAccessToken;
|
||||||
|
private DateTimeOffset _cacheExpiry;
|
||||||
|
// The refresh token that minted the cached access token. When the stored refresh token
|
||||||
|
// changes (sign-out, or signing in as a different user), the cache is no longer valid.
|
||||||
|
private string? _refreshTokenUsed;
|
||||||
|
|
||||||
|
// Cached token endpoint URL (discovered once).
|
||||||
|
private string? _tokenEndpoint;
|
||||||
|
|
||||||
|
public ZitadelAuthProvider(
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
OnlineTokenStore tokenStore,
|
||||||
|
OnlineInboxConfig config,
|
||||||
|
ILogger<ZitadelAuthProvider> logger)
|
||||||
|
{
|
||||||
|
_httpClientFactory = httpClientFactory;
|
||||||
|
_tokenStore = tokenStore;
|
||||||
|
_config = config;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<string?> GetAccessTokenAsync(CancellationToken ct = default)
|
||||||
|
=> GetAccessTokenAsync(false, ct);
|
||||||
|
|
||||||
|
public async Task<string?> GetAccessTokenAsync(bool forceRefresh, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var refreshToken = _tokenStore.Read();
|
||||||
|
|
||||||
|
// Fast path: cached token is valid, not forced, and was minted from the still-current
|
||||||
|
// refresh token (i.e. the signed-in user hasn't changed).
|
||||||
|
if (IsCacheUsable(forceRefresh, refreshToken))
|
||||||
|
return _cachedAccessToken;
|
||||||
|
|
||||||
|
await _lock.WaitAsync(ct);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Re-read + re-check inside the lock (double-checked locking).
|
||||||
|
refreshToken = _tokenStore.Read();
|
||||||
|
if (IsCacheUsable(forceRefresh, refreshToken))
|
||||||
|
return _cachedAccessToken;
|
||||||
|
|
||||||
|
// Drop any stale access token so a fresh one is minted for the current user.
|
||||||
|
_cachedAccessToken = null;
|
||||||
|
_cacheExpiry = default;
|
||||||
|
|
||||||
|
if (refreshToken is null)
|
||||||
|
{
|
||||||
|
_refreshTokenUsed = null;
|
||||||
|
_logger.LogDebug("No refresh token stored; skipping token refresh.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return await RefreshAsync(refreshToken, ct);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_lock.Release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool IsCacheUsable(bool forceRefresh, string? storedRefreshToken) =>
|
||||||
|
!forceRefresh
|
||||||
|
&& _cachedAccessToken is not null
|
||||||
|
&& DateTimeOffset.UtcNow < _cacheExpiry
|
||||||
|
&& storedRefreshToken == _refreshTokenUsed;
|
||||||
|
|
||||||
|
private async Task<string?> RefreshAsync(string refreshToken, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var tokenEndpoint = await GetTokenEndpointAsync(ct);
|
||||||
|
if (tokenEndpoint is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
using var http = _httpClientFactory.CreateClient(nameof(ZitadelAuthProvider));
|
||||||
|
|
||||||
|
var form = new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["grant_type"] = "refresh_token",
|
||||||
|
["refresh_token"] = refreshToken,
|
||||||
|
["client_id"] = _config.Zitadel.ClientId,
|
||||||
|
["scope"] = _config.Zitadel.Scopes,
|
||||||
|
};
|
||||||
|
|
||||||
|
HttpResponseMessage response;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
response = await http.PostAsync(tokenEndpoint, new FormUrlEncodedContent(form), ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Token refresh request failed.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
var body = await response.Content.ReadAsStringAsync(ct);
|
||||||
|
if ((int)response.StatusCode == 400 && body.Contains("invalid_grant"))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Refresh token rejected (invalid_grant). Will retry once a new token is stored.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Token refresh returned {Status}: {Body}", (int)response.StatusCode, body);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenResponse = await response.Content.ReadFromJsonAsync<TokenResponse>(ct);
|
||||||
|
if (tokenResponse?.AccessToken is null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Token refresh response missing access_token.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If Zitadel rotated the refresh token, persist the new one.
|
||||||
|
var persistedRefreshToken = refreshToken;
|
||||||
|
if (tokenResponse.RefreshToken is not null && tokenResponse.RefreshToken != refreshToken)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Refresh token rotated; persisting new token.");
|
||||||
|
_tokenStore.Save(tokenResponse.RefreshToken);
|
||||||
|
persistedRefreshToken = tokenResponse.RefreshToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the access token (subtract 60 s safety margin; minimum 0 to avoid far-future expiry on zero).
|
||||||
|
// Remember which refresh token it was minted from so the cache invalidates on a user switch.
|
||||||
|
_cachedAccessToken = tokenResponse.AccessToken;
|
||||||
|
_cacheExpiry = DateTimeOffset.UtcNow.AddSeconds(tokenResponse.ExpiresIn - 60);
|
||||||
|
_refreshTokenUsed = persistedRefreshToken;
|
||||||
|
|
||||||
|
return _cachedAccessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string?> GetTokenEndpointAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (_tokenEndpoint is not null)
|
||||||
|
return _tokenEndpoint;
|
||||||
|
|
||||||
|
var discoveryUrl = _config.Zitadel.Authority.TrimEnd('/') + "/.well-known/openid-configuration";
|
||||||
|
|
||||||
|
using var http = _httpClientFactory.CreateClient(nameof(ZitadelAuthProvider));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var doc = await http.GetFromJsonAsync<OidcDiscovery>(discoveryUrl, ct);
|
||||||
|
_tokenEndpoint = doc?.TokenEndpoint;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Failed to discover OIDC configuration from {Url}.", discoveryUrl);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_tokenEndpoint is null)
|
||||||
|
_logger.LogWarning("OIDC discovery at {Url} did not return a token_endpoint.", discoveryUrl);
|
||||||
|
|
||||||
|
return _tokenEndpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class OidcDiscovery
|
||||||
|
{
|
||||||
|
[JsonPropertyName("token_endpoint")]
|
||||||
|
public string? TokenEndpoint { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TokenResponse
|
||||||
|
{
|
||||||
|
[JsonPropertyName("access_token")]
|
||||||
|
public string? AccessToken { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("expires_in")]
|
||||||
|
public int ExpiresIn { get; init; }
|
||||||
|
|
||||||
|
[JsonPropertyName("refresh_token")]
|
||||||
|
public string? RefreshToken { get; init; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,15 +37,20 @@ public sealed class PlanningMcpService
|
|||||||
private Task BroadcastTaskUpdatedAsync(string taskId, CancellationToken ct)
|
private Task BroadcastTaskUpdatedAsync(string taskId, CancellationToken ct)
|
||||||
=> _broadcaster.TaskUpdated(taskId);
|
=> _broadcaster.TaskUpdated(taskId);
|
||||||
|
|
||||||
[McpServerTool, Description("Create a new draft child task under the current planning session's parent task.")]
|
[McpServerTool, Description(
|
||||||
|
"Create a new draft child task under the current planning session's parent task. " +
|
||||||
|
"Set model to the cheapest model that can do this subtask well — 'haiku' for trivial/mechanical " +
|
||||||
|
"work, 'sonnet' for normal coding (the default), 'opus' only for complex or cross-cutting work. " +
|
||||||
|
"Leave model null to inherit the list/global default.")]
|
||||||
public async Task<CreatedChildDto> CreateChildTask(
|
public async Task<CreatedChildDto> CreateChildTask(
|
||||||
string title,
|
string title,
|
||||||
string? description,
|
string? description,
|
||||||
string? commitType,
|
string? commitType,
|
||||||
|
string? model,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var ctx = _contextAccessor.Current;
|
var ctx = _contextAccessor.Current;
|
||||||
var child = await _tasks.CreateChildAsync(ctx.ParentTaskId, title, description, commitType, createdBy: null, cancellationToken);
|
var child = await _tasks.CreateChildAsync(ctx.ParentTaskId, title, description, commitType, createdBy: null, model: model, ct: cancellationToken);
|
||||||
await BroadcastTaskUpdatedAsync(child.Id, cancellationToken);
|
await BroadcastTaskUpdatedAsync(child.Id, cancellationToken);
|
||||||
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
|
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
|
||||||
return new CreatedChildDto(child.Id, child.Status.ToString());
|
return new CreatedChildDto(child.Id, child.Status.ToString());
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using ClaudeDo.Data.Git;
|
|||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using ClaudeDo.Worker.Hub;
|
using ClaudeDo.Worker.Hub;
|
||||||
using ClaudeDo.Worker.Lifecycle;
|
using ClaudeDo.Worker.Lifecycle;
|
||||||
|
using ClaudeDo.Worker.State;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ public sealed class PlanningMergeOrchestrator
|
|||||||
private readonly PlanningAggregator _aggregator;
|
private readonly PlanningAggregator _aggregator;
|
||||||
private readonly HubBroadcaster _broadcaster;
|
private readonly HubBroadcaster _broadcaster;
|
||||||
private readonly GitService _git;
|
private readonly GitService _git;
|
||||||
|
private readonly ITaskStateService _state;
|
||||||
private readonly ILogger<PlanningMergeOrchestrator> _logger;
|
private readonly ILogger<PlanningMergeOrchestrator> _logger;
|
||||||
|
|
||||||
private sealed class State
|
private sealed class State
|
||||||
@@ -34,6 +36,7 @@ public sealed class PlanningMergeOrchestrator
|
|||||||
PlanningAggregator aggregator,
|
PlanningAggregator aggregator,
|
||||||
HubBroadcaster broadcaster,
|
HubBroadcaster broadcaster,
|
||||||
GitService git,
|
GitService git,
|
||||||
|
ITaskStateService state,
|
||||||
ILogger<PlanningMergeOrchestrator> logger)
|
ILogger<PlanningMergeOrchestrator> logger)
|
||||||
{
|
{
|
||||||
_dbFactory = dbFactory;
|
_dbFactory = dbFactory;
|
||||||
@@ -41,6 +44,7 @@ public sealed class PlanningMergeOrchestrator
|
|||||||
_aggregator = aggregator;
|
_aggregator = aggregator;
|
||||||
_broadcaster = broadcaster;
|
_broadcaster = broadcaster;
|
||||||
_git = git;
|
_git = git;
|
||||||
|
_state = state;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +85,8 @@ public sealed class PlanningMergeOrchestrator
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (await _git.IsMidMergeAsync(workingDir, ct))
|
if (await _git.IsMidMergeAsync(workingDir, ct))
|
||||||
throw new InvalidOperationException("repo is mid-merge");
|
throw new InvalidOperationException(
|
||||||
|
"repo is mid-merge; use AbortPlanningMerge to reset the repository, then Approve again");
|
||||||
if (await _git.HasChangesAsync(workingDir, ct))
|
if (await _git.HasChangesAsync(workingDir, ct))
|
||||||
throw new InvalidOperationException("working tree has uncommitted changes");
|
throw new InvalidOperationException("working tree has uncommitted changes");
|
||||||
|
|
||||||
@@ -106,7 +111,8 @@ public sealed class PlanningMergeOrchestrator
|
|||||||
public async Task ContinueAsync(string planningTaskId, CancellationToken ct)
|
public async Task ContinueAsync(string planningTaskId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (!_states.TryGetValue(planningTaskId, out var state) || state.CurrentSubtaskId is null)
|
if (!_states.TryGetValue(planningTaskId, out var state) || state.CurrentSubtaskId is null)
|
||||||
throw new InvalidOperationException("no in-progress merge to continue");
|
throw new InvalidOperationException(
|
||||||
|
"no in-progress merge to continue; if the worker was restarted during a conflict, use AbortPlanningMerge to reset the repository");
|
||||||
|
|
||||||
var current = state.CurrentSubtaskId;
|
var current = state.CurrentSubtaskId;
|
||||||
var result = await _merge.ContinueMergeAsync(current, ct);
|
var result = await _merge.ContinueMergeAsync(current, ct);
|
||||||
@@ -136,13 +142,40 @@ public sealed class PlanningMergeOrchestrator
|
|||||||
public async Task AbortAsync(string planningTaskId, CancellationToken ct)
|
public async Task AbortAsync(string planningTaskId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (!_states.TryGetValue(planningTaskId, out var state) || state.CurrentSubtaskId is null)
|
if (!_states.TryGetValue(planningTaskId, out var state) || state.CurrentSubtaskId is null)
|
||||||
throw new InvalidOperationException("no in-progress merge to abort");
|
{
|
||||||
|
// No in-memory state — worker may have been restarted while a conflict was paused.
|
||||||
|
// Check whether the list repo is still mid-merge and abort it directly.
|
||||||
|
await AbortStatelessAsync(planningTaskId, ct);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await _merge.AbortMergeAsync(state.CurrentSubtaskId, ct);
|
await _merge.AbortMergeAsync(state.CurrentSubtaskId, ct);
|
||||||
_states.TryRemove(planningTaskId, out _);
|
_states.TryRemove(planningTaskId, out _);
|
||||||
await _broadcaster.PlanningMergeAborted(planningTaskId);
|
await _broadcaster.PlanningMergeAborted(planningTaskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task AbortStatelessAsync(string planningTaskId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
string? workingDir;
|
||||||
|
await using (var ctx = _dbFactory.CreateDbContext())
|
||||||
|
{
|
||||||
|
workingDir = await ctx.Tasks
|
||||||
|
.Where(t => t.Id == planningTaskId)
|
||||||
|
.Select(t => t.List.WorkingDir)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(workingDir) || !await _git.IsMidMergeAsync(workingDir, ct))
|
||||||
|
throw new InvalidOperationException("no in-progress merge to abort");
|
||||||
|
|
||||||
|
await _git.MergeAbortAsync(workingDir, ct);
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Stateless abort of mid-merge for planning task {ParentId} (post-restart recovery)",
|
||||||
|
planningTaskId);
|
||||||
|
await _broadcaster.PlanningMergeAborted(planningTaskId);
|
||||||
|
// Parent remains WaitingForReview — Approve will restart the unit merge from scratch.
|
||||||
|
}
|
||||||
|
|
||||||
private async Task DrainAsync(string planningTaskId, CancellationToken ct)
|
private async Task DrainAsync(string planningTaskId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
if (!_states.TryGetValue(planningTaskId, out var state)) return;
|
if (!_states.TryGetValue(planningTaskId, out var state)) return;
|
||||||
@@ -181,7 +214,8 @@ public sealed class PlanningMergeOrchestrator
|
|||||||
}
|
}
|
||||||
|
|
||||||
state.CurrentSubtaskId = null;
|
state.CurrentSubtaskId = null;
|
||||||
await FinalizeParentDoneAsync(planningTaskId, state.IsPlanning, ct);
|
var finalized = await FinalizeParentDoneAsync(planningTaskId, state.IsPlanning, ct);
|
||||||
|
if (finalized)
|
||||||
await _broadcaster.PlanningCompleted(planningTaskId);
|
await _broadcaster.PlanningCompleted(planningTaskId);
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -190,18 +224,30 @@ public sealed class PlanningMergeOrchestrator
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task FinalizeParentDoneAsync(string parentTaskId, bool isPlanning, CancellationToken ct)
|
private async Task<bool> FinalizeParentDoneAsync(string parentTaskId, bool isPlanning, CancellationToken ct)
|
||||||
{
|
{
|
||||||
using var ctx = _dbFactory.CreateDbContext();
|
var result = await _state.ApproveReviewAsync(parentTaskId, ct);
|
||||||
var parent = await ctx.Tasks.SingleOrDefaultAsync(t => t.Id == parentTaskId, ct);
|
if (!result.Ok)
|
||||||
if (parent is null) return;
|
{
|
||||||
parent.Status = TaskStatus.Done;
|
// ApproveReviewAsync requires WaitingForReview. For improvement parents whose own
|
||||||
parent.FinishedAt = DateTime.UtcNow;
|
// worktree is in the merge queue, TaskMergeService.ApproveIfWaitingForReviewAsync
|
||||||
await ctx.SaveChangesAsync(ct);
|
// already approved the parent during the drain — check for that expected path.
|
||||||
|
await using var ctx = _dbFactory.CreateDbContext();
|
||||||
|
var current = await ctx.Tasks
|
||||||
|
.Where(t => t.Id == parentTaskId)
|
||||||
|
.Select(t => (TaskStatus?)t.Status)
|
||||||
|
.FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
// Surface the Done transition to the UI. Without this the parent row stays
|
if (current != TaskStatus.Done)
|
||||||
// visibly stuck in WaitingForReview even though the unit merge completed.
|
{
|
||||||
await _broadcaster.TaskUpdated(parentTaskId);
|
// Parent was cancelled or moved to an unexpected state during the merge drain.
|
||||||
|
// Do not overwrite — the external transition takes precedence.
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Unit-merge drain completed but parent {ParentTaskId} could not be finalized (status: {Status}): {Reason}",
|
||||||
|
parentTaskId, current, result.Reason);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Only planning builds an integration branch via the aggregator; skip cleanup otherwise.
|
// Only planning builds an integration branch via the aggregator; skip cleanup otherwise.
|
||||||
if (isPlanning)
|
if (isPlanning)
|
||||||
@@ -209,5 +255,7 @@ public sealed class PlanningMergeOrchestrator
|
|||||||
try { await _aggregator.CleanupIntegrationBranchAsync(parentTaskId, ct); }
|
try { await _aggregator.CleanupIntegrationBranchAsync(parentTaskId, ct); }
|
||||||
catch (Exception ex) { _logger.LogWarning(ex, "integration branch cleanup failed"); }
|
catch (Exception ex) { _logger.LogWarning(ex, "integration branch cleanup failed"); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ using ClaudeDo.Worker.Planning;
|
|||||||
using ClaudeDo.Worker.Queue;
|
using ClaudeDo.Worker.Queue;
|
||||||
using ClaudeDo.Worker.Runner;
|
using ClaudeDo.Worker.Runner;
|
||||||
using ClaudeDo.Worker.State;
|
using ClaudeDo.Worker.State;
|
||||||
|
using ClaudeDo.Worker.Online;
|
||||||
|
using ClaudeDo.Worker.Online.Interfaces;
|
||||||
using ClaudeDo.Worker.Prime;
|
using ClaudeDo.Worker.Prime;
|
||||||
using ClaudeDo.Worker.Refine;
|
using ClaudeDo.Worker.Refine;
|
||||||
using ClaudeDo.Worker.Report;
|
using ClaudeDo.Worker.Report;
|
||||||
@@ -149,6 +151,28 @@ builder.Services.AddMcpServer()
|
|||||||
.WithTools<PlanningMcpService>()
|
.WithTools<PlanningMcpService>()
|
||||||
.WithTools<TaskRunMcpService>();
|
.WithTools<TaskRunMcpService>();
|
||||||
|
|
||||||
|
// OnlineInboxConfig and OnlineTokenStore are always registered so hub methods work
|
||||||
|
// even when sync is disabled. The sync stack (api client, auth, hosted service) is
|
||||||
|
// only registered when enabled.
|
||||||
|
builder.Services.AddSingleton(cfg.OnlineInbox);
|
||||||
|
#pragma warning disable CA1416 // ClaudeDo.Worker is Windows-only; DPAPI is fine here.
|
||||||
|
builder.Services.AddSingleton<OnlineTokenStore>();
|
||||||
|
#pragma warning restore CA1416
|
||||||
|
|
||||||
|
if (cfg.OnlineInbox.Enabled)
|
||||||
|
{
|
||||||
|
OnlineInboxApiClient.ValidateBaseUrl(cfg.OnlineInbox.ApiBaseUrl);
|
||||||
|
builder.Services.AddHttpClient();
|
||||||
|
#pragma warning disable CA1416
|
||||||
|
builder.Services.AddSingleton<IOnlineAuthProvider, ZitadelAuthProvider>();
|
||||||
|
#pragma warning restore CA1416
|
||||||
|
builder.Services.AddHttpClient<IOnlineInboxApi, OnlineInboxApiClient>(client =>
|
||||||
|
{
|
||||||
|
client.BaseAddress = new Uri(cfg.OnlineInbox.ApiBaseUrl.TrimEnd('/') + "/");
|
||||||
|
});
|
||||||
|
builder.Services.AddHostedService<OnlineSyncService>();
|
||||||
|
}
|
||||||
|
|
||||||
// Loopback-only bind. Firewall is irrelevant for 127.0.0.1.
|
// Loopback-only bind. Firewall is irrelevant for 127.0.0.1.
|
||||||
builder.WebHost.UseUrls($"http://127.0.0.1:{cfg.SignalRPort}");
|
builder.WebHost.UseUrls($"http://127.0.0.1:{cfg.SignalRPort}");
|
||||||
|
|
||||||
|
|||||||
@@ -191,14 +191,14 @@ public sealed class QueueService : BackgroundService
|
|||||||
|
|
||||||
if (sessionId is not null)
|
if (sessionId is not null)
|
||||||
{
|
{
|
||||||
await _runner.ContinueAsync(taskId, feedback, "queue", ct);
|
await _runner.ContinueAsync(taskId, feedback, "queue", ct, alreadyClaimed: true);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
task.Description = string.IsNullOrWhiteSpace(task.Description)
|
task.Description = string.IsNullOrWhiteSpace(task.Description)
|
||||||
? $"Reviewer feedback: {feedback}"
|
? $"Reviewer feedback: {feedback}"
|
||||||
: $"{task.Description}\n\nReviewer feedback: {feedback}";
|
: $"{task.Description}\n\nReviewer feedback: {feedback}";
|
||||||
await _runner.RunAsync(task, "queue", ct);
|
await _runner.RunAsync(task, "queue", ct, alreadyClaimed: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear the consumed feedback only once the run reached a successful
|
// Clear the consumed feedback only once the run reached a successful
|
||||||
@@ -212,7 +212,7 @@ public sealed class QueueService : BackgroundService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await _runner.RunAsync(task, "queue", ct);
|
await _runner.RunAsync(task, "queue", ct, alreadyClaimed: true);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -25,10 +25,13 @@ public sealed class TaskRunMcpService
|
|||||||
"File an out-of-scope improvement as a child task of the current task. The child runs " +
|
"File an out-of-scope improvement as a child task of the current task. The child runs " +
|
||||||
"automatically after this task finishes and is surfaced for review alongside it. Use ONLY " +
|
"automatically after this task finishes and is surfaced for review alongside it. Use ONLY " +
|
||||||
"for work that is genuinely outside this task's scope (a refactor, follow-up, or tech debt) " +
|
"for work that is genuinely outside this task's scope (a refactor, follow-up, or tech debt) " +
|
||||||
"— never for work that belongs to the current task.")]
|
"— never for work that belongs to the current task. 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 complex work. Leave model null to inherit the list/global default.")]
|
||||||
public async Task<SuggestedImprovementDto> SuggestImprovement(
|
public async Task<SuggestedImprovementDto> SuggestImprovement(
|
||||||
string title,
|
string title,
|
||||||
string description,
|
string description,
|
||||||
|
string? model,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var callerId = _ctx.Current.CallerTaskId;
|
var callerId = _ctx.Current.CallerTaskId;
|
||||||
@@ -39,7 +42,7 @@ public sealed class TaskRunMcpService
|
|||||||
"A child task cannot suggest further improvements (improvements are one layer deep).");
|
"A child task cannot suggest further improvements (improvements are one layer deep).");
|
||||||
|
|
||||||
var child = await _tasks.CreateChildAsync(
|
var child = await _tasks.CreateChildAsync(
|
||||||
callerId, title, description, commitType: null, createdBy: callerId, cancellationToken);
|
callerId, title, description, commitType: null, createdBy: callerId, model: model, ct: cancellationToken);
|
||||||
await _broadcaster.TaskUpdated(child.Id);
|
await _broadcaster.TaskUpdated(child.Id);
|
||||||
await _broadcaster.TaskUpdated(callerId);
|
await _broadcaster.TaskUpdated(callerId);
|
||||||
return new SuggestedImprovementDto(child.Id);
|
return new SuggestedImprovementDto(child.Id);
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ public sealed class TaskRunner
|
|||||||
_tokens = tokens;
|
_tokens = tokens;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task RunAsync(TaskEntity task, string slot, CancellationToken ct)
|
public async Task RunAsync(TaskEntity task, string slot, CancellationToken ct, bool alreadyClaimed = false)
|
||||||
{
|
{
|
||||||
string? mcpToken = null;
|
string? mcpToken = null;
|
||||||
string? mcpConfigPath = null;
|
string? mcpConfigPath = null;
|
||||||
@@ -98,7 +98,17 @@ public sealed class TaskRunner
|
|||||||
}
|
}
|
||||||
|
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
await _state.StartRunningAsync(task.Id, now, ct);
|
// The queue picker claims Queued→Running atomically (incl. StartedAt) before
|
||||||
|
// dispatching; only unclaimed dispatches (override slot) claim here.
|
||||||
|
if (!alreadyClaimed)
|
||||||
|
{
|
||||||
|
var startResult = await _state.StartRunningAsync(task.Id, now, ct);
|
||||||
|
if (!startResult.Ok)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Task {TaskId} skipped: StartRunningAsync rejected ({Reason})", task.Id, startResult.Reason);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
await _broadcaster.TaskStarted(slot, task.Id, now);
|
await _broadcaster.TaskStarted(slot, task.Id, now);
|
||||||
|
|
||||||
// Build prompt: title + description + only the OPEN sub-tasks (resolved ones are dropped).
|
// Build prompt: title + description + only the OPEN sub-tasks (resolved ones are dropped).
|
||||||
@@ -162,7 +172,7 @@ public sealed class TaskRunner
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ContinueAsync(string taskId, string followUpPrompt, string slot, CancellationToken ct)
|
public async Task ContinueAsync(string taskId, string followUpPrompt, string slot, CancellationToken ct, bool alreadyClaimed = false)
|
||||||
{
|
{
|
||||||
TaskEntity task;
|
TaskEntity task;
|
||||||
TaskRunEntity lastRun;
|
TaskRunEntity lastRun;
|
||||||
@@ -208,7 +218,16 @@ public sealed class TaskRunner
|
|||||||
}
|
}
|
||||||
|
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
await _state.StartRunningAsync(taskId, now, ct);
|
// See RunAsync: queue dispatches arrive pre-claimed by the picker.
|
||||||
|
if (!alreadyClaimed)
|
||||||
|
{
|
||||||
|
var startResult = await _state.StartRunningAsync(taskId, now, ct);
|
||||||
|
if (!startResult.Ok)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Task {TaskId} skipped: StartRunningAsync rejected ({Reason})", taskId, startResult.Reason);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
await _broadcaster.TaskStarted(slot, taskId, now);
|
await _broadcaster.TaskStarted(slot, taskId, now);
|
||||||
|
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -126,11 +126,14 @@ public sealed class TaskStateService : ITaskStateService
|
|||||||
|
|
||||||
public async Task<TransitionResult> ApproveReviewAsync(string taskId, CancellationToken ct)
|
public async Task<TransitionResult> ApproveReviewAsync(string taskId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
await using (var ctx = await _dbFactory.CreateDbContextAsync(ct))
|
await using (var ctx = await _dbFactory.CreateDbContextAsync(ct))
|
||||||
{
|
{
|
||||||
var affected = await ctx.Tasks
|
var affected = await ctx.Tasks
|
||||||
.Where(t => t.Id == taskId && t.Status == TaskStatus.WaitingForReview)
|
.Where(t => t.Id == taskId && t.Status == TaskStatus.WaitingForReview)
|
||||||
.ExecuteUpdateAsync(s => s.SetProperty(t => t.Status, TaskStatus.Done), ct);
|
.ExecuteUpdateAsync(s => s
|
||||||
|
.SetProperty(t => t.Status, TaskStatus.Done)
|
||||||
|
.SetProperty(t => t.FinishedAt, now), ct);
|
||||||
|
|
||||||
if (affected == 0)
|
if (affected == 0)
|
||||||
return new TransitionResult(false, "Task is not waiting for review; cannot approve.");
|
return new TransitionResult(false, "Task is not waiting for review; cannot approve.");
|
||||||
@@ -195,6 +198,9 @@ public sealed class TaskStateService : ITaskStateService
|
|||||||
{
|
{
|
||||||
await using (var ctx = await _dbFactory.CreateDbContextAsync(ct))
|
await using (var ctx = await _dbFactory.CreateDbContextAsync(ct))
|
||||||
{
|
{
|
||||||
|
// Queued is intentional: OverrideSlotService dispatches RunAsync before calling
|
||||||
|
// StartRunningAsync, so a preflight failure (list not found, worktree setup) can
|
||||||
|
// reach MarkFailed while the task is still Queued in the DB.
|
||||||
var affected = await ctx.Tasks
|
var affected = await ctx.Tasks
|
||||||
.Where(t => t.Id == taskId &&
|
.Where(t => t.Id == taskId &&
|
||||||
(t.Status == TaskStatus.Running || t.Status == TaskStatus.Queued))
|
(t.Status == TaskStatus.Running || t.Status == TaskStatus.Queued))
|
||||||
|
|||||||
123
tests/ClaudeDo.Data.Tests/ConflictMarkerParserTests.cs
Normal file
123
tests/ClaudeDo.Data.Tests/ConflictMarkerParserTests.cs
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
using System.Linq;
|
||||||
|
using ClaudeDo.Data.Git;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Tests;
|
||||||
|
|
||||||
|
public class ConflictMarkerParserTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void NoMarkers_YieldsSingleStableSegment_AndRoundTrips()
|
||||||
|
{
|
||||||
|
const string text = "just\nsome\nplain\nlines\n";
|
||||||
|
|
||||||
|
var segments = ConflictMarkerParser.Parse(text);
|
||||||
|
|
||||||
|
var only = Assert.Single(segments);
|
||||||
|
Assert.False(only.IsConflict);
|
||||||
|
Assert.Equal(text, ConflictMarkerParser.Compose(segments, c => c.Ours));
|
||||||
|
Assert.False(ConflictMarkerParser.HasConflicts(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SimpleConflict_SplitsIntoStable_Conflict_Stable()
|
||||||
|
{
|
||||||
|
const string text =
|
||||||
|
"line1\n<<<<<<< HEAD\nours line\n=======\ntheirs line\n>>>>>>> branch\nline2\n";
|
||||||
|
|
||||||
|
var segments = ConflictMarkerParser.Parse(text);
|
||||||
|
|
||||||
|
Assert.Equal(3, segments.Count);
|
||||||
|
Assert.Equal("line1\n", segments[0].Text);
|
||||||
|
Assert.True(segments[1].IsConflict);
|
||||||
|
Assert.Equal("ours line\n", segments[1].Ours);
|
||||||
|
Assert.Equal("theirs line\n", segments[1].Theirs);
|
||||||
|
Assert.Null(segments[1].Base);
|
||||||
|
Assert.Equal("line2\n", segments[2].Text);
|
||||||
|
Assert.True(ConflictMarkerParser.HasConflicts(text));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void AcceptingOurs_Or_Theirs_ProducesTheResolvedFile()
|
||||||
|
{
|
||||||
|
const string text =
|
||||||
|
"line1\n<<<<<<< HEAD\nours line\n=======\ntheirs line\n>>>>>>> branch\nline2\n";
|
||||||
|
|
||||||
|
var segments = ConflictMarkerParser.Parse(text);
|
||||||
|
|
||||||
|
Assert.Equal("line1\nours line\nline2\n",
|
||||||
|
ConflictMarkerParser.Compose(segments, c => c.Ours));
|
||||||
|
Assert.Equal("line1\ntheirs line\nline2\n",
|
||||||
|
ConflictMarkerParser.Compose(segments, c => c.Theirs));
|
||||||
|
// "Accept both" = ours followed by theirs.
|
||||||
|
Assert.Equal("line1\nours line\ntheirs line\nline2\n",
|
||||||
|
ConflictMarkerParser.Compose(segments, c => c.Ours + c.Theirs));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Diff3Style_CapturesTheMergeBase()
|
||||||
|
{
|
||||||
|
const string text =
|
||||||
|
"a\n<<<<<<< HEAD\nX\n||||||| base\nB\n=======\nY\n>>>>>>> branch\nz\n";
|
||||||
|
|
||||||
|
var segments = ConflictMarkerParser.Parse(text);
|
||||||
|
|
||||||
|
Assert.Equal(3, segments.Count);
|
||||||
|
Assert.Equal("X\n", segments[1].Ours);
|
||||||
|
Assert.Equal("B\n", segments[1].Base);
|
||||||
|
Assert.Equal("Y\n", segments[1].Theirs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MultipleConflicts_AreEachCaptured()
|
||||||
|
{
|
||||||
|
const string text =
|
||||||
|
"<<<<<<< HEAD\nA\n=======\nB\n>>>>>>> br\nmid\n<<<<<<< HEAD\nC\n=======\nD\n>>>>>>> br\n";
|
||||||
|
|
||||||
|
var segments = ConflictMarkerParser.Parse(text);
|
||||||
|
|
||||||
|
Assert.Equal(3, segments.Count);
|
||||||
|
Assert.True(segments[0].IsConflict);
|
||||||
|
Assert.Equal("A\n", segments[0].Ours);
|
||||||
|
Assert.Equal("mid\n", segments[1].Text);
|
||||||
|
Assert.True(segments[2].IsConflict);
|
||||||
|
Assert.Equal("D\n", segments[2].Theirs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CrlfLineEndings_ArePreserved()
|
||||||
|
{
|
||||||
|
const string text =
|
||||||
|
"a\r\n<<<<<<< HEAD\r\nX\r\n=======\r\nY\r\n>>>>>>> br\r\nb\r\n";
|
||||||
|
|
||||||
|
var segments = ConflictMarkerParser.Parse(text);
|
||||||
|
|
||||||
|
Assert.Equal("a\r\nX\r\nb\r\n", ConflictMarkerParser.Compose(segments, c => c.Ours));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConflictAtEndOfFile_WithoutTrailingNewline_IsParsed()
|
||||||
|
{
|
||||||
|
const string text = "a\n<<<<<<< HEAD\nX\n=======\nY\n>>>>>>> br";
|
||||||
|
|
||||||
|
var segments = ConflictMarkerParser.Parse(text);
|
||||||
|
|
||||||
|
Assert.Equal(2, segments.Count);
|
||||||
|
Assert.Equal("a\n", segments[0].Text);
|
||||||
|
Assert.True(segments[1].IsConflict);
|
||||||
|
Assert.Equal("X\n", segments[1].Ours);
|
||||||
|
Assert.Equal("Y\n", segments[1].Theirs);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SevenEqualsInOrdinaryText_IsNotTreatedAsAConflict()
|
||||||
|
{
|
||||||
|
const string text = "title\n=======\nbody\n";
|
||||||
|
|
||||||
|
var segments = ConflictMarkerParser.Parse(text);
|
||||||
|
|
||||||
|
var only = Assert.Single(segments);
|
||||||
|
Assert.False(only.IsConflict);
|
||||||
|
Assert.Equal(text, ConflictMarkerParser.Compose(segments, c => c.Ours));
|
||||||
|
}
|
||||||
|
}
|
||||||
36
tests/ClaudeDo.Data.Tests/ModelRegistryTests.cs
Normal file
36
tests/ClaudeDo.Data.Tests/ModelRegistryTests.cs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Tests;
|
||||||
|
|
||||||
|
public class ModelRegistryTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("sonnet", "sonnet")]
|
||||||
|
[InlineData("OPUS", "opus")]
|
||||||
|
[InlineData(" haiku ", "haiku")]
|
||||||
|
public void NormalizeAlias_canonicalizes_known_aliases(string input, string expected)
|
||||||
|
{
|
||||||
|
Assert.Equal(expected, ModelRegistry.NormalizeAlias(input));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(null)]
|
||||||
|
[InlineData("")]
|
||||||
|
[InlineData(" ")]
|
||||||
|
public void NormalizeAlias_blank_means_inherit(string? input)
|
||||||
|
{
|
||||||
|
Assert.Null(ModelRegistry.NormalizeAlias(input));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NormalizeAlias_unknown_throws()
|
||||||
|
{
|
||||||
|
Assert.Throws<ArgumentException>(() => ModelRegistry.NormalizeAlias("gpt4"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ByCostAscending_is_haiku_sonnet_opus()
|
||||||
|
{
|
||||||
|
Assert.Equal(new[] { "haiku", "sonnet", "opus" }, ModelRegistry.ByCostAscending);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user