53 Commits

Author SHA1 Message Date
Mika Kuns
23a93ce0bb fix(merge): unresolved conflicts compose to empty, not Ours (+ review nits)
All checks were successful
Changelog / changelog (push) Successful in 2s
Release / release (push) Successful in 43s
Code-review follow-ups before push:
- MergeFile.ResultText/Compose() fell back to Ours for unresolved conflicts while
  the editor seeds them empty — align both on empty so the public model matches the
  pane and Continue can't silently auto-accept Ours.
- Bound the gutter re-layout retry (was an unbounded Background re-post when the
  editor isn't laid out, e.g. minimized).
- Pluralize the readout ('1 conflict' not '1 conflicts'). Tests updated. Ui 128 green.
2026-06-19 13:14:51 +02:00
Mika Kuns
29a294b7f3 feat(merge): diff Merge opens the 3-pane editor + conflict overview ruler
- The Merge button in the Diff window now hands a conflicting merge to the in-app
  3-pane editor (MergeModal routes 'conflict' through RequestConflictResolution,
  the same seam Approve uses) instead of dead-ending on a conflict message.
- Add a conflict overview ruler right of the Result pane: a proportional map of
  every conflict in the file, recolored by resolved state, click a tick to jump —
  so conflicts are findable in long files without scrolling.
- New MergeResolvedEdgeBrush token + conflictMap en/de key. Ui 128 + Loc 16 green.
2026-06-19 11:31:34 +02:00
Mika Kuns
ca4377e641 feat(merge): toggle add/remove per side, MAIN/INCOMING labels, files readout
- Conflict accept is now a per-side toggle: > adds MAIN (ours), < adds INCOMING
  (theirs) in click order (first on top); clicking again removes that side, so each
  side is included at most once. Region content is rebuilt from the included set.
- Drop the separate reset (x) control — toggling both off clears the region.
- Relabel the panes/tooltips Ours/Theirs -> MAIN/INCOMING (merge target vs task).
- Add a cross-file 'N of M files unresolved' readout (FilesSummary) so you can see
  how many more files still have conflicts. en/de updated; Ui 128 + Loc 16 green.
2026-06-19 11:12:02 +02:00
Mika Kuns
d5eec75bea feat(merge): additive conflict accept — stack ours/theirs in click order
Replace the single-side replace (and the short-lived accept-both button) with
additive accepts: each result conflict region starts EMPTY (thin marker bar), and
the gutter controls append a side in click order — > adds ours, < adds theirs
(first pick on top, next below), x clears. Controls stay visible after the first
pick so both sides can be stacked; empty/unresolved regions render a marker so they
stay visible. en/de keys updated; Ui 128 + Localization 16 green.
2026-06-19 10:50:57 +02:00
Mika Kuns
18479c023e feat(merge): add accept-both control to the 3-pane conflict gutter
The between-pane gutter only offered single-side replace (accept ours / accept
theirs). Add an 'accept both' (⊕) control under the ours chevron that drops
ours-then-theirs into the result region, so a conflict can be combined in one
click instead of picking one side and hand-adding the other. en/de keys added.
2026-06-19 10:43:35 +02:00
Mika Kuns
869dd25a23 fix(merge): harden 3-pane editor + document the new conflict resolver
Review follow-ups: coalesce gutter re-layout posts (avoid dispatcher flooding when
visual lines aren't ready), drop the zero-length deletable segment (undo hygiene),
and clear stale scroll-sync hooks on DataContext swap. Update Ui/CLAUDE.md to the
3-pane editor and log visual-verification items (incl. empty-side + alignment edges)
in docs/open.md.
2026-06-19 10:21:32 +02:00
Mika Kuns
c4d1acc75b feat(merge): Rider-style 3-pane conflict editor view
Replace the Base|Ours|Theirs read-only columns + single-conflict result with a
whole-file 3-pane editor: Ours (read-only) | editable Result | Theirs (read-only),
reconstructed from the active file's segments so the panes line up on stable text.

- IBackgroundRenderer paints each conflict block (unresolved=blood, resolved=green)
  across all three panes.
- Result document edits are gated by an IReadOnlySectionProvider (stable text is
  read-only; only conflict regions, tracked via TextAnchors, are editable); edits
  flow back to the owning block.
- Between-pane gutters host inline accept controls (>/< ) positioned per conflict;
  click accepts ours/theirs into the result.
- Proportional synced vertical scroll across the panes; file switcher + change-nav
  arrows (F8 / Shift+F8); active-file 'M conflicts - K resolved' readout.
- Merge block tints + AmberBrush tokens; en/de keys for the new labels.

Seam unchanged. App builds; Ui.Tests 128, Localization.Tests 16.
2026-06-19 10:15:12 +02:00
Mika Kuns
378a92c156 feat(merge): unify planning conflicts onto the resolver + 3-pane VM foundation
Route planning unit-merge conflicts through ConflictResolverViewModel
(OpenForPlanningAsync) and delete the old ConflictResolutionViewModel dialog.
Add active-file 3-pane reconstruction (MergeFile OursText/TheirsText/ResultText,
ActiveFile, SelectFileCommand, active-file readout) as the VM foundation for the
Rider-style editor. Seam preserved; Ui.Tests 128/128.
2026-06-19 09:58:32 +02:00
Mika Kuns
983c177c9a docs(merge): spec + plan for Rider-style 3-pane merge editor 2026-06-19 09:56:15 +02:00
Mika Kuns
3e4e4a03f7 feat(ui): move review feedback to the Output tab + review/worktree polish
- Feedback box + a new "Resume session" button move from the Git tab to the
  Output tab; the Git review block keeps Approve & Merge / Park / Cancel / Reset.
- Add a "Parked" chip for Idle tasks that still hold an Active worktree.
- Stop showing the "Session was Cancelled" band on cancel (failed-only now).
- Fix the Worktrees-overview state-chip contrast (dark text on the colour).
2026-06-19 09:31:53 +02:00
Mika Kuns
92767c646e feat(merge): in-app 3-way merge editor (chunk 2b)
Replace the whole-file conflict resolver with a real 3-way merge editor
built on the line-level hunk pipeline.

- ConflictModels: MergeFile/MergeFileSegment/MergeConflictBlock with
  Compose() that reassembles stable text + chosen resolutions
- ConflictResolverViewModel (same seam contract): loads conflict
  documents, flattens conflicts for one-at-a-time navigation, per-block
  Accept Ours/Base/Theirs/Both + editable result, binary files block continue
- ConflictResolverView: 3-column Base|Ours|Theirs + editable result via
  AvaloniaEdit with TextMate syntax highlighting by file extension;
  editors synced in code-behind
- add Avalonia.AvaloniaEdit + AvaloniaEdit.TextMate + TextMateSharp.Grammars;
  AvaloniaEdit theme StyleInclude in App.axaml
- rewrite ConflictResolverViewModel tests (load/gating/compose/nav/binary/abort)
2026-06-18 16:46:43 +02:00
Mika Kuns
e779e13654 feat(merge): real conflict-hunk parsing pipeline (chunk 2 backend)
Replace the whole-file conflict model with line-level hunks, the
foundation for the full in-app merge editor.

- ConflictMarkerParser: parses git conflict markers (incl. diff3 base)
  into ordered stable/conflict MergeSegments; exact round-trip + Compose
- GitService.MergeNoFfAsync passes -c merge.conflictStyle=diff3 so the
  working tree carries the merge base in conflict markers
- TaskMergeService.GetConflictDocumentsAsync: reads each conflicted file,
  parses into segments, flags binary files
- hub GetMergeConflictDocuments + DTOs (MergeConflictDocumentsDto/
  ConflictDocumentDto/MergeSegmentDto), IWorkerClient + both fakes
- tests: 8 parser unit tests + a real-git integration test asserting
  line-level hunks with a diff3 base
2026-06-18 16:22:56 +02:00
Mika Kuns
4847c5c0a4 feat(ui): My Day actions, orphan-aware grouping, menu restructure
Pending UI work:
- My Day add/remove context actions on task rows (parent removal cascades to children)
- orphan-aware grouping: a child whose parent isn't in view renders as a top-level row, not an indented draft
- shell menu restructure (Worker / Repositories submenus); 'Finalize plan' action, drop 'Queue subtasks sequentially'
- notes editor refinements
- subtask-row hover tweak (Surface3, no transition)
- bump Avalonia 12.0.0 -> 12.0.4
2026-06-18 16:22:29 +02:00
Mika Kuns
43fb506e87 feat(review): unify review actions into the Git-tab cockpit
Grow the detail-pane Git tab into the review+merge cockpit: target,
pre-flight mergeability, inspect actions, then the four review verbs
(Approve & Merge / Send back / Park / Cancel) plus a demoted
Reset (discard branch).

The decision block is gated independently of the merge controls so
sandbox (no-worktree) review tasks still get the buttons.

- Add ParkReviewCommand (-> RejectReviewToIdleAsync)
- Send back (reject-to-queue) disabled until feedback is entered
- Remove the mislabeled [Continue]/[Reset] line from the Output tab
- Accent dot on the Git tab while awaiting review
2026-06-18 15:52:41 +02:00
mika kuns
b75a7b1b5a Merge remote-tracking branch 'origin/main' 2026-06-15 15:40:15 +02:00
mika kuns
824f785fd0 fix(): Maximize button hides the window instead of maximizing 2026-06-15 15:11:49 +02:00
mika kuns
0d1475cb7a fix(claude-do): Maximize button hides the window instead of maximizing
## Bug
Clicking the maximize control in the custom title bar makes the main window disappear/hide instead of filling the screen. Restore is then hard or impossible.

## Where
`MainWindow` uses custom client-area chrome, so the OS does not manage maximize:
- `src/ClaudeDo.Ui/Views/MainWindow.axaml:14-16` — `WindowDecorations="BorderOnly"`, `ExtendClientAreaToDecorationsHint="True"`, `ExtendClientAr

ClaudeDo-Task: 7d3d9501a8eb4111b9d433fd917f5a22
2026-06-15 15:08:02 +02:00
mika kuns
cfe23cdd23 fix(online-inbox): invalidate cached access token when the signed-in user changes
ZitadelAuthProvider cached the access token in memory and only re-read the
refresh token when the cache expired. Re-signing as a different user saved a
new refresh token but the worker kept serving the previous user's cached
access token until it expired — so sync (and ownerId stamping) continued under
the old identity.

Track the refresh token that minted the cached token and invalidate the cache
when the stored refresh token changes (user switch or sign-out). Switching
users now takes effect on the next sync without a worker restart.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 10:38:31 +02:00
mika kuns
cee051bb6d feat(online-inbox): carry ownerId on sync to prepare for multi-user
Plumb a per-resource owner (Zitadel sub) through the sync contract without
enforcing isolation client-side — the server stays the authority.

- Dtos: add optional ownerId to RemoteList/RemoteTask/MirrorTask
- JwtClaims: decode the sub claim from the access token (never throws)
- OnlineSyncService: stamp ownerId on pushed lists + mirror; defensively skip
  pulled tasks owned by a different user (unowned tasks still sync, so
  single-user behavior is unchanged)
- docs: contract documents ownerId + multi-user readiness

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 13:57:39 +02:00
mika kuns
23c3065f20 feat(online-inbox): gate access on Zitadel "user" project role
The Online API now requires the "user" project role (claim
urn:zitadel:iam:org:project:roles) instead of an ALLOWED_USER_IDS allowlist.

- IOnlineAuthProvider: add GetAccessTokenAsync(forceRefresh) overload
- ZitadelAuthProvider: forceRefresh drops the cached token and re-runs the
  refresh-token grant to mint a fresh, role-bearing token
- OnlineInboxApiClient: on 401, force-refresh and retry once; if still 401,
  throw a clear "missing 'user' role" error
- OnlineSyncService: surface the 401 at Error level (no longer silent)
- UI: ZitadelTokenInspector decodes the access token after login and warns
  early when the "user" role is absent (fail-open); shown in settings
- docs: online-inbox-api-contract reflects role-based access (no allowlist)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 13:46:17 +02:00
mika kuns
80a2de6c74 feat(ui): Online Inbox settings tab + auth-code/PKCE login
New Settings tab: enable toggle, config fields, sign-in/out + status.
OnlineLoginService runs the PKCE loopback flow (Duende.IdentityModel.OidcClient
7.1.0), opens the system browser, captures the callback, hands the refresh
token to the Worker. en/de localized. Fixes: loopback callback URL built from
host:port base (avoids doubled redirect path); PollIntervalSeconds threaded
through the state DTO so it loads instead of resetting to 60.

Visual layout + the live sign-in round-trip need manual verification.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 11:02:14 +02:00
mika kuns
17c7ff517a feat(worker,ui): Online Inbox config + auth hub plumbing (Phase 2)
Hub: GetOnlineInboxState / SetOnlineInboxConfig / SetOnlineInboxAuth /
ClearOnlineInboxAuth. WorkerConfig.SaveOnlineInbox persists only the
online_inbox section. OnlineTokenStore + config registered always so hub
methods work when sync is disabled. IWorkerClient surface + all test fakes
synced. RedirectUri config (default http://localhost:8765/callback).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:49:49 +02:00
mika kuns
8b347de131 fix(worker): preserve API base path in Online Inbox client
The API base URL is https://claudedo.kuns.dev/api — leading-slash request
paths discarded the /api segment. Use relative paths so they nest under the
base. Tests now use a /api/ base to guard the regression.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:35:30 +02:00
mika kuns
619bc0c38d feat(worker): real ZitadelAuthProvider (refresh-token grant, auth-code+PKCE)
Headless refresh-token -> access-token exchange via OIDC discovery + token
endpoint. Cached to expiry (60s margin), thread-safe, persists rotated refresh
tokens, graceful null on invalid_grant/network errors. Wired into DI when
online_inbox is enabled. Interactive PKCE login (UI) still pending the
registered redirect URI. 7 tests, stubbed HttpMessageHandler.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:08:33 +02:00
mika kuns
96da9fbae5 docs(online-inbox): KunsZitadel is server-side only; desktop uses an OIDC client flow
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:02:12 +02:00
mika kuns
1ac9ced0bd feat(worker): Online Inbox sync engine (Phase 1)
Optional, opt-in (online_inbox.enabled, default false → zero network).
Worker-side reconcile loop: pull web-created tasks down as Idle, push the
list catalog and the Idle backlog mirror up. Auth behind IOnlineAuthProvider
(StaticTokenAuthProvider default; ZitadelAuthProvider stubbed for Phase 2).
DPAPI refresh-token store. 35 tests, no real network/Zitadel/Claude.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 09:55:20 +02:00
mika kuns
8cbe1adb32 docs(online-inbox): API contract, desktop design spec, and implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 09:35:20 +02:00
mika kuns
23ff3916cc docs: close out the review round in open.md, sync CLAUDE.md with merges
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 00:40:55 +02:00
mika kuns
360ff77e18 Merge task branch for: refactor(ui): DetailsIslandViewModel (1431 Zeilen) in Sektions-VMs aufteilen 2026-06-10 00:34:31 +02:00
mika kuns
e272053e72 chore(claude-do): refactor(ui): DetailsIslandViewModel (1431 Zeilen) in Sektio
Kontext: src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs ist mit 1431 Zeilen ein God-VM mit ~12 Concerns (Log-Streaming, Titel/Description-Editing, Subtasks, Child-Outcomes, Merge-Preview/-Targets, Diff, Agent-Settings-Overrides, Notes-Mode, Prep-Mode, Tabs, Session-Outcome/Roadblocks, Worktree-Info). Jedes neue Feature landet dort.

Änderungen — drei klar abgrenzbare Sektionen als ei

ClaudeDo-Task: 483e419f-1ec8-46ba-986b-8b90d6596b49
2026-06-10 00:31:09 +02:00
mika kuns
74ca2e0dcd fix(worker): queue dispatches skip the StartRunning re-claim
The picker claims Queued->Running atomically before dispatch; the new
StartRunningAsync guard then rejected every queue-dispatched run. Add
alreadyClaimed to RunAsync/ContinueAsync (queue passes true, override
slot keeps the guard) and align the routing tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 23:59:56 +02:00
mika kuns
0cba9f9640 Merge task branch for: fix(worker): Abort-Pfad für unterbrochenen Unit-Merge nach Worker-Restart 2026-06-09 23:46:37 +02:00
mika kuns
c6534165b2 Merge task branch for: fix(worker): FailAsync-Guard untersuchen — ist Queued→Failed erreichbar/gewollt? 2026-06-09 23:46:18 +02:00
mika kuns
290b4a602a Merge task branch for: refactor(hub): Konflikt-Merge-Methoden eindeutig benennen (ContinueMerge → ContinueConflictMerge) 2026-06-09 23:45:49 +02:00
mika kuns
fe73f45b74 fix(worker): document and test Queued→Failed guard in FailAsync
OverrideSlotService dispatches RunAsync before calling StartRunningAsync,
so a preflight failure (list not found, worktree setup) can reach MarkFailed
while the task is still Queued. The guard is intentional, not dead code.

- Add comment in FailAsync explaining the OverrideSlotService preflight gap
- Add FailAsync_FromQueued_TransitionsToFailed test
- Update CLAUDE.md transition table with the precise rationale

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 23:41:12 +02:00
mika kuns
d2a08d2cda chore(claude-do): refactor(hub): Konflikt-Merge-Methoden eindeutig benennen (C
Kontext: Auf der Hub/Client-Ebene existieren zwei fast gleichnamige Methodenpaare mit unterschiedlicher Semantik: ContinueMerge/AbortMerge (Single-Task-Konflikt-Resolver, Layer C) vs. ContinuePlanningMerge/AbortPlanningMerge (Unit-Merge eines Parents mit Kindern). Verwechslungsgefahr.

Änderungen (NUR die Hub/Client/UI-Ebene umbenennen):
1. src/ClaudeDo.Worker/Hub/WorkerHub.cs: ContinueMerge → Con

ClaudeDo-Task: 5f2e0f88-d4c9-490b-95a7-46244465dbb6
2026-06-09 23:36:18 +02:00
mika kuns
8194dadb6a Merge task branch for: fix(worker): TaskRunner bricht ab, wenn StartRunningAsync fehlschlägt (Doppellauf-Race) 2026-06-09 23:36:07 +02:00
mika kuns
fb1d799b82 fix(worker): stateless AbortPlanningMerge after worker restart mid-merge
PlanningMergeOrchestrator._states is in-memory. A worker restart during a
conflict pause left the list repo mid-merge with no recovery path: both
ContinuePlanningMerge and AbortPlanningMerge threw "no in-progress merge",
and re-Approving failed on the IsMidMergeAsync guard.

AbortAsync now falls through to a stateless path when no _states entry exists:
it looks up the parent's list WorkingDir and, if the repo is mid-merge, runs
git merge --abort there directly, then broadcasts PlanningMergeAborted.
Parent remains WaitingForReview — the next Approve restarts the unit merge
(already-Merged child worktrees are skipped as before).

ContinueAsync error message now points to AbortPlanningMerge as the recovery
action. StartAsync mid-merge guard also carries an actionable hint.

Tests: AbortAsync stateless + mid-merge (restart recovery), AbortAsync
stateless + clean repo (clear error).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 23:35:08 +02:00
mika kuns
12fdb55a8e chore(claude-do): fix(worker): TaskRunner bricht ab, wenn StartRunningAsync fe
Befund (bestätigt): src/ClaudeDo.Worker/Runner/TaskRunner.cs:101 (RunAsync) und :211 (ContinueAsync) ignorieren das TransitionResult von _state.StartRunningAsync. Race-Szenario: Der QueuePicker claimt Queued→Running atomar; ruft der Override-Pfad (RunNow) kurz danach RunAsync für denselben Task auf, schlägt StartRunningAsync fehl (0 rows affected), der Runner startet Claude aber trotzdem → derselb

ClaudeDo-Task: 44f86be2-7f3d-462e-98b3-eb94c0174eea
2026-06-09 23:32:57 +02:00
mika kuns
eee5c99e2f Merge task branch for: fix(ui): DiffModal — Commit-Range ohne HeadCommit zeigt stillen Falsch-Diff 2026-06-09 23:21:56 +02:00
mika kuns
37df51475e Merge task branch for: fix(worker): FinalizeParentDoneAsync über TaskStateService statt Status-Direkt-Write 2026-06-09 23:21:35 +02:00
mika kuns
53b666dfbd Merge task branch for: refactor(ui): IWorkerClient auf Parität mit WorkerClient bringen 2026-06-09 23:21:23 +02:00
mika kuns
cd5501e6a6 Merge task branch for: test(worker): Fakes nach Infrastructure/ konsolidieren + Tag-Ära-Namen aufräumen 2026-06-09 23:21:11 +02:00
mika kuns
b5417f6b09 refactor(ui): bring IWorkerClient to parity with WorkerClient
Add 16 missing members to IWorkerClient (IsReconnecting, WorkerLogReceivedEvent,
PrimeFired, LastApproveTarget, Refresh/RestoreDefaultAgents, UpdateAppSettings,
prime schedule CRUD, UpdateList/UpdateListConfig, all worktree ops).
Switch all production consumers off the concrete WorkerClient type; only
Program.cs/App host still resolves the concrete registration.
Update StubWorkerClient and FakeWorkerClient to satisfy the expanded interface.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 23:15:05 +02:00
mika kuns
7e739afafb chore(claude-do): fix(ui): DiffModal — Commit-Range ohne HeadCommit zeigt stil
Befund (bestätigt): src/ClaudeDo.Ui/ViewModels/Modals/DiffModalViewModel.cs, LoadAsync (~Zeile 116): bei FromCommitRange=true aber HeadCommit==null fällt der Ternary still auf GetBranchDiffAsync(WorktreePath, BaseRef) zurück. In diesem Modus ist WorktreePath aber das Listen-Working-Dir (Repo-Root, kein Worktree) — es wird ein falscher Diff angezeigt, ohne jeden Hinweis.

Änderungen:
1. Guard: From

ClaudeDo-Task: d667c80c-3f32-478c-8584-46aec78357b6
2026-06-09 23:14:37 +02:00
mika kuns
e9e4ad8fbc fix(worker): route FinalizeParentDoneAsync through TaskStateService
Replaces the direct EF Status write in PlanningMergeOrchestrator with
_state.ApproveReviewAsync, enforcing the TaskStateService invariant as
sole owner of Status writes. Handles the improvement-parent path where
TaskMergeService already approved the parent's own worktree during the
drain (status == Done on entry → still success). If the parent was
concurrently cancelled, the transition guard rejects the approve,
PlanningCompleted is not broadcast, and the cancelled status is
preserved. ApproveReviewAsync now also sets FinishedAt.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 23:13:30 +02:00
mika kuns
d4af345ac3 test(worker): consolidate fakes into Infrastructure/, drop tag-era names
- Extract FakeClaudeProcess to Infrastructure/FakeClaudeProcess.cs (was
  defined inline in QueueServiceTests #region); all consumers updated
- Replace duplicate FakeHubContext/FakeHubClients/FakeClientProxy
  (QueueServiceTests) with existing CapturingHubContext from Infrastructure
  across all 7 affected files; Planning's file-local FakeHubContext kept
- Rename SeedListWithAgentTag → SeedListAsync (return Task<string>, drop
  unused agentTagId tuple element) and SeedListWithAgentTagAsync → SeedListAsync
- PrimeRunnerTests keeps its private nested FakeClaudeProcess: constructor
  API (delay/exitCode/lines/result params) differs from the shared one and
  replacement would require rewriting every test in that file

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 23:04:59 +02:00
mika kuns
ddeded988a docs(open): record correctness-review findings (4 confirmed as tasks)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 22:48:33 +02:00
mika kuns
c27a179d2b feat(worker): let Claude set the cheapest model per generated task via MCP
AddTask, planning CreateChildTask, and SuggestImprovement now accept an
optional alias-validated model (haiku/sonnet/opus; blank = inherit) so the
model is chosen at creation time instead of a follow-up set_task_config call.
The planning, system, and improvement prompts instruct Claude to pick the
cheapest capable model (haiku < sonnet < opus).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 22:22:17 +02:00
mika kuns
1448794748 docs(open): record review findings as refactoring backlog
Five findings filed as ClaudeDo tasks (IWorkerClient parity, merge-API
naming, DetailsIslandViewModel split, test-fake hygiene, FailAsync guard)
plus the deferred WorkerHub split and the AgentMcpTools file move.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 22:18:48 +02:00
mika kuns
51ef488d2f docs: spec + plan for per-task model override via MCP 2026-06-09 22:05:01 +02:00
mika kuns
49046310ef docs: refresh CLAUDE.md files and open.md to current code state
- Ui CLAUDE.md rewritten around the islands architecture (old
  MainWindow/TaskList/StatusBar VMs no longer exist)
- Worker: folder layout (Refine/, Lifecycle/Planning extras), full hub
  method/event surface, external MCP tool inventory
- Data: complete GitService operation list incl. commit-range diffs
- App: missing DI registrations; Tests: current test-area overview
- root: project list (Localization, Installer, six test projects) and
  honest docs index; plan.md/improvement-plan.md marked historical
- open.md: date bump + visual check for new diff viewer / attention band

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 22:00:55 +02:00
ClaudeDo CI
f8f20bf6ed docs(changelog): update for v1.8.0 2026-06-09 14:41:28 +00:00
141 changed files with 8106 additions and 1357 deletions

View File

@@ -1,36 +1,920 @@
# Changelog
All notable changes to ClaudeDo are documented here.
## v1.8.0 — 2026-06-09
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
Versions are derived from git tags (`vX.Y.Z`) via MinVer.
### Features
## [Unreleased]
- 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)
### Fixed
### Fixes
- Review queue is no longer permanently empty: the review virtual list now
matches tasks in `WaitingForReview` (it previously matched `Done` + active
worktree, a state successful runs never land in).
- UI ViewModels (`Details`, `Tasks`, `Lists` islands and the shell) now dispose
their `Loc.LanguageChanged`, worker-event, and timer subscriptions, fixing
long-lived subscription leaks.
- Stopping a task while the worker is offline no longer throws: `StopAsync`
guards on task/running/connection state and handles hub errors.
- 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)
### Hardening
### Refactoring
- Release-readiness audit pass across Worker, Data, UI, MCP, App and Installer
(see `docs/open.md` backlog for tracked follow-ups).
- 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)
## [1.7.0]
### Documentation
- Agent roadblock + run-outcome surfacing in the task detail pane.
- i18n: localized task-header, task-row and prime-schedule tooltips.
- CI: dependency-audit and changelog Gitea workflows.
- Layer A/B/C git merge & conflict-resolution cockpit (multi-worktree
batch-merge, inline conflict resolver).
- 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

View File

@@ -10,7 +10,11 @@ Two-process system communicating over SignalR (`127.0.0.1:47821`):
- **ClaudeDo.Ui** — Views, ViewModels, SignalR client (MVVM with CommunityToolkit.Mvvm)
- **ClaudeDo.Data** — SQLite data layer, repositories, models, GitService
- **ClaudeDo.Worker** — ASP.NET Core hosted service, task queue, Claude CLI runner
- **ClaudeDo.Worker.Tests** — xUnit integration tests with real SQLite and real git
- **ClaudeDo.Localization** — `locales/en.json` + `locales/de.json` and the lookup service
- **ClaudeDo.Installer** — WPF (`UseWPF`) setup app; install/update/uninstall step pipeline
- **tests/** — six xUnit projects (Worker, Data, Ui, Localization, Installer, Releases); Worker.Tests run real SQLite and real git
Each project has its own `CLAUDE.md` — those are the living per-project docs.
## Tech Stack
@@ -75,6 +79,8 @@ dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
## Docs
- `docs/plan.md`full architecture and design spec
- `docs/open.md`verification checklist and improvement backlog
- `docs/improvement-plan.md`prioritized improvement items
- `docs/open.md`open verification items and remaining code TODOs (the only doc kept current besides the CLAUDE.md files)
- `docs/plan.md`original design spec (historical; tag-queue/schema.sql parts are outdated)
- `docs/improvement-plan.md`improvement snapshot from 2026-04-13 (historical)
- `docs/prompts-inventory.md`, `docs/mailbox-proposal.md` — reference material (mailbox integration is parked)
- `CHANGELOG.md` — Keep a Changelog format, maintained on release

View File

@@ -1,5 +1,7 @@
# ClaudeDo — Improvement Plan (Session 2026-04-13)
> **Hinweis (2026-06-09):** Historischer Snapshot — bewusst nicht nachgepflegt. U.a. erledigt/überholt: IP-1 (Auto-Reconnect ist implementiert), `schema.sql` → EF-Core-Migrations, `StatusBarViewModel` existiert nicht mehr (Connection-State lebt in `IslandsShellViewModel`), Tags sind Junction-Tabellen statt JSON-Spalten. Offene Punkte stehen in `open.md`.
Erfasst während manuellem Walkthrough der App. Priorisiert nach Schmerz/Aufwand.
---

View 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.

View File

@@ -1,6 +1,6 @@
# ClaudeDo — Offene Punkte
Stand: 2026-06-04. **Nur noch offene Punkte.** Was erledigt ist, steht in den Commits und im Code — nicht hier.
Stand: 2026-06-10. **Nur noch offene Punkte.** Was erledigt ist, steht in den Commits und im Code — nicht hier.
---
@@ -13,11 +13,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`.
- Kein Git-Repo (`working_dir=C:\Temp`) → `status='Failed'`, **keine** `worktrees`-Row, Git-Fehler im Log.
- **Feature-Walkthroughs:** Planning-Session-Flow (Draft→Finalize→Chain), Prime/Daily-Prep-Trigger, Weekly-Report-Generierung, Self-Update (Banner → Update → „up to date").
- **UI-Sichtprüfung (neu, 2026-06-09):** Diff-Viewer (Dateiliste, Added/Deleted/Renamed/Binary-Erkennung, Commit-Range-Diff nach Merge) und das „children need attention"-Band auf dem Session-Tab des Parents.
- **UI-Sichtprüfung (neu, 2026-06-10, nach Refactoring-Merges):** Detail-Insel komplett durchklicken (Output/Git/Session-Tabs, Merge-Sektion, Agent-Settings-Overrides, Prep-Panel) — `DetailsIslandViewModel` wurde in Sektions-VMs aufgeteilt, Bindings angepasst. Außerdem: DiffModal-Fehler-State „Diff nicht mehr verfügbar" (Commit-Range ohne aufgezeichnete Commits) und der In-App-Konflikt-Resolver (Hub-Methoden umbenannt).
- **UI-Sichtprüfung (neu, 2026-06-19, Rider-Style 3-Pane Merge-Editor):** Echten Konflikt auslösen (Single-Task-Approve mit Konflikt **und** Planning-Unit-Merge) und prüfen: drei Panes (Ours read-only | Result editierbar | Theirs read-only), Konfliktblöcke rot / aufgelöst grün in allen Panes, Inline-Accept ``/`` in den Zwischen-Guttern landen die jeweilige Seite im Result, nur Konfliktregionen im Result editierbar (Stable read-only), synchrones vertikales Scrollen, File-Switcher bei mehreren Dateien, `M conflicts · K resolved`-Readout, Continue erst bei allen Konflikten gelöst, Binär-Guard. **Bekannte Kanten:** (1) Konflikt mit leerer Ours-Seite → Result-Region ist null-lang (Gutter via 1-Zeichen-Probe positioniert, Accept funktioniert; nur Hand-Tippen in die leere Region ist fummelig). (2) Gutter-Y nutzt `TranslatePoint` vom Result-`TextView` — bei sehr hohen Fenstern / großen Scrollständen die Ausrichtung gegenprüfen. (3) Blöcke richten sich nur über Stable-Text aus; nach einem Konflikt mit unterschiedlicher Zeilenzahl je Seite driften nachfolgende Blöcke vertikal (aligned/virtual-space Scroll ist bewusst zurückgestellt).
- **Worker-Autostart am Gerät:** Logoff/Logon-Autostart, Update-Pfad, Uninstall entfernt die Startup-`.lnk`.
## Offene Code-Punkte
- **Status-Bar Live-Update:** Prüfen, ob `RunNow`-Enable/Disable pro Task-Row bei Connection-Change sauber re-evaluiert. Connection-Status lebt in `IslandsShellViewModel` / `WorkerConnectionModalViewModel` (es gibt keinen `StatusBarViewModel` mehr). Erst messen, dann ggf. fixen. Klein.
- **`AgentMcpTools` liegt in `LifecycleMcpTools.cs`** — beim Suchen irreführend; in eigene Datei verschieben. Ein-Minuten-Fix, lohnt keinen Agent-Lauf — beim nächsten Worker-Touch mitnehmen.
## Nachklapp Refactoring-/Bug-Runde (2026-06-09/10)
Alle 9 Review-Tasks (5 Refactorings, 4 Bugfixes) sind umgesetzt und gemerged; Details in den Commits. Offen geblieben:
- **`DetailsIslandViewModel` ist nach dem Split noch 1258 Zeilen** (Ziel war ~800) — die drei Sektions-VMs (AgentSettings, Merge, Prep) sind extrahiert, weitere Extraktion (z.B. ChildOutcomes/Subtasks-Sektion) lohnt erst, wenn die Datei wieder wächst.
- **Bewusst zurückgestellt:** WorkerHub-Split nach Concern (~60 Methoden in einer Hub-Klasse). Die Interface-Parität löst das akute Testbarkeits-Problem; ein Hub-Split ist eine größere Architekturentscheidung → erst besprechen.
- **Lessons learned:** Der `StartRunningAsync`-Guard-Task hat isoliert grün getestet, aber den Queue-Pfad gebrochen (Picker claimt vor dem Dispatch) — Integrationsfix `74ca2e0`. Bei parallelen Tasks, die denselben Pfad berühren, nach JEDEM Merge-Schwung die volle Suite auf main fahren.
## Bug-Befunde (Korrektheits-Review 2026-06-09)
**Plausibel, noch nicht einzeln verifiziert (bei Gelegenheit prüfen):**
- Cancel eines `WaitingForChildren`-Parents kaskadiert nicht auf laufende/queued Kinder (verwaiste Worktree-Commits).
- Ketten-Kaskade stoppt an einem `Idle`-Mittelglied (`OnChildFinishedAsync` prüft `CancelAsync`-Ergebnis nicht) → Rest bleibt `Queued+blocked`.
- Delete des *letzten* nicht-terminalen Kindes triggert kein `TryAdvanceParentAsync` → Parent kann in `WaitingForChildren` hängen (FK `SET NULL` rettet nur die Blocked-Kette).
- `ContinueMergeAsync` staged per `git add -A` vor dem Konflikt-Check (Marker im Index, Abort danach ggf. unsauber).
- `HasChangesAsync` zählt untracked Files → blockiert Merges unnötig (`--untracked-files=no`).
- `UnifiedDiffParser`: Pfade mit Leerzeichen / git-gequotete Pfade aus `diff --git` falsch geparst.
- Kleinkram: MergePreview-Race bei schnellem Target-Wechsel, CTS-Dispose-Leak in Debounce-Saves, `Environment.CurrentDirectory`-Fallback im Konflikt-Dialog, Doppel-Continue-Fenster im Orchestrator.
**Geprüft und verworfen (keine Bugs):** ReviewFeedback-„Endlosschleife" (Fallback existiert), Cross-Thread-Crashes im DetailsIslandViewModel (Dispatcher-Marshalling im WorkerClient), Chain-Wedge nach Child-Delete (FK `ON DELETE SET NULL`), `\ No newline`-Parsing.
---

View File

@@ -1,5 +1,7 @@
# ToDo-App mit autonomem Agent-Worker — Design
> **Hinweis (2026-06-09):** Historisches Design-Dokument vom Projektstart — bewusst nicht nachgepflegt. Überholt sind insbesondere: die Tag-basierte Queue (entfernt; der Picker nutzt `Status=Queued` + `BlockedByTaskId IS NULL`), `schema.sql` (Schema läuft über EF-Core-Migrations) und das Projektlayout (inzwischen sechs Testprojekte). Lebende Doku sind die `CLAUDE.md`-Dateien pro Projekt.
## Context
Ziel: eine persönliche ToDo-App als Desktop-Anwendung, in der mehrere Listen verwaltet werden können. Ein Teil der Tasks soll autonom von Claude abgearbeitet werden (z.B. Recherche, Code-Aufgaben, Notizen-Verarbeitung). Die Autonomie läuft in einem getrennten Hintergrund-Prozess, damit die UI davon entkoppelt bleibt.

View File

@@ -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 34; 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.

View 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.

View 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.

View File

@@ -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).

View 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.)

View 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.

View File

@@ -31,6 +31,7 @@
<Application.Styles>
<FluentTheme />
<StyleInclude Source="avares://AvaloniaEdit/Themes/Fluent/AvaloniaEdit.xaml" />
<StyleInclude Source="avares://ClaudeDo.Ui/Design/IslandStyles.axaml" />
<!-- Global defaults: every Window inherits Inter Tight + body size.
Controls that need mono opt in via their own class/style. -->

View File

@@ -19,8 +19,8 @@ Desktop entry point for the ClaudeDo application. Configures DI, initializes the
## DI Registration Pattern
- **Singletons**: `IDbContextFactory`, all Repositories, GitService, WorkerClient, `IReleaseClient`, `InstallerLocator` / `WorkerLocator`, the island VMs (`ListsIslandViewModel`, `TasksIslandViewModel`, `DetailsIslandViewModel`) and `IslandsShellViewModel` (the window's DataContext)
- **Transients**: modal VMs (`SettingsModalViewModel`, `MergeModalViewModel`, `ListSettingsModalViewModel`, `RepoImportModalViewModel`, `WorktreeModalViewModel`, `WorktreesOverviewModalViewModel`, `PrimeClaudeTabViewModel`), several exposed as `Func<T>` factories for on-demand dialog creation
- **Singletons**: `IDbContextFactory`, all Repositories, GitService, WorkerClient, `IReleaseClient`, `UpdateCheckService`, `IPrimeScheduleApi`/`WorkerPrimeScheduleApi`, `INotesApi`/`WorkerNotesApi`, `InstallerLocator` / `WorkerLocator`, the island VMs (`ListsIslandViewModel`, `TasksIslandViewModel`, `DetailsIslandViewModel`) and `IslandsShellViewModel` (the window's DataContext)
- **Transients**: modal VMs (`SettingsModalViewModel`, `MergeModalViewModel`, `ListSettingsModalViewModel`, `RepoImportModalViewModel`, `WeeklyReportModalViewModel`, `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

View File

@@ -14,10 +14,12 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="12.0.0" />
<PackageReference Include="Avalonia.Desktop" Version="12.0.0" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.0" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.0" />
<PackageReference Include="Avalonia" Version="12.0.4" />
<PackageReference Include="Avalonia.Desktop" Version="12.0.4" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.4" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="12.0.4" />
<!-- Direct ref so the App.axaml AvaloniaEdit theme (avares://AvaloniaEdit/...) resolves at runtime. -->
<PackageReference Include="Avalonia.AvaloniaEdit" Version="12.0.0" />
<PackageReference Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.0">
<IncludeAssets Condition="'$(Configuration)' != 'Debug'">None</IncludeAssets>
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>

View File

@@ -123,6 +123,7 @@ sealed class Program
sc.AddTransient<Func<WorktreesOverviewModalViewModel>>(sp => () => sp.GetRequiredService<WorktreesOverviewModalViewModel>());
sc.AddSingleton<IPrimeScheduleApi, WorkerPrimeScheduleApi>();
sc.AddSingleton<INotesApi, WorkerNotesApi>();
sc.AddSingleton<IOnlineLoginService, OnlineLoginService>();
sc.AddTransient<PrimeClaudeTabViewModel>();
sc.AddTransient<SettingsModalViewModel>();
sc.AddTransient<MergeModalViewModel>();
@@ -134,22 +135,22 @@ sealed class Program
sc.AddTransient<Func<WeeklyReportModalViewModel>>(sp => () => sp.GetRequiredService<WeeklyReportModalViewModel>());
sc.AddSingleton<Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>>(sp =>
taskId => new ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel(
sp.GetRequiredService<WorkerClient>(), taskId));
sp.GetRequiredService<IWorkerClient>(), taskId));
// Islands shell VMs
sc.AddSingleton<ListsIslandViewModel>(sp =>
new ListsIslandViewModel(
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
sp,
sp.GetRequiredService<WorkerClient>()));
sp.GetRequiredService<IWorkerClient>()));
sc.AddSingleton<TasksIslandViewModel>(sp =>
new TasksIslandViewModel(
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
sp.GetRequiredService<WorkerClient>()));
sp.GetRequiredService<IWorkerClient>()));
sc.AddSingleton<DetailsIslandViewModel>(sp =>
new DetailsIslandViewModel(
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
sp.GetRequiredService<WorkerClient>(),
sp.GetRequiredService<IWorkerClient>(),
sp,
sp.GetRequiredService<INotesApi>()));
sc.AddSingleton<IslandsShellViewModel>(sp =>

View File

@@ -35,7 +35,7 @@ All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. The atomic `Q
## 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

View 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;
}
}

View File

@@ -252,8 +252,11 @@ public sealed class GitService
public async Task<(int ExitCode, string Stderr)> MergeNoFfAsync(
string repoDir, string sourceBranch, string message, CancellationToken ct = default)
{
// diff3 conflict style writes the merge base (|||||||) into conflict markers so the
// in-app resolver can show a true three-way view. It only enriches conflicted hunks;
// clean merges are unaffected.
var (exitCode, _, stderr) = await RunGitAsync(repoDir,
["merge", "--no-ff", "-m", message, sourceBranch], ct);
["-c", "merge.conflictStyle=diff3", "merge", "--no-ff", "-m", message, sourceBranch], ct);
return (exitCode, stderr);
}

View File

@@ -4,9 +4,26 @@ public static class ModelRegistry
{
public static readonly IReadOnlyList<string> Aliases = new[] { "sonnet", "opus", "haiku" };
/// <summary>Model aliases ordered cheapest → most capable. Single source for prompt cost guidance.</summary>
public static readonly IReadOnlyList<string> ByCostAscending = new[] { "haiku", "sonnet", "opus" };
public const string DefaultAlias = "sonnet";
public const string PlanningAlias = "opus";
public const string ListDefaultSentinel = "(default)";
public const string TaskInheritSentinel = "(inherit)";
/// <summary>
/// Validate a model alias from external input. Null/blank → null (inherit).
/// Returns the canonical lowercase alias; throws on an unknown value.
/// </summary>
public static string? NormalizeAlias(string? model)
{
var m = model?.Trim();
if (string.IsNullOrEmpty(m)) return null;
foreach (var alias in Aliases)
if (string.Equals(alias, m, StringComparison.OrdinalIgnoreCase))
return alias;
throw new ArgumentException($"Unknown model '{model}'. Allowed: {string.Join(", ", Aliases)}.");
}
}

View File

@@ -82,7 +82,10 @@ public static class PromptFiles
## Out-of-scope improvements
If you notice worthwhile work that is genuinely outside this task's scope
(a refactor, a follow-up, tech debt), do NOT do it here. File it with
SuggestImprovement(title, description) and stay focused on the task at hand.
SuggestImprovement(title, description, model) and stay focused on the task at hand.
Set `model` to the cheapest model that can do the follow-up well 'haiku' for
trivial/mechanical work, 'sonnet' for normal coding, 'opus' only for genuinely
complex work (cheapest to most capable: haiku < sonnet < opus).
## Working in the repo
- Read a file before editing it. Match the conventions already in this codebase
@@ -122,8 +125,8 @@ public static class PromptFiles
# Out-of-scope follow-up
You are an improvement follow-up that another task filed via SuggestImprovement.
It was deliberately scoped narrow. Do EXACTLY what this task's title and
description ask nothing more.
It was deliberately scoped narrow, and is intentionally a small, cheap unit of
work. Do EXACTLY what this task's title and description ask nothing more.
- Make the smallest change that satisfies the task. No opportunistic refactors,
renames, reformatting, or "while I'm here" cleanup beyond what is asked.
@@ -150,6 +153,14 @@ public static class PromptFiles
Once the design is approved, create the child tasks with CreateChildTask, then
call Finalize. Keep each subtask concrete and self-contained with a clear
done-state, ordered so dependencies come first.
For each subtask, pass CreateChildTask's `model` argument set to the CHEAPEST
model that can do that subtask well. Models, cheapest to most capable:
haiku < sonnet < opus.
- haiku trivial/mechanical work: doc tweaks, simple renames, small localized edits.
- sonnet normal coding work; the sensible default when unsure.
- opus only for genuinely complex, cross-cutting, or hard-to-debug work.
Do not default everything to opus most subtasks are haiku or sonnet.
""";
private const string PlanningInitialDefault = """

View File

@@ -87,6 +87,22 @@ public sealed class TaskRepository
.ToListAsync(ct);
}
/// <summary>
/// Returns all tasks that qualify as "real" Idle backlog items for online mirroring:
/// Status==Idle, no parent, PlanningPhase==None, not blocked.
/// </summary>
public async Task<List<TaskEntity>> GetAllIdleBacklogAsync(CancellationToken ct = default)
{
return await _context.Tasks
.AsNoTracking()
.Where(t => t.Status == TaskStatus.Idle
&& t.ParentTaskId == null
&& t.PlanningPhase == PlanningPhase.None
&& t.BlockedByTaskId == null)
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
.ToListAsync(ct);
}
#endregion
#region Status transitions
@@ -197,6 +213,7 @@ public sealed class TaskRepository
string? description,
string? commitType,
string? createdBy = null,
string? model = null,
CancellationToken ct = default)
{
// AsNoTracking: SetPlanningStartedAsync mutates via ExecuteUpdate which
@@ -223,6 +240,7 @@ public sealed class TaskRepository
ParentTaskId = parentId,
SortOrder = (maxSort ?? -1) + 1,
CreatedBy = createdBy,
Model = ModelRegistry.NormalizeAlias(model),
};
_context.Tasks.Add(child);
await _context.SaveChangesAsync(ct);

View File

@@ -63,6 +63,26 @@
"daySa": "Sa",
"daySu": "So"
},
"onlineInbox": {
"tabHeader": "Online-Posteingang",
"enabledLabel": "Online-Posteingang-Sync aktivieren",
"restartHint": "Aktivieren oder Deaktivieren wird erst nach einem Worker-Neustart wirksam.",
"apiBaseUrlLabel": "API-Basis-URL",
"apiBaseUrlPlaceholder": "https://inbox.claudedo.kuns.dev",
"authorityLabel": "Zitadel-Authority (Issuer-URL)",
"authorityPlaceholder": "https://auth.example.com",
"clientIdLabel": "Client-ID",
"scopesLabel": "Scopes",
"redirectUriLabel": "Redirect-URI",
"pollIntervalLabel": "Abfrageintervall (Sekunden)",
"statusSection": "AUTH-STATUS",
"signedInStatus": "Angemeldet",
"signedOutStatus": "Nicht angemeldet",
"signInButton": "Im Browser anmelden",
"signOutButton": "Abmelden",
"configSection": "KONFIGURATION",
"saveButton": "Konfiguration speichern"
},
"inherit": {
"inheritedFromList": "geerbt · Liste",
"inheritedFromGlobal": "geerbt · Global",
@@ -90,10 +110,12 @@
"ctxRunInteractively": "Interaktiv ausführen",
"ctxOpenPlanningSession": "Planungssitzung öffnen",
"ctxResumePlanningSession": "Planungssitzung fortsetzen",
"ctxFinalizePlanningSession": "Plan finalisieren",
"ctxDiscardPlanningSession": "Planungssitzung verwerfen",
"ctxQueueSubtasks": "Teilaufgaben nacheinander einreihen",
"ctxScheduleFor": "Planen für...",
"ctxClearSchedule": "Zeitplan entfernen",
"ctxRemoveFromMyDay": "Aus Mein Tag entfernen",
"ctxAddToMyDay": "Zu Mein Tag hinzufügen",
"badgeDraft": "ENTWURF",
"badgePlanned": "GEPLANT",
"approve": "Genehmigen",
@@ -378,13 +400,17 @@
"windowTitle": "Merge-Konflikte lösen",
"modalTitle": "KONFLIKTE LÖSEN",
"loading": "Konflikte werden geladen…",
"current": "Aktuell (unsere)",
"incoming": "Eingehend (ihre)",
"mergedResult": "Zusammengeführtes Ergebnis",
"acceptCurrent": "Aktuelle übernehmen",
"acceptIncoming": "Eingehende übernehmen",
"acceptBoth": "Beide übernehmen",
"editManually": "Manuell bearbeiten",
"ours": "MAIN · Ziel-Branch",
"result": "ERGEBNIS",
"theirs": "INCOMING · Task-Branch",
"binaryHint": "Binärdateien können hier nicht zusammengeführt werden — brich ab und löse sie in deinem Editor:",
"prevConflict": "Vorheriger Konflikt (Umschalt+F8)",
"nextConflict": "Nächster Konflikt (F8)",
"conflictMap": "Konflikte in dieser Datei — Marker anklicken zum Springen",
"acceptOurs": "Main hinzufügen",
"acceptTheirs": "Incoming hinzufügen",
"removeOurs": "Main entfernen",
"removeTheirs": "Incoming entfernen",
"continue": "Lösen & fortfahren",
"abort": "Merge abbrechen"
},
@@ -401,6 +427,8 @@
"shell": {
"menu": {
"help": "Hilfe",
"worker": "Worker",
"repositories": "Repositories",
"checkForUpdates": "Nach Updates suchen",
"restartWorker": "Worker neu starten",
"worktrees": "Worktrees…",
@@ -418,15 +446,16 @@
"connection": { "online": "Online", "connecting": "Verbinden…", "offline": "Offline" },
"shell": { "restartingWorker": "Worker wird neu gestartet…" },
"agentStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "review": "Prüfung", "children": "Wartet auf Verbesserungen", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen" },
"taskStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "waitingForReview": "Wartet auf Prüfung", "waitingForChildren": "Wartet auf Verbesserungen", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen" },
"taskStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "waitingForReview": "Wartet auf Prüfung", "waitingForChildren": "Wartet auf Verbesserungen", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen", "parked": "Geparkt" },
"planningBadge": { "active": "PLANUNG", "finalized": "GEPLANT" },
"taskRow": { "createdPrefix": "Erstellt {0}", "stepsText": "{0}/{1} Schritte" },
"tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}" },
"diff": { "loadFailed": "Diff konnte nicht geladen werden: {0}", "noChanges": "Keine Änderungen anzuzeigen." },
"diff": { "loadFailed": "Diff konnte nicht geladen werden: {0}", "noChanges": "Keine Änderungen anzuzeigen.", "unavailable": "Diff nicht mehr verfügbar — Commit-Bereich unvollständig." },
"planningDiff": { "hubError": "Kombinierte Vorschau konnte nicht erstellt werden (Hub-Fehler).", "conflict": "Kombinierte Vorschau nicht möglich: Teilaufgabe {0} steht im Konflikt mit einer früheren Teilaufgabe ({1} Dateien)." },
"merge": { "commitMessage": "Merge-Aufgabe: {0}", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "conflict": "Merge-Konflikt — Ziel-Branch wiederhergestellt. Manuell oder über Fortsetzen lösen, dann erneut versuchen.", "blocked": "Blockiert: {0}", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" },
"conflictResolution": { "vsCodeError": "VS Code konnte nicht gestartet werden: {0}. Die Pfade sind oben aufgeführt — kopiere sie manuell.", "subtaskPrefix": "Konflikte in Teilaufgabe: {0}", "targetPrefix": "Zusammenführen in: {0}" },
"settingsModal": { "workerOffline": "Worker offline — Einstellungen schreibgeschützt.", "saveFailed": "Speichern fehlgeschlagen: {0}" },
"onlineInbox": { "workerOffline": "Worker offline — Konfiguration kann nicht geladen werden.", "saved": "Konfiguration gespeichert.", "saveFailed": "Speichern fehlgeschlagen: {0}", "signedIn": "Erfolgreich angemeldet.", "signedInNoRole": "Angemeldet, aber diesem Konto fehlt die Rolle 'user' in Zitadel — die Online-Synchronisierung wird abgelehnt, bis die Rolle im ClaudeDo-Projekt zugewiesen wird.", "signInFailed": "Anmeldung fehlgeschlagen: {0}", "signedOut": "Abgemeldet.", "signOutFailed": "Abmeldung fehlgeschlagen: {0}" },
"weeklyReport": { "invalidRange": "Ungültiger Datumsbereich.", "generating": "Bericht wird erstellt…", "error": "Fehler: {0}" },
"filesTab": { "workerOffline": "Worker offline.", "noneBundled": "Keine Standard-Agenten mitgeliefert.", "allPresent": "Alle Standard-Agenten bereits vorhanden.", "restored": "{0} Standard-Agent(en) wiederhergestellt.", "restoreFailed": "Wiederherstellung fehlgeschlagen: {0}", "openFailed": "Öffnen fehlgeschlagen: {0}" },
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "{0} Worktree(s) entfernt.", "blocked": "Zwangsentfernung nicht möglich: {0} Aufgabe(n) laufen noch. Brich sie zuerst ab.", "removedFrom": "{0} Worktree(s) von {1} Aufgabe(n) entfernt." },

View File

@@ -63,6 +63,26 @@
"daySa": "Sa",
"daySu": "Su"
},
"onlineInbox": {
"tabHeader": "Online Inbox",
"enabledLabel": "Enable online inbox sync",
"restartHint": "Enabling or disabling takes effect after a Worker restart.",
"apiBaseUrlLabel": "API base URL",
"apiBaseUrlPlaceholder": "https://inbox.claudedo.kuns.dev",
"authorityLabel": "Zitadel authority (issuer URL)",
"authorityPlaceholder": "https://auth.example.com",
"clientIdLabel": "Client ID",
"scopesLabel": "Scopes",
"redirectUriLabel": "Redirect URI",
"pollIntervalLabel": "Poll interval (seconds)",
"statusSection": "AUTH STATUS",
"signedInStatus": "Signed in",
"signedOutStatus": "Not signed in",
"signInButton": "Sign in via browser",
"signOutButton": "Sign out",
"configSection": "CONFIGURATION",
"saveButton": "Save config"
},
"inherit": {
"inheritedFromList": "inherited · List",
"inheritedFromGlobal": "inherited · Global",
@@ -90,10 +110,12 @@
"ctxRunInteractively": "Run interactively",
"ctxOpenPlanningSession": "Open planning Session",
"ctxResumePlanningSession": "Resume planning Session",
"ctxFinalizePlanningSession": "Finalize plan",
"ctxDiscardPlanningSession": "Discard planning session",
"ctxQueueSubtasks": "Queue subtasks sequentially",
"ctxScheduleFor": "Schedule for...",
"ctxClearSchedule": "Clear schedule",
"ctxRemoveFromMyDay": "Remove from My Day",
"ctxAddToMyDay": "Add to My Day",
"badgeDraft": "DRAFT",
"badgePlanned": "PLANNED",
"approve": "Approve",
@@ -378,13 +400,17 @@
"windowTitle": "Resolve merge conflicts",
"modalTitle": "RESOLVE CONFLICTS",
"loading": "Loading conflicts…",
"current": "Current (ours)",
"incoming": "Incoming (theirs)",
"mergedResult": "Merged result",
"acceptCurrent": "Accept Current",
"acceptIncoming": "Accept Incoming",
"acceptBoth": "Accept Both",
"editManually": "Edit manually",
"ours": "MAIN · merge target",
"result": "RESULT",
"theirs": "INCOMING · task branch",
"binaryHint": "Binary files can't be merged here — abort and resolve them in your editor:",
"prevConflict": "Previous conflict (Shift+F8)",
"nextConflict": "Next conflict (F8)",
"conflictMap": "Conflicts in this file — click a marker to jump",
"acceptOurs": "Add main",
"acceptTheirs": "Add incoming",
"removeOurs": "Remove main",
"removeTheirs": "Remove incoming",
"continue": "Resolve & continue",
"abort": "Abort merge"
},
@@ -401,6 +427,8 @@
"shell": {
"menu": {
"help": "Help",
"worker": "Worker",
"repositories": "Repositories",
"checkForUpdates": "Check for updates",
"restartWorker": "Restart worker",
"worktrees": "Worktrees…",
@@ -418,15 +446,16 @@
"connection": { "online": "Online", "connecting": "Connecting…", "offline": "Offline" },
"shell": { "restartingWorker": "Restarting worker…" },
"agentStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "review": "Review", "children": "Waiting for Improvements", "done": "Done", "failed": "Failed", "cancelled": "Cancelled" },
"taskStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "waitingForReview": "Waiting for Review", "waitingForChildren": "Waiting for Improvements", "done": "Done", "failed": "Failed", "cancelled": "Cancelled" },
"taskStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "waitingForReview": "Waiting for Review", "waitingForChildren": "Waiting for Improvements", "done": "Done", "failed": "Failed", "cancelled": "Cancelled", "parked": "Parked" },
"planningBadge": { "active": "PLANNING", "finalized": "PLANNED" },
"taskRow": { "createdPrefix": "Created {0}", "stepsText": "{0}/{1} steps" },
"tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}" },
"diff": { "loadFailed": "Failed to load diff: {0}", "noChanges": "No changes to show." },
"diff": { "loadFailed": "Failed to load diff: {0}", "noChanges": "No changes to show.", "unavailable": "Diff no longer available — commit range incomplete." },
"planningDiff": { "hubError": "Could not build combined preview (hub error).", "conflict": "Cannot build combined preview: subtask {0} conflicts with an earlier subtask ({1} files)." },
"merge": { "commitMessage": "Merge task: {0}", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "conflict": "Merge conflict — target branch restored. Resolve manually or via Continue, then retry.", "blocked": "Blocked: {0}", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" },
"conflictResolution": { "vsCodeError": "Could not launch VS Code: {0}. Paths are listed above — copy them manually.", "subtaskPrefix": "Conflicts in subtask: {0}", "targetPrefix": "Merging into: {0}" },
"settingsModal": { "workerOffline": "Worker offline — settings read-only.", "saveFailed": "Save failed: {0}" },
"onlineInbox": { "workerOffline": "Worker offline — cannot load config.", "saved": "Config saved.", "saveFailed": "Save failed: {0}", "signedIn": "Signed in successfully.", "signedInNoRole": "Signed in, but this account is missing the 'user' role in Zitadel — online sync will be rejected until the role is granted in the ClaudeDo project.", "signInFailed": "Sign-in failed: {0}", "signedOut": "Signed out.", "signOutFailed": "Sign-out failed: {0}" },
"weeklyReport": { "invalidRange": "Invalid date range.", "generating": "Generating report…", "error": "Error: {0}" },
"filesTab": { "workerOffline": "Worker offline.", "noneBundled": "No default agents bundled.", "allPresent": "All default agents already present.", "restored": "Restored {0} default agent(s).", "restoreFailed": "Restore failed: {0}", "openFailed": "Open failed: {0}" },
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "Removed {0} worktree(s).", "blocked": "Cannot force-remove: {0} task(s) still running. Cancel them first.", "removedFrom": "Removed {0} worktree(s) from {1} task(s)." },

View File

@@ -8,56 +8,61 @@ MVVM with CommunityToolkit.Mvvm source generators:
- `[ObservableProperty]` for bindable properties
- `[RelayCommand]` for commands (supports async and CanExecute)
- All ViewModels inherit `ViewModelBase` (extends `ObservableObject`)
- All views use compiled bindings (`x:DataType`)
## Views
## Layout: Islands
- **MainWindow** — 3-column DockPanel layout (lists | tasks | detail) with GridSplitter, status bar at bottom
- **TaskListView** — ListBox of tasks with add/edit/delete toolbar
- **TaskDetailView** — Task info, live log output, worktree section (merge/keep/discard)
- **TaskEditorView** — Modal dialog for task create/edit
- **ListEditorView** — Modal dialog for list create/edit
- **StatusBarView** — Connection status indicator, active task display
- **ListSettingsModalView** — edits list name, working dir, default commit type, and per-list Model/SystemPrompt/AgentPath/MaxTurns, each showing the inherited (global) value with a source-aware "inherited · Global / override" badge and a reset-to-inherited button; also deletes the list (and its tasks) via a confirmed "Delete list" button. Opened via context menu or gear button on a list row.
- **RepoImportModalView** — bulk-creates lists from git repos discovered under chosen parent folders. Opened via the folder button beside "New list" in the Lists island, or the "Add repos as lists…" Help-menu item. Repos already wired to a list show as disabled/"(already added)".
- **DetailsIslandView** — contains an "Agent settings (overrides)" expander with per-task Model/MaxTurns/AgentPath (override semantics, each showing the resolved inherited value as a placeholder plus a source-aware "inherited · List / inherited · Global / override" badge and reset button via the reusable `InheritedBadge` control + `InheritanceResolver`) and a SystemPrompt text box (additive — shows the inherited prompt as a "prepended automatically" note). Disabled while task is running. When notes mode is active (`IsNotesMode`), it hosts **NotesEditorView** instead of the task detail. When prep mode is active (`IsPrepMode`), it hosts the daily-prep panel (Plan day button, empty-state hint, embedded **SessionTerminalView**). The task header, metadata footer (delete/close), and **AgentStripView** are gated on `IsTaskDetailVisible` — they are hidden in both notes and prep mode.
- **WeeklyReportModalView** — opened from Help menu ("Wochenbericht…"); date-range pickers default to "since last standup weekday → today"; Generate/Regenerate button; renders markdown via MarkdownView; reports are cached per range.
- **NotesEditorView** — day navigator (prev/next/date-picker/Today), bullet add/edit/delete for daily notes.
- **SessionTerminalView** — reusable log terminal; exposes StyledProperties `Entries`, `Label`, `IsRunning`, `IsDone`, `IsFailed`. Used for both the task `Log` and the prep `PrepLog`.
- **SettingsModalView** — Prime Claude tab contains a `DailyPrepMaxTasks` numeric editor.
- **TasksIslandView** (MyDay header) — icon buttons visible only when `IsMyDayList`: broom icon = `ClearDayCommand`, stroked-sun icon ("Plan My Day") = `ShowPrepLogCommand`.
`MainWindow` hosts three "islands" (lists | tasks | details). There is no MainWindowViewModel, StatusBarView, or task/list editor modal — the root coordinator is **IslandsShellViewModel**, and task/list editing happens inline in the islands.
All views use compiled bindings (`x:DataType`).
```
ViewModels/
IslandsShellViewModel.cs — root coordinator
Islands/ — ListsIsland, TasksIsland, DetailsIsland, TaskRow, ListNavItem,
NotesEditor, MergePreviewPresenter
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
- **MainWindowViewModel** — root coordinator; manages list collection, selected list, dialog creation via `Func<T>` factories
- **TaskListViewModel** — manages task collection for selected list; handles CRUD, "Run Now"
- **TaskDetailViewModel** — displays task details, streams live log, controls worktree operations
- **TaskItemViewModel** / **ListItemViewModel** — lightweight display VMs
- **TaskEditorViewModel** / **ListEditorViewModel**dialog VMs with validation
- **StatusBarViewModel** — connection state and active tasks
- **WeeklyReportModalViewModel** — drives the weekly report modal
- **NotesEditorViewModel** — manages daily note bullet CRUD for the selected day
- **DetailsIslandViewModel** gains `IsNotesMode`, `ShowNotes()`, and hosts `NotesEditorViewModel`. Also gains daily-prep mode: `IsPrepMode`, `PrepLog` (`ObservableCollection<LogLineViewModel>`), `ShowPrep()`, `PlanDayCommand` (calls `RunDailyPrepNowAsync`), `ShowPrepEmptyState`, and computed `IsTaskDetailVisible` (= `!IsNotesMode && !IsPrepMode`). Subscribes to `PrepStartedEvent`, `PrepLineEvent`, `PrepFinishedEvent`; streams lines into `PrepLog` via `StreamLineFormatter` (same path as task logs). On open, if log is empty and no run is in progress, loads persisted last run via `GetLastPrepLogAsync`. The WorkConsole Session tab gains a mergeability indicator (`MergePreviewPresenter`) and a single-task Merge button; indicator is populated via `PreviewMergeAsync` and displayed for tasks in WaitingForReview.
- **TasksIslandViewModel** gains a pinned "Notes" pseudo-row (`ShowNotesRow`, `OpenNotesCommand`, `NotesRequested` event) that switches the Details island to notes mode. Also gains `IsMyDayList` (true when selected list is `smart:my-day`), `ShowPrepLogCommand` (raises `PrepRequested` event → shell calls `Details.ShowPrep()`), and `ClearDayCommand` (calls `ClearMyDayAsync`).
- **IslandsShellViewModel** — root coordinator; owns the three island VMs and the `WorkerClient`, wires cross-island events (selection, notes/prep mode, conflict resolution), owns connection state, the update banner, the inline worker-log strip, 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`.
- **ListsIslandViewModel** — smart lists (My Day, Important, Planned, virtual queued/running/review), user lists, selection, list CRUD, drag-reorder, badge counts, opens list settings / repo import / worktrees overview, `OpenInExplorer`/`OpenInTerminal`.
- **TasksIslandViewModel** — open/overdue/completed groups for the selected list with hierarchy-aware regrouping; task CRUD, drag-reorder, toggle done/star, schedule, enqueue/dequeue, cancel; review actions (approve, reject-rerun, reject-park, cancel); planning session lifecycle (open/resume/discard/finalize, `QueuePlanningSubtasksAsync`); `RunInteractivelyAsync`, `RefineTask`; MyDay extras (`IsMyDayList`, `ClearDayCommand`, `ShowPrepLogCommand`) and the pinned Notes pseudo-row (`ShowNotesRow`, `OpenNotesCommand`). Raises `NotesRequested`/`PrepRequested` events consumed by the shell.
- **DetailsIslandViewModel** — the detail pane for a bound `TaskRowViewModel`. Owns live-log streaming (`Log` via `StreamLineFormatter`), debounced title/description editing, subtasks, session-outcome/roadblock split (splits `Result` at the roadblock marker into two cards), the three-tab work console (`output`/`git`/`session`), child surfacing (`ChildOutcomes` rows plus `ChildrenNeedingAttention`/`HasChildrenNeedingAttention` — children that failed, were cancelled, await review, or reported roadblocks — drive an attention band on the Session tab, which is only visible when `HasChildOutcomes`), and the modes: `IsNotesMode` (hosts `NotesEditorViewModel`), `IsPrepMode`, computed `IsTaskDetailVisible = !IsNotesMode && !IsPrepMode`. Three concerns are extracted into section VMs exposed as properties: **AgentSettingsSectionViewModel** (per-task Model/MaxTurns/AgentPath overrides with `InheritedBadge` + `InheritanceResolver`, additive SystemPrompt, debounced save), **MergeSectionViewModel** (merge-target selection, mergeability indicator via `MergePreviewPresenter` over `PreviewMergeAsync`, `OpenDiffAsync` — live worktree or commit range after merge —, `ReviewCombinedDiffCommand``PlanningDiffViewModel`), **PrepPanelViewModel** (daily-prep panel: `PrepLog`, `PlanDayCommand``RunDailyPrepNowAsync`, persisted last run via `GetLastPrepLogAsync`). Helper rows (`ChildOutcomeRowViewModel`, `SubtaskRowViewModel`, `LogLineViewModel`) live in the same file.
- **TaskRowViewModel** / **ListNavItemViewModel**lightweight display VMs (task row: status, planning phase, parent/blocked links, roadblock count, computed `IsDraft`/`IsPlanned`/`IsChild`/`IsPlanningParent`/`CanRefine`; list row: kind Smart/Virtual/User, count, icon/dot keys, drop hints).
- **NotesEditorViewModel** — day navigator + bullet CRUD for daily notes via `INotesApi`.
- **Modal VMs** — `SettingsModalViewModel` (four tabs: General, Worktrees, Files prompt-paths, Prime Claude incl. `DailyPrepMaxTasks` + prime-schedule rows), `ListSettingsModalViewModel` (name, working dir, commit type, per-list Model/SystemPrompt/AgentPath/MaxTurns with inherited-badge + reset, delete list), `RepoImportModalViewModel` (bulk-create lists from git repos found under chosen parents; already-wired repos disabled), `WeeklyReportModalViewModel` (range pickers default "since last standup weekday → today", cached per range, markdown via MarkdownView), `MergeModalViewModel` (single-task merge form, called from the diff modal), `WorktreesOverviewModalViewModel` (global/per-list worktree rows, batch merge + state ops), `UnfinishedPlanningModalViewModel` (Resume/FinalizeNow/Discard for a draft planning session), `WorkerConnectionModalViewModel` (offline help), `AboutModalViewModel`.
- **Diff stack** — `UnifiedDiffParser` (static; parses `git diff` output into `DiffFileViewModel`s, detecting added/deleted/renamed/binary files and per-line numbers; `Flatten` injects file-header rows for a combined single-pane view). `DiffModalViewModel` has two modes: live worktree (branch diff vs base, with a Merge action) and commit-range `base..head` (`FromCommitRange = true` — shows a merged task's diff after its worktree is gone; no merge action). The view renders a file list with binary/empty placeholders via `DiffLinesView`.
- **Planning/Conflicts** — `PlanningDiffViewModel` (per-subtask diffs via `GetPlanningAggregateAsync`, toggle to combined integration-branch diff, conflict warnings), `ConflictResolverViewModel` (in-app **Rider-style 3-pane merge editor** for both single-task and planning unit-merge conflicts: single-task starts the conflict merge, parses each conflicted file into stable/conflict `MergeFileSegment`s via the worker's `GetMergeConflictDocuments`; exposes the active file's three reconstructed documents — `ActiveOursText` / `ActiveResultText` / `ActiveTheirsText` (from `MergeFile.OursText/ResultText/TheirsText`; Result seeds unresolved conflicts with Ours) — plus `ActiveFile`/`SelectFileCommand` (multi-file switcher), `Current`/`Next`/`Previous` (focused-conflict nav), a per-active-file `PositionText` readout, per-block `AcceptOurs/Theirs/Both/Base` + `MergeFile.Compose`, and `CanContinue` gated on every file resolved + no binary; writes each file via `WriteConflictResolution`, continue/abort; **planning mode** via `OpenForPlanningAsync(parentId, subtaskId)` loads the current subtask's mid-merge conflicts without re-starting the merge and routes continue/abort to `ContinuePlanningMerge`/`AbortPlanningMerge`, so a unit-merge conflict re-opens the editor per subtask via the `PlanningMergeConflict` broadcast). The view (`Views/Conflicts/ConflictResolverView`) shows the whole file in three **AvaloniaEdit** panes — MAIN/ours (read-only) | editable Result | INCOMING/theirs (read-only) — with TextMate highlighting by extension (theme `StyleInclude` in `App.axaml`); a code-behind `IBackgroundRenderer` tints each conflict block (unresolved/resolved) across panes, an `IReadOnlySectionProvider` + `TextAnchor` regions keep only conflict spans editable in Result (edits flow back to the block); each unresolved conflict starts EMPTY (a thin marker bar); the between-pane gutter controls **toggle** each side in/out of the result — ``/`` add MAIN/INCOMING in click order (first pick on top), clicking again removes that side — so a conflict can take main, incoming, both, or neither; a `FilesSummary` readout shows how many files still have conflicts, and the three panes share a proportional synced vertical scroll. A conflict overview ruler right of the Result pane (`ConflictMap`) maps every conflict in the file proportionally (click a tick to jump) — handy for long files. Conflict block tints live in `Tokens.axaml` (`Merge*TintBrush`). The editor is reached from review **Approve** on conflict and from the **Merge** button in the Diff window (a conflicting `MergeTask` hands off to the resolver via `RequestConflictResolution`).
## Services
- **WorkerClient** / **IWorkerClient** — SignalR client connecting to `http://127.0.0.1:47821/hub`. Auto-reconnect with exponential backoff. Methods: StartAsync, RunNowAsync, CancelTaskAsync, WakeQueueAsync, ContinueTaskAsync, ResetTaskAsync, GetAgentsAsync, UpdateAppSettingsAsync, UpdateListAsync, UpdateListConfigAsync, GetListConfigAsync, UpdateTaskAgentSettingsAsync, `ApproveReviewAsync(taskId, targetBranch) -> MergeResultDto`, `PreviewMergeAsync(taskId, targetBranch) -> MergePreviewDto`, `MergeTaskAsync`, `GetWeekReportAsync`, `GenerateWeekReportAsync`, `GetDailyNotesAsync`, `AddDailyNoteAsync`, `UpdateDailyNoteAsync`, `DeleteDailyNoteAsync`, `RunDailyPrepNowAsync`, `ClearMyDayAsync`, `GetLastPrepLogAsync`. Events: TaskStarted, TaskFinished, TaskMessage, TaskUpdated, WorktreeUpdated, ListUpdated, `PrepStartedEvent`, `PrepLineEvent`, `PrepFinishedEvent`
- **INotesApi** / **WorkerNotesApi**thin wrapper over the daily-note WorkerClient methods (mirrors `IPrimeScheduleApi`). UI DTO: `DailyNoteDto(Id, Date, Text, SortOrder)`.
- **WorkerClient** / **IWorkerClient** — SignalR client connecting to `http://127.0.0.1:47821/hub`, auto-reconnect with exponential backoff. The surface tracks `WorkerHub` (see `src/ClaudeDo.Worker/CLAUDE.md` for the canonical method/event list); groups: task execution (RunNow/Cancel/Continue/Reset/SetTaskStatus), review (`ApproveReviewAsync(taskId, targetBranch) -> MergeResultDto`, reject-to-queue/idle, cancel review, `PreviewMergeAsync -> MergePreviewDto`), planning sessions (start/resume/discard/finalize, queue subtasks, pending draft count, interactive terminal, refine), planning aggregate/integration-branch diffs, unit-merge continue/abort, single-task conflict resolving (start/get-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**daily-note CRUD (`ListAsync(day)`, `AddAsync`, `UpdateAsync`, `DeleteAsync`); UI DTO `DailyNoteDto(Id, Date, Text, SortOrder)`.
- **IPrimeScheduleApi** — prime-schedule CRUD (`ListAsync`, `UpsertAsync`, `DeleteAsync`).
- **UpdateCheckService** — polls releases, exposes `LastCheckStatus`/`LatestVersion`/`CheckNowAsync` (feeds the shell's update banner).
- **InheritanceResolver** — resolves the task → list → global override chain to `(value, source)` for the inherited badges.
- **RepoScanner**, **InstallArtifactLocator**/**InstallerLocator**/**WorkerLocator**, **ForegroundHelper** (Win32 foreground before launching a terminal), **FocusClearing**.
## Converters
- **StatusColorConverter** — task status string -> color (Queued=Blue, Running=Orange, Done=Green, Failed=Red, Manual=Gray)
- **ConnectionColorConverter** — connection state -> color (Online=Green, Offline=Red)
`StatusColorConverter` (+ `ConnectionColorConverter` in the same file), `WorktreeStateColorConverter`, `WorkerLogLevelToBrushConverter`, `DotBrushConverter`, `EqStatusConverter`, `IconKeyConverter`, `CheckboxBorderConverter`, `StrikeIfTrueConverter`, `BoolToItalicConverter`, `BoolToDraftOpacityConverter`, `NotNullToBoolConverter`, `UpperCaseConverter`, `DateOnlyToDateTimeConverter`.
## Dialog Pattern
Editor dialogs use `TaskCompletionSource<bool>` — the dialog sets the result on save/cancel, and the caller awaits the TCS.
Modals use `TaskCompletionSource` results behind the reusable `ModalShell` control — the dialog sets the result on save/cancel, and the caller awaits the TCS.
## Notes
- Context menus are on both list items and task items
- Right-click selects the item before showing the context menu
- Context menus exist on both list rows and task rows; right-click selects before opening the menu
- "Run Now" CanExecute re-evaluates when worker connection state changes
- Icon gotcha: `PathIcon` fills geometry. Line-art/stroke icons must be defined as filled geometry or rendered as a stroked `Path` (e.g. `Icon.PlanDay` via the `Path.plan-icon` style); a pure stroke path used with `PathIcon` is invisible.
- `SessionTerminalView` is the reusable log terminal (StyledProperties `Entries`, `Label`, `IsRunning`, `IsDone`, `IsFailed`) used for both the task `Log` and the prep `PrepLog`.

View File

@@ -7,11 +7,15 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="12.0.0" />
<PackageReference Include="Avalonia" Version="12.0.4" />
<PackageReference Include="Avalonia.AvaloniaEdit" Version="12.0.0" />
<PackageReference Include="AvaloniaEdit.TextMate" Version="12.0.0" />
<PackageReference Include="TextMateSharp.Grammars" Version="2.0.3" />
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
<PackageReference Include="Duende.IdentityModel.OidcClient" Version="7.1.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -229,6 +229,15 @@
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
</Style>
<!-- parked → slate-blue: an Idle task still holding its Active worktree -->
<Style Selector="Border.chip.parked">
<Setter Property="Background" Value="#22303A" />
<Setter Property="BorderBrush" Value="#3A5060" />
</Style>
<Style Selector="Border.chip.parked > TextBlock">
<Setter Property="Foreground" Value="#8FB9D6" />
</Style>
<!-- ============================================================ -->
<!-- BUTTONS -->
<!-- ============================================================ -->
@@ -871,14 +880,9 @@
<Setter Property="Padding" Value="8,5" />
<Setter Property="CornerRadius" Value="6" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="Transitions">
<Transitions>
<BrushTransition Property="Background" Duration="0:0:0.10"/>
</Transitions>
</Setter>
</Style>
<Style Selector="Border.subtask-row:pointerover">
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
<Setter Property="Background" Value="{StaticResource Surface3Brush}" />
</Style>
<Style Selector="Border.subtask-row.done TextBlock.subtask-title">
<Setter Property="Opacity" Value="0.5" />

View File

@@ -100,6 +100,15 @@
<SolidColorBrush x:Key="DoneTintBrush" Color="#1F6FA86B" />
<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) -->
<LinearGradientBrush x:Key="DesktopBackgroundBrush" StartPoint="0%,0%" EndPoint="0%,100%">
<GradientStop Offset="0" Color="#FF05070A" />

View 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);
}

View File

@@ -8,6 +8,7 @@ namespace ClaudeDo.Ui.Services;
public interface IWorkerClient : INotifyPropertyChanged
{
bool IsConnected { get; }
bool IsReconnecting { get; }
event Action<string, string, DateTime>? TaskStartedEvent;
event Action<string, string, string, DateTime>? TaskFinishedEvent;
@@ -17,6 +18,7 @@ public interface IWorkerClient : INotifyPropertyChanged
event Action<string>? WorktreeUpdatedEvent;
event Action<string>? ListUpdatedEvent;
event Action<string, string>? TaskMessageEvent;
event Action<WorkerLogEntry>? WorkerLogReceivedEvent;
event Action? PrepStartedEvent;
event Action<string>? PrepLineEvent;
@@ -28,12 +30,18 @@ public interface IWorkerClient : INotifyPropertyChanged
event Action<string>? PlanningMergeAbortedEvent;
event Action<string>? PlanningCompletedEvent;
event Action<PrimeFiredEvent>? PrimeFired;
string? LastApproveTarget { get; }
Task WakeQueueAsync();
Task RunNowAsync(string taskId);
Task ContinueTaskAsync(string taskId, string followUpPrompt);
Task ResetTaskAsync(string taskId);
Task CancelTaskAsync(string taskId);
Task<List<AgentInfo>> GetAgentsAsync();
Task RefreshAgentsAsync();
Task<SeedResultDto?> RestoreDefaultAgentsAsync();
Task<ListConfigDto?> GetListConfigAsync(string listId);
Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto);
Task SetTaskStatusAsync(string taskId, TaskStatus status);
@@ -47,9 +55,10 @@ public interface IWorkerClient : INotifyPropertyChanged
// ── Conflict resolution (worker hub side implemented by Layer C) ──
Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch);
Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId);
Task<MergeConflictDocumentsDto> GetMergeConflictDocumentsAsync(string taskId);
Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent);
Task<MergeResultDto> ContinueMergeAsync(string taskId);
Task AbortMergeAsync(string taskId);
Task<MergeResultDto> ContinueConflictMergeAsync(string taskId);
Task AbortConflictMergeAsync(string taskId);
Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default);
Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default);
Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default);
@@ -71,9 +80,28 @@ public interface IWorkerClient : INotifyPropertyChanged
event Action<string, bool, string?>? RefineFinishedEvent;
Task ClearMyDayAsync();
Task<AppSettingsDto?> GetAppSettingsAsync();
Task UpdateAppSettingsAsync(AppSettingsDto dto);
Task<List<DailyNoteDto>> GetDailyNotesAsync(DateOnly day);
Task<DailyNoteDto?> AddDailyNoteAsync(DateOnly day, string text);
Task UpdateDailyNoteAsync(string id, string text);
Task DeleteDailyNoteAsync(string id);
Task<string> GetLastPrepLogAsync();
Task<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();
}

View 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();
}
}
}

View File

@@ -275,14 +275,17 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId)
=> _hub.InvokeAsync<MergeConflictsDto>("GetMergeConflicts", taskId);
public Task<MergeConflictDocumentsDto> GetMergeConflictDocumentsAsync(string taskId)
=> _hub.InvokeAsync<MergeConflictDocumentsDto>("GetMergeConflictDocuments", taskId);
public Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent)
=> _hub.InvokeAsync("WriteConflictResolution", taskId, path, resolvedContent);
public Task<MergeResultDto> ContinueMergeAsync(string taskId)
=> _hub.InvokeAsync<MergeResultDto>("ContinueMerge", taskId);
public Task<MergeResultDto> ContinueConflictMergeAsync(string taskId)
=> _hub.InvokeAsync<MergeResultDto>("ContinueConflictMerge", taskId);
public Task AbortMergeAsync(string taskId)
=> _hub.InvokeAsync("AbortMerge", taskId);
public Task AbortConflictMergeAsync(string taskId)
=> _hub.InvokeAsync("AbortConflictMerge", taskId);
public Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId)
=> TryInvokeAsync<MergeTargetsDto>("GetMergeTargets", taskId);
@@ -504,6 +507,18 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
await _hub.InvokeAsync("QueuePlanningSubtasksAsync", parentTaskId, ct);
}
public Task<OnlineInboxStateDto?> GetOnlineInboxStateAsync()
=> TryInvokeAsync<OnlineInboxStateDto>("GetOnlineInboxState");
public async Task SetOnlineInboxConfigAsync(OnlineInboxConfigInputDto input)
=> await _hub.InvokeAsync("SetOnlineInboxConfig", input);
public async Task SetOnlineInboxAuthAsync(string refreshToken)
=> await _hub.InvokeAsync("SetOnlineInboxAuth", refreshToken);
public async Task ClearOnlineInboxAuthAsync()
=> await _hub.InvokeAsync("ClearOnlineInboxAuth");
// IWorkerClient explicit implementations (drop typed return values)
async Task IWorkerClient.StartPlanningSessionAsync(string taskId, CancellationToken ct)
=> await StartPlanningSessionAsync(taskId, ct);
@@ -547,6 +562,9 @@ public record MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalB
public record MergeConflictsDto(string TaskId, IReadOnlyList<ConflictFileDto> Files);
public record ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks);
public record ConflictHunkDto(string Ours, string Theirs, string? Base);
public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDocumentDto> Files);
public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList<MergeSegmentDto> Segments);
public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs);
public sealed record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType);
public sealed record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
public sealed record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
@@ -568,3 +586,22 @@ public sealed record WorktreeOverviewDto(
bool PathExistsOnDisk);
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);

View File

@@ -4,8 +4,8 @@ namespace ClaudeDo.Ui.Services;
public sealed class WorkerNotesApi : INotesApi
{
private readonly WorkerClient _client;
public WorkerNotesApi(WorkerClient client) => _client = client;
private readonly IWorkerClient _client;
public WorkerNotesApi(IWorkerClient client) => _client = client;
public Task<List<DailyNoteDto>> ListAsync(DateOnly day) => _client.GetDailyNotesAsync(day);
public Task<DailyNoteDto?> AddAsync(DateOnly day, string text) => _client.AddDailyNoteAsync(day, text);
public Task UpdateAsync(string id, string text) => _client.UpdateDailyNoteAsync(id, text);

View File

@@ -2,8 +2,8 @@ namespace ClaudeDo.Ui.Services;
public sealed class WorkerPrimeScheduleApi : IPrimeScheduleApi
{
private readonly WorkerClient _client;
public WorkerPrimeScheduleApi(WorkerClient client) => _client = client;
private readonly IWorkerClient _client;
public WorkerPrimeScheduleApi(IWorkerClient client) => _client = client;
public Task<List<PrimeScheduleDto>> ListAsync() => _client.GetPrimeSchedulesAsync();
public Task<PrimeScheduleDto?> UpsertAsync(PrimeScheduleDto dto) => _client.UpsertPrimeScheduleAsync(dto);
public Task DeleteAsync(Guid id) => _client.DeletePrimeScheduleAsync(id);

View 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);
}
}

View File

@@ -5,45 +5,89 @@ using CommunityToolkit.Mvvm.Input;
namespace ClaudeDo.Ui.ViewModels.Conflicts;
public sealed partial class ConflictHunk : ObservableObject
/// <summary>
/// One conflict region in a file: the two competing versions (and the merge base when the
/// merge used diff3 style), plus the chosen <see cref="Resolution"/> (null until resolved).
/// </summary>
public sealed partial class MergeConflictBlock : ObservableObject
{
public string Ours { get; }
public string Theirs { get; }
public string? Base { get; }
public string Theirs { get; }
[ObservableProperty] private string? _resolution;
public bool IsResolved => Resolution is not null;
public bool HasBase => Base is not null;
public ConflictHunk(string ours, string theirs, string? @base)
public MergeConflictBlock(string ours, string? @base, string theirs)
{
Ours = ours;
Theirs = theirs;
Base = @base;
Theirs = theirs;
}
partial void OnResolutionChanged(string? value) => OnPropertyChanged(nameof(IsResolved));
[RelayCommand] private void AcceptCurrent() => Resolution = Ours;
[RelayCommand] private void AcceptIncoming() => Resolution = Theirs;
[RelayCommand] private void AcceptOurs() => Resolution = Ours;
[RelayCommand] private void AcceptTheirs() => Resolution = 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 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;
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
/// (single whole-file hunk today; concatenation stays correct for multi-hunk later).</summary>
public string ComposeResolvedContent() => string.Concat(Hunks.Select(h => h.Resolution));
/// <summary>Reassemble the file: stable text verbatim, each conflict replaced by its resolution
/// (empty when unresolved — the same "empty start" the editor shows; Continue is gated on
/// <see cref="AllResolved"/> so an unresolved conflict never actually reaches here).</summary>
public string Compose() => string.Concat(
Segments.Select(s => s.IsConflict ? (s.Conflict!.Resolution ?? "") : s.StableText));
/// <summary>Left pane document: stable regions verbatim, conflict regions show Ours text.</summary>
public string OursText => string.Concat(
Segments.Select(s => s.IsConflict ? s.Conflict!.Ours : s.StableText));
/// <summary>Right pane document: stable regions verbatim, conflict regions show Theirs text.</summary>
public string TheirsText => string.Concat(
Segments.Select(s => s.IsConflict ? s.Conflict!.Theirs : s.StableText));
/// <summary>Middle (result) pane document: stable regions verbatim, conflict regions show the
/// chosen Resolution, or empty when unresolved (the editor builds each conflict up from empty).</summary>
public string ResultText => string.Concat(
Segments.Select(s => s.IsConflict ? (s.Conflict!.Resolution ?? "") : s.StableText));
}

View File

@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
@@ -14,23 +15,115 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
private readonly IWorkerClient _worker;
private readonly string _taskId;
public ObservableCollection<ConflictFile> Files { get; } = new();
// The task whose conflicted working tree is read/written. For a single-task merge this is
// _taskId; for a planning unit-merge it's the subtask currently being merged.
private string _conflictTaskId;
// When set, this is a planning unit-merge: continue/abort drive the orchestrator on the parent.
private string? _planningParentId;
public ObservableCollection<MergeFile> Files { get; } = new();
// All text conflicts across all files, flattened for one-at-a-time navigation.
private readonly List<(MergeFile File, MergeConflictBlock Block)> _flat = new();
[ObservableProperty] private bool _isBusy;
[ObservableProperty] private string? _error;
[ObservableProperty] private bool _canContinue;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ContinueHint))]
private bool _canContinue;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasCurrent))]
private MergeConflictBlock? _current;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(PositionText))]
[NotifyPropertyChangedFor(nameof(CurrentPath))]
[NotifyCanExecuteChangedFor(nameof(NextCommand))]
[NotifyCanExecuteChangedFor(nameof(PreviousCommand))]
private int _currentIndex = -1;
[ObservableProperty] private MergeFile? _activeFile;
/// <summary>Raised when the active file changes so the view can rebuild its three documents.</summary>
public event Action? ActiveFileChanged;
partial void OnActiveFileChanged(MergeFile? value)
{
ActiveFileChanged?.Invoke();
OnPropertyChanged(nameof(ActiveOursText));
OnPropertyChanged(nameof(ActiveTheirsText));
OnPropertyChanged(nameof(ActiveResultText));
OnPropertyChanged(nameof(PositionText));
// Keep the focused conflict inside the active file (e.g. when switched via the file picker).
if (value is not null && (Current is null || !value.Conflicts.Contains(Current)))
{
var idx = _flat.FindIndex(x => x.File == value);
if (idx >= 0) MoveTo(idx);
}
}
public string ActiveOursText => ActiveFile?.OursText ?? "";
public string ActiveTheirsText => ActiveFile?.TheirsText ?? "";
public string ActiveResultText => ActiveFile?.ResultText ?? "";
public bool HasCurrent => Current is not null;
public int TotalConflicts => _flat.Count;
public int ResolvedCount => _flat.Count(x => x.Block.IsResolved);
public string? CurrentPath => InRange ? _flat[CurrentIndex].File.Path : null;
public string PositionText
{
get
{
if (ActiveFile is null || ActiveFile.Conflicts.Count == 0) return "No text conflicts";
var count = ActiveFile.Conflicts.Count;
var resolved = ActiveFile.Conflicts.Count(c => c.IsResolved);
return $"{count} {(count == 1 ? "conflict" : "conflicts")} · {resolved} resolved";
}
}
public IReadOnlyList<string> BinaryFilePaths => Files.Where(f => f.IsBinary).Select(f => f.Path).ToList();
public bool HasBinaryFiles => Files.Any(f => f.IsBinary);
public bool HasMultipleFiles => Files.Count > 1;
/// <summary>Cross-file progress shown in the editor: how many files still have unresolved
/// (or binary) conflicts, so you can see how many more need attention.</summary>
public string FilesSummary
{
get
{
var total = Files.Count;
if (total == 0) return "";
var unresolved = Files.Count(f => !f.AllResolved);
return unresolved == 0 ? $"All {total} files resolved" : $"{unresolved} of {total} files unresolved";
}
}
public string ContinueHint => HasBinaryFiles
? "Binary conflicts must be resolved externally — abort and resolve in your editor."
: "";
private bool InRange => CurrentIndex >= 0 && CurrentIndex < _flat.Count;
public string TaskId => _taskId;
public Action? CloseRequested { get; set; }
/// <summary>Raised when the current conflict changes so the view can reload its editors.</summary>
public event Action? CurrentChanged;
public ConflictResolverViewModel(IWorkerClient worker, string taskId)
{
_worker = worker;
_taskId = taskId;
_conflictTaskId = taskId;
}
/// <summary>Starts the conflict merge and loads ours/theirs/base per file.
/// Returns true when there are conflicts to resolve (caller should show the dialog).</summary>
/// <summary>Starts the conflict merge and loads the conflicted files as line-level segments.
/// Returns true when there is something to resolve (caller should show the dialog).</summary>
public async Task<bool> OpenAsync(string targetBranch)
{
IsBusy = true;
@@ -44,21 +137,7 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
Error = start.ErrorMessage;
return false;
}
var conflicts = await _worker.GetMergeConflictsAsync(_taskId);
Files.Clear();
foreach (var f in conflicts.Files)
{
var hunks = f.Hunks.Select(h =>
{
var hk = new ConflictHunk(h.Ours, h.Theirs, h.Base);
hk.PropertyChanged += OnHunkChanged;
return hk;
}).ToList();
Files.Add(new ConflictFile(f.Path, hunks));
}
RecomputeCanContinue();
return Files.Count > 0;
return await LoadDocumentsAsync();
}
catch (Exception ex)
{
@@ -68,14 +147,104 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
finally { IsBusy = false; }
}
private void OnHunkChanged(object? sender, PropertyChangedEventArgs e)
/// <summary>Resolves a planning unit-merge conflict for <paramref name="subtaskId"/>. The merge is
/// already mid-conflict (driven by the orchestrator), so this only loads the conflicted files;
/// continue/abort hand back to the orchestrator on <paramref name="planningParentId"/>.</summary>
public async Task<bool> OpenForPlanningAsync(string planningParentId, string subtaskId)
{
if (e.PropertyName is nameof(ConflictHunk.IsResolved) or nameof(ConflictHunk.Resolution))
RecomputeCanContinue();
_planningParentId = planningParentId;
_conflictTaskId = subtaskId;
IsBusy = true;
Error = null;
try
{
return await LoadDocumentsAsync();
}
catch (Exception ex)
{
Error = ex.Message;
return false;
}
finally { IsBusy = false; }
}
private void RecomputeCanContinue()
=> CanContinue = Files.Count > 0 && Files.All(f => f.AllHunksResolved);
private async Task<bool> LoadDocumentsAsync()
{
var docs = await _worker.GetMergeConflictDocumentsAsync(_conflictTaskId);
Files.Clear();
_flat.Clear();
foreach (var f in docs.Files)
{
var segments = f.Segments.Select(s => s.IsConflict
? MergeFileSegment.FromConflict(Hook(new MergeConflictBlock(s.Ours, s.Base, s.Theirs)))
: MergeFileSegment.Stable(s.Text)).ToList();
var file = new MergeFile(f.Path, f.IsBinary, segments);
Files.Add(file);
foreach (var c in file.Conflicts) _flat.Add((file, c));
}
OnPropertyChanged(nameof(TotalConflicts));
OnPropertyChanged(nameof(BinaryFilePaths));
OnPropertyChanged(nameof(HasBinaryFiles));
OnPropertyChanged(nameof(HasMultipleFiles));
OnPropertyChanged(nameof(FilesSummary));
RecomputeCanContinue();
if (_flat.Count > 0)
MoveTo(0); // also sets ActiveFile via MoveTo
else if (Files.Count > 0)
ActiveFile = Files[0];
return Files.Count > 0;
}
private MergeConflictBlock Hook(MergeConflictBlock block)
{
block.PropertyChanged += OnBlockChanged;
return block;
}
private void OnBlockChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName is nameof(MergeConflictBlock.IsResolved) or nameof(MergeConflictBlock.Resolution))
{
RecomputeCanContinue();
OnPropertyChanged(nameof(ResolvedCount));
OnPropertyChanged(nameof(PositionText));
OnPropertyChanged(nameof(ActiveResultText));
OnPropertyChanged(nameof(FilesSummary));
}
}
private void RecomputeCanContinue() =>
CanContinue = Files.Count > 0 && Files.All(f => f.AllResolved);
private void MoveTo(int index)
{
CurrentIndex = index;
Current = _flat[index].Block;
ActiveFile = _flat[index].File;
OnPropertyChanged(nameof(CurrentPath));
CurrentChanged?.Invoke();
}
[RelayCommand]
private void SelectFile(MergeFile file)
{
// Jump to the first conflict in this file (if any); otherwise just switch the active file.
var idx = _flat.FindIndex(x => x.File == file);
if (idx >= 0)
MoveTo(idx);
else
ActiveFile = file;
}
private bool CanGoNext() => CurrentIndex >= 0 && CurrentIndex < _flat.Count - 1;
private bool CanGoPrevious() => CurrentIndex > 0;
[RelayCommand(CanExecute = nameof(CanGoNext))]
private void Next() => MoveTo(CurrentIndex + 1);
[RelayCommand(CanExecute = nameof(CanGoPrevious))]
private void Previous() => MoveTo(CurrentIndex - 1);
[RelayCommand]
private async Task ContinueAsync()
@@ -85,10 +254,19 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
Error = null;
try
{
foreach (var file in Files)
await _worker.WriteConflictResolutionAsync(_taskId, file.Path, file.ComposeResolvedContent());
foreach (var file in Files.Where(f => !f.IsBinary))
await _worker.WriteConflictResolutionAsync(_conflictTaskId, file.Path, file.Compose());
var result = await _worker.ContinueMergeAsync(_taskId);
if (_planningParentId is not null)
{
// Hand back to the orchestrator: it commits this subtask and drains the rest.
// A later subtask conflict re-opens this editor via the PlanningMergeConflict broadcast.
await _worker.ContinuePlanningMergeAsync(_planningParentId);
CloseRequested?.Invoke();
return;
}
var result = await _worker.ContinueConflictMergeAsync(_taskId);
if (string.Equals(result.Status, "merged", StringComparison.Ordinal))
CloseRequested?.Invoke();
else
@@ -105,7 +283,13 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
private async Task AbortAsync()
{
IsBusy = true;
try { await _worker.AbortMergeAsync(_taskId); }
try
{
if (_planningParentId is not null)
await _worker.AbortPlanningMergeAsync(_planningParentId);
else
await _worker.AbortConflictMergeAsync(_taskId);
}
catch (Exception ex) { Error = ex.Message; }
finally
{

View File

@@ -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

View File

@@ -20,7 +20,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
{
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly IServiceProvider? _services;
private readonly WorkerClient? _worker;
private readonly IWorkerClient? _worker;
private static readonly TaskListFilterRegistry _filters = new();
public event EventHandler? SelectionChanged;
@@ -143,7 +143,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
private readonly EventHandler _langChangedHandler;
public ListsIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IServiceProvider? services = null, WorkerClient? worker = null)
public ListsIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IServiceProvider? services = null, IWorkerClient? worker = null)
{
_dbFactory = dbFactory;
_services = services;

View 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;
}

View File

@@ -7,24 +7,15 @@ namespace ClaudeDo.Ui.ViewModels.Islands;
public sealed partial class NoteBulletViewModel : ViewModelBase
{
private readonly Func<NoteBulletViewModel, Task> _save;
private readonly Func<NoteBulletViewModel, Task> _delete;
public string Id { get; }
[ObservableProperty] private string _text;
public NoteBulletViewModel(string id, string text,
Func<NoteBulletViewModel, Task> save, Func<NoteBulletViewModel, Task> delete)
public NoteBulletViewModel(string id, string text)
{
Id = id;
_text = text;
_save = save;
_delete = delete;
}
[RelayCommand] private Task Save() => _save(this);
[RelayCommand] private Task Delete() => _delete(this);
}
public sealed partial class NotesEditorViewModel : ViewModelBase
@@ -57,7 +48,7 @@ public sealed partial class NotesEditorViewModel : ViewModelBase
}
private NoteBulletViewModel MakeBullet(string id, string text) =>
new(id, text, SaveBulletAsync, DeleteBulletAsync);
new(id, text);
[RelayCommand]
private async Task AddBullet()
@@ -73,11 +64,17 @@ public sealed partial class NotesEditorViewModel : ViewModelBase
[RelayCommand] private Task NextDay() => LoadDayAsync(CurrentDay.AddDays(1));
[RelayCommand] private Task Today() => LoadDayAsync(DateOnly.FromDateTime(DateTime.Today));
private Task SaveBulletAsync(NoteBulletViewModel b) => _api.UpdateAsync(b.Id, b.Text);
private async Task DeleteBulletAsync(NoteBulletViewModel b)
[RelayCommand]
private async Task CommitBullet(NoteBulletViewModel? b)
{
if (b is null) return;
var text = b.Text?.Trim() ?? "";
if (text.Length == 0)
{
await _api.DeleteAsync(b.Id);
Bullets.Remove(b);
return;
}
await _api.UpdateAsync(b.Id, text);
}
}

View 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)..]);
}
}
}

View File

@@ -18,6 +18,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
[ObservableProperty] private PlanningPhase _planningPhase;
[ObservableProperty] private string? _branch;
[ObservableProperty] private string? _diffStat;
[ObservableProperty] private ClaudeDo.Data.Models.WorktreeState? _worktreeState;
[ObservableProperty] private DateTime? _scheduledFor;
[ObservableProperty] private int _diffAdditions;
[ObservableProperty] private int _diffDeletions;
@@ -31,6 +32,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
[ObservableProperty] private bool _hasQueuedSubtasks;
[ObservableProperty] private bool _showListChip = true;
[ObservableProperty] private bool _parentFinalized;
[ObservableProperty] private bool _parentInView = true;
[ObservableProperty] private int _roadblockCount;
[ObservableProperty] private bool _isRefining;
@@ -46,9 +48,13 @@ public sealed partial class TaskRowViewModel : ViewModelBase
public bool IsAgentSuggested => IsChild && !string.IsNullOrEmpty(CreatedBy) && CreatedBy == ParentTaskId;
public bool IsPlanningParent => PlanningPhase != PlanningPhase.None
|| HasPlanningChildren;
// A child only reads as a child while its parent shares the current view. When the parent is
// absent (removed from My Day, or daily-prep placed a lone child there), the row renders as a
// normal top-level task instead of an orphaned, indented Draft.
public bool ShowAsChild => IsChild && ParentInView;
// A subtask is Draft until its planning parent is finalized, then Planned (queueable).
public bool IsDraft => IsChild && Status == TaskStatus.Idle && !ParentFinalized;
public bool IsPlanned => IsChild && Status == TaskStatus.Idle && ParentFinalized;
public bool IsDraft => ShowAsChild && Status == TaskStatus.Idle && !ParentFinalized;
public bool IsPlanned => ShowAsChild && Status == TaskStatus.Idle && ParentFinalized;
public bool CanOpenPlanningSession => Status == TaskStatus.Idle
&& PlanningPhase == PlanningPhase.None
@@ -71,16 +77,28 @@ public sealed partial class TaskRowViewModel : ViewModelBase
public bool IsOverdue => ScheduledFor is { } d && d.Date < DateTime.Today && !Done;
public bool IsRunning => Status == TaskStatus.Running;
public bool IsWaitingForReview => Status == TaskStatus.WaitingForReview;
// Parked = set aside from review: Idle but still holding its Active worktree (vs a plain Idle task).
public bool IsParked => Status == TaskStatus.Idle && WorktreeState == ClaudeDo.Data.Models.WorktreeState.Active;
public bool IsQueued => Status == TaskStatus.Queued && string.IsNullOrEmpty(BlockedByTaskId);
public bool IsWaiting => Status == TaskStatus.Queued && !string.IsNullOrEmpty(BlockedByTaskId);
public bool CanRemoveFromQueue => IsQueued || HasQueuedSubtasks;
// "Send to queue" is the single queue entry. On a finalized planning parent it queues the
// plan (children) via CanQueuePlan; an Active (not-yet-finalized) planning parent is hidden —
// it must be finalized first.
public bool CanSendToQueue => !IsRunning && !IsQueued && !IsWaitingForReview && !HasQueuedSubtasks
&& (!IsChild || ParentFinalized);
&& (!IsChild || ParentFinalized)
&& PlanningPhase != PlanningPhase.Active;
// Parent-level "send plan to queue" — only once the plan is finalized (children Planned).
// Drives the routing inside SendToQueue, not a separate menu entry.
public bool CanQueuePlan => !IsChild && HasPlanningChildren
&& PlanningPhase == PlanningPhase.Finalized
&& !HasQueuedSubtasks;
// User-triggered finalize for a planning parent whose session was closed before finalizing.
public bool CanFinalizePlanning => PlanningPhase == PlanningPhase.Active && !IsChild;
public bool HasSchedule => ScheduledFor.HasValue;
// "Add to My Day" — shown on any task not already in My Day; a Done task has no place in
// today's focus list. The mirror of "Remove from My Day" (gated on IsMyDay).
public bool CanAddToMyDay => !IsMyDay && !Done;
public bool HasRoadblock => RoadblockCount > 0;
public string RoadblockTooltip => RoadblockCount == 1
? "1 roadblock reported during the run — see details"
@@ -90,7 +108,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
public string DiffDeletionsText => $"{DiffDeletions}";
public string StepsText => Loc.T("vm.taskRow.stepsText", StepsCompleted, StepsCount);
public string StatusLabel => Status switch
public string StatusLabel => IsParked ? Loc.T("vm.taskStatus.parked") : Status switch
{
TaskStatus.Idle => Loc.T("vm.taskStatus.idle"),
TaskStatus.Queued => Loc.T("vm.taskStatus.queued"),
@@ -121,6 +139,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
OnPropertyChanged(nameof(StatusLabel));
OnPropertyChanged(nameof(IsRunning));
OnPropertyChanged(nameof(IsWaitingForReview));
OnPropertyChanged(nameof(IsParked));
OnPropertyChanged(nameof(IsQueued));
OnPropertyChanged(nameof(IsWaiting));
OnPropertyChanged(nameof(IsDraft));
@@ -135,12 +154,20 @@ public sealed partial class TaskRowViewModel : ViewModelBase
{
OnPropertyChanged(nameof(IsChild));
OnPropertyChanged(nameof(IsAgentSuggested));
OnPropertyChanged(nameof(ShowAsChild));
OnPropertyChanged(nameof(IsDraft));
OnPropertyChanged(nameof(IsPlanned));
OnPropertyChanged(nameof(CanSendToQueue));
OnPropertyChanged(nameof(CanOpenPlanningSession));
}
partial void OnParentInViewChanged(bool value)
{
OnPropertyChanged(nameof(ShowAsChild));
OnPropertyChanged(nameof(IsDraft));
OnPropertyChanged(nameof(IsPlanned));
}
partial void OnCreatedByChanged(string? value) => OnPropertyChanged(nameof(IsAgentSuggested));
partial void OnParentFinalizedChanged(bool value)
@@ -159,6 +186,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase
OnPropertyChanged(nameof(CanOpenPlanningSession));
OnPropertyChanged(nameof(CanResumeOrDiscardPlanning));
OnPropertyChanged(nameof(CanQueuePlan));
OnPropertyChanged(nameof(CanSendToQueue));
OnPropertyChanged(nameof(CanFinalizePlanning));
OnPropertyChanged(nameof(CanRefine));
}
@@ -185,7 +214,17 @@ public sealed partial class TaskRowViewModel : ViewModelBase
}
partial void OnBranchChanged(string? value) => OnPropertyChanged(nameof(HasBranch));
partial void OnDoneChanged(bool value) => OnPropertyChanged(nameof(IsOverdue));
partial void OnWorktreeStateChanged(ClaudeDo.Data.Models.WorktreeState? value)
{
OnPropertyChanged(nameof(IsParked));
OnPropertyChanged(nameof(StatusLabel));
}
partial void OnDoneChanged(bool value)
{
OnPropertyChanged(nameof(IsOverdue));
OnPropertyChanged(nameof(CanAddToMyDay));
}
partial void OnIsMyDayChanged(bool value) => OnPropertyChanged(nameof(CanAddToMyDay));
partial void OnScheduledForChanged(DateTime? value)
{
OnPropertyChanged(nameof(IsOverdue));
@@ -222,6 +261,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
PlanningPhase = t.PlanningPhase;
Branch = t.Worktree?.BranchName;
DiffStat = t.Worktree?.DiffStat;
WorktreeState = t.Worktree?.State;
ScheduledFor = t.ScheduledFor;
DiffAdditions = add;
DiffDeletions = del;

View File

@@ -334,6 +334,10 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
// Items is already ordered by SortOrder from the DB query.
// Treat rows whose ParentTaskId is not in the current view as orphans -> top-level.
var visibleIds = Items.Select(r => r.Id).ToHashSet();
// A child reads as a child only while its parent is in the view. Flag orphans so they
// render flat (no indent, no Draft/Planned badge) instead of breaking the layout.
foreach (var r in Items)
r.ParentInView = string.IsNullOrEmpty(r.ParentTaskId) || visibleIds.Contains(r.ParentTaskId!);
bool IsTopLevel(TaskRowViewModel r) =>
!r.IsChild
|| string.IsNullOrEmpty(r.ParentTaskId)
@@ -571,6 +575,52 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
TasksChanged?.Invoke(this, EventArgs.Empty);
}
[RelayCommand]
private async Task AddToMyDayAsync(TaskRowViewModel? row)
{
if (row is null || row.IsMyDay) return;
row.IsMyDay = true;
await using var db = await _dbFactory.CreateDbContextAsync();
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
if (entity != null)
{
entity.IsMyDay = true;
await db.SaveChangesAsync();
}
Regroup();
UpdateSubtitle();
TasksChanged?.Invoke(this, EventArgs.Empty);
}
[RelayCommand]
private async Task RemoveFromMyDayAsync(TaskRowViewModel? row)
{
if (row is null) return;
row.IsMyDay = false;
await using var db = await _dbFactory.CreateDbContextAsync();
// Removing a parent takes its whole plan off My Day: clear the task and every child, so no
// orphaned child is left behind (independently-IsMyDay children included). A leaf child has
// no children of its own, so this collapses to just clearing the row itself.
var affected = await db.Tasks
.Where(t => t.Id == row.Id || t.ParentTaskId == row.Id)
.ToListAsync();
foreach (var t in affected)
t.IsMyDay = false;
if (affected.Count > 0)
await db.SaveChangesAsync();
if (_currentList?.Id == "smart:my-day")
{
var drop = Items
.Where(r => r.Id == row.Id || r.ParentTaskId == row.Id)
.ToList();
foreach (var r in drop)
Items.Remove(r);
}
Regroup();
UpdateSubtitle();
TasksChanged?.Invoke(this, EventArgs.Empty);
}
public async Task SetStatusOnRowAsync(TaskRowViewModel row, TaskStatus status)
{
if (_worker is null) return;
@@ -582,6 +632,12 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
private async Task SendToQueueAsync(TaskRowViewModel? row)
{
if (row is null || row.IsRunning) return;
// A finalized planning parent queues its plan (children sequentially), not itself.
if (row.CanQueuePlan)
{
await QueuePlanningSubtasksAsync(row);
return;
}
await using var db = await _dbFactory.CreateDbContextAsync();
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
if (entity is null) return;

View File

@@ -20,7 +20,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
public ListsIslandViewModel? Lists { get; }
public TasksIslandViewModel? Tasks { get; }
public DetailsIslandViewModel? Details { get; }
public WorkerClient? Worker { get; }
public IWorkerClient? Worker { get; }
public UpdateCheckService UpdateCheck => _updateCheck;
public string ConnectionText =>
@@ -41,9 +41,6 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
public Func<MergeModalViewModel> ResolveMergeVm => _mergeVmFactory;
// Set by MainWindow to open the conflict resolution dialog.
public Func<ConflictResolutionViewModel, Task>? ShowConflictDialog { get; set; }
// Layer C seam: composition root sets the factory; MainWindow sets the dialog opener.
// The integrator connects Layer A/B's RequestConflictResolution(taskId, target) to this method.
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)
{
// Already on UI thread (WorkerClient dispatches via Dispatcher.UIThread.Post).
_ = OpenConflictDialogAsync(planningTaskId, subtaskId, conflictedFiles);
// A unit-merge conflict resolves in the same in-app 3-way editor as a single-task merge.
_ = OpenPlanningConflictAsync(planningTaskId, subtaskId);
}
private async Task OpenConflictDialogAsync(string planningTaskId, string subtaskId, IReadOnlyList<string> conflictedFiles)
private async Task OpenPlanningConflictAsync(string planningTaskId, string subtaskId)
{
if (ShowConflictDialog == null || _dbFactory == null) return;
string subtaskTitle = subtaskId;
// The conflict lives in the list's working dir (the repo being merged into),
// not the subtask worktree. VS Code must open this folder to show the merge UI.
string repoDirectory = System.Environment.CurrentDirectory;
string targetBranch = Worker?.LastApproveTarget ?? "main";
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
var entity = await ctx.Tasks
.Include(t => t.List)
.AsNoTracking()
.FirstOrDefaultAsync(t => t.Id == subtaskId);
if (entity != null)
{
subtaskTitle = entity.Title;
if (entity.List?.WorkingDir is { } dir && !string.IsNullOrWhiteSpace(dir))
repoDirectory = dir;
}
}
catch { /* Non-fatal: fall back to subtaskId and cwd */ }
var vm = new ConflictResolutionViewModel(
Worker!,
planningTaskId,
subtaskTitle,
targetBranch,
conflictedFiles,
repoDirectory);
await ShowConflictDialog(vm);
if (ConflictResolverFactory is null || ShowConflictResolver is null) return;
var vm = ConflictResolverFactory(subtaskId);
var hasConflicts = await vm.OpenForPlanningAsync(planningTaskId, subtaskId);
if (hasConflicts)
await ShowConflictResolver(vm);
}
// For tests only — does NOT wire up events.
@@ -193,7 +163,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
ListsIslandViewModel lists,
TasksIslandViewModel tasks,
DetailsIslandViewModel details,
WorkerClient worker,
IWorkerClient worker,
UpdateCheckService updateCheck,
InstallerLocator installerLocator,
WorkerLocator workerLocator,
@@ -232,7 +202,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
Details.RequestConflictResolution = RequestConflictResolutionAsync;
Worker.PropertyChanged += (_, e) =>
{
if (e.PropertyName is nameof(WorkerClient.IsConnected) or nameof(WorkerClient.IsReconnecting))
if (e.PropertyName is nameof(IWorkerClient.IsConnected) or nameof(IWorkerClient.IsReconnecting))
{
OnPropertyChanged(nameof(ConnectionText));
OnPropertyChanged(nameof(IsOffline));

View File

@@ -72,6 +72,7 @@ public sealed partial class DiffModalViewModel : ViewModelBase
public string TaskTitle { get; init; } = "";
public Func<MergeModalViewModel, Task>? ShowMergeModal { get; set; }
public Func<MergeModalViewModel>? ResolveMergeVm { get; set; }
public Func<string, string, Task>? RequestConflictResolution { get; set; }
public ObservableCollection<DiffFileViewModel> Files { get; } = new();
@@ -99,10 +100,11 @@ public sealed partial class DiffModalViewModel : ViewModelBase
{
if (TaskId is null || ShowMergeModal is null || ResolveMergeVm is null) return;
var vm = ResolveMergeVm();
vm.RequestConflictResolution = RequestConflictResolution;
await vm.InitializeAsync(TaskId, TaskTitle);
await ShowMergeModal(vm);
// The diff is stale once the worktree has been merged away — close it too.
if (vm.Merged) CloseAction?.Invoke();
// The diff is stale once the worktree merged away or a conflict opened the editor.
if (vm.Merged || vm.RoutedToResolver) CloseAction?.Invoke();
}
public async Task LoadAsync(CancellationToken ct = default)
@@ -110,6 +112,12 @@ public sealed partial class DiffModalViewModel : ViewModelBase
Files.Clear();
StatusMessage = null;
if (FromCommitRange && (BaseRef is null || HeadCommit is null))
{
StatusMessage = Loc.T("vm.diff.unavailable");
return;
}
string raw;
try
{

View File

@@ -12,7 +12,7 @@ namespace ClaudeDo.Ui.ViewModels.Modals;
public sealed partial class ListSettingsModalViewModel : ViewModelBase
{
private readonly WorkerClient _worker;
private readonly IWorkerClient _worker;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
public string ListId { get; set; } = "";
@@ -50,7 +50,7 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
public Action? CloseAction { get; set; }
public ListSettingsModalViewModel(WorkerClient worker, IDbContextFactory<ClaudeDoDbContext> dbFactory)
public ListSettingsModalViewModel(IWorkerClient worker, IDbContextFactory<ClaudeDoDbContext> dbFactory)
{
_worker = worker;
_dbFactory = dbFactory;

View File

@@ -8,7 +8,7 @@ namespace ClaudeDo.Ui.ViewModels.Modals;
public sealed partial class MergeModalViewModel : ViewModelBase
{
private readonly WorkerClient _worker;
private readonly IWorkerClient _worker;
public string TaskId { get; set; } = "";
public string TaskTitle { get; set; } = "";
@@ -28,11 +28,18 @@ public sealed partial class MergeModalViewModel : ViewModelBase
public Action? CloseAction { get; set; }
/// Set by the caller to hand a conflicting merge off to the in-app 3-pane editor
/// instead of dead-ending on the conflict message.
public Func<string, string, Task>? RequestConflictResolution { get; set; }
/// True once a merge has succeeded — lets the caller (e.g. the diff window)
/// close itself after this modal closes.
public bool Merged { get; private set; }
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;
}
@@ -96,9 +103,21 @@ public sealed partial class MergeModalViewModel : ViewModelBase
});
break;
case "conflict":
// Hand off to the in-app 3-pane merge editor when wired (MergeTask aborted
// cleanly, so the resolver re-starts the merge leaving conflicts in the tree).
if (RequestConflictResolution is not null)
{
var branch = SelectedBranch!;
RoutedToResolver = true;
CloseAction?.Invoke();
await RequestConflictResolution(TaskId, branch);
}
else
{
HasConflict = true;
ConflictFiles = result.ConflictFiles;
ErrorMessage = Loc.T("vm.merge.conflict");
}
break;
case "blocked":
ErrorMessage = Loc.T("vm.merge.blocked", result.ErrorMessage ?? "");

View File

@@ -9,7 +9,7 @@ namespace ClaudeDo.Ui.ViewModels.Modals.Settings;
public sealed partial class FilesSettingsTabViewModel : ViewModelBase
{
private readonly WorkerClient _worker;
private readonly IWorkerClient _worker;
[ObservableProperty] private string _statusMessage = "";
[ObservableProperty] private bool _isBusy;
@@ -21,7 +21,7 @@ public sealed partial class FilesSettingsTabViewModel : ViewModelBase
public string DailyPrepPromptPath { get; } = PromptFiles.PathFor(PromptKind.DailyPrep);
public string WeeklyReportPromptPath { get; } = PromptFiles.PathFor(PromptKind.WeeklyReport);
public FilesSettingsTabViewModel(WorkerClient worker) => _worker = worker;
public FilesSettingsTabViewModel(IWorkerClient worker) => _worker = worker;
[RelayCommand]
private async Task RestoreDefaultAgents()

View File

@@ -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; }
}
}

View File

@@ -8,7 +8,7 @@ namespace ClaudeDo.Ui.ViewModels.Modals.Settings;
public sealed partial class WorktreesSettingsTabViewModel : ViewModelBase
{
private readonly WorkerClient _worker;
private readonly IWorkerClient _worker;
[ObservableProperty] private string _worktreeStrategy = "sibling";
[ObservableProperty] private string? _centralWorktreeRoot;
@@ -21,7 +21,7 @@ public sealed partial class WorktreesSettingsTabViewModel : ViewModelBase
public IReadOnlyList<string> WorktreeStrategies { get; } = new[] { "sibling", "central" };
public WorktreesSettingsTabViewModel(WorkerClient worker) => _worker = worker;
public WorktreesSettingsTabViewModel(IWorkerClient worker) => _worker = worker;
public string? Validate()
{

View File

@@ -11,12 +11,13 @@ namespace ClaudeDo.Ui.ViewModels.Modals;
public sealed partial class SettingsModalViewModel : ViewModelBase
{
private readonly WorkerClient _worker;
private readonly IWorkerClient _worker;
public GeneralSettingsTabViewModel General { get; }
public WorktreesSettingsTabViewModel Worktrees { get; }
public FilesSettingsTabViewModel Files { get; }
public PrimeClaudeTabViewModel Prime { get; }
public OnlineInboxSettingsViewModel OnlineInbox { get; }
[ObservableProperty] private string _validationError = "";
[ObservableProperty] private bool _isBusy;
@@ -24,7 +25,8 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
public Action? CloseAction { get; set; }
public SettingsModalViewModel(WorkerClient worker, PrimeClaudeTabViewModel prime,
public SettingsModalViewModel(IWorkerClient worker, PrimeClaudeTabViewModel prime,
IOnlineLoginService onlineLoginService,
ILocalizer localizer, AppSettings appSettings)
{
_worker = worker;
@@ -36,6 +38,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
Worktrees = new WorktreesSettingsTabViewModel(worker);
Files = new FilesSettingsTabViewModel(worker);
Prime = prime;
OnlineInbox = new OnlineInboxSettingsViewModel(worker, onlineLoginService);
}
public async Task LoadAsync()
@@ -65,6 +68,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
else StatusMessage = Loc.T("vm.settingsModal.workerOffline");
await Prime.LoadAsync();
await OnlineInbox.LoadAsync();
}
finally { IsBusy = false; }
}

View File

@@ -60,7 +60,7 @@ public sealed partial class WorktreesGroupViewModel : ViewModelBase
public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
{
private readonly WorkerClient _worker;
private readonly IWorkerClient _worker;
private readonly Func<WorktreeModalViewModel> _diffVmFactory;
[ObservableProperty] private string? _listIdFilter;
@@ -89,7 +89,7 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
public Func<MergeModalViewModel>? ResolveMergeVm { get; set; }
public Func<MergeModalViewModel, Task>? ShowMergeAction { get; set; }
public WorktreesOverviewModalViewModel(WorkerClient worker, Func<WorktreeModalViewModel> diffVmFactory)
public WorktreesOverviewModalViewModel(IWorkerClient worker, Func<WorktreeModalViewModel> diffVmFactory)
{
_worker = worker;
_diffVmFactory = diffVmFactory;

View File

@@ -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; }
}
}

View File

@@ -2,11 +2,12 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Conflicts"
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
xmlns:ae="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
xmlns:loc="using:ClaudeDo.Ui.Localization"
x:DataType="vm:ConflictResolverViewModel"
x:Class="ClaudeDo.Ui.Views.Conflicts.ConflictResolverView"
Title="{loc:Tr conflictResolver.windowTitle}"
Width="760" Height="640" MinWidth="560" MinHeight="420"
Width="1280" Height="820" MinWidth="960" MinHeight="560"
CanResize="True"
WindowDecorations="BorderOnly"
ExtendClientAreaToDecorationsHint="True"
@@ -16,67 +17,157 @@
<Window.KeyBindings>
<KeyBinding Gesture="Escape" Command="{Binding AbortCommand}"/>
<KeyBinding Gesture="F8" Command="{Binding NextCommand}"/>
<KeyBinding Gesture="Shift+F8" Command="{Binding PreviousCommand}"/>
</Window.KeyBindings>
<Window.Styles>
<Style Selector="ae|TextEditor">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="{StaticResource FontSizeMono}" />
<Setter Property="Background" Value="{DynamicResource Surface2Brush}" />
<Setter Property="Padding" Value="4,2" />
<Setter Property="WordWrap" Value="False" />
</Style>
<Style Selector="Border.col-head">
<Setter Property="Padding" Value="8,4" />
<Setter Property="BorderThickness" Value="0,0,0,1" />
<Setter Property="BorderBrush" Value="{DynamicResource LineBrush}" />
<Setter Property="Background" Value="{DynamicResource Surface2Brush}" />
</Style>
<Style Selector="Border.pane">
<Setter Property="BorderBrush" Value="{DynamicResource LineBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="6" />
<Setter Property="ClipToBounds" Value="True" />
</Style>
<!-- Inline accept controls in the between-pane gutters -->
<Style Selector="Button.accept-gutter">
<Setter Property="Width" Value="22" />
<Setter Property="Height" Value="20" />
<Setter Property="Padding" Value="0" />
<Setter Property="FontSize" Value="13" />
<Setter Property="FontWeight" Value="Bold" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Background" Value="{DynamicResource Surface2Brush}" />
<Setter Property="BorderBrush" Value="{DynamicResource MergeConflictEdgeBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="4" />
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
</Style>
<Style Selector="Button.accept-gutter:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{DynamicResource MergeConflictTintBrush}" />
</Style>
</Window.Styles>
<ctl:ModalShell Title="{loc:Tr conflictResolver.modalTitle}" CloseCommand="{Binding AbortCommand}">
<ctl:ModalShell.Footer>
<StackPanel Orientation="Horizontal" Spacing="8"
HorizontalAlignment="Right" VerticalAlignment="Center">
<Button Classes="btn" Content="{loc:Tr conflictResolver.continue}"
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0" Classes="meta" VerticalAlignment="Center"
Foreground="{DynamicResource BloodBrush}"
Text="{Binding ContinueHint}"
IsVisible="{Binding HasBinaryFiles}"/>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
<Button Classes="btn accent" Content="{loc:Tr conflictResolver.continue}"
Command="{Binding ContinueCommand}" IsEnabled="{Binding CanContinue}"/>
<Button Classes="btn" Content="{loc:Tr conflictResolver.abort}" Command="{Binding AbortCommand}"/>
</StackPanel>
</Grid>
</ctl:ModalShell.Footer>
<Grid RowDefinitions="Auto,*" Margin="16,12">
<TextBlock Grid.Row="0" Classes="meta" Margin="0,0,0,8"
Text="{loc:Tr conflictResolver.loading}"
IsVisible="{Binding IsBusy}"/>
<Grid Margin="14,10" RowDefinitions="Auto,Auto,Auto,*">
<!-- Busy / error -->
<TextBlock Grid.Row="0" Classes="meta" Margin="0,0,0,6"
Text="{loc:Tr conflictResolver.loading}" IsVisible="{Binding IsBusy}"/>
<TextBlock Grid.Row="0" Classes="meta" Foreground="{DynamicResource BloodBrush}"
Text="{Binding Error}" TextWrapping="Wrap"
IsVisible="{Binding Error, Converter={x:Static ObjectConverters.IsNotNull}}"/>
<ScrollViewer Grid.Row="1">
<ItemsControl ItemsSource="{Binding Files}">
<!-- Binary-conflict banner -->
<Border Grid.Row="1" Margin="0,0,0,8" Padding="10,7" CornerRadius="6"
Background="{DynamicResource ErrorTintBrush}"
BorderBrush="{DynamicResource BloodBrush}" BorderThickness="1"
IsVisible="{Binding HasBinaryFiles}">
<StackPanel Spacing="3">
<TextBlock Classes="meta" Foreground="{DynamicResource BloodBrush}"
Text="{loc:Tr conflictResolver.binaryHint}"/>
<ItemsControl ItemsSource="{Binding BinaryFilePaths}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ConflictFile">
<StackPanel Spacing="8" Margin="0,0,0,16">
<TextBlock Classes="path-mono heading" Text="{Binding Path}"/>
<ItemsControl ItemsSource="{Binding Hunks}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ConflictHunk">
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="1"
CornerRadius="6" Padding="10" Margin="0,0,0,8">
<StackPanel Spacing="6">
<TextBlock Classes="meta" Text="{loc:Tr conflictResolver.current}"/>
<TextBox Text="{Binding Ours, Mode=OneWay}" IsReadOnly="True"
AcceptsReturn="True" MaxHeight="120"/>
<TextBlock Classes="meta" Text="{loc:Tr conflictResolver.incoming}"/>
<TextBox Text="{Binding Theirs, Mode=OneWay}" IsReadOnly="True"
AcceptsReturn="True" MaxHeight="120"/>
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="btn" Content="{loc:Tr conflictResolver.acceptCurrent}"
Command="{Binding AcceptCurrentCommand}"/>
<Button Classes="btn" Content="{loc:Tr conflictResolver.acceptIncoming}"
Command="{Binding AcceptIncomingCommand}"/>
<Button Classes="btn" Content="{loc:Tr conflictResolver.acceptBoth}"
Command="{Binding AcceptBothCommand}"/>
<Button Classes="btn" Content="{loc:Tr conflictResolver.editManually}"
Command="{Binding EditManuallyCommand}"/>
</StackPanel>
<TextBlock Classes="meta" Text="{loc:Tr conflictResolver.mergedResult}"/>
<TextBox Text="{Binding Resolution, Mode=TwoWay}"
AcceptsReturn="True" MinHeight="80" MaxHeight="200"/>
<DataTemplate x:DataType="x:String">
<TextBlock Classes="path-mono" Text="{Binding}"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<!-- Toolbar: change nav · file switcher · readout -->
<Grid Grid.Row="2" ColumnDefinitions="Auto,Auto,Auto,*,Auto,Auto" Margin="0,0,0,8"
IsVisible="{Binding HasCurrent}">
<Button Grid.Column="0" Classes="btn" Content="↑" Margin="0,0,4,0" Padding="10,4"
ToolTip.Tip="{loc:Tr conflictResolver.prevConflict}"
Command="{Binding PreviousCommand}"/>
<Button Grid.Column="1" Classes="btn" Content="↓" Margin="0,0,12,0" Padding="10,4"
ToolTip.Tip="{loc:Tr conflictResolver.nextConflict}"
Command="{Binding NextCommand}"/>
<ComboBox Grid.Column="2" MinWidth="240" MaxWidth="520"
ItemsSource="{Binding Files}"
SelectedItem="{Binding ActiveFile, Mode=TwoWay}">
<ComboBox.ItemTemplate>
<DataTemplate x:DataType="vm:MergeFile">
<TextBlock Classes="path-mono" Text="{Binding Path}" TextTrimming="CharacterEllipsis"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Grid.Column="4" Classes="meta" VerticalAlignment="Center" Margin="0,0,14,0"
Foreground="{DynamicResource AmberBrush}"
IsVisible="{Binding HasMultipleFiles}"
Text="{Binding FilesSummary}"/>
<TextBlock Grid.Column="5" Classes="meta" VerticalAlignment="Center"
Foreground="{DynamicResource TextDimBrush}"
Text="{Binding PositionText}"/>
</Grid>
<!-- Three panes: Ours | (gutter) | Result | (gutter) | Theirs -->
<Grid Grid.Row="3" ColumnDefinitions="*,26,*,26,*" IsVisible="{Binding HasCurrent}">
<Border Grid.Column="0" Classes="pane">
<DockPanel>
<Border Classes="col-head" DockPanel.Dock="Top">
<TextBlock Classes="eyebrow" Text="{loc:Tr conflictResolver.ours}"
Foreground="{DynamicResource MossBrush}"/>
</Border>
<ae:TextEditor Name="OursEditor" IsReadOnly="True" ShowLineNumbers="True"/>
</DockPanel>
</Border>
<Canvas Grid.Column="1" Name="LeftGutter" Background="Transparent"/>
<Border Grid.Column="2" Classes="pane">
<DockPanel>
<Border Classes="col-head" DockPanel.Dock="Top">
<TextBlock Classes="eyebrow" Text="{loc:Tr conflictResolver.result}"/>
</Border>
<Canvas Name="ConflictMap" DockPanel.Dock="Right" Width="13"
Background="{DynamicResource Surface2Brush}"
ToolTip.Tip="{loc:Tr conflictResolver.conflictMap}"/>
<ae:TextEditor Name="ResultEditor" ShowLineNumbers="True"/>
</DockPanel>
</Border>
<Canvas Grid.Column="3" Name="RightGutter" Background="Transparent"/>
<Border Grid.Column="4" Classes="pane">
<DockPanel>
<Border Classes="col-head" DockPanel.Dock="Top">
<TextBlock Classes="eyebrow" Text="{loc:Tr conflictResolver.theirs}"
Foreground="{DynamicResource AmberBrush}"/>
</Border>
<ae:TextEditor Name="TheirsEditor" IsReadOnly="True" ShowLineNumbers="True"/>
</DockPanel>
</Border>
</Grid>
</Grid>
</ctl:ModalShell>
</Window>

View File

@@ -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.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 TextMateSharp.Grammars;
namespace ClaudeDo.Ui.Views.Conflicts;
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()
{
InitializeComponent();
}
protected override void OnDataContextChanged(System.EventArgs e)
protected override void OnDataContextChanged(EventArgs 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);
}
}
}
}

View File

@@ -138,8 +138,8 @@
<!-- Action buttons -->
<StackPanel Orientation="Horizontal" Spacing="6" Margin="0,4,0,0">
<Button Classes="btn" Content="{loc:Tr agent.openDiff}" Command="{Binding OpenDiffCommand}"/>
<Button Classes="btn" Command="{Binding OpenWorktreeCommand}"
<Button Classes="btn" Content="{loc:Tr agent.openDiff}" Command="{Binding Merge.OpenDiffCommand}"/>
<Button Classes="btn" Command="{Binding Merge.OpenWorktreeCommand}"
ToolTip.Tip="{loc:Tr agent.openWorktreeTip}">
<StackPanel Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
<PathIcon Data="{StaticResource Icon.ArrowOut}"

View File

@@ -29,7 +29,9 @@ public partial class DescriptionStepsCard : UserControl
private void OnSubtaskEditLostFocus(object? sender, RoutedEventArgs e)
{
if (sender is TextBox { DataContext: SubtaskRowViewModel row })
row.IsEditing = false;
if (sender is TextBox { DataContext: SubtaskRowViewModel row }
&& DataContext is DetailsIslandViewModel vm
&& vm.CommitSubtaskEditCommand.CanExecute(row))
vm.CommitSubtaskEditCommand.Execute(row);
}
}

View File

@@ -52,7 +52,7 @@
<!-- Column 2: gear button with agent settings flyout -->
<Button Grid.Column="2" Classes="icon-btn"
ToolTip.Tip="{loc:Tr details.agentSettingsTip}"
IsEnabled="{Binding IsAgentSectionEnabled}"
IsEnabled="{Binding AgentSettings.IsAgentSectionEnabled}"
VerticalAlignment="Top"
Margin="6,0,0,0">
<TextBlock Text="⚙" FontSize="{StaticResource FontSizeTaskTitle}"/>
@@ -64,50 +64,50 @@
<StackPanel Spacing="2">
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
<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}"
Command="{Binding ResetTaskModelCommand}"/>
Command="{Binding AgentSettings.ResetTaskModelCommand}"/>
</Grid>
<ComboBox ItemsSource="{Binding TaskModelOptions}"
SelectedItem="{Binding TaskModelSelection, Mode=TwoWay}"
PlaceholderText="{Binding ModelInheritedHint}"
<ComboBox ItemsSource="{Binding AgentSettings.TaskModelOptions}"
SelectedItem="{Binding AgentSettings.TaskModelSelection, Mode=TwoWay}"
PlaceholderText="{Binding AgentSettings.ModelInheritedHint}"
HorizontalAlignment="Stretch"/>
</StackPanel>
<StackPanel Spacing="2">
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
<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}"
Command="{Binding ResetTaskTurnsCommand}"/>
Command="{Binding AgentSettings.ResetTaskTurnsCommand}"/>
</Grid>
<NumericUpDown Value="{Binding TaskMaxTurns, Mode=TwoWay}"
PlaceholderText="{Binding TurnsInheritedHint}"
<NumericUpDown Value="{Binding AgentSettings.TaskMaxTurns, Mode=TwoWay}"
PlaceholderText="{Binding AgentSettings.TurnsInheritedHint}"
Minimum="1" Maximum="200" Increment="1" FormatString="0"
HorizontalAlignment="Stretch"/>
</StackPanel>
<StackPanel Spacing="2">
<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"/>
<TextBlock Classes="meta" Opacity="0.6"
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"
Text="{Binding EffectiveSystemPromptHint}"
IsVisible="{Binding EffectiveSystemPromptHint, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
Text="{Binding AgentSettings.EffectiveSystemPromptHint}"
IsVisible="{Binding AgentSettings.EffectiveSystemPromptHint, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<StackPanel Spacing="2">
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
<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}"
Command="{Binding ResetTaskAgentCommand}"/>
Command="{Binding AgentSettings.ResetTaskAgentCommand}"/>
</Grid>
<ComboBox ItemsSource="{Binding TaskAgentOptions}"
SelectedItem="{Binding TaskSelectedAgent, Mode=TwoWay}"
<ComboBox ItemsSource="{Binding AgentSettings.TaskAgentOptions}"
SelectedItem="{Binding AgentSettings.TaskSelectedAgent, Mode=TwoWay}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate>

View File

@@ -167,9 +167,16 @@
CommandParameter="output" />
<Button Classes="tab-btn"
Classes.active="{Binding IsGitTab}"
Content="Git"
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"
Classes.active="{Binding IsSessionTab}"
Content="Session"
@@ -205,41 +212,27 @@
</StackPanel>
</Border>
<!-- Review prompt — sits directly on the terminal, like a shell input line;
only while awaiting review. No border/fill so it reads as part of the log. -->
<Grid DockPanel.Dock="Bottom"
<!-- Review footer: feedback + Resume session, shown while awaiting review.
Lives here (with the live log) rather than the Git tab. -->
<Border DockPanel.Dock="Bottom"
IsVisible="{Binding IsWaitingForReview}"
ColumnDefinitions="Auto,*,Auto"
Margin="12,2,12,8">
<TextBlock Grid.Column="0" Text="&#x276F;"
FontFamily="{StaticResource MonoFont}"
FontSize="{StaticResource FontSizeMono}"
Foreground="{DynamicResource AccentBrush}"
VerticalAlignment="Top" Margin="0,2,8,0" />
<TextBox Grid.Column="1"
Name="ReviewInput"
Margin="12,6,12,2">
<StackPanel Spacing="8">
<TextBox Name="ReviewInput"
KeyDown="OnReviewInputKeyDown"
Text="{Binding ReviewFeedback, Mode=TwoWay}"
AcceptsReturn="True"
TextWrapping="Wrap"
MaxHeight="160"
PlaceholderText="Feedback for the next run…"
Background="Transparent"
BorderThickness="0"
Padding="0"
VerticalContentAlignment="Center"
MaxHeight="120"
PlaceholderText="Feedback for a re-run…"
FontFamily="{StaticResource MonoFont}"
FontSize="{StaticResource FontSizeMono}" />
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="10"
VerticalAlignment="Top" Margin="12,2,0,0">
<Button Classes="prompt-action accent" Content="[Continue]"
<Button Classes="btn" Content="Resume session"
HorizontalAlignment="Left"
ToolTip.Tip="{loc:Tr session.reviewContinueTip}"
Command="{Binding RejectReviewCommand}" />
<Button Classes="prompt-action" Content="[Reset]"
ToolTip.Tip="{loc:Tr session.reviewResetTip}"
Command="{Binding ResetReviewCommand}" />
</StackPanel>
</Grid>
</Border>
<ScrollViewer Name="LogScroll"
VerticalScrollBarVisibility="Visible"
@@ -264,49 +257,83 @@
</DockPanel>
<!-- Git: one Approve + merge cockpit -->
<!-- Git: the review + merge cockpit -->
<ScrollViewer IsVisible="{Binding IsGitTab}" Padding="14,10">
<StackPanel Spacing="12" IsVisible="{Binding ShowMergeSection}">
<TextBlock Classes="section-label" Text="MERGE" />
<StackPanel Spacing="14">
<!-- 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">
<TextBlock Classes="field-label" Text="Target branch" />
<ComboBox ItemsSource="{Binding MergeTargetBranches}"
SelectedItem="{Binding SelectedMergeTarget, Mode=TwoWay}"
<ComboBox ItemsSource="{Binding Merge.MergeTargetBranches}"
SelectedItem="{Binding Merge.SelectedMergeTarget, Mode=TwoWay}"
HorizontalAlignment="Stretch" />
</StackPanel>
<StackPanel Spacing="0">
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
<TextBlock Classes="meta" Text="{Binding Merge.MergePreviewText}" TextWrapping="Wrap"
Foreground="{DynamicResource MossBrush}"
IsVisible="{Binding MergeIsClean}" />
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
IsVisible="{Binding Merge.MergeIsClean}" />
<TextBlock Classes="meta" Text="{Binding Merge.MergePreviewText}" TextWrapping="Wrap"
Foreground="{DynamicResource BloodBrush}"
IsVisible="{Binding MergeIsConflict}" />
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
IsVisible="{Binding Merge.MergeIsConflict}" />
<TextBlock Classes="meta" Text="{Binding Merge.MergePreviewText}" TextWrapping="Wrap"
Foreground="{DynamicResource TextMuteBrush}"
IsVisible="{Binding ShowMergePreviewMuted}" />
IsVisible="{Binding Merge.ShowMergePreviewMuted}" />
</StackPanel>
<!-- Primary action: Approve flows straight into the merge. -->
<!-- Inspect: diff / worktree / combined diff -->
<WrapPanel Orientation="Horizontal">
<Button Classes="btn accent" Content="Approve &amp; Merge" Margin="0,0,8,8"
Command="{Binding ApproveReviewCommand}"
IsVisible="{Binding IsWaitingForReview}" />
<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"
ToolTip.Tip="{loc:Tr agent.openWorktreeTip}"
Command="{Binding OpenWorktreeCommand}">
Command="{Binding Merge.OpenWorktreeCommand}">
<StackPanel Orientation="Horizontal" Spacing="5">
<TextBlock Text="Worktree" />
<PathIcon Data="{StaticResource Icon.ArrowOut}" Width="11" Height="11" />
</StackPanel>
</Button>
<Button Classes="btn" Content="Review Combined Diff" Margin="0,0,8,8"
Command="{Binding ReviewCombinedDiffCommand}" />
Command="{Binding Merge.ReviewCombinedDiffCommand}" />
</WrapPanel>
</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 &amp; 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>
<!-- Session: subtask outcomes (review lives in Output, merge in Git) -->

View File

@@ -104,16 +104,16 @@
<DockPanel>
<Border DockPanel.Dock="Top" Padding="12,8">
<Button Classes="btn primary"
Command="{Binding PlanDayCommand}"
IsEnabled="{Binding !IsPrepRunning}"
Command="{Binding Prep.PlanDayCommand}"
IsEnabled="{Binding !Prep.IsPrepRunning}"
Content="{loc:Tr details.planDay}"/>
</Border>
<Panel>
<islands:SessionTerminalView
Margin="18,8,18,0"
Entries="{Binding PrepLog}" Label="daily-prep"
IsRunning="{Binding IsPrepRunning}"/>
<TextBlock IsVisible="{Binding ShowPrepEmptyState}"
Entries="{Binding Prep.PrepLog}" Label="daily-prep"
IsRunning="{Binding Prep.IsPrepRunning}"/>
<TextBlock IsVisible="{Binding Prep.ShowPrepEmptyState}"
HorizontalAlignment="Center" VerticalAlignment="Center"
Foreground="{DynamicResource TextMuteBrush}"
Text="{loc:Tr details.prepEmpty}"/>

View File

@@ -35,6 +35,13 @@ public partial class DetailsIslandView : UserControl
if (h <= 0) return;
DetailBodyGrid.RowDefinitions[0].MaxHeight = h * 2.0 / 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)
@@ -48,7 +55,7 @@ public partial class DetailsIslandView : UserControl
vm.PropertyChanged += OnViewModelPropertyChanged;
ApplyResizeStateForCurrentTask();
vm.ShowDiffModal = async (diffVm) =>
vm.Merge.ShowDiffModal = async (diffVm) =>
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner == null) return;
@@ -56,7 +63,7 @@ public partial class DetailsIslandView : UserControl
await modal.ShowDialog(owner);
};
vm.ShowMergeModal = async (mergeVm) =>
vm.Merge.ShowMergeModal = async (mergeVm) =>
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner == null) return;
@@ -64,7 +71,7 @@ public partial class DetailsIslandView : UserControl
await modal.ShowDialog(owner);
};
vm.ShowPlanningDiffModal = async (planningDiffVm) =>
vm.Merge.ShowPlanningDiffModal = async (planningDiffVm) =>
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner == null) return;

View File

@@ -28,11 +28,8 @@
<ItemsControl ItemsSource="{Binding Bullets}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:NoteBulletViewModel">
<Grid ColumnDefinitions="*,Auto,Auto" Margin="0,2" ColumnSpacing="6">
<TextBox Grid.Column="0" Text="{Binding Text}"/>
<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>
<TextBox Text="{Binding Text}" Margin="0,2"
LostFocus="OnBulletLostFocus"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>

View File

@@ -1,8 +1,18 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using ClaudeDo.Ui.ViewModels.Islands;
namespace ClaudeDo.Ui.Views.Islands;
public partial class NotesEditorView : UserControl
{
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);
}
}

View File

@@ -20,8 +20,8 @@
<!-- Indent wrapper: col 0 = 24px child indent track, col 1 = content -->
<Grid Grid.Row="1" ColumnDefinitions="Auto,*">
<!-- Indent track (only visible for child tasks) -->
<Border Grid.Column="0" Width="24" IsVisible="{Binding IsChild}" VerticalAlignment="Stretch">
<!-- Indent track (only while the parent shares this view; orphaned children render flat) -->
<Border Grid.Column="0" Width="24" IsVisible="{Binding ShowAsChild}" VerticalAlignment="Stretch">
<Rectangle Width="1" Fill="{DynamicResource LineBrush}"
HorizontalAlignment="Right" Margin="0,4"/>
</Border>
@@ -56,17 +56,23 @@
<MenuItem Header="{loc:Tr tasks.ctxResumePlanningSession}"
Click="OnResumePlanningSessionClick"
IsVisible="{Binding CanResumeOrDiscardPlanning}"/>
<MenuItem Header="{loc:Tr tasks.ctxFinalizePlanningSession}"
Click="OnFinalizePlanningSessionClick"
IsVisible="{Binding CanFinalizePlanning}"/>
<MenuItem Header="{loc:Tr tasks.ctxDiscardPlanningSession}"
Click="OnDiscardPlanningSessionClick"
IsVisible="{Binding CanResumeOrDiscardPlanning}"/>
<MenuItem Header="{loc:Tr tasks.ctxQueueSubtasks}"
Click="OnQueuePlanningSubtasksClick"
IsVisible="{Binding CanQueuePlan}"/>
<Separator/>
<MenuItem Header="{loc:Tr tasks.ctxScheduleFor}" Click="OnScheduleForClick"/>
<MenuItem Header="{loc:Tr tasks.ctxClearSchedule}"
IsVisible="{Binding HasSchedule}"
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>
</Border.ContextMenu>
<Grid ColumnDefinitions="0,18,32,*,Auto,Auto,32" Margin="6,8,10,8">
@@ -146,6 +152,7 @@
<!-- Status chip -->
<Border Classes="chip"
Classes.parked="{Binding IsParked}"
Classes.running="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Running}"
Classes.review="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=WaitingForReview}"
Classes.children="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=WaitingForChildren}"

View File

@@ -48,6 +48,18 @@ public partial class TaskRowView : UserControl
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)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
@@ -72,10 +84,10 @@ public partial class TaskRowView : UserControl
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)
await vm.QueuePlanningSubtasksCommand.ExecuteAsync(row);
await vm.FinalizePlanningSessionCommand.ExecuteAsync(row);
}
private async void OnSetStatusClick(object? sender, RoutedEventArgs e)

View File

@@ -22,7 +22,7 @@
<KeyBinding Gesture="Shift+OemQuestion" Command="{Binding FocusSearchCommand}"/>
<KeyBinding Gesture="Ctrl+N" Command="{Binding FocusAddTaskCommand}"/>
</Window.KeyBindings>
<Grid RowDefinitions="36,Auto,*,22">
<Grid x:Name="RootGrid" RowDefinitions="36,Auto,*,22">
<!-- Custom title bar -->
<Border Grid.Row="0"
Background="{DynamicResource DeepBrush}"
@@ -57,18 +57,26 @@
<Menu Margin="12,0,0,0"
Background="Transparent"
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}"
FontSize="{StaticResource FontSizeMono}"
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.about}" Command="{Binding OpenAboutCommand}"/>
<MenuItem Header="{loc:Tr shell.menu.addRepos}" Command="{Binding OpenRepoImportCommand}"/>
</MenuItem>
</Menu>
</StackPanel>

View File

@@ -27,6 +27,8 @@ public partial class MainWindow : Window
base.OnPropertyChanged(change);
if (change.Property == WindowStateProperty)
UpdateMaxIcon();
if (change.Property == OffScreenMarginProperty)
RootGrid.Margin = OffScreenMargin;
}
private void UpdateMaxIcon()
@@ -40,11 +42,6 @@ public partial class MainWindow : Window
{
if (DataContext is IslandsShellViewModel vm)
{
vm.ShowConflictDialog = async (conflictVm) =>
{
var modal = new ConflictResolutionView { DataContext = conflictVm };
await modal.ShowDialog(this);
};
vm.ShowAboutModal = async (aboutVm) =>
{
var dlg = new AboutModalView { DataContext = aboutVm };

View File

@@ -261,6 +261,99 @@
</ScrollViewer>
</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>
</DockPanel>

View File

@@ -81,7 +81,7 @@
IsVisible="{Binding HasOutcome}"/>
<Border Grid.Column="3" CornerRadius="3" Padding="6,2" VerticalAlignment="Center"
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"/>
</Border>
<TextBlock Grid.Column="4" Classes="meta" Text="{Binding DiffStat}" VerticalAlignment="Center"/>

View File

@@ -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>

View File

@@ -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;
}
}

View File

@@ -8,15 +8,18 @@ ASP.NET Core hosted service that executes tasks via Claude CLI in isolated envir
Worker/
State/ — TaskStateService + TransitionResult (sole owner of Status/PlanningPhase/BlockedBy writes)
Queue/ — IQueueWaker, IQueuePicker, QueueService (BackgroundService), OverrideSlotService
Lifecycle/ — StaleTaskRecovery, TaskResetService, TaskMergeService
Lifecycle/ — StaleTaskRecovery, TaskResetService, TaskMergeService, ClaudeCliPreflight, OrphanRecovery, PlanningLineageRecovery
Worktrees/ — WorktreeMaintenanceService
Agents/ — AgentFileService, DefaultAgentSeeder
Runner/ — TaskRunner + Claude CLI integration
Planning/ — PlanningSessionManager, PlanningChainCoordinator, PlanningMcpService
External/ — ExternalMcpService
Runner/ — TaskRunner + Claude CLI integration; TaskRunMcpService/TaskRunMcpContext/TaskRunTokenRegistry (in-task MCP wired during execution)
Planning/ — PlanningSessionManager, PlanningChainCoordinator, PlanningMcpService, PlanningMergeOrchestrator, PlanningAggregator, PlanningSessionContext/PlanningTokenAuth/PlanningMcpContextAccessor, WindowsTerminalPlanningLauncher (IPlanningTerminalLauncher)
Refine/ — RefineRunner + RefinePrompt (hub `RefineTask`; broadcasts RefineStarted/RefineFinished)
External/ — ExternalMcpService + sibling tool classes
Config/ — WorkerConfig
Hub/ — WorkerHub, HubBroadcaster
Report/ — ClaudeHistoryReader, WeekReportPromptBuilder, WeekReportService; interfaces in Report/Interfaces/
Prime/ — daily-prep ("Prime Claude"): PrimeScheduler (BackgroundService), PrimeRunner (runs the daily prep), DailyPrepPrompt (fixed prompt + CLI args + LogPath() helper), NextDueCalculator, PrimeScheduleSignal; interfaces in Prime/Interfaces/ (IPrimeRunner, IPrimeClock, IPrimeScheduleSignal, IPrimeBroadcaster)
Online/ — optional Online Inbox sync: OnlineInboxConfig (config record), Dtos (RemoteList/RemoteTask/MirrorTask), IOnlineInboxApi, OnlineInboxApiClient (typed HttpClient, bearer auth, HTTPS guard), OnlineTokenStore (DPAPI refresh-token store, Windows-only), StaticTokenAuthProvider (default/test IOnlineAuthProvider), ZitadelAuthProvider (stub — TODO(online-inbox) Phase 2), OnlineSyncService (BackgroundService: reconcile loop), OnlineBacklog (Idle-backlog filter/query); interface in Online/Interfaces/ (IOnlineAuthProvider)
```
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).
- **StaleTaskRecovery** — startup-only service; calls `TaskStateService.RecoverStaleRunningAsync` to flip orphaned `Running` rows to `Failed`.
- **External/*** — always-on MCP tools for general Claude sessions, scoped to *starting* and *observing* sessions (no worktree/merge, multi-turn, planning, or app-settings writes). Auth via optional `X-ClaudeDo-Key` header. Registered explicitly in `Program.cs`'s external app via `.WithTools<T>()`. Organized by concern:
- `ExternalMcpService` — task CRUD + execution: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `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`
- `ConfigMcpTools``GetListConfig`, `SetListConfig`, `SetTaskConfig`
- `ConfigMcpTools``GetListConfig`, `SetListConfig`, `GetTaskConfig`, `SetTaskConfig`
- `RunHistoryMcpTools``ListRuns`, `GetRun`, `GetTaskLog` (latest run's log, tail-capped at 256 KB)
- `AgentMcpTools``ListAgents`
- `AgentMcpTools``ListAgents` (class lives in `LifecycleMcpTools.cs`)
- `LifecycleMcpTools``ResetFailedTask`
- `AppSettingsMcpTools``GetAppSettings` (read-only)
- `ExternalMcpService` also exposes two daily-prep tools:
@@ -67,7 +70,7 @@ Allowed transitions (enforced by `TaskStateService`):
```
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)
| WaitingForChildren (parent with pending children)
| 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
**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
@@ -155,8 +166,14 @@ Loaded from `~/.todo-app/worker.config.json`:
- `queue_backstop_interval_ms` (default 30000)
- `signalr_port` (default 47821)
- `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

View File

@@ -14,6 +14,7 @@
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="ModelContextProtocol" Version="1.2.0" />
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.2.0" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -1,6 +1,8 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
using ClaudeDo.Data;
using ClaudeDo.Worker.Online;
namespace ClaudeDo.Worker.Config;
@@ -39,6 +41,9 @@ public sealed class WorkerConfig
[JsonPropertyName("external_mcp_api_key")]
public string? ExternalMcpApiKey { get; set; }
[JsonPropertyName("online_inbox")]
public OnlineInboxConfig OnlineInbox { get; set; } = new();
public static string DefaultConfigPath =>
Path.Combine(Paths.AppDataRoot(), "worker.config.json");
@@ -70,9 +75,38 @@ public sealed class WorkerConfig
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()
{
ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true,
};
private static readonly JsonSerializerOptions InboxSerializerOpts = new()
{
WriteIndented = false,
};
private static readonly JsonSerializerOptions WriteOpts = new()
{
WriteIndented = true,
};
}

View File

@@ -142,13 +142,18 @@ public sealed class ExternalMcpService
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(
string listId,
string title,
string? description = null,
string? createdBy = null,
bool queueImmediately = false,
string? model = null,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(listId))
@@ -169,6 +174,7 @@ public sealed class ExternalMcpService
CreatedAt = DateTime.UtcNow,
CommitType = list.DefaultCommitType,
CreatedBy = createdBy.NullIfBlank() ?? "mcp",
Model = ModelRegistry.NormalizeAlias(model),
};
await _tasks.AddAsync(entity, cancellationToken);

View File

@@ -4,7 +4,9 @@ using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Agents;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Online;
using ClaudeDo.Worker.Planning;
using ClaudeDo.Worker.Prime;
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 ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks);
public record ConflictHunkDto(string Ours, string Theirs, string? Base);
public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDocumentDto> Files);
public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList<MergeSegmentDto> Segments);
public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs);
public record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType);
public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
public record 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
{
private static readonly string Version =
@@ -89,6 +113,9 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
private readonly ITaskStateService _state;
private readonly IWeekReportService _report;
private readonly IRefineRunner _refineRunner;
private readonly WorkerConfig _cfg;
private readonly OnlineInboxConfig _onlineInboxConfig;
private readonly OnlineTokenStore _onlineTokenStore;
public WorkerHub(
QueueService queue,
@@ -109,7 +136,10 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
IPrimeRunner primeRunner,
ITaskStateService state,
IWeekReportService report,
IRefineRunner refineRunner)
IRefineRunner refineRunner,
WorkerConfig cfg,
OnlineInboxConfig onlineInboxConfig,
OnlineTokenStore onlineTokenStore)
{
_queue = queue;
_waker = waker;
@@ -130,6 +160,9 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
_state = state;
_report = report;
_refineRunner = refineRunner;
_cfg = cfg;
_onlineInboxConfig = onlineInboxConfig;
_onlineTokenStore = onlineTokenStore;
}
// 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());
});
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)
=> HubGuard(() => _mergeService.WriteResolutionAsync(
taskId, path, resolvedContent ?? "", CancellationToken.None));
public Task<MergeResultDto> ContinueMerge(string taskId)
public Task<MergeResultDto> ContinueConflictMerge(string taskId)
=> HubGuard(async () =>
{
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);
});
public Task AbortMerge(string taskId)
public Task AbortConflictMerge(string taskId)
=> HubGuard(async () =>
{
var r = await _mergeService.AbortMergeAsync(taskId, CancellationToken.None);
@@ -684,4 +729,42 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
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
}

View File

@@ -33,6 +33,15 @@ public sealed record ConflictFileContent(
string Theirs,
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 const string StatusMerged = "merged";
@@ -256,6 +265,45 @@ public sealed class TaskMergeService
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)
{
var (_, list, _) = await LoadMergeContextAsync(taskId, ct);

View 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);

View 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);
}

View 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);
}

View 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);
}
}

View 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;
}

View 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);
}
}

View 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();
}

View 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;
}
}

View 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);
}
}

View 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);
}
}

View 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);
}

View 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; }
}
}

View File

@@ -37,15 +37,20 @@ public sealed class PlanningMcpService
private Task BroadcastTaskUpdatedAsync(string taskId, CancellationToken ct)
=> _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(
string title,
string? description,
string? commitType,
string? model,
CancellationToken cancellationToken)
{
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(ctx.ParentTaskId, cancellationToken);
return new CreatedChildDto(child.Id, child.Status.ToString());

View File

@@ -4,6 +4,7 @@ using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.State;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
@@ -16,6 +17,7 @@ public sealed class PlanningMergeOrchestrator
private readonly PlanningAggregator _aggregator;
private readonly HubBroadcaster _broadcaster;
private readonly GitService _git;
private readonly ITaskStateService _state;
private readonly ILogger<PlanningMergeOrchestrator> _logger;
private sealed class State
@@ -34,6 +36,7 @@ public sealed class PlanningMergeOrchestrator
PlanningAggregator aggregator,
HubBroadcaster broadcaster,
GitService git,
ITaskStateService state,
ILogger<PlanningMergeOrchestrator> logger)
{
_dbFactory = dbFactory;
@@ -41,6 +44,7 @@ public sealed class PlanningMergeOrchestrator
_aggregator = aggregator;
_broadcaster = broadcaster;
_git = git;
_state = state;
_logger = logger;
}
@@ -81,7 +85,8 @@ public sealed class PlanningMergeOrchestrator
}
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))
throw new InvalidOperationException("working tree has uncommitted changes");
@@ -106,7 +111,8 @@ public sealed class PlanningMergeOrchestrator
public async Task ContinueAsync(string planningTaskId, CancellationToken ct)
{
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 result = await _merge.ContinueMergeAsync(current, ct);
@@ -136,13 +142,40 @@ public sealed class PlanningMergeOrchestrator
public async Task AbortAsync(string planningTaskId, CancellationToken ct)
{
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);
_states.TryRemove(planningTaskId, out _);
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)
{
if (!_states.TryGetValue(planningTaskId, out var state)) return;
@@ -181,7 +214,8 @@ public sealed class PlanningMergeOrchestrator
}
state.CurrentSubtaskId = null;
await FinalizeParentDoneAsync(planningTaskId, state.IsPlanning, ct);
var finalized = await FinalizeParentDoneAsync(planningTaskId, state.IsPlanning, ct);
if (finalized)
await _broadcaster.PlanningCompleted(planningTaskId);
}
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 parent = await ctx.Tasks.SingleOrDefaultAsync(t => t.Id == parentTaskId, ct);
if (parent is null) return;
parent.Status = TaskStatus.Done;
parent.FinishedAt = DateTime.UtcNow;
await ctx.SaveChangesAsync(ct);
var result = await _state.ApproveReviewAsync(parentTaskId, ct);
if (!result.Ok)
{
// ApproveReviewAsync requires WaitingForReview. For improvement parents whose own
// worktree is in the merge queue, TaskMergeService.ApproveIfWaitingForReviewAsync
// 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
// visibly stuck in WaitingForReview even though the unit merge completed.
await _broadcaster.TaskUpdated(parentTaskId);
if (current != TaskStatus.Done)
{
// 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.
if (isPlanning)
@@ -209,5 +255,7 @@ public sealed class PlanningMergeOrchestrator
try { await _aggregator.CleanupIntegrationBranchAsync(parentTaskId, ct); }
catch (Exception ex) { _logger.LogWarning(ex, "integration branch cleanup failed"); }
}
return true;
}
}

View File

@@ -11,6 +11,8 @@ using ClaudeDo.Worker.Planning;
using ClaudeDo.Worker.Queue;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.State;
using ClaudeDo.Worker.Online;
using ClaudeDo.Worker.Online.Interfaces;
using ClaudeDo.Worker.Prime;
using ClaudeDo.Worker.Refine;
using ClaudeDo.Worker.Report;
@@ -149,6 +151,28 @@ builder.Services.AddMcpServer()
.WithTools<PlanningMcpService>()
.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.
builder.WebHost.UseUrls($"http://127.0.0.1:{cfg.SignalRPort}");

View File

@@ -191,14 +191,14 @@ public sealed class QueueService : BackgroundService
if (sessionId is not null)
{
await _runner.ContinueAsync(taskId, feedback, "queue", ct);
await _runner.ContinueAsync(taskId, feedback, "queue", ct, alreadyClaimed: true);
}
else
{
task.Description = string.IsNullOrWhiteSpace(task.Description)
? $"Reviewer 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
@@ -212,7 +212,7 @@ public sealed class QueueService : BackgroundService
return;
}
await _runner.RunAsync(task, "queue", ct);
await _runner.RunAsync(task, "queue", ct, alreadyClaimed: true);
}
catch (Exception ex)
{

View File

@@ -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 " +
"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) " +
"— 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(
string title,
string description,
string? model,
CancellationToken cancellationToken)
{
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).");
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(callerId);
return new SuggestedImprovementDto(child.Id);

View File

@@ -44,7 +44,7 @@ public sealed class TaskRunner
_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? mcpConfigPath = null;
@@ -98,7 +98,17 @@ public sealed class TaskRunner
}
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);
// 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;
TaskRunEntity lastRun;
@@ -208,7 +218,16 @@ public sealed class TaskRunner
}
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);
try

View File

@@ -126,11 +126,14 @@ public sealed class TaskStateService : ITaskStateService
public async Task<TransitionResult> ApproveReviewAsync(string taskId, CancellationToken ct)
{
var now = DateTime.UtcNow;
await using (var ctx = await _dbFactory.CreateDbContextAsync(ct))
{
var affected = await ctx.Tasks
.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)
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))
{
// 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
.Where(t => t.Id == taskId &&
(t.Status == TaskStatus.Running || t.Status == TaskStatus.Queued))

View 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));
}
}

View 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