52 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
140 changed files with 7197 additions and 1332 deletions

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

View File

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

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

View File

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

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

View File

@@ -19,8 +19,8 @@ Desktop entry point for the ClaudeDo application. Configures DI, initializes the
## DI Registration Pattern ## DI Registration Pattern
- **Singletons**: `IDbContextFactory`, all Repositories, GitService, WorkerClient, `IReleaseClient`, `InstallerLocator` / `WorkerLocator`, the island VMs (`ListsIslandViewModel`, `TasksIslandViewModel`, `DetailsIslandViewModel`) and `IslandsShellViewModel` (the window's DataContext) - **Singletons**: `IDbContextFactory`, all Repositories, GitService, WorkerClient, `IReleaseClient`, `UpdateCheckService`, `IPrimeScheduleApi`/`WorkerPrimeScheduleApi`, `INotesApi`/`WorkerNotesApi`, `InstallerLocator` / `WorkerLocator`, the island VMs (`ListsIslandViewModel`, `TasksIslandViewModel`, `DetailsIslandViewModel`) and `IslandsShellViewModel` (the window's DataContext)
- **Transients**: modal VMs (`SettingsModalViewModel`, `MergeModalViewModel`, `ListSettingsModalViewModel`, `RepoImportModalViewModel`, `WorktreeModalViewModel`, `WorktreesOverviewModalViewModel`, `PrimeClaudeTabViewModel`), several exposed as `Func<T>` factories for on-demand dialog creation - **Transients**: modal VMs (`SettingsModalViewModel`, `MergeModalViewModel`, `ListSettingsModalViewModel`, `RepoImportModalViewModel`, `WeeklyReportModalViewModel`, `WorktreeModalViewModel`, `WorktreesOverviewModalViewModel`, `PrimeClaudeTabViewModel`), several exposed as `Func<T>` factories for on-demand dialog creation; `ConflictResolverViewModel` via a `Func<string, ConflictResolverViewModel>` factory keyed by taskId (singleton factory, handed to `IslandsShellViewModel.ConflictResolverFactory`)
## Notes ## Notes

View File

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

View File

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

View File

@@ -35,7 +35,7 @@ All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. The atomic `Q
## Git ## Git
- **GitService** — async wrapper around git CLI (ProcessStartInfo, no shell). Operations: worktree add/remove, add all, commit (stdin for message), merge ff-only, rev-parse, diff-stat, has-changes, is-git-repo, `PreviewMergeAsync` (non-destructive mergeability check via `git merge-tree --write-tree`), `CountChangedFilesAsync` - **GitService** — async wrapper around git CLI (ProcessStartInfo, no shell). Worktree ops (add — serialized to avoid a commondir race —, remove, prune, list paths for branch), branch ops (current, list local, checkout, delete), staging/commit (status porcelain, add-all, add-path, commit via stdin), diffs (working tree, branch vs base, commit range `base..head` — used to show a merged task's diff after the worktree is gone —, per-file, diff-stat, committed files, has-changes), merge (ff-only, no-ff, abort, mid-merge detection, conflicted files, show-stage for conflict hunks), `PreviewMergeAsync` (non-destructive mergeability check via `git merge-tree --write-tree`), `CountChangedFilesAsync`, rev-parse, is-git-repo
## Schema ## Schema

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -100,6 +100,15 @@
<SolidColorBrush x:Key="DoneTintBrush" Color="#1F6FA86B" /> <SolidColorBrush x:Key="DoneTintBrush" Color="#1F6FA86B" />
<SolidColorBrush x:Key="DoneTintBorderBrush" Color="#4C6FA86B" /> <SolidColorBrush x:Key="DoneTintBorderBrush" Color="#4C6FA86B" />
<!-- Merge editor (3-pane conflict resolver) block tints -->
<SolidColorBrush x:Key="MergeOursTintBrush" Color="#1F7C9166" /> <!-- ours side (moss) -->
<SolidColorBrush x:Key="MergeTheirsTintBrush" Color="#1FD4A574" /> <!-- theirs side (amber) -->
<SolidColorBrush x:Key="MergeConflictTintBrush" Color="#28C87060" /> <!-- unresolved conflict (blood) -->
<SolidColorBrush x:Key="MergeConflictEdgeBrush" Color="#80C87060" /> <!-- unresolved conflict gutter edge / map tick -->
<SolidColorBrush x:Key="MergeResolvedTintBrush" Color="#206FA86B" /> <!-- resolved conflict (green) -->
<SolidColorBrush x:Key="MergeResolvedEdgeBrush" Color="#806FA86B" /> <!-- resolved conflict map tick -->
<SolidColorBrush x:Key="AmberBrush" Color="#FFD4A574" /> <!-- solid amber (theirs label) -->
<!-- Window-body gradient layers (apply as LinearGradientBrush in the main content Border) --> <!-- Window-body gradient layers (apply as LinearGradientBrush in the main content Border) -->
<LinearGradientBrush x:Key="DesktopBackgroundBrush" StartPoint="0%,0%" EndPoint="0%,100%"> <LinearGradientBrush x:Key="DesktopBackgroundBrush" StartPoint="0%,0%" EndPoint="0%,100%">
<GradientStop Offset="0" Color="#FF05070A" /> <GradientStop Offset="0" Color="#FF05070A" />

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 public interface IWorkerClient : INotifyPropertyChanged
{ {
bool IsConnected { get; } bool IsConnected { get; }
bool IsReconnecting { get; }
event Action<string, string, DateTime>? TaskStartedEvent; event Action<string, string, DateTime>? TaskStartedEvent;
event Action<string, string, string, DateTime>? TaskFinishedEvent; event Action<string, string, string, DateTime>? TaskFinishedEvent;
@@ -17,6 +18,7 @@ public interface IWorkerClient : INotifyPropertyChanged
event Action<string>? WorktreeUpdatedEvent; event Action<string>? WorktreeUpdatedEvent;
event Action<string>? ListUpdatedEvent; event Action<string>? ListUpdatedEvent;
event Action<string, string>? TaskMessageEvent; event Action<string, string>? TaskMessageEvent;
event Action<WorkerLogEntry>? WorkerLogReceivedEvent;
event Action? PrepStartedEvent; event Action? PrepStartedEvent;
event Action<string>? PrepLineEvent; event Action<string>? PrepLineEvent;
@@ -28,12 +30,18 @@ public interface IWorkerClient : INotifyPropertyChanged
event Action<string>? PlanningMergeAbortedEvent; event Action<string>? PlanningMergeAbortedEvent;
event Action<string>? PlanningCompletedEvent; event Action<string>? PlanningCompletedEvent;
event Action<PrimeFiredEvent>? PrimeFired;
string? LastApproveTarget { get; }
Task WakeQueueAsync(); Task WakeQueueAsync();
Task RunNowAsync(string taskId); Task RunNowAsync(string taskId);
Task ContinueTaskAsync(string taskId, string followUpPrompt); Task ContinueTaskAsync(string taskId, string followUpPrompt);
Task ResetTaskAsync(string taskId); Task ResetTaskAsync(string taskId);
Task CancelTaskAsync(string taskId); Task CancelTaskAsync(string taskId);
Task<List<AgentInfo>> GetAgentsAsync(); Task<List<AgentInfo>> GetAgentsAsync();
Task RefreshAgentsAsync();
Task<SeedResultDto?> RestoreDefaultAgentsAsync();
Task<ListConfigDto?> GetListConfigAsync(string listId); Task<ListConfigDto?> GetListConfigAsync(string listId);
Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto); Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto);
Task SetTaskStatusAsync(string taskId, TaskStatus status); Task SetTaskStatusAsync(string taskId, TaskStatus status);
@@ -47,9 +55,10 @@ public interface IWorkerClient : INotifyPropertyChanged
// ── Conflict resolution (worker hub side implemented by Layer C) ── // ── Conflict resolution (worker hub side implemented by Layer C) ──
Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch); Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch);
Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId); Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId);
Task<MergeConflictDocumentsDto> GetMergeConflictDocumentsAsync(string taskId);
Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent); Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent);
Task<MergeResultDto> ContinueMergeAsync(string taskId); Task<MergeResultDto> ContinueConflictMergeAsync(string taskId);
Task AbortMergeAsync(string taskId); Task AbortConflictMergeAsync(string taskId);
Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default); Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default);
Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default); Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default);
Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default); Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default);
@@ -71,9 +80,28 @@ public interface IWorkerClient : INotifyPropertyChanged
event Action<string, bool, string?>? RefineFinishedEvent; event Action<string, bool, string?>? RefineFinishedEvent;
Task ClearMyDayAsync(); Task ClearMyDayAsync();
Task<AppSettingsDto?> GetAppSettingsAsync(); Task<AppSettingsDto?> GetAppSettingsAsync();
Task UpdateAppSettingsAsync(AppSettingsDto dto);
Task<List<DailyNoteDto>> GetDailyNotesAsync(DateOnly day); Task<List<DailyNoteDto>> GetDailyNotesAsync(DateOnly day);
Task<DailyNoteDto?> AddDailyNoteAsync(DateOnly day, string text); Task<DailyNoteDto?> AddDailyNoteAsync(DateOnly day, string text);
Task UpdateDailyNoteAsync(string id, string text); Task UpdateDailyNoteAsync(string id, string text);
Task DeleteDailyNoteAsync(string id); Task DeleteDailyNoteAsync(string id);
Task<string> GetLastPrepLogAsync(); Task<string> GetLastPrepLogAsync();
Task<List<PrimeScheduleDto>> GetPrimeSchedulesAsync();
Task<PrimeScheduleDto?> UpsertPrimeScheduleAsync(PrimeScheduleDto dto);
Task DeletePrimeScheduleAsync(Guid id);
Task UpdateListAsync(UpdateListDto dto);
Task UpdateListConfigAsync(UpdateListConfigDto dto);
Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync(string? listId = null);
Task<WorktreeResetDto?> ResetAllWorktreesAsync();
Task<List<WorktreeOverviewDto>> GetWorktreesOverviewAsync(string? listId);
Task<(bool Ok, string? Error)> SetWorktreeStateAsync(string taskId, WorktreeState newState);
Task<ForceRemoveResultDto?> ForceRemoveWorktreeAsync(string taskId);
Task<OnlineInboxStateDto?> GetOnlineInboxStateAsync();
Task SetOnlineInboxConfigAsync(OnlineInboxConfigInputDto input);
Task SetOnlineInboxAuthAsync(string refreshToken);
Task ClearOnlineInboxAuthAsync();
} }

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

View File

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

View File

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

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; namespace ClaudeDo.Ui.ViewModels.Conflicts;
public sealed partial class ConflictHunk : ObservableObject /// <summary>
/// One conflict region in a file: the two competing versions (and the merge base when the
/// merge used diff3 style), plus the chosen <see cref="Resolution"/> (null until resolved).
/// </summary>
public sealed partial class MergeConflictBlock : ObservableObject
{ {
public string Ours { get; } public string Ours { get; }
public string Theirs { get; }
public string? Base { get; } public string? Base { get; }
public string Theirs { get; }
[ObservableProperty] private string? _resolution; [ObservableProperty] private string? _resolution;
public bool IsResolved => Resolution is not null; public bool IsResolved => Resolution is not null;
public bool HasBase => Base is not null;
public ConflictHunk(string ours, string theirs, string? @base) public MergeConflictBlock(string ours, string? @base, string theirs)
{ {
Ours = ours; Ours = ours;
Theirs = theirs;
Base = @base; Base = @base;
Theirs = theirs;
} }
partial void OnResolutionChanged(string? value) => OnPropertyChanged(nameof(IsResolved)); partial void OnResolutionChanged(string? value) => OnPropertyChanged(nameof(IsResolved));
[RelayCommand] private void AcceptCurrent() => Resolution = Ours; [RelayCommand] private void AcceptOurs() => Resolution = Ours;
[RelayCommand] private void AcceptIncoming() => Resolution = Theirs; [RelayCommand] private void AcceptTheirs() => Resolution = Theirs;
[RelayCommand] private void AcceptBoth() => Resolution = Ours + Theirs; [RelayCommand] private void AcceptBoth() => Resolution = Ours + Theirs;
[RelayCommand] private void EditManually() => Resolution ??= Ours; [RelayCommand] private void AcceptBase() => Resolution = Base ?? "";
} }
public sealed class ConflictFile /// <summary>An ordered piece of a conflicted file: either stable common text or a conflict block.</summary>
public sealed class MergeFileSegment
{ {
public string Path { get; } public bool IsConflict { get; }
public IReadOnlyList<ConflictHunk> Hunks { get; } public string StableText { get; }
public MergeConflictBlock? Conflict { get; }
public ConflictFile(string path, IReadOnlyList<ConflictHunk> hunks) private MergeFileSegment(bool isConflict, string stableText, MergeConflictBlock? conflict)
{ {
Path = path; IsConflict = isConflict;
Hunks = hunks; StableText = stableText;
Conflict = conflict;
} }
public bool AllHunksResolved => Hunks.Count > 0 && Hunks.All(h => h.IsResolved); public static MergeFileSegment Stable(string text) => new(false, text, null);
public static MergeFileSegment FromConflict(MergeConflictBlock block) => new(true, "", block);
/// <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>A conflicted file: its ordered segments (for reassembly) and just its conflict blocks.</summary>
public sealed class MergeFile
{
public string Path { get; }
public bool IsBinary { get; }
public IReadOnlyList<MergeFileSegment> Segments { get; }
public IReadOnlyList<MergeConflictBlock> Conflicts { get; }
public MergeFile(string path, bool isBinary, IReadOnlyList<MergeFileSegment> segments)
{
Path = path;
IsBinary = isBinary;
Segments = segments;
Conflicts = segments.Where(s => s.IsConflict).Select(s => s.Conflict!).ToList();
}
/// <summary>A binary file can't be resolved in-app; a text file is done once every block is resolved.</summary>
public bool AllResolved => !IsBinary && Conflicts.All(c => c.IsResolved);
/// <summary>Reassemble the file: stable text verbatim, each conflict replaced by its resolution
/// (empty when unresolved — the same "empty start" the editor shows; Continue is gated on
/// <see cref="AllResolved"/> so an unresolved conflict never actually reaches here).</summary>
public string Compose() => string.Concat(
Segments.Select(s => s.IsConflict ? (s.Conflict!.Resolution ?? "") : s.StableText));
/// <summary>Left pane document: stable regions verbatim, conflict regions show Ours text.</summary>
public string OursText => string.Concat(
Segments.Select(s => s.IsConflict ? s.Conflict!.Ours : s.StableText));
/// <summary>Right pane document: stable regions verbatim, conflict regions show Theirs text.</summary>
public string TheirsText => string.Concat(
Segments.Select(s => s.IsConflict ? s.Conflict!.Theirs : s.StableText));
/// <summary>Middle (result) pane document: stable regions verbatim, conflict regions show the
/// chosen Resolution, or empty when unresolved (the editor builds each conflict up from empty).</summary>
public string ResultText => string.Concat(
Segments.Select(s => s.IsConflict ? (s.Conflict!.Resolution ?? "") : s.StableText));
} }

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,7 @@ namespace ClaudeDo.Ui.ViewModels.Modals;
public sealed partial class MergeModalViewModel : ViewModelBase public sealed partial class MergeModalViewModel : ViewModelBase
{ {
private readonly WorkerClient _worker; private readonly IWorkerClient _worker;
public string TaskId { get; set; } = ""; public string TaskId { get; set; } = "";
public string TaskTitle { get; set; } = ""; public string TaskTitle { get; set; } = "";
@@ -28,11 +28,18 @@ public sealed partial class MergeModalViewModel : ViewModelBase
public Action? CloseAction { get; set; } public Action? CloseAction { get; set; }
/// Set by the caller to hand a conflicting merge off to the in-app 3-pane editor
/// instead of dead-ending on the conflict message.
public Func<string, string, Task>? RequestConflictResolution { get; set; }
/// True once a merge has succeeded — lets the caller (e.g. the diff window) /// True once a merge has succeeded — lets the caller (e.g. the diff window)
/// close itself after this modal closes. /// close itself after this modal closes.
public bool Merged { get; private set; } public bool Merged { get; private set; }
public MergeModalViewModel(WorkerClient worker) /// True once a conflict has been handed off to the resolver — also a cue to close the diff window.
public bool RoutedToResolver { get; private set; }
public MergeModalViewModel(IWorkerClient worker)
{ {
_worker = worker; _worker = worker;
} }
@@ -96,9 +103,21 @@ public sealed partial class MergeModalViewModel : ViewModelBase
}); });
break; break;
case "conflict": case "conflict":
HasConflict = true; // Hand off to the in-app 3-pane merge editor when wired (MergeTask aborted
ConflictFiles = result.ConflictFiles; // cleanly, so the resolver re-starts the merge leaving conflicts in the tree).
ErrorMessage = Loc.T("vm.merge.conflict"); 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; break;
case "blocked": case "blocked":
ErrorMessage = Loc.T("vm.merge.blocked", result.ErrorMessage ?? ""); 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 public sealed partial class FilesSettingsTabViewModel : ViewModelBase
{ {
private readonly WorkerClient _worker; private readonly IWorkerClient _worker;
[ObservableProperty] private string _statusMessage = ""; [ObservableProperty] private string _statusMessage = "";
[ObservableProperty] private bool _isBusy; [ObservableProperty] private bool _isBusy;
@@ -21,7 +21,7 @@ public sealed partial class FilesSettingsTabViewModel : ViewModelBase
public string DailyPrepPromptPath { get; } = PromptFiles.PathFor(PromptKind.DailyPrep); public string DailyPrepPromptPath { get; } = PromptFiles.PathFor(PromptKind.DailyPrep);
public string WeeklyReportPromptPath { get; } = PromptFiles.PathFor(PromptKind.WeeklyReport); public string WeeklyReportPromptPath { get; } = PromptFiles.PathFor(PromptKind.WeeklyReport);
public FilesSettingsTabViewModel(WorkerClient worker) => _worker = worker; public FilesSettingsTabViewModel(IWorkerClient worker) => _worker = worker;
[RelayCommand] [RelayCommand]
private async Task RestoreDefaultAgents() private async Task RestoreDefaultAgents()

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 public sealed partial class WorktreesSettingsTabViewModel : ViewModelBase
{ {
private readonly WorkerClient _worker; private readonly IWorkerClient _worker;
[ObservableProperty] private string _worktreeStrategy = "sibling"; [ObservableProperty] private string _worktreeStrategy = "sibling";
[ObservableProperty] private string? _centralWorktreeRoot; [ObservableProperty] private string? _centralWorktreeRoot;
@@ -21,7 +21,7 @@ public sealed partial class WorktreesSettingsTabViewModel : ViewModelBase
public IReadOnlyList<string> WorktreeStrategies { get; } = new[] { "sibling", "central" }; public IReadOnlyList<string> WorktreeStrategies { get; } = new[] { "sibling", "central" };
public WorktreesSettingsTabViewModel(WorkerClient worker) => _worker = worker; public WorktreesSettingsTabViewModel(IWorkerClient worker) => _worker = worker;
public string? Validate() public string? Validate()
{ {

View File

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

View File

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

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:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Conflicts" xmlns:vm="using:ClaudeDo.Ui.ViewModels.Conflicts"
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls" xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
xmlns:ae="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
xmlns:loc="using:ClaudeDo.Ui.Localization" xmlns:loc="using:ClaudeDo.Ui.Localization"
x:DataType="vm:ConflictResolverViewModel" x:DataType="vm:ConflictResolverViewModel"
x:Class="ClaudeDo.Ui.Views.Conflicts.ConflictResolverView" x:Class="ClaudeDo.Ui.Views.Conflicts.ConflictResolverView"
Title="{loc:Tr conflictResolver.windowTitle}" Title="{loc:Tr conflictResolver.windowTitle}"
Width="760" Height="640" MinWidth="560" MinHeight="420" Width="1280" Height="820" MinWidth="960" MinHeight="560"
CanResize="True" CanResize="True"
WindowDecorations="BorderOnly" WindowDecorations="BorderOnly"
ExtendClientAreaToDecorationsHint="True" ExtendClientAreaToDecorationsHint="True"
@@ -16,67 +17,157 @@
<Window.KeyBindings> <Window.KeyBindings>
<KeyBinding Gesture="Escape" Command="{Binding AbortCommand}"/> <KeyBinding Gesture="Escape" Command="{Binding AbortCommand}"/>
<KeyBinding Gesture="F8" Command="{Binding NextCommand}"/>
<KeyBinding Gesture="Shift+F8" Command="{Binding PreviousCommand}"/>
</Window.KeyBindings> </Window.KeyBindings>
<Window.Styles>
<Style Selector="ae|TextEditor">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="{StaticResource FontSizeMono}" />
<Setter Property="Background" Value="{DynamicResource Surface2Brush}" />
<Setter Property="Padding" Value="4,2" />
<Setter Property="WordWrap" Value="False" />
</Style>
<Style Selector="Border.col-head">
<Setter Property="Padding" Value="8,4" />
<Setter Property="BorderThickness" Value="0,0,0,1" />
<Setter Property="BorderBrush" Value="{DynamicResource LineBrush}" />
<Setter Property="Background" Value="{DynamicResource Surface2Brush}" />
</Style>
<Style Selector="Border.pane">
<Setter Property="BorderBrush" Value="{DynamicResource LineBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="6" />
<Setter Property="ClipToBounds" Value="True" />
</Style>
<!-- Inline accept controls in the between-pane gutters -->
<Style Selector="Button.accept-gutter">
<Setter Property="Width" Value="22" />
<Setter Property="Height" Value="20" />
<Setter Property="Padding" Value="0" />
<Setter Property="FontSize" Value="13" />
<Setter Property="FontWeight" Value="Bold" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Background" Value="{DynamicResource Surface2Brush}" />
<Setter Property="BorderBrush" Value="{DynamicResource MergeConflictEdgeBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="4" />
<Setter Property="Foreground" Value="{DynamicResource TextBrush}" />
</Style>
<Style Selector="Button.accept-gutter:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{DynamicResource MergeConflictTintBrush}" />
</Style>
</Window.Styles>
<ctl:ModalShell Title="{loc:Tr conflictResolver.modalTitle}" CloseCommand="{Binding AbortCommand}"> <ctl:ModalShell Title="{loc:Tr conflictResolver.modalTitle}" CloseCommand="{Binding AbortCommand}">
<ctl:ModalShell.Footer> <ctl:ModalShell.Footer>
<StackPanel Orientation="Horizontal" Spacing="8" <Grid ColumnDefinitions="*,Auto">
HorizontalAlignment="Right" VerticalAlignment="Center"> <TextBlock Grid.Column="0" Classes="meta" VerticalAlignment="Center"
<Button Classes="btn" Content="{loc:Tr conflictResolver.continue}" Foreground="{DynamicResource BloodBrush}"
Command="{Binding ContinueCommand}" IsEnabled="{Binding CanContinue}"/> Text="{Binding ContinueHint}"
<Button Classes="btn" Content="{loc:Tr conflictResolver.abort}" Command="{Binding AbortCommand}"/> IsVisible="{Binding HasBinaryFiles}"/>
</StackPanel> <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> </ctl:ModalShell.Footer>
<Grid RowDefinitions="Auto,*" Margin="16,12"> <Grid Margin="14,10" RowDefinitions="Auto,Auto,Auto,*">
<TextBlock Grid.Row="0" Classes="meta" Margin="0,0,0,8"
Text="{loc:Tr conflictResolver.loading}" <!-- Busy / error -->
IsVisible="{Binding IsBusy}"/> <TextBlock Grid.Row="0" Classes="meta" Margin="0,0,0,6"
Text="{loc:Tr conflictResolver.loading}" IsVisible="{Binding IsBusy}"/>
<TextBlock Grid.Row="0" Classes="meta" Foreground="{DynamicResource BloodBrush}" <TextBlock Grid.Row="0" Classes="meta" Foreground="{DynamicResource BloodBrush}"
Text="{Binding Error}" TextWrapping="Wrap" Text="{Binding Error}" TextWrapping="Wrap"
IsVisible="{Binding Error, Converter={x:Static ObjectConverters.IsNotNull}}"/> IsVisible="{Binding Error, Converter={x:Static ObjectConverters.IsNotNull}}"/>
<ScrollViewer Grid.Row="1"> <!-- Binary-conflict banner -->
<ItemsControl ItemsSource="{Binding Files}"> <Border Grid.Row="1" Margin="0,0,0,8" Padding="10,7" CornerRadius="6"
<ItemsControl.ItemTemplate> Background="{DynamicResource ErrorTintBrush}"
<DataTemplate x:DataType="vm:ConflictFile"> BorderBrush="{DynamicResource BloodBrush}" BorderThickness="1"
<StackPanel Spacing="8" Margin="0,0,0,16"> IsVisible="{Binding HasBinaryFiles}">
<TextBlock Classes="path-mono heading" Text="{Binding Path}"/> <StackPanel Spacing="3">
<ItemsControl ItemsSource="{Binding Hunks}"> <TextBlock Classes="meta" Foreground="{DynamicResource BloodBrush}"
<ItemsControl.ItemTemplate> Text="{loc:Tr conflictResolver.binaryHint}"/>
<DataTemplate x:DataType="vm:ConflictHunk"> <ItemsControl ItemsSource="{Binding BinaryFilePaths}">
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="1" <ItemsControl.ItemTemplate>
CornerRadius="6" Padding="10" Margin="0,0,0,8"> <DataTemplate x:DataType="x:String">
<StackPanel Spacing="6"> <TextBlock Classes="path-mono" Text="{Binding}"/>
<TextBlock Classes="meta" Text="{loc:Tr conflictResolver.current}"/> </DataTemplate>
<TextBox Text="{Binding Ours, Mode=OneWay}" IsReadOnly="True" </ItemsControl.ItemTemplate>
AcceptsReturn="True" MaxHeight="120"/> </ItemsControl>
<TextBlock Classes="meta" Text="{loc:Tr conflictResolver.incoming}"/> </StackPanel>
<TextBox Text="{Binding Theirs, Mode=OneWay}" IsReadOnly="True" </Border>
AcceptsReturn="True" MaxHeight="120"/>
<StackPanel Orientation="Horizontal" Spacing="6"> <!-- Toolbar: change nav · file switcher · readout -->
<Button Classes="btn" Content="{loc:Tr conflictResolver.acceptCurrent}" <Grid Grid.Row="2" ColumnDefinitions="Auto,Auto,Auto,*,Auto,Auto" Margin="0,0,0,8"
Command="{Binding AcceptCurrentCommand}"/> IsVisible="{Binding HasCurrent}">
<Button Classes="btn" Content="{loc:Tr conflictResolver.acceptIncoming}" <Button Grid.Column="0" Classes="btn" Content="↑" Margin="0,0,4,0" Padding="10,4"
Command="{Binding AcceptIncomingCommand}"/> ToolTip.Tip="{loc:Tr conflictResolver.prevConflict}"
<Button Classes="btn" Content="{loc:Tr conflictResolver.acceptBoth}" Command="{Binding PreviousCommand}"/>
Command="{Binding AcceptBothCommand}"/> <Button Grid.Column="1" Classes="btn" Content="↓" Margin="0,0,12,0" Padding="10,4"
<Button Classes="btn" Content="{loc:Tr conflictResolver.editManually}" ToolTip.Tip="{loc:Tr conflictResolver.nextConflict}"
Command="{Binding EditManuallyCommand}"/> Command="{Binding NextCommand}"/>
</StackPanel> <ComboBox Grid.Column="2" MinWidth="240" MaxWidth="520"
<TextBlock Classes="meta" Text="{loc:Tr conflictResolver.mergedResult}"/> ItemsSource="{Binding Files}"
<TextBox Text="{Binding Resolution, Mode=TwoWay}" SelectedItem="{Binding ActiveFile, Mode=TwoWay}">
AcceptsReturn="True" MinHeight="80" MaxHeight="200"/> <ComboBox.ItemTemplate>
</StackPanel> <DataTemplate x:DataType="vm:MergeFile">
</Border> <TextBlock Classes="path-mono" Text="{Binding Path}" TextTrimming="CharacterEllipsis"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ComboBox.ItemTemplate>
</ItemsControl> </ComboBox>
</ScrollViewer> <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> </Grid>
</ctl:ModalShell> </ctl:ModalShell>
</Window> </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;
using Avalonia.Controls.Shapes;
using Avalonia.Media;
using Avalonia.Threading;
using Avalonia.VisualTree;
using AvaloniaEdit;
using AvaloniaEdit.Document;
using AvaloniaEdit.Editing;
using AvaloniaEdit.Rendering;
using AvaloniaEdit.TextMate;
using ClaudeDo.Ui.ViewModels.Conflicts; using ClaudeDo.Ui.ViewModels.Conflicts;
using TextMateSharp.Grammars;
namespace ClaudeDo.Ui.Views.Conflicts; namespace ClaudeDo.Ui.Views.Conflicts;
public partial class ConflictResolverView : Window public partial class ConflictResolverView : Window
{ {
private ConflictResolverViewModel? _vm;
private RegistryOptions? _registry;
private TextMate.Installation? _oursTm, _resultTm, _theirsTm;
// Fixed conflict spans for the read-only side panes (recomputed each rebuild).
private List<(int Offset, int Length, MergeConflictBlock Block)> _oursSpans = new();
private List<(int Offset, int Length, MergeConflictBlock Block)> _theirsSpans = new();
// Live, edit-tracked conflict regions in the editable result document.
private readonly List<ResultRegion> _resultRegions = new();
private readonly List<MergeConflictBlock> _hookedBlocks = new();
private ScrollViewer?[] _scrollViewers = Array.Empty<ScrollViewer?>();
private bool _wired;
private bool _rebuilding;
private bool _applyingAccept;
private bool _syncing;
private bool _gutterPending;
private int _gutterRetries;
public ConflictResolverView() public ConflictResolverView()
{ {
InitializeComponent(); InitializeComponent();
} }
protected override void OnDataContextChanged(System.EventArgs e) protected override void OnDataContextChanged(EventArgs e)
{ {
base.OnDataContextChanged(e); base.OnDataContextChanged(e);
if (DataContext is ConflictResolverViewModel vm)
vm.CloseRequested = Close; if (_vm is not null)
{
_vm.ActiveFileChanged -= Rebuild;
_vm.CurrentChanged -= ScrollToCurrent;
}
// The editors persist across a DataContext swap, so drop stale scroll-sync hooks first.
foreach (var sv in _scrollViewers)
if (sv is not null) sv.ScrollChanged -= OnPaneScroll;
_scrollViewers = Array.Empty<ScrollViewer?>();
_wired = false;
_vm = DataContext as ConflictResolverViewModel;
if (_vm is null) return;
_vm.CloseRequested = Close;
EnsureEditors();
_vm.ActiveFileChanged += Rebuild;
_vm.CurrentChanged += ScrollToCurrent;
Rebuild();
}
// ── One-time editor setup ────────────────────────────────────────────────
private void EnsureEditors()
{
if (_registry is not null) return;
_registry = new RegistryOptions(ThemeName.DarkPlus);
_oursTm = OursEditor.InstallTextMate(_registry);
_resultTm = ResultEditor.InstallTextMate(_registry);
_theirsTm = TheirsEditor.InstallTextMate(_registry);
ResultEditor.Document ??= new TextDocument();
ResultEditor.Document.Changed += OnResultDocumentChanged;
ResultEditor.TextArea.ReadOnlySectionProvider =
new ConflictReadOnlyProvider(() => _resultRegions.Select(r => (r.Start.Offset, r.End.Offset)));
var conflict = BrushRes("MergeConflictTintBrush", Color.Parse("#28C87060"));
var resolved = BrushRes("MergeResolvedTintBrush", Color.Parse("#206FA86B"));
OursEditor.TextArea.TextView.BackgroundRenderers.Add(new MergeBlockRenderer(
() => _oursSpans.Select(s => (s.Offset, s.Length, s.Block.IsResolved)), conflict, resolved));
ResultEditor.TextArea.TextView.BackgroundRenderers.Add(new MergeBlockRenderer(
() => _resultRegions.Select(r => (r.Start.Offset, r.End.Offset - r.Start.Offset, r.Block.IsResolved)), conflict, resolved));
TheirsEditor.TextArea.TextView.BackgroundRenderers.Add(new MergeBlockRenderer(
() => _theirsSpans.Select(s => (s.Offset, s.Length, s.Block.IsResolved)), conflict, resolved));
}
private IBrush BrushRes(string key, Color fallback)
{
if (this.TryGetResource(key, null, out var v) && v is IBrush b)
return b;
return new SolidColorBrush(fallback);
}
// ── Rebuild the three documents for the active file ───────────────────────
private void Rebuild()
{
if (_vm is null) return;
_rebuilding = true;
_gutterRetries = 0; // fresh retry budget for this file's gutter layout
try
{
ClearGutters();
UnhookBlocks();
_resultRegions.Clear();
var file = _vm.ActiveFile;
if (file is null || file.IsBinary)
{
OursEditor.Text = TheirsEditor.Text = "";
if (ResultEditor.Document is { } d0) d0.Text = "";
_oursSpans = new(); _theirsSpans = new();
InvalidateRenderers();
return;
}
var (oursText, oursSpans) = BuildSide(file, b => b.Ours);
var (theirsText, theirsSpans) = BuildSide(file, b => b.Theirs);
// Unresolved conflicts start EMPTY — the user builds the result by appending sides.
var (resultText, resultSpans) = BuildSide(file, b => b.Resolution ?? "");
_oursSpans = oursSpans;
_theirsSpans = theirsSpans;
OursEditor.Text = oursText;
TheirsEditor.Text = theirsText;
ResultEditor.Document ??= new TextDocument();
ResultEditor.Document.Text = resultText;
var doc = ResultEditor.Document;
foreach (var (offset, length, block) in resultSpans)
{
var start = doc.CreateAnchor(offset);
start.MovementType = AnchorMovementType.BeforeInsertion;
var end = doc.CreateAnchor(offset + length);
end.MovementType = AnchorMovementType.AfterInsertion;
_resultRegions.Add(new ResultRegion(block, start, end));
block.PropertyChanged += OnBlockChanged;
_hookedBlocks.Add(block);
}
ApplyGrammar(file.Path);
InvalidateRenderers();
}
finally { _rebuilding = false; }
if (!_wired)
{
_wired = true;
Dispatcher.UIThread.Post(HookScrollSync, DispatcherPriority.Loaded);
}
QueueGutters();
}
private static (string Text, List<(int Offset, int Length, MergeConflictBlock Block)> Spans) BuildSide(
MergeFile file, Func<MergeConflictBlock, string> pick)
{
var sb = new StringBuilder();
var spans = new List<(int, int, MergeConflictBlock)>();
foreach (var seg in file.Segments)
{
if (seg.IsConflict)
{
var text = pick(seg.Conflict!);
spans.Add((sb.Length, text.Length, seg.Conflict!));
sb.Append(text);
}
else
{
sb.Append(seg.StableText);
}
}
return (sb.ToString(), spans);
}
private void UnhookBlocks()
{
foreach (var b in _hookedBlocks) b.PropertyChanged -= OnBlockChanged;
_hookedBlocks.Clear();
}
private void OnBlockChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName is nameof(MergeConflictBlock.IsResolved) or nameof(MergeConflictBlock.Resolution))
{
InvalidateRenderers();
QueueGutters();
}
}
// ── User edits in the result document flow back to the owning conflict ────
private void OnResultDocumentChanged(object? sender, DocumentChangeEventArgs e)
{
if (_rebuilding || _applyingAccept) return;
foreach (var r in _resultRegions)
{
if (e.Offset >= r.Start.Offset && e.Offset <= r.End.Offset)
{
r.Block.Resolution = ResultEditor.Document.GetText(r.Start.Offset, Math.Max(0, r.End.Offset - r.Start.Offset));
break;
}
}
QueueGutters();
}
// ── Toggle a side in/out of the result region ────────────────────────────
// Each side can be included at most once. Clicking adds it (in click order, first on
// top); clicking again removes it. The region content is rebuilt from the included set.
private void ToggleSide(ResultRegion region, char side)
{
if (region.Order.Contains(side)) region.Order.Remove(side);
else region.Order.Add(side);
var text = string.Concat(region.Order.Select(c => c == 'o' ? region.Block.Ours : region.Block.Theirs));
_applyingAccept = true;
try { ResultEditor.Document.Replace(region.Start.Offset, region.End.Offset - region.Start.Offset, text); }
finally { _applyingAccept = false; }
region.Block.Resolution = region.Order.Count == 0 ? null : text;
InvalidateRenderers();
PositionGutters();
}
// ── Inline accept controls in the between-pane gutters ────────────────────
private void ClearGutters()
{
LeftGutter.Children.Clear();
RightGutter.Children.Clear();
}
// Coalesce gutter re-layouts so repeated change/scroll events can't flood the dispatcher.
private void QueueGutters()
{
if (_gutterPending) return;
_gutterPending = true;
Dispatcher.UIThread.Post(() => { _gutterPending = false; PositionGutters(); }, DispatcherPriority.Background);
}
private void PositionGutters()
{
ClearGutters();
PopulateConflictMap();
if (_vm?.ActiveFile is null) return;
var tv = ResultEditor.TextArea.TextView;
if (!tv.VisualLinesValid)
{
// Retry until the editor is laid out, but bounded so a never-laid-out editor
// (e.g. minimized window) can't busy-loop the dispatcher.
if (_gutterRetries++ < 40) QueueGutters();
return;
}
_gutterRetries = 0;
var doc = ResultEditor.Document;
foreach (var region in _resultRegions)
{
// Controls stay visible whether or not a side is included, so either can be toggled.
var len = region.End.Offset - region.Start.Offset;
ISegment probe = len > 0
? new Seg(region.Start.Offset, len)
: new Seg(region.Start.Offset, region.Start.Offset < doc.TextLength ? 1 : 0);
var rects = BackgroundGeometryBuilder.GetRectsForSegment(tv, probe).ToList();
if (rects.Count == 0) continue;
var y = rects[0].Top;
var r = region;
var oursIn = region.Order.Contains('o');
var theirsIn = region.Order.Contains('t');
if (tv.TranslatePoint(new Point(0, y), LeftGutter) is { } pl &&
pl.Y > -24 && pl.Y < LeftGutter.Bounds.Height + 24)
AddAcceptButton(LeftGutter, pl.Y, oursIn ? "" : "", () => ToggleSide(r, 'o'),
Tr(oursIn ? "conflictResolver.removeOurs" : "conflictResolver.acceptOurs"));
if (tv.TranslatePoint(new Point(0, y), RightGutter) is { } pr &&
pr.Y > -24 && pr.Y < RightGutter.Bounds.Height + 24)
AddAcceptButton(RightGutter, pr.Y, theirsIn ? "" : "", () => ToggleSide(r, 't'),
Tr(theirsIn ? "conflictResolver.removeTheirs" : "conflictResolver.acceptTheirs"));
}
}
private void AddAcceptButton(Canvas canvas, double y, string glyph, Action onClick, string tip)
{
var b = new Button { Content = glyph };
b.Classes.Add("accept-gutter");
ToolTip.SetTip(b, tip);
b.Click += (_, _) => onClick();
Canvas.SetLeft(b, 1);
Canvas.SetTop(b, Math.Max(0, y));
canvas.Children.Add(b);
}
// ── Conflict overview ruler (right of the result pane) ───────────────────
// A proportional map of every conflict in the active file so they're findable in
// long files without scrolling; ticks recolor by resolved state and jump on click.
private void PopulateConflictMap()
{
ConflictMap.Children.Clear();
if (_vm?.ActiveFile is null || _resultRegions.Count == 0) return;
var h = ConflictMap.Bounds.Height;
if (h <= 1) return;
var doc = ResultEditor.Document;
var totalLines = Math.Max(1, doc.LineCount);
var unresolved = BrushRes("MergeConflictEdgeBrush", Color.Parse("#80C87060"));
var resolved = BrushRes("MergeResolvedEdgeBrush", Color.Parse("#806FA86B"));
foreach (var region in _resultRegions)
{
var line = doc.GetLineByOffset(region.Start.Offset).LineNumber;
var y = (line - 1) / (double)totalLines * h;
var tick = new Rectangle
{
Width = 9,
Height = 4,
Fill = region.Block.IsResolved ? resolved : unresolved,
Cursor = new Avalonia.Input.Cursor(Avalonia.Input.StandardCursorType.Hand),
};
Canvas.SetLeft(tick, 2);
Canvas.SetTop(tick, Math.Min(h - 4, Math.Max(0, y)));
var r = region;
tick.PointerPressed += (_, _) => JumpToRegion(r);
ConflictMap.Children.Add(tick);
}
}
private void JumpToRegion(ResultRegion region)
{
var line = ResultEditor.Document.GetLineByOffset(region.Start.Offset).LineNumber;
ResultEditor.ScrollToLine(line);
QueueGutters();
}
private static string Tr(string key) => ClaudeDo.Ui.Localization.Loc.T(key);
// ── Synced vertical scroll across the three panes ─────────────────────────
private void HookScrollSync()
{
_scrollViewers = new[] { OursEditor, ResultEditor, TheirsEditor }
.Select(ed => ed.FindDescendantOfType<ScrollViewer>())
.ToArray();
foreach (var sv in _scrollViewers)
if (sv is not null) sv.ScrollChanged += OnPaneScroll;
}
private void OnPaneScroll(object? sender, ScrollChangedEventArgs e)
{
if (_syncing || sender is not ScrollViewer src) return;
_syncing = true;
try
{
foreach (var sv in _scrollViewers)
if (sv is not null && !ReferenceEquals(sv, src) && Math.Abs(sv.Offset.Y - src.Offset.Y) > 0.5)
sv.Offset = new Vector(sv.Offset.X, src.Offset.Y);
}
finally { _syncing = false; }
PositionGutters();
}
private void ScrollToCurrent()
{
if (_vm?.Current is not { } block) return;
var region = _resultRegions.FirstOrDefault(r => ReferenceEquals(r.Block, block));
if (region is null) return;
var line = ResultEditor.Document.GetLineByOffset(region.Start.Offset).LineNumber;
ResultEditor.ScrollToLine(line);
QueueGutters();
}
private void InvalidateRenderers()
{
OursEditor.TextArea.TextView.InvalidateVisual();
ResultEditor.TextArea.TextView.InvalidateVisual();
TheirsEditor.TextArea.TextView.InvalidateVisual();
}
private void ApplyGrammar(string? path)
{
if (_registry is null || string.IsNullOrEmpty(path)) return;
var ext = System.IO.Path.GetExtension(path);
if (string.IsNullOrEmpty(ext)) return;
var language = _registry.GetLanguageByExtension(ext);
if (language is null) return;
var scope = _registry.GetScopeByLanguageId(language.Id);
_oursTm?.SetGrammar(scope);
_resultTm?.SetGrammar(scope);
_theirsTm?.SetGrammar(scope);
}
// ── Helper types (single-consumer; live with their consumer per repo style) ─
/// <summary>A minimal <see cref="ISegment"/> for geometry/read-only queries.</summary>
private readonly struct Seg : ISegment
{
public Seg(int offset, int length) { Offset = offset; Length = length; }
public int Offset { get; }
public int Length { get; }
public int EndOffset => Offset + Length;
}
/// <summary>An editable conflict region in the result document, tracking which sides are
/// currently included (in click order — <c>'o'</c> = ours/main, <c>'t'</c> = theirs/incoming).</summary>
private sealed class ResultRegion
{
public ResultRegion(MergeConflictBlock block, TextAnchor start, TextAnchor end)
{
Block = block; Start = start; End = end;
}
public MergeConflictBlock Block { get; }
public TextAnchor Start { get; }
public TextAnchor End { get; }
public List<char> Order { get; } = new();
}
/// <summary>Paints each conflict block with the unresolved/resolved tint across a pane.</summary>
private sealed class MergeBlockRenderer : IBackgroundRenderer
{
private readonly Func<IEnumerable<(int Offset, int Length, bool Resolved)>> _spans;
private readonly IBrush _conflict;
private readonly IBrush _resolved;
public MergeBlockRenderer(Func<IEnumerable<(int, int, bool)>> spans, IBrush conflict, IBrush resolved)
{
_spans = spans; _conflict = conflict; _resolved = resolved;
}
public KnownLayer Layer => KnownLayer.Background;
public void Draw(TextView textView, DrawingContext drawingContext)
{
if (!textView.VisualLinesValid) return;
foreach (var (offset, length, resolved) in _spans())
{
var brush = resolved ? _resolved : _conflict;
if (length > 0)
{
var builder = new BackgroundGeometryBuilder { AlignToWholePixels = true, CornerRadius = 2 };
builder.AddSegment(textView, new Seg(offset, length));
var geo = builder.CreateGeometry();
if (geo is not null) drawingContext.DrawGeometry(brush, null, geo);
}
else
{
// Empty region (nothing accepted yet): a thin marker bar marks the spot.
var at = offset < textView.Document.TextLength ? offset : Math.Max(0, offset - 1);
var rects = BackgroundGeometryBuilder.GetRectsForSegment(textView, new Seg(at, 1)).ToList();
if (rects.Count > 0)
drawingContext.FillRectangle(brush, new Rect(0, rects[0].Top, textView.Bounds.Width, 3));
}
}
}
}
/// <summary>Makes everything read-only except the live conflict regions in the result document.</summary>
private sealed class ConflictReadOnlyProvider : IReadOnlySectionProvider
{
private readonly Func<IEnumerable<(int Start, int End)>> _regions;
public ConflictReadOnlyProvider(Func<IEnumerable<(int, int)>> regions) => _regions = regions;
public bool CanInsert(int offset) => _regions().Any(r => offset >= r.Start && offset <= r.End);
public IEnumerable<ISegment> GetDeletableSegments(ISegment segment)
{
foreach (var (start, end) in _regions())
{
var s = Math.Max(segment.Offset, start);
var e = Math.Min(segment.EndOffset, end);
if (e > s) yield return new Seg(s, e - s);
}
}
} }
} }

View File

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

View File

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

View File

@@ -52,7 +52,7 @@
<!-- Column 2: gear button with agent settings flyout --> <!-- Column 2: gear button with agent settings flyout -->
<Button Grid.Column="2" Classes="icon-btn" <Button Grid.Column="2" Classes="icon-btn"
ToolTip.Tip="{loc:Tr details.agentSettingsTip}" ToolTip.Tip="{loc:Tr details.agentSettingsTip}"
IsEnabled="{Binding IsAgentSectionEnabled}" IsEnabled="{Binding AgentSettings.IsAgentSectionEnabled}"
VerticalAlignment="Top" VerticalAlignment="Top"
Margin="6,0,0,0"> Margin="6,0,0,0">
<TextBlock Text="⚙" FontSize="{StaticResource FontSizeTaskTitle}"/> <TextBlock Text="⚙" FontSize="{StaticResource FontSizeTaskTitle}"/>
@@ -64,50 +64,50 @@
<StackPanel Spacing="2"> <StackPanel Spacing="2">
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6"> <Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
<TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr details.modelLabel}" VerticalAlignment="Center"/> <TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr details.modelLabel}" VerticalAlignment="Center"/>
<ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding ModelBadge}"/> <ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding AgentSettings.ModelBadge}"/>
<Button Grid.Column="3" Classes="icon-btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}" <Button Grid.Column="3" Classes="icon-btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
Command="{Binding ResetTaskModelCommand}"/> Command="{Binding AgentSettings.ResetTaskModelCommand}"/>
</Grid> </Grid>
<ComboBox ItemsSource="{Binding TaskModelOptions}" <ComboBox ItemsSource="{Binding AgentSettings.TaskModelOptions}"
SelectedItem="{Binding TaskModelSelection, Mode=TwoWay}" SelectedItem="{Binding AgentSettings.TaskModelSelection, Mode=TwoWay}"
PlaceholderText="{Binding ModelInheritedHint}" PlaceholderText="{Binding AgentSettings.ModelInheritedHint}"
HorizontalAlignment="Stretch"/> HorizontalAlignment="Stretch"/>
</StackPanel> </StackPanel>
<StackPanel Spacing="2"> <StackPanel Spacing="2">
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6"> <Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
<TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr details.maxTurnsLabel}" VerticalAlignment="Center"/> <TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr details.maxTurnsLabel}" VerticalAlignment="Center"/>
<ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding TurnsBadge}"/> <ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding AgentSettings.TurnsBadge}"/>
<Button Grid.Column="3" Classes="icon-btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}" <Button Grid.Column="3" Classes="icon-btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
Command="{Binding ResetTaskTurnsCommand}"/> Command="{Binding AgentSettings.ResetTaskTurnsCommand}"/>
</Grid> </Grid>
<NumericUpDown Value="{Binding TaskMaxTurns, Mode=TwoWay}" <NumericUpDown Value="{Binding AgentSettings.TaskMaxTurns, Mode=TwoWay}"
PlaceholderText="{Binding TurnsInheritedHint}" PlaceholderText="{Binding AgentSettings.TurnsInheritedHint}"
Minimum="1" Maximum="200" Increment="1" FormatString="0" Minimum="1" Maximum="200" Increment="1" FormatString="0"
HorizontalAlignment="Stretch"/> HorizontalAlignment="Stretch"/>
</StackPanel> </StackPanel>
<StackPanel Spacing="2"> <StackPanel Spacing="2">
<TextBlock Classes="field-label" Text="{loc:Tr details.systemPromptLabel}"/> <TextBlock Classes="field-label" Text="{loc:Tr details.systemPromptLabel}"/>
<TextBox Text="{Binding TaskSystemPrompt, Mode=TwoWay}" <TextBox Text="{Binding AgentSettings.TaskSystemPrompt, Mode=TwoWay}"
AcceptsReturn="True" TextWrapping="Wrap" MinHeight="70"/> AcceptsReturn="True" TextWrapping="Wrap" MinHeight="70"/>
<TextBlock Classes="meta" Opacity="0.6" <TextBlock Classes="meta" Opacity="0.6"
Text="{loc:Tr details.systemPromptPrepended}" Text="{loc:Tr details.systemPromptPrepended}"
IsVisible="{Binding EffectiveSystemPromptHint, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/> IsVisible="{Binding AgentSettings.EffectiveSystemPromptHint, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock Classes="meta" Opacity="0.6" TextWrapping="Wrap" <TextBlock Classes="meta" Opacity="0.6" TextWrapping="Wrap"
Text="{Binding EffectiveSystemPromptHint}" Text="{Binding AgentSettings.EffectiveSystemPromptHint}"
IsVisible="{Binding EffectiveSystemPromptHint, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/> IsVisible="{Binding AgentSettings.EffectiveSystemPromptHint, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel> </StackPanel>
<StackPanel Spacing="2"> <StackPanel Spacing="2">
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6"> <Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
<TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr details.agentFileLabel}" VerticalAlignment="Center"/> <TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr details.agentFileLabel}" VerticalAlignment="Center"/>
<ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding AgentBadge}"/> <ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding AgentSettings.AgentBadge}"/>
<Button Grid.Column="3" Classes="icon-btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}" <Button Grid.Column="3" Classes="icon-btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
Command="{Binding ResetTaskAgentCommand}"/> Command="{Binding AgentSettings.ResetTaskAgentCommand}"/>
</Grid> </Grid>
<ComboBox ItemsSource="{Binding TaskAgentOptions}" <ComboBox ItemsSource="{Binding AgentSettings.TaskAgentOptions}"
SelectedItem="{Binding TaskSelectedAgent, Mode=TwoWay}" SelectedItem="{Binding AgentSettings.TaskSelectedAgent, Mode=TwoWay}"
HorizontalAlignment="Stretch"> HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate> <ComboBox.ItemTemplate>
<DataTemplate> <DataTemplate>

View File

@@ -167,9 +167,16 @@
CommandParameter="output" /> CommandParameter="output" />
<Button Classes="tab-btn" <Button Classes="tab-btn"
Classes.active="{Binding IsGitTab}" Classes.active="{Binding IsGitTab}"
Content="Git"
Command="{Binding SelectTabCommand}" Command="{Binding SelectTabCommand}"
CommandParameter="git" /> CommandParameter="git">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="Git" VerticalAlignment="Center" />
<!-- Review-pending dot: where to act when a task awaits review -->
<Ellipse Width="6" Height="6" VerticalAlignment="Center"
Fill="{DynamicResource AccentBrush}"
IsVisible="{Binding IsWaitingForReview}" />
</StackPanel>
</Button>
<Button Classes="tab-btn" <Button Classes="tab-btn"
Classes.active="{Binding IsSessionTab}" Classes.active="{Binding IsSessionTab}"
Content="Session" Content="Session"
@@ -205,41 +212,27 @@
</StackPanel> </StackPanel>
</Border> </Border>
<!-- Review prompt — sits directly on the terminal, like a shell input line; <!-- Review footer: feedback + Resume session, shown while awaiting review.
only while awaiting review. No border/fill so it reads as part of the log. --> Lives here (with the live log) rather than the Git tab. -->
<Grid DockPanel.Dock="Bottom" <Border DockPanel.Dock="Bottom"
IsVisible="{Binding IsWaitingForReview}" IsVisible="{Binding IsWaitingForReview}"
ColumnDefinitions="Auto,*,Auto" Margin="12,6,12,2">
Margin="12,2,12,8"> <StackPanel Spacing="8">
<TextBlock Grid.Column="0" Text="&#x276F;" <TextBox Name="ReviewInput"
KeyDown="OnReviewInputKeyDown"
Text="{Binding ReviewFeedback, Mode=TwoWay}"
AcceptsReturn="True"
TextWrapping="Wrap"
MaxHeight="120"
PlaceholderText="Feedback for a re-run…"
FontFamily="{StaticResource MonoFont}" FontFamily="{StaticResource MonoFont}"
FontSize="{StaticResource FontSizeMono}" FontSize="{StaticResource FontSizeMono}" />
Foreground="{DynamicResource AccentBrush}" <Button Classes="btn" Content="Resume session"
VerticalAlignment="Top" Margin="0,2,8,0" /> HorizontalAlignment="Left"
<TextBox Grid.Column="1"
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"
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]"
ToolTip.Tip="{loc:Tr session.reviewContinueTip}" ToolTip.Tip="{loc:Tr session.reviewContinueTip}"
Command="{Binding RejectReviewCommand}" /> Command="{Binding RejectReviewCommand}" />
<Button Classes="prompt-action" Content="[Reset]"
ToolTip.Tip="{loc:Tr session.reviewResetTip}"
Command="{Binding ResetReviewCommand}" />
</StackPanel> </StackPanel>
</Grid> </Border>
<ScrollViewer Name="LogScroll" <ScrollViewer Name="LogScroll"
VerticalScrollBarVisibility="Visible" VerticalScrollBarVisibility="Visible"
@@ -264,48 +257,82 @@
</DockPanel> </DockPanel>
<!-- Git: one Approve + merge cockpit --> <!-- Git: the review + merge cockpit -->
<ScrollViewer IsVisible="{Binding IsGitTab}" Padding="14,10"> <ScrollViewer IsVisible="{Binding IsGitTab}" Padding="14,10">
<StackPanel Spacing="12" IsVisible="{Binding ShowMergeSection}"> <StackPanel Spacing="14">
<TextBlock Classes="section-label" Text="MERGE" />
<StackPanel Spacing="4"> <!-- Merge controls — shown whenever there's a worktree / unit to merge.
<TextBlock Classes="field-label" Text="Target branch" /> Header reads REVIEW while a decision is pending, otherwise MERGE. -->
<ComboBox ItemsSource="{Binding MergeTargetBranches}" <StackPanel Spacing="14" IsVisible="{Binding Merge.ShowMergeSection}">
SelectedItem="{Binding SelectedMergeTarget, Mode=TwoWay}" <TextBlock Classes="section-label" Text="REVIEW"
HorizontalAlignment="Stretch" /> 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 Merge.MergeTargetBranches}"
SelectedItem="{Binding Merge.SelectedMergeTarget, Mode=TwoWay}"
HorizontalAlignment="Stretch" />
</StackPanel>
<StackPanel Spacing="0">
<TextBlock Classes="meta" Text="{Binding Merge.MergePreviewText}" TextWrapping="Wrap"
Foreground="{DynamicResource MossBrush}"
IsVisible="{Binding Merge.MergeIsClean}" />
<TextBlock Classes="meta" Text="{Binding Merge.MergePreviewText}" TextWrapping="Wrap"
Foreground="{DynamicResource BloodBrush}"
IsVisible="{Binding Merge.MergeIsConflict}" />
<TextBlock Classes="meta" Text="{Binding Merge.MergePreviewText}" TextWrapping="Wrap"
Foreground="{DynamicResource TextMuteBrush}"
IsVisible="{Binding Merge.ShowMergePreviewMuted}" />
</StackPanel>
<!-- Inspect: diff / worktree / combined diff -->
<WrapPanel Orientation="Horizontal">
<Button Classes="btn" Content="Open Diff" Margin="0,0,8,8"
Command="{Binding Merge.OpenDiffCommand}" />
<Button Classes="btn" Margin="0,0,8,8"
ToolTip.Tip="{loc:Tr agent.openWorktreeTip}"
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 Merge.ReviewCombinedDiffCommand}" />
</WrapPanel>
</StackPanel> </StackPanel>
<StackPanel Spacing="0"> <!-- Review decision — the merge verbs. Feedback + Resume session moved to the
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap" Output tab. Present while awaiting review, even for sandbox runs. -->
Foreground="{DynamicResource MossBrush}" <StackPanel Spacing="10" IsVisible="{Binding IsWaitingForReview}">
IsVisible="{Binding MergeIsClean}" /> <Border Height="1" Background="{DynamicResource LineBrush}"
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap" IsVisible="{Binding Merge.ShowMergeSection}" />
Foreground="{DynamicResource BloodBrush}"
IsVisible="{Binding MergeIsConflict}" />
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
Foreground="{DynamicResource TextMuteBrush}"
IsVisible="{Binding ShowMergePreviewMuted}" />
</StackPanel>
<!-- Primary action: Approve flows straight into the merge. --> <WrapPanel Orientation="Horizontal">
<WrapPanel Orientation="Horizontal"> <Button Classes="btn accent" Content="Approve &amp; Merge" Margin="0,0,8,8"
<Button Classes="btn accent" Content="Approve &amp; Merge" Margin="0,0,8,8" Command="{Binding ApproveReviewCommand}" />
Command="{Binding ApproveReviewCommand}" <Button Classes="btn" Content="Park" Margin="0,0,8,8"
IsVisible="{Binding IsWaitingForReview}" /> ToolTip.Tip="Set aside — back to Idle, keeps the worktree"
<Button Classes="btn" Content="Open Diff" Margin="0,0,8,8" Command="{Binding ParkReviewCommand}" />
Command="{Binding OpenDiffCommand}" /> <Button Classes="btn" Content="Cancel" Margin="0,0,8,8"
<Button Classes="btn" Margin="0,0,8,8" Command="{Binding CancelReviewCommand}" />
ToolTip.Tip="{loc:Tr agent.openWorktreeTip}" </WrapPanel>
Command="{Binding OpenWorktreeCommand}">
<StackPanel Orientation="Horizontal" Spacing="5"> <Button Classes="prompt-action" Content="Reset (discard branch)…"
<TextBlock Text="Worktree" /> ToolTip.Tip="{loc:Tr session.reviewResetTip}"
<PathIcon Data="{StaticResource Icon.ArrowOut}" Width="11" Height="11" /> Command="{Binding ResetReviewCommand}" />
</StackPanel> </StackPanel>
</Button>
<Button Classes="btn" Content="Review Combined Diff" Margin="0,0,8,8"
Command="{Binding ReviewCombinedDiffCommand}" />
</WrapPanel>
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>

View File

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

View File

@@ -35,6 +35,13 @@ public partial class DetailsIslandView : UserControl
if (h <= 0) return; if (h <= 0) return;
DetailBodyGrid.RowDefinitions[0].MaxHeight = h * 2.0 / 3.0; DetailBodyGrid.RowDefinitions[0].MaxHeight = h * 2.0 / 3.0;
DetailBodyGrid.RowDefinitions[1].MinHeight = h / 3.0; DetailBodyGrid.RowDefinitions[1].MinHeight = h / 3.0;
// The description sits in an Auto row, which measures its cell with
// infinite height — so the card's inner ScrollViewer thinks everything
// fits and never scrolls. Bounding the card itself gives that
// ScrollViewer a finite measure constraint so it engages once the
// content exceeds 2/3 of the island. (RowDefinition.MaxHeight above only
// clamps the drag and the final row height, not the measure constraint.)
DescriptionCard.MaxHeight = h * 2.0 / 3.0;
} }
private void OnDataContextChanged(object? sender, EventArgs e) private void OnDataContextChanged(object? sender, EventArgs e)
@@ -48,7 +55,7 @@ public partial class DetailsIslandView : UserControl
vm.PropertyChanged += OnViewModelPropertyChanged; vm.PropertyChanged += OnViewModelPropertyChanged;
ApplyResizeStateForCurrentTask(); ApplyResizeStateForCurrentTask();
vm.ShowDiffModal = async (diffVm) => vm.Merge.ShowDiffModal = async (diffVm) =>
{ {
var owner = TopLevel.GetTopLevel(this) as Window; var owner = TopLevel.GetTopLevel(this) as Window;
if (owner == null) return; if (owner == null) return;
@@ -56,7 +63,7 @@ public partial class DetailsIslandView : UserControl
await modal.ShowDialog(owner); await modal.ShowDialog(owner);
}; };
vm.ShowMergeModal = async (mergeVm) => vm.Merge.ShowMergeModal = async (mergeVm) =>
{ {
var owner = TopLevel.GetTopLevel(this) as Window; var owner = TopLevel.GetTopLevel(this) as Window;
if (owner == null) return; if (owner == null) return;
@@ -64,7 +71,7 @@ public partial class DetailsIslandView : UserControl
await modal.ShowDialog(owner); await modal.ShowDialog(owner);
}; };
vm.ShowPlanningDiffModal = async (planningDiffVm) => vm.Merge.ShowPlanningDiffModal = async (planningDiffVm) =>
{ {
var owner = TopLevel.GetTopLevel(this) as Window; var owner = TopLevel.GetTopLevel(this) as Window;
if (owner == null) return; if (owner == null) return;

View File

@@ -28,11 +28,8 @@
<ItemsControl ItemsSource="{Binding Bullets}"> <ItemsControl ItemsSource="{Binding Bullets}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:NoteBulletViewModel"> <DataTemplate x:DataType="vm:NoteBulletViewModel">
<Grid ColumnDefinitions="*,Auto,Auto" Margin="0,2" ColumnSpacing="6"> <TextBox Text="{Binding Text}" Margin="0,2"
<TextBox Grid.Column="0" Text="{Binding Text}"/> LostFocus="OnBulletLostFocus"/>
<Button Grid.Column="1" Classes="btn" Content="{loc:Tr notes.save}" Command="{Binding SaveCommand}"/>
<Button Grid.Column="2" Classes="btn" Content="{loc:Tr notes.delete}" Command="{Binding DeleteCommand}"/>
</Grid>
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>

View File

@@ -1,8 +1,18 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity;
using ClaudeDo.Ui.ViewModels.Islands;
namespace ClaudeDo.Ui.Views.Islands; namespace ClaudeDo.Ui.Views.Islands;
public partial class NotesEditorView : UserControl public partial class NotesEditorView : UserControl
{ {
public NotesEditorView() => InitializeComponent(); public NotesEditorView() => InitializeComponent();
private void OnBulletLostFocus(object? sender, RoutedEventArgs e)
{
if (sender is TextBox { DataContext: NoteBulletViewModel bullet }
&& DataContext is NotesEditorViewModel vm
&& vm.CommitBulletCommand.CanExecute(bullet))
vm.CommitBulletCommand.Execute(bullet);
}
} }

View File

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

View File

@@ -48,6 +48,18 @@ public partial class TaskRowView : UserControl
await vm.ClearScheduleCommand.ExecuteAsync(row); await vm.ClearScheduleCommand.ExecuteAsync(row);
} }
private async void OnAddToMyDayClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
await vm.AddToMyDayCommand.ExecuteAsync(row);
}
private async void OnRemoveFromMyDayClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
await vm.RemoveFromMyDayCommand.ExecuteAsync(row);
}
private async void OnOpenPlanningSessionClick(object? sender, RoutedEventArgs e) private async void OnOpenPlanningSessionClick(object? sender, RoutedEventArgs e)
{ {
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm) if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
@@ -72,10 +84,10 @@ public partial class TaskRowView : UserControl
await vm.DiscardPlanningSessionCommand.ExecuteAsync(row); await vm.DiscardPlanningSessionCommand.ExecuteAsync(row);
} }
private async void OnQueuePlanningSubtasksClick(object? sender, RoutedEventArgs e) private async void OnFinalizePlanningSessionClick(object? sender, RoutedEventArgs e)
{ {
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm) if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
await vm.QueuePlanningSubtasksCommand.ExecuteAsync(row); await vm.FinalizePlanningSessionCommand.ExecuteAsync(row);
} }
private async void OnSetStatusClick(object? sender, RoutedEventArgs e) private async void OnSetStatusClick(object? sender, RoutedEventArgs e)

View File

@@ -22,7 +22,7 @@
<KeyBinding Gesture="Shift+OemQuestion" Command="{Binding FocusSearchCommand}"/> <KeyBinding Gesture="Shift+OemQuestion" Command="{Binding FocusSearchCommand}"/>
<KeyBinding Gesture="Ctrl+N" Command="{Binding FocusAddTaskCommand}"/> <KeyBinding Gesture="Ctrl+N" Command="{Binding FocusAddTaskCommand}"/>
</Window.KeyBindings> </Window.KeyBindings>
<Grid RowDefinitions="36,Auto,*,22"> <Grid x:Name="RootGrid" RowDefinitions="36,Auto,*,22">
<!-- Custom title bar --> <!-- Custom title bar -->
<Border Grid.Row="0" <Border Grid.Row="0"
Background="{DynamicResource DeepBrush}" Background="{DynamicResource DeepBrush}"
@@ -57,18 +57,26 @@
<Menu Margin="12,0,0,0" <Menu Margin="12,0,0,0"
Background="Transparent" Background="Transparent"
VerticalAlignment="Center"> VerticalAlignment="Center">
<MenuItem Header="{loc:Tr shell.menu.worker}"
FontSize="{StaticResource FontSizeMono}"
Foreground="{DynamicResource TextDimBrush}">
<MenuItem Header="{loc:Tr shell.menu.restartWorker}"
Command="{Binding RestartWorkerCommand}"/>
<MenuItem Header="{loc:Tr shell.menu.checkForUpdates}"
Command="{Binding CheckForUpdatesCommand}"/>
</MenuItem>
<MenuItem Header="{loc:Tr shell.menu.repositories}"
FontSize="{StaticResource FontSizeMono}"
Foreground="{DynamicResource TextDimBrush}">
<MenuItem Header="{loc:Tr shell.menu.addRepos}" Command="{Binding OpenRepoImportCommand}"/>
<MenuItem Header="{loc:Tr shell.menu.worktrees}"
Command="{Binding OpenWorktreesOverviewGlobalCommand}"/>
</MenuItem>
<MenuItem Header="{loc:Tr shell.menu.help}" <MenuItem Header="{loc:Tr shell.menu.help}"
FontSize="{StaticResource FontSizeMono}" FontSize="{StaticResource FontSizeMono}"
Foreground="{DynamicResource TextDimBrush}"> Foreground="{DynamicResource TextDimBrush}">
<MenuItem Header="{loc:Tr shell.menu.checkForUpdates}"
Command="{Binding CheckForUpdatesCommand}"/>
<MenuItem Header="{loc:Tr shell.menu.restartWorker}"
Command="{Binding RestartWorkerCommand}"/>
<MenuItem Header="{loc:Tr shell.menu.worktrees}"
Command="{Binding OpenWorktreesOverviewGlobalCommand}"/>
<MenuItem Header="{loc:Tr shell.menu.weeklyReport}" Command="{Binding OpenWeeklyReportCommand}"/> <MenuItem Header="{loc:Tr shell.menu.weeklyReport}" Command="{Binding OpenWeeklyReportCommand}"/>
<MenuItem Header="{loc:Tr shell.menu.about}" Command="{Binding OpenAboutCommand}"/> <MenuItem Header="{loc:Tr shell.menu.about}" Command="{Binding OpenAboutCommand}"/>
<MenuItem Header="{loc:Tr shell.menu.addRepos}" Command="{Binding OpenRepoImportCommand}"/>
</MenuItem> </MenuItem>
</Menu> </Menu>
</StackPanel> </StackPanel>

View File

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

View File

@@ -261,6 +261,99 @@
</ScrollViewer> </ScrollViewer>
</TabItem> </TabItem>
<TabItem Header="{loc:Tr settings.onlineInbox.tabHeader}">
<ScrollViewer>
<StackPanel Spacing="14" Margin="0,8,0,0">
<!-- Enable toggle + restart hint -->
<StackPanel Spacing="4">
<CheckBox IsChecked="{Binding OnlineInbox.Enabled, Mode=TwoWay}"
Content="{loc:Tr settings.onlineInbox.enabledLabel}"/>
<TextBlock Classes="meta" Text="{loc:Tr settings.onlineInbox.restartHint}"
TextWrapping="Wrap" Opacity="0.6"/>
</StackPanel>
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="0,1,0,0" Margin="0,2,0,0"/>
<!-- Auth status section -->
<StackPanel Spacing="8">
<TextBlock Classes="section-label" Text="{loc:Tr settings.onlineInbox.statusSection}"/>
<StackPanel Orientation="Horizontal" Spacing="10" VerticalAlignment="Center"
IsVisible="{Binding OnlineInbox.SignedIn}">
<Border Width="8" Height="8" CornerRadius="4"
Background="{DynamicResource StatusRunningBrush}"/>
<TextBlock Classes="body" VerticalAlignment="Center"
Text="{loc:Tr settings.onlineInbox.signedInStatus}"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="10" VerticalAlignment="Center"
IsVisible="{Binding !OnlineInbox.SignedIn}">
<Border Width="8" Height="8" CornerRadius="4"
Background="{DynamicResource StatusIdleBrush}"/>
<TextBlock Classes="body" VerticalAlignment="Center"
Text="{loc:Tr settings.onlineInbox.signedOutStatus}"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="btn"
Content="{loc:Tr settings.onlineInbox.signInButton}"
Command="{Binding OnlineInbox.SignInCommand}"
IsEnabled="{Binding !OnlineInbox.IsBusy}"
IsVisible="{Binding !OnlineInbox.SignedIn}"/>
<Button Classes="btn danger"
Content="{loc:Tr settings.onlineInbox.signOutButton}"
Command="{Binding OnlineInbox.SignOutCommand}"
IsEnabled="{Binding !OnlineInbox.IsBusy}"
IsVisible="{Binding OnlineInbox.SignedIn}"/>
</StackPanel>
</StackPanel>
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="0,1,0,0" Margin="0,2,0,0"/>
<!-- Config fields -->
<StackPanel Spacing="10">
<TextBlock Classes="section-label" Text="{loc:Tr settings.onlineInbox.configSection}"/>
<StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="{loc:Tr settings.onlineInbox.apiBaseUrlLabel}"/>
<TextBox Text="{Binding OnlineInbox.ApiBaseUrl, Mode=TwoWay}"
PlaceholderText="{loc:Tr settings.onlineInbox.apiBaseUrlPlaceholder}"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="{loc:Tr settings.onlineInbox.authorityLabel}"/>
<TextBox Text="{Binding OnlineInbox.Authority, Mode=TwoWay}"
PlaceholderText="{loc:Tr settings.onlineInbox.authorityPlaceholder}"/>
</StackPanel>
<Grid ColumnDefinitions="*,12,*">
<StackPanel Grid.Column="0" Spacing="4">
<TextBlock Classes="field-label" Text="{loc:Tr settings.onlineInbox.clientIdLabel}"/>
<TextBox Text="{Binding OnlineInbox.ClientId, Mode=TwoWay}"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Classes="field-label" Text="{loc:Tr settings.onlineInbox.scopesLabel}"/>
<TextBox Text="{Binding OnlineInbox.Scopes, Mode=TwoWay}"/>
</StackPanel>
</Grid>
<StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="{loc:Tr settings.onlineInbox.redirectUriLabel}"/>
<TextBox Text="{Binding OnlineInbox.RedirectUri, Mode=TwoWay}"/>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="{loc:Tr settings.onlineInbox.pollIntervalLabel}"/>
<NumericUpDown Value="{Binding OnlineInbox.PollIntervalSeconds, Mode=TwoWay}"
Minimum="10" Maximum="3600" Increment="10" FormatString="0"
HorizontalAlignment="Left" Width="140"/>
</StackPanel>
<Button Classes="btn"
Content="{loc:Tr settings.onlineInbox.saveButton}"
Command="{Binding OnlineInbox.SaveCommand}"
IsEnabled="{Binding !OnlineInbox.IsBusy}"
HorizontalAlignment="Left"/>
</StackPanel>
<TextBlock Classes="meta" Text="{Binding OnlineInbox.StatusMessage}"
IsVisible="{Binding OnlineInbox.StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</ScrollViewer>
</TabItem>
</TabControl> </TabControl>
</DockPanel> </DockPanel>

View File

@@ -81,7 +81,7 @@
IsVisible="{Binding HasOutcome}"/> IsVisible="{Binding HasOutcome}"/>
<Border Grid.Column="3" CornerRadius="3" Padding="6,2" VerticalAlignment="Center" <Border Grid.Column="3" CornerRadius="3" Padding="6,2" VerticalAlignment="Center"
Background="{Binding State, Converter={StaticResource WorktreeStateColor}}"> Background="{Binding State, Converter={StaticResource WorktreeStateColor}}">
<TextBlock Classes="meta" Text="{Binding State}" Foreground="{DynamicResource TextBrush}" <TextBlock Classes="meta" Text="{Binding State}" Foreground="{DynamicResource DeepBrush}"
HorizontalAlignment="Center"/> HorizontalAlignment="Center"/>
</Border> </Border>
<TextBlock Grid.Column="4" Classes="meta" Text="{Binding DiffStat}" VerticalAlignment="Center"/> <TextBlock Grid.Column="4" Classes="meta" Text="{Binding DiffStat}" VerticalAlignment="Center"/>

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/ Worker/
State/ — TaskStateService + TransitionResult (sole owner of Status/PlanningPhase/BlockedBy writes) State/ — TaskStateService + TransitionResult (sole owner of Status/PlanningPhase/BlockedBy writes)
Queue/ — IQueueWaker, IQueuePicker, QueueService (BackgroundService), OverrideSlotService Queue/ — IQueueWaker, IQueuePicker, QueueService (BackgroundService), OverrideSlotService
Lifecycle/ — StaleTaskRecovery, TaskResetService, TaskMergeService Lifecycle/ — StaleTaskRecovery, TaskResetService, TaskMergeService, ClaudeCliPreflight, OrphanRecovery, PlanningLineageRecovery
Worktrees/ — WorktreeMaintenanceService Worktrees/ — WorktreeMaintenanceService
Agents/ — AgentFileService, DefaultAgentSeeder Agents/ — AgentFileService, DefaultAgentSeeder
Runner/ — TaskRunner + Claude CLI integration Runner/ — TaskRunner + Claude CLI integration; TaskRunMcpService/TaskRunMcpContext/TaskRunTokenRegistry (in-task MCP wired during execution)
Planning/ — PlanningSessionManager, PlanningChainCoordinator, PlanningMcpService Planning/ — PlanningSessionManager, PlanningChainCoordinator, PlanningMcpService, PlanningMergeOrchestrator, PlanningAggregator, PlanningSessionContext/PlanningTokenAuth/PlanningMcpContextAccessor, WindowsTerminalPlanningLauncher (IPlanningTerminalLauncher)
External/ — ExternalMcpService Refine/ — RefineRunner + RefinePrompt (hub `RefineTask`; broadcasts RefineStarted/RefineFinished)
External/ — ExternalMcpService + sibling tool classes
Config/ — WorkerConfig
Hub/ — WorkerHub, HubBroadcaster Hub/ — WorkerHub, HubBroadcaster
Report/ — ClaudeHistoryReader, WeekReportPromptBuilder, WeekReportService; interfaces in Report/Interfaces/ Report/ — ClaudeHistoryReader, WeekReportPromptBuilder, WeekReportService; interfaces in Report/Interfaces/
Prime/ — daily-prep ("Prime Claude"): PrimeScheduler (BackgroundService), PrimeRunner (runs the daily prep), DailyPrepPrompt (fixed prompt + CLI args + LogPath() helper), NextDueCalculator, PrimeScheduleSignal; interfaces in Prime/Interfaces/ (IPrimeRunner, IPrimeClock, IPrimeScheduleSignal, IPrimeBroadcaster) Prime/ — daily-prep ("Prime Claude"): PrimeScheduler (BackgroundService), PrimeRunner (runs the daily prep), DailyPrepPrompt (fixed prompt + CLI args + LogPath() helper), NextDueCalculator, PrimeScheduleSignal; interfaces in Prime/Interfaces/ (IPrimeRunner, IPrimeClock, IPrimeScheduleSignal, IPrimeBroadcaster)
Online/ — optional Online Inbox sync: OnlineInboxConfig (config record), Dtos (RemoteList/RemoteTask/MirrorTask), IOnlineInboxApi, OnlineInboxApiClient (typed HttpClient, bearer auth, HTTPS guard), OnlineTokenStore (DPAPI refresh-token store, Windows-only), StaticTokenAuthProvider (default/test IOnlineAuthProvider), ZitadelAuthProvider (stub — TODO(online-inbox) Phase 2), OnlineSyncService (BackgroundService: reconcile loop), OnlineBacklog (Idle-backlog filter/query); interface in Online/Interfaces/ (IOnlineAuthProvider)
``` ```
Interfaces (e.g. `IQueueWaker`, `IPrimeClock`, `ITaskStateService`) live in an `Interfaces/` subfolder within their area; the namespace stays the area namespace. Interfaces (e.g. `IQueueWaker`, `IPrimeClock`, `ITaskStateService`) live in an `Interfaces/` subfolder within their area; the namespace stays the area namespace.
@@ -29,11 +32,11 @@ Interfaces (e.g. `IQueueWaker`, `IPrimeClock`, `ITaskStateService`) live in an `
- **OverrideSlotService** — owns `RunNow` / `ContinueTask`; goes through `TaskStateService.StartRunningAsync` (caller-driven, serialized by slot lock). - **OverrideSlotService** — owns `RunNow` / `ContinueTask`; goes through `TaskStateService.StartRunningAsync` (caller-driven, serialized by slot lock).
- **StaleTaskRecovery** — startup-only service; calls `TaskStateService.RecoverStaleRunningAsync` to flip orphaned `Running` rows to `Failed`. - **StaleTaskRecovery** — startup-only service; calls `TaskStateService.RecoverStaleRunningAsync` to flip orphaned `Running` rows to `Failed`.
- **External/*** — always-on MCP tools for general Claude sessions, scoped to *starting* and *observing* sessions (no worktree/merge, multi-turn, planning, or app-settings writes). Auth via optional `X-ClaudeDo-Key` header. Registered explicitly in `Program.cs`'s external app via `.WithTools<T>()`. Organized by concern: - **External/*** — always-on MCP tools for general Claude sessions, scoped to *starting* and *observing* sessions (no worktree/merge, multi-turn, planning, or app-settings writes). Auth via optional `X-ClaudeDo-Key` header. Registered explicitly in `Program.cs`'s external app via `.WithTools<T>()`. Organized by concern:
- `ExternalMcpService` — task CRUD + execution: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `UpdateTask`, `UpdateTaskStatus` (`Idle` / `Queued`), `ReviewTask` (`approve` / `reject_rerun` / `reject_park` / `cancel` for a WaitingForReview task), `RunTaskNow`, `CancelTask`, `DeleteTask` - `ExternalMcpService` — task CRUD + execution: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `AddSubtask`, `UpdateTask`, `UpdateTaskStatus` (`Idle` / `Queued`), `GetTaskStatusValues`, `ReviewTask` (`approve` / `reject_rerun` / `reject_park` / `cancel` for a WaitingForReview task), `RunTaskNow`, `ContinueTask`, `CancelTask`, `DeleteTask`; worktree/git: `GetTaskWorktree`, `GetTaskDiff`, `MergeTask`, `ListWorktrees`, `CleanupTaskWorktree`
- `ListMcpTools``CreateList`, `UpdateList`, `DeleteList` - `ListMcpTools``CreateList`, `UpdateList`, `DeleteList`
- `ConfigMcpTools``GetListConfig`, `SetListConfig`, `SetTaskConfig` - `ConfigMcpTools``GetListConfig`, `SetListConfig`, `GetTaskConfig`, `SetTaskConfig`
- `RunHistoryMcpTools``ListRuns`, `GetRun`, `GetTaskLog` (latest run's log, tail-capped at 256 KB) - `RunHistoryMcpTools``ListRuns`, `GetRun`, `GetTaskLog` (latest run's log, tail-capped at 256 KB)
- `AgentMcpTools``ListAgents` - `AgentMcpTools``ListAgents` (class lives in `LifecycleMcpTools.cs`)
- `LifecycleMcpTools``ResetFailedTask` - `LifecycleMcpTools``ResetFailedTask`
- `AppSettingsMcpTools``GetAppSettings` (read-only) - `AppSettingsMcpTools``GetAppSettings` (read-only)
- `ExternalMcpService` also exposes two daily-prep tools: - `ExternalMcpService` also exposes two daily-prep tools:
@@ -67,7 +70,7 @@ Allowed transitions (enforced by `TaskStateService`):
``` ```
Idle → Queued | Running (RunNow) Idle → Queued | Running (RunNow)
Queued → Running | Cancelled | Idle Queued → Running | Cancelled | Idle | Failed (OverrideSlotService preflight gap: RunAsync can fail before StartRunningAsync is called)
Running → WaitingForReview (standalone success, no children) Running → WaitingForReview (standalone success, no children)
| WaitingForChildren (parent with pending children) | WaitingForChildren (parent with pending children)
| Done (planning/improvement child success) | Failed | Cancelled | Done (planning/improvement child success) | Failed | Cancelled
@@ -143,9 +146,17 @@ Each CLI invocation is recorded in the `task_runs` table via `TaskRunRepository`
## SignalR Hub ## SignalR Hub
**WorkerHub** methods: `Ping`, `GetActive`, `RunNow`, `CancelTask`, `WakeQueue`, `ContinueTask`, `ResetTask`, `ApproveReview(taskId, targetBranch) -> MergeResultDto` (childless task: merges its worktree then Done, conflict stays WaitingForReview; task with children: drives `PlanningMergeOrchestrator` to merge the whole unit), `ContinuePlanningMerge` / `AbortPlanningMerge` (resolve a unit-merge conflict), `PreviewMerge(taskId, targetBranch) -> MergePreviewDto` (non-destructive mergeability check), `RejectReviewToQueue`, `RejectReviewToIdle`, `CancelReview`, `GetAgents`, `RefreshAgents`, `GetAppSettings`, `UpdateAppSettings`, `CleanupFinishedWorktrees`, `ResetAllWorktrees`, `MergeTask`, `GetMergeTargets`, `UpdateList`, `UpdateListConfig`, `GetListConfig`, `UpdateTaskAgentSettings`, `GetWeekReport`, `GenerateWeekReport`, `GetDailyNotes`, `AddDailyNote`, `UpdateDailyNote`, `DeleteDailyNote`, `RunDailyPrepNow`, `ClearMyDay`, `GetLastPrepLog` **WorkerHub** methods, grouped:
**HubBroadcaster** events: `TaskStarted`, `TaskFinished`, `TaskMessage`, `WorktreeUpdated`, `TaskUpdated`, `RunCreated`, `ListUpdated`, `PrimeFired`, `PrepStarted`, `PrepLine`, `PrepFinished` - Execution: `Ping`, `GetActive`, `RunNow`, `CancelTask`, `WakeQueue`, `ContinueTask`, `ResetTask`, `SetTaskStatus`, `RefineTask`
- Review/merge: `ApproveReview(taskId, targetBranch) -> MergeResultDto` (childless task: merges its worktree then Done, conflict stays WaitingForReview; task with children: drives `PlanningMergeOrchestrator` to merge the whole unit), `ContinuePlanningMerge` / `AbortPlanningMerge` (resolve a unit-merge conflict), `PreviewMerge(taskId, targetBranch) -> MergePreviewDto` (non-destructive mergeability check), `RejectReviewToQueue`, `RejectReviewToIdle`, `CancelReview`, `MergeTask`, `GetMergeTargets`
- Single-task conflict resolver (Layer C): `StartConflictMerge`, `GetMergeConflicts` (hunks), `WriteConflictResolution`, `ContinueConflictMerge`, `AbortConflictMerge` (service-level `TaskMergeService.ContinueMergeAsync`/`AbortMergeAsync` keep their names)
- Planning sessions: `StartPlanningSession`, `ResumePlanningSession`, `DiscardPlanningSession`, `FinalizePlanningSession`, `QueuePlanningSubtasks`, `GetPendingDraftCount`, `OpenInteractiveTerminal`, `GetPlanningAggregate` (per-subtask diffs), `BuildPlanningIntegrationBranch` (combined diff)
- Worktrees: `CleanupFinishedWorktrees`, `ResetAllWorktrees`, `GetWorktreesOverview`, `SetWorktreeState`, `ForceRemoveWorktree`
- Agents/settings/lists: `GetAgents`, `RefreshAgents`, `RestoreDefaultAgents`, `GetAppSettings`, `UpdateAppSettings`, `UpdateList`, `UpdateListConfig`, `GetListConfig`, `UpdateTaskAgentSettings`
- Reports/notes/prep: `GetWeekReport`, `GenerateWeekReport`, `GetDailyNotes`, `AddDailyNote`, `UpdateDailyNote`, `DeleteDailyNote`, `RunDailyPrepNow`, `ClearMyDay`, `GetLastPrepLog`, `ListPrimeSchedules`, `UpsertPrimeSchedule`, `DeletePrimeSchedule`
**HubBroadcaster** events: `TaskStarted`, `TaskFinished`, `TaskMessage`, `WorktreeUpdated`, `TaskUpdated`, `RunCreated`, `ListUpdated`, `WorkerLog`, `PrimeFired`, `PrepStarted`, `PrepLine`, `PrepFinished`, `PlanningMergeStarted`, `PlanningSubtaskMerged`, `PlanningMergeConflict`, `PlanningMergeAborted`, `PlanningCompleted`, `RefineStarted`, `RefineFinished`
## Config ## Config
@@ -155,8 +166,14 @@ Loaded from `~/.todo-app/worker.config.json`:
- `queue_backstop_interval_ms` (default 30000) - `queue_backstop_interval_ms` (default 30000)
- `signalr_port` (default 47821) - `signalr_port` (default 47821)
- `claude_bin` (path to claude CLI) - `claude_bin` (path to claude CLI)
- `online_inbox` — Online Inbox config (default: `enabled=false`, zero network when disabled):
- `enabled` (bool, default false) — when false the entire `Online/` stack is not registered
- `api_base_url` (string) — must be HTTPS or loopback; validated at startup when enabled
- `poll_interval_seconds` (int, default 60)
- `zitadel.authority`, `zitadel.client_id`, `zitadel.scopes` (Phase 2; not used until ZitadelAuthProvider is wired)
- The refresh token is NOT in this file — stored encrypted via DPAPI at `~/.todo-app/online-inbox.token`
Per-list config (`list_config` in DB) provides defaults for `model`, `system_prompt`, `agent_path`; tasks can override each individually. Per-list config (`list_config` in DB) provides defaults for `model`, `system_prompt`, `agent_path`; tasks can override each individually. Task-generating MCP tools (`AddTask`, planning `CreateChildTask`, `SuggestImprovement`) accept an optional `model` (alias-validated via `ModelRegistry.NormalizeAlias` — `haiku`/`sonnet`/`opus`, blank = inherit) so Claude assigns the cheapest capable model at creation time; the planning/system/improvement prompts instruct it to do so (`ModelRegistry.ByCostAscending` = the cost order).
## Notes ## Notes

View File

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

View File

@@ -1,6 +1,8 @@
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using ClaudeDo.Data; using ClaudeDo.Data;
using ClaudeDo.Worker.Online;
namespace ClaudeDo.Worker.Config; namespace ClaudeDo.Worker.Config;
@@ -39,6 +41,9 @@ public sealed class WorkerConfig
[JsonPropertyName("external_mcp_api_key")] [JsonPropertyName("external_mcp_api_key")]
public string? ExternalMcpApiKey { get; set; } public string? ExternalMcpApiKey { get; set; }
[JsonPropertyName("online_inbox")]
public OnlineInboxConfig OnlineInbox { get; set; } = new();
public static string DefaultConfigPath => public static string DefaultConfigPath =>
Path.Combine(Paths.AppDataRoot(), "worker.config.json"); Path.Combine(Paths.AppDataRoot(), "worker.config.json");
@@ -70,9 +75,38 @@ public sealed class WorkerConfig
return cfg; return cfg;
} }
/// <summary>
/// Persists ONLY the <c>online_inbox</c> section back to <paramref name="path"/>
/// (defaults to <see cref="DefaultConfigPath"/>) without rewriting any other fields.
/// Reads the existing JSON, replaces the <c>online_inbox</c> node, and writes back indented.
/// </summary>
public void SaveOnlineInbox(string? path = null)
{
path ??= DefaultConfigPath;
var root = File.Exists(path)
? JsonNode.Parse(File.ReadAllText(path)) as JsonObject ?? new JsonObject()
: new JsonObject();
root["online_inbox"] = JsonSerializer.SerializeToNode(OnlineInbox, InboxSerializerOpts);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, root.ToJsonString(WriteOpts));
}
private static readonly JsonSerializerOptions JsonOpts = new() private static readonly JsonSerializerOptions JsonOpts = new()
{ {
ReadCommentHandling = JsonCommentHandling.Skip, ReadCommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true, AllowTrailingCommas = true,
}; };
private static readonly JsonSerializerOptions InboxSerializerOpts = new()
{
WriteIndented = false,
};
private static readonly JsonSerializerOptions WriteOpts = new()
{
WriteIndented = true,
};
} }

View File

@@ -142,13 +142,18 @@ public sealed class ExternalMcpService
return ToDto(task); return ToDto(task);
} }
[McpServerTool, Description("Create a new task in the given list. Set queueImmediately=true to enqueue it for agent execution.")] [McpServerTool, Description(
"Create a new task in the given list. Set queueImmediately=true to enqueue it for agent execution. " +
"Set model to the cheapest model that can do the task well — 'haiku' for trivial/mechanical work, " +
"'sonnet' for normal coding (the default), 'opus' only for complex or cross-cutting work. " +
"Leave model null to inherit the list/global default.")]
public async Task<TaskDto> AddTask( public async Task<TaskDto> AddTask(
string listId, string listId,
string title, string title,
string? description = null, string? description = null,
string? createdBy = null, string? createdBy = null,
bool queueImmediately = false, bool queueImmediately = false,
string? model = null,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
if (string.IsNullOrWhiteSpace(listId)) if (string.IsNullOrWhiteSpace(listId))
@@ -169,6 +174,7 @@ public sealed class ExternalMcpService
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
CommitType = list.DefaultCommitType, CommitType = list.DefaultCommitType,
CreatedBy = createdBy.NullIfBlank() ?? "mcp", CreatedBy = createdBy.NullIfBlank() ?? "mcp",
Model = ModelRegistry.NormalizeAlias(model),
}; };
await _tasks.AddAsync(entity, cancellationToken); await _tasks.AddAsync(entity, cancellationToken);

View File

@@ -4,7 +4,9 @@ using ClaudeDo.Data;
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories; using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Agents; using ClaudeDo.Worker.Agents;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Lifecycle; using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Online;
using ClaudeDo.Worker.Planning; using ClaudeDo.Worker.Planning;
using ClaudeDo.Worker.Prime; using ClaudeDo.Worker.Prime;
using ClaudeDo.Worker.Queue; using ClaudeDo.Worker.Queue;
@@ -59,12 +61,34 @@ public record MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalB
public record MergeConflictsDto(string TaskId, IReadOnlyList<ConflictFileDto> Files); public record MergeConflictsDto(string TaskId, IReadOnlyList<ConflictFileDto> Files);
public record ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks); public record ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks);
public record ConflictHunkDto(string Ours, string Theirs, string? Base); public record ConflictHunkDto(string Ours, string Theirs, string? Base);
public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDocumentDto> Files);
public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList<MergeSegmentDto> Segments);
public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs);
public record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType); public record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType);
public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null); public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null); public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null); public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
public record SeedResultDto(int Copied, int Skipped); public record SeedResultDto(int Copied, int Skipped);
public record OnlineInboxStateDto(
bool Enabled,
string ApiBaseUrl,
string Authority,
string ClientId,
string Scopes,
string RedirectUri,
bool SignedIn,
int PollIntervalSeconds);
public record OnlineInboxConfigInput(
bool Enabled,
string ApiBaseUrl,
int PollIntervalSeconds,
string Authority,
string ClientId,
string Scopes,
string RedirectUri);
public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
{ {
private static readonly string Version = private static readonly string Version =
@@ -89,6 +113,9 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
private readonly ITaskStateService _state; private readonly ITaskStateService _state;
private readonly IWeekReportService _report; private readonly IWeekReportService _report;
private readonly IRefineRunner _refineRunner; private readonly IRefineRunner _refineRunner;
private readonly WorkerConfig _cfg;
private readonly OnlineInboxConfig _onlineInboxConfig;
private readonly OnlineTokenStore _onlineTokenStore;
public WorkerHub( public WorkerHub(
QueueService queue, QueueService queue,
@@ -109,7 +136,10 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
IPrimeRunner primeRunner, IPrimeRunner primeRunner,
ITaskStateService state, ITaskStateService state,
IWeekReportService report, IWeekReportService report,
IRefineRunner refineRunner) IRefineRunner refineRunner,
WorkerConfig cfg,
OnlineInboxConfig onlineInboxConfig,
OnlineTokenStore onlineTokenStore)
{ {
_queue = queue; _queue = queue;
_waker = waker; _waker = waker;
@@ -130,6 +160,9 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
_state = state; _state = state;
_report = report; _report = report;
_refineRunner = refineRunner; _refineRunner = refineRunner;
_cfg = cfg;
_onlineInboxConfig = onlineInboxConfig;
_onlineTokenStore = onlineTokenStore;
} }
// Maps the two exceptions service methods throw into client-facing HubExceptions: // Maps the two exceptions service methods throw into client-facing HubExceptions:
@@ -353,11 +386,23 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
new[] { new ConflictHunkDto(f.Ours, f.Theirs, f.Base) })).ToList()); new[] { new ConflictHunkDto(f.Ours, f.Theirs, f.Base) })).ToList());
}); });
public Task<MergeConflictDocumentsDto> GetMergeConflictDocuments(string taskId)
=> HubGuard(async () =>
{
var c = await _mergeService.GetConflictDocumentsAsync(taskId, CancellationToken.None);
return new MergeConflictDocumentsDto(
c.TaskId,
c.Files.Select(f => new ConflictDocumentDto(
f.Path, f.IsBinary,
f.Segments.Select(s => new MergeSegmentDto(
s.IsConflict, s.Text, s.Ours, s.Base, s.Theirs)).ToList())).ToList());
});
public Task WriteConflictResolution(string taskId, string path, string resolvedContent) public Task WriteConflictResolution(string taskId, string path, string resolvedContent)
=> HubGuard(() => _mergeService.WriteResolutionAsync( => HubGuard(() => _mergeService.WriteResolutionAsync(
taskId, path, resolvedContent ?? "", CancellationToken.None)); taskId, path, resolvedContent ?? "", CancellationToken.None));
public Task<MergeResultDto> ContinueMerge(string taskId) public Task<MergeResultDto> ContinueConflictMerge(string taskId)
=> HubGuard(async () => => HubGuard(async () =>
{ {
var r = await _mergeService.ContinueMergeAsync(taskId, CancellationToken.None); var r = await _mergeService.ContinueMergeAsync(taskId, CancellationToken.None);
@@ -366,7 +411,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage); return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
}); });
public Task AbortMerge(string taskId) public Task AbortConflictMerge(string taskId)
=> HubGuard(async () => => HubGuard(async () =>
{ {
var r = await _mergeService.AbortMergeAsync(taskId, CancellationToken.None); var r = await _mergeService.AbortMergeAsync(taskId, CancellationToken.None);
@@ -684,4 +729,42 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
return ids.Count; return ids.Count;
} }
#pragma warning disable CA1416 // ClaudeDo.Worker is Windows-only; DPAPI calls are safe here.
public OnlineInboxStateDto GetOnlineInboxState()
{
var signedIn = _onlineTokenStore.Read() is not null;
return new OnlineInboxStateDto(
_onlineInboxConfig.Enabled,
_onlineInboxConfig.ApiBaseUrl,
_onlineInboxConfig.Zitadel.Authority,
_onlineInboxConfig.Zitadel.ClientId,
_onlineInboxConfig.Zitadel.Scopes,
_onlineInboxConfig.RedirectUri,
signedIn,
_onlineInboxConfig.PollIntervalSeconds);
}
public void SetOnlineInboxConfig(OnlineInboxConfigInput input)
{
_onlineInboxConfig.Enabled = input.Enabled;
_onlineInboxConfig.ApiBaseUrl = input.ApiBaseUrl ?? "";
_onlineInboxConfig.PollIntervalSeconds = input.PollIntervalSeconds;
_onlineInboxConfig.RedirectUri = input.RedirectUri ?? "http://localhost:8765/callback";
_onlineInboxConfig.Zitadel.Authority = input.Authority ?? "";
_onlineInboxConfig.Zitadel.ClientId = input.ClientId ?? "";
_onlineInboxConfig.Zitadel.Scopes = input.Scopes ?? "openid offline_access";
_cfg.SaveOnlineInbox();
}
public void SetOnlineInboxAuth(string refreshToken)
{
_onlineTokenStore.Save(refreshToken);
}
public void ClearOnlineInboxAuth()
{
_onlineTokenStore.Clear();
}
#pragma warning restore CA1416
} }

View File

@@ -33,6 +33,15 @@ public sealed record ConflictFileContent(
string Theirs, string Theirs,
string? Base); string? Base);
public sealed record ConflictDocuments(
string TaskId,
IReadOnlyList<ConflictDocumentContent> Files);
public sealed record ConflictDocumentContent(
string Path,
bool IsBinary,
IReadOnlyList<MergeSegment> Segments);
public sealed class TaskMergeService public sealed class TaskMergeService
{ {
public const string StatusMerged = "merged"; public const string StatusMerged = "merged";
@@ -256,6 +265,45 @@ public sealed class TaskMergeService
return new MergeConflicts(taskId, result); return new MergeConflicts(taskId, result);
} }
/// <summary>
/// Reads each conflicted working-tree file and parses its conflict markers into line-level
/// segments (with the diff3 merge base when present). Binary files are flagged and skipped.
/// </summary>
public async Task<ConflictDocuments> GetConflictDocumentsAsync(string taskId, CancellationToken ct)
{
var (_, list, _) = await LoadMergeContextAsync(taskId, ct);
if (string.IsNullOrWhiteSpace(list.WorkingDir))
throw new InvalidOperationException("list has no working directory");
var files = await _git.ListConflictedFilesAsync(list.WorkingDir, ct);
var result = new List<ConflictDocumentContent>(files.Count);
foreach (var path in files)
{
var full = Path.Combine(list.WorkingDir, path.Replace('/', Path.DirectorySeparatorChar));
string text;
try { text = await File.ReadAllTextAsync(full, ct); }
catch { text = ""; }
if (LooksBinary(text))
{
result.Add(new ConflictDocumentContent(path, true, Array.Empty<MergeSegment>()));
continue;
}
result.Add(new ConflictDocumentContent(path, false, ConflictMarkerParser.Parse(text)));
}
return new ConflictDocuments(taskId, result);
}
// A NUL byte in the head of the file is the conventional binary sniff.
private static bool LooksBinary(string text)
{
var n = Math.Min(text.Length, 8000);
for (var i = 0; i < n; i++)
if (text[i] == '\0') return true;
return false;
}
public async Task WriteResolutionAsync(string taskId, string path, string content, CancellationToken ct) public async Task WriteResolutionAsync(string taskId, string path, string content, CancellationToken ct)
{ {
var (_, list, _) = await LoadMergeContextAsync(taskId, ct); var (_, list, _) = await LoadMergeContextAsync(taskId, ct);

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) private Task BroadcastTaskUpdatedAsync(string taskId, CancellationToken ct)
=> _broadcaster.TaskUpdated(taskId); => _broadcaster.TaskUpdated(taskId);
[McpServerTool, Description("Create a new draft child task under the current planning session's parent task.")] [McpServerTool, Description(
"Create a new draft child task under the current planning session's parent task. " +
"Set model to the cheapest model that can do this subtask well — 'haiku' for trivial/mechanical " +
"work, 'sonnet' for normal coding (the default), 'opus' only for complex or cross-cutting work. " +
"Leave model null to inherit the list/global default.")]
public async Task<CreatedChildDto> CreateChildTask( public async Task<CreatedChildDto> CreateChildTask(
string title, string title,
string? description, string? description,
string? commitType, string? commitType,
string? model,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var ctx = _contextAccessor.Current; var ctx = _contextAccessor.Current;
var child = await _tasks.CreateChildAsync(ctx.ParentTaskId, title, description, commitType, createdBy: null, cancellationToken); var child = await _tasks.CreateChildAsync(ctx.ParentTaskId, title, description, commitType, createdBy: null, model: model, ct: cancellationToken);
await BroadcastTaskUpdatedAsync(child.Id, cancellationToken); await BroadcastTaskUpdatedAsync(child.Id, cancellationToken);
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken); await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
return new CreatedChildDto(child.Id, child.Status.ToString()); return new CreatedChildDto(child.Id, child.Status.ToString());

View File

@@ -4,6 +4,7 @@ using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models; using ClaudeDo.Data.Models;
using ClaudeDo.Worker.Hub; using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Lifecycle; using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.State;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus; using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
@@ -16,6 +17,7 @@ public sealed class PlanningMergeOrchestrator
private readonly PlanningAggregator _aggregator; private readonly PlanningAggregator _aggregator;
private readonly HubBroadcaster _broadcaster; private readonly HubBroadcaster _broadcaster;
private readonly GitService _git; private readonly GitService _git;
private readonly ITaskStateService _state;
private readonly ILogger<PlanningMergeOrchestrator> _logger; private readonly ILogger<PlanningMergeOrchestrator> _logger;
private sealed class State private sealed class State
@@ -34,6 +36,7 @@ public sealed class PlanningMergeOrchestrator
PlanningAggregator aggregator, PlanningAggregator aggregator,
HubBroadcaster broadcaster, HubBroadcaster broadcaster,
GitService git, GitService git,
ITaskStateService state,
ILogger<PlanningMergeOrchestrator> logger) ILogger<PlanningMergeOrchestrator> logger)
{ {
_dbFactory = dbFactory; _dbFactory = dbFactory;
@@ -41,6 +44,7 @@ public sealed class PlanningMergeOrchestrator
_aggregator = aggregator; _aggregator = aggregator;
_broadcaster = broadcaster; _broadcaster = broadcaster;
_git = git; _git = git;
_state = state;
_logger = logger; _logger = logger;
} }
@@ -81,7 +85,8 @@ public sealed class PlanningMergeOrchestrator
} }
if (await _git.IsMidMergeAsync(workingDir, ct)) if (await _git.IsMidMergeAsync(workingDir, ct))
throw new InvalidOperationException("repo is mid-merge"); throw new InvalidOperationException(
"repo is mid-merge; use AbortPlanningMerge to reset the repository, then Approve again");
if (await _git.HasChangesAsync(workingDir, ct)) if (await _git.HasChangesAsync(workingDir, ct))
throw new InvalidOperationException("working tree has uncommitted changes"); throw new InvalidOperationException("working tree has uncommitted changes");
@@ -106,7 +111,8 @@ public sealed class PlanningMergeOrchestrator
public async Task ContinueAsync(string planningTaskId, CancellationToken ct) public async Task ContinueAsync(string planningTaskId, CancellationToken ct)
{ {
if (!_states.TryGetValue(planningTaskId, out var state) || state.CurrentSubtaskId is null) if (!_states.TryGetValue(planningTaskId, out var state) || state.CurrentSubtaskId is null)
throw new InvalidOperationException("no in-progress merge to continue"); throw new InvalidOperationException(
"no in-progress merge to continue; if the worker was restarted during a conflict, use AbortPlanningMerge to reset the repository");
var current = state.CurrentSubtaskId; var current = state.CurrentSubtaskId;
var result = await _merge.ContinueMergeAsync(current, ct); var result = await _merge.ContinueMergeAsync(current, ct);
@@ -136,13 +142,40 @@ public sealed class PlanningMergeOrchestrator
public async Task AbortAsync(string planningTaskId, CancellationToken ct) public async Task AbortAsync(string planningTaskId, CancellationToken ct)
{ {
if (!_states.TryGetValue(planningTaskId, out var state) || state.CurrentSubtaskId is null) if (!_states.TryGetValue(planningTaskId, out var state) || state.CurrentSubtaskId is null)
throw new InvalidOperationException("no in-progress merge to abort"); {
// No in-memory state — worker may have been restarted while a conflict was paused.
// Check whether the list repo is still mid-merge and abort it directly.
await AbortStatelessAsync(planningTaskId, ct);
return;
}
await _merge.AbortMergeAsync(state.CurrentSubtaskId, ct); await _merge.AbortMergeAsync(state.CurrentSubtaskId, ct);
_states.TryRemove(planningTaskId, out _); _states.TryRemove(planningTaskId, out _);
await _broadcaster.PlanningMergeAborted(planningTaskId); await _broadcaster.PlanningMergeAborted(planningTaskId);
} }
private async Task AbortStatelessAsync(string planningTaskId, CancellationToken ct)
{
string? workingDir;
await using (var ctx = _dbFactory.CreateDbContext())
{
workingDir = await ctx.Tasks
.Where(t => t.Id == planningTaskId)
.Select(t => t.List.WorkingDir)
.FirstOrDefaultAsync(ct);
}
if (string.IsNullOrWhiteSpace(workingDir) || !await _git.IsMidMergeAsync(workingDir, ct))
throw new InvalidOperationException("no in-progress merge to abort");
await _git.MergeAbortAsync(workingDir, ct);
_logger.LogInformation(
"Stateless abort of mid-merge for planning task {ParentId} (post-restart recovery)",
planningTaskId);
await _broadcaster.PlanningMergeAborted(planningTaskId);
// Parent remains WaitingForReview — Approve will restart the unit merge from scratch.
}
private async Task DrainAsync(string planningTaskId, CancellationToken ct) private async Task DrainAsync(string planningTaskId, CancellationToken ct)
{ {
if (!_states.TryGetValue(planningTaskId, out var state)) return; if (!_states.TryGetValue(planningTaskId, out var state)) return;
@@ -181,8 +214,9 @@ public sealed class PlanningMergeOrchestrator
} }
state.CurrentSubtaskId = null; state.CurrentSubtaskId = null;
await FinalizeParentDoneAsync(planningTaskId, state.IsPlanning, ct); var finalized = await FinalizeParentDoneAsync(planningTaskId, state.IsPlanning, ct);
await _broadcaster.PlanningCompleted(planningTaskId); if (finalized)
await _broadcaster.PlanningCompleted(planningTaskId);
} }
finally finally
{ {
@@ -190,18 +224,30 @@ public sealed class PlanningMergeOrchestrator
} }
} }
private async Task FinalizeParentDoneAsync(string parentTaskId, bool isPlanning, CancellationToken ct) private async Task<bool> FinalizeParentDoneAsync(string parentTaskId, bool isPlanning, CancellationToken ct)
{ {
using var ctx = _dbFactory.CreateDbContext(); var result = await _state.ApproveReviewAsync(parentTaskId, ct);
var parent = await ctx.Tasks.SingleOrDefaultAsync(t => t.Id == parentTaskId, ct); if (!result.Ok)
if (parent is null) return; {
parent.Status = TaskStatus.Done; // ApproveReviewAsync requires WaitingForReview. For improvement parents whose own
parent.FinishedAt = DateTime.UtcNow; // worktree is in the merge queue, TaskMergeService.ApproveIfWaitingForReviewAsync
await ctx.SaveChangesAsync(ct); // already approved the parent during the drain — check for that expected path.
await using var ctx = _dbFactory.CreateDbContext();
var current = await ctx.Tasks
.Where(t => t.Id == parentTaskId)
.Select(t => (TaskStatus?)t.Status)
.FirstOrDefaultAsync(ct);
// Surface the Done transition to the UI. Without this the parent row stays if (current != TaskStatus.Done)
// visibly stuck in WaitingForReview even though the unit merge completed. {
await _broadcaster.TaskUpdated(parentTaskId); // Parent was cancelled or moved to an unexpected state during the merge drain.
// Do not overwrite — the external transition takes precedence.
_logger.LogWarning(
"Unit-merge drain completed but parent {ParentTaskId} could not be finalized (status: {Status}): {Reason}",
parentTaskId, current, result.Reason);
return false;
}
}
// Only planning builds an integration branch via the aggregator; skip cleanup otherwise. // Only planning builds an integration branch via the aggregator; skip cleanup otherwise.
if (isPlanning) if (isPlanning)
@@ -209,5 +255,7 @@ public sealed class PlanningMergeOrchestrator
try { await _aggregator.CleanupIntegrationBranchAsync(parentTaskId, ct); } try { await _aggregator.CleanupIntegrationBranchAsync(parentTaskId, ct); }
catch (Exception ex) { _logger.LogWarning(ex, "integration branch cleanup failed"); } catch (Exception ex) { _logger.LogWarning(ex, "integration branch cleanup failed"); }
} }
return true;
} }
} }

View File

@@ -11,6 +11,8 @@ using ClaudeDo.Worker.Planning;
using ClaudeDo.Worker.Queue; using ClaudeDo.Worker.Queue;
using ClaudeDo.Worker.Runner; using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.State; using ClaudeDo.Worker.State;
using ClaudeDo.Worker.Online;
using ClaudeDo.Worker.Online.Interfaces;
using ClaudeDo.Worker.Prime; using ClaudeDo.Worker.Prime;
using ClaudeDo.Worker.Refine; using ClaudeDo.Worker.Refine;
using ClaudeDo.Worker.Report; using ClaudeDo.Worker.Report;
@@ -149,6 +151,28 @@ builder.Services.AddMcpServer()
.WithTools<PlanningMcpService>() .WithTools<PlanningMcpService>()
.WithTools<TaskRunMcpService>(); .WithTools<TaskRunMcpService>();
// OnlineInboxConfig and OnlineTokenStore are always registered so hub methods work
// even when sync is disabled. The sync stack (api client, auth, hosted service) is
// only registered when enabled.
builder.Services.AddSingleton(cfg.OnlineInbox);
#pragma warning disable CA1416 // ClaudeDo.Worker is Windows-only; DPAPI is fine here.
builder.Services.AddSingleton<OnlineTokenStore>();
#pragma warning restore CA1416
if (cfg.OnlineInbox.Enabled)
{
OnlineInboxApiClient.ValidateBaseUrl(cfg.OnlineInbox.ApiBaseUrl);
builder.Services.AddHttpClient();
#pragma warning disable CA1416
builder.Services.AddSingleton<IOnlineAuthProvider, ZitadelAuthProvider>();
#pragma warning restore CA1416
builder.Services.AddHttpClient<IOnlineInboxApi, OnlineInboxApiClient>(client =>
{
client.BaseAddress = new Uri(cfg.OnlineInbox.ApiBaseUrl.TrimEnd('/') + "/");
});
builder.Services.AddHostedService<OnlineSyncService>();
}
// Loopback-only bind. Firewall is irrelevant for 127.0.0.1. // Loopback-only bind. Firewall is irrelevant for 127.0.0.1.
builder.WebHost.UseUrls($"http://127.0.0.1:{cfg.SignalRPort}"); builder.WebHost.UseUrls($"http://127.0.0.1:{cfg.SignalRPort}");

View File

@@ -191,14 +191,14 @@ public sealed class QueueService : BackgroundService
if (sessionId is not null) if (sessionId is not null)
{ {
await _runner.ContinueAsync(taskId, feedback, "queue", ct); await _runner.ContinueAsync(taskId, feedback, "queue", ct, alreadyClaimed: true);
} }
else else
{ {
task.Description = string.IsNullOrWhiteSpace(task.Description) task.Description = string.IsNullOrWhiteSpace(task.Description)
? $"Reviewer feedback: {feedback}" ? $"Reviewer feedback: {feedback}"
: $"{task.Description}\n\nReviewer feedback: {feedback}"; : $"{task.Description}\n\nReviewer feedback: {feedback}";
await _runner.RunAsync(task, "queue", ct); await _runner.RunAsync(task, "queue", ct, alreadyClaimed: true);
} }
// Clear the consumed feedback only once the run reached a successful // Clear the consumed feedback only once the run reached a successful
@@ -212,7 +212,7 @@ public sealed class QueueService : BackgroundService
return; return;
} }
await _runner.RunAsync(task, "queue", ct); await _runner.RunAsync(task, "queue", ct, alreadyClaimed: true);
} }
catch (Exception ex) catch (Exception ex)
{ {

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 " + "File an out-of-scope improvement as a child task of the current task. The child runs " +
"automatically after this task finishes and is surfaced for review alongside it. Use ONLY " + "automatically after this task finishes and is surfaced for review alongside it. Use ONLY " +
"for work that is genuinely outside this task's scope (a refactor, follow-up, or tech debt) " + "for work that is genuinely outside this task's scope (a refactor, follow-up, or tech debt) " +
"— never for work that belongs to the current task.")] "— never for work that belongs to the current task. Set model to the cheapest model that can " +
"do the follow-up well — 'haiku' for trivial/mechanical work, 'sonnet' for normal coding, " +
"'opus' only for complex work. Leave model null to inherit the list/global default.")]
public async Task<SuggestedImprovementDto> SuggestImprovement( public async Task<SuggestedImprovementDto> SuggestImprovement(
string title, string title,
string description, string description,
string? model,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var callerId = _ctx.Current.CallerTaskId; var callerId = _ctx.Current.CallerTaskId;
@@ -39,7 +42,7 @@ public sealed class TaskRunMcpService
"A child task cannot suggest further improvements (improvements are one layer deep)."); "A child task cannot suggest further improvements (improvements are one layer deep).");
var child = await _tasks.CreateChildAsync( var child = await _tasks.CreateChildAsync(
callerId, title, description, commitType: null, createdBy: callerId, cancellationToken); callerId, title, description, commitType: null, createdBy: callerId, model: model, ct: cancellationToken);
await _broadcaster.TaskUpdated(child.Id); await _broadcaster.TaskUpdated(child.Id);
await _broadcaster.TaskUpdated(callerId); await _broadcaster.TaskUpdated(callerId);
return new SuggestedImprovementDto(child.Id); return new SuggestedImprovementDto(child.Id);

View File

@@ -44,7 +44,7 @@ public sealed class TaskRunner
_tokens = tokens; _tokens = tokens;
} }
public async Task RunAsync(TaskEntity task, string slot, CancellationToken ct) public async Task RunAsync(TaskEntity task, string slot, CancellationToken ct, bool alreadyClaimed = false)
{ {
string? mcpToken = null; string? mcpToken = null;
string? mcpConfigPath = null; string? mcpConfigPath = null;
@@ -98,7 +98,17 @@ public sealed class TaskRunner
} }
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
await _state.StartRunningAsync(task.Id, now, ct); // The queue picker claims Queued→Running atomically (incl. StartedAt) before
// dispatching; only unclaimed dispatches (override slot) claim here.
if (!alreadyClaimed)
{
var startResult = await _state.StartRunningAsync(task.Id, now, ct);
if (!startResult.Ok)
{
_logger.LogWarning("Task {TaskId} skipped: StartRunningAsync rejected ({Reason})", task.Id, startResult.Reason);
return;
}
}
await _broadcaster.TaskStarted(slot, task.Id, now); await _broadcaster.TaskStarted(slot, task.Id, now);
// Build prompt: title + description + only the OPEN sub-tasks (resolved ones are dropped). // Build prompt: title + description + only the OPEN sub-tasks (resolved ones are dropped).
@@ -162,7 +172,7 @@ public sealed class TaskRunner
} }
} }
public async Task ContinueAsync(string taskId, string followUpPrompt, string slot, CancellationToken ct) public async Task ContinueAsync(string taskId, string followUpPrompt, string slot, CancellationToken ct, bool alreadyClaimed = false)
{ {
TaskEntity task; TaskEntity task;
TaskRunEntity lastRun; TaskRunEntity lastRun;
@@ -208,7 +218,16 @@ public sealed class TaskRunner
} }
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
await _state.StartRunningAsync(taskId, now, ct); // See RunAsync: queue dispatches arrive pre-claimed by the picker.
if (!alreadyClaimed)
{
var startResult = await _state.StartRunningAsync(taskId, now, ct);
if (!startResult.Ok)
{
_logger.LogWarning("Task {TaskId} skipped: StartRunningAsync rejected ({Reason})", taskId, startResult.Reason);
return;
}
}
await _broadcaster.TaskStarted(slot, taskId, now); await _broadcaster.TaskStarted(slot, taskId, now);
try try

View File

@@ -126,11 +126,14 @@ public sealed class TaskStateService : ITaskStateService
public async Task<TransitionResult> ApproveReviewAsync(string taskId, CancellationToken ct) public async Task<TransitionResult> ApproveReviewAsync(string taskId, CancellationToken ct)
{ {
var now = DateTime.UtcNow;
await using (var ctx = await _dbFactory.CreateDbContextAsync(ct)) await using (var ctx = await _dbFactory.CreateDbContextAsync(ct))
{ {
var affected = await ctx.Tasks var affected = await ctx.Tasks
.Where(t => t.Id == taskId && t.Status == TaskStatus.WaitingForReview) .Where(t => t.Id == taskId && t.Status == TaskStatus.WaitingForReview)
.ExecuteUpdateAsync(s => s.SetProperty(t => t.Status, TaskStatus.Done), ct); .ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, TaskStatus.Done)
.SetProperty(t => t.FinishedAt, now), ct);
if (affected == 0) if (affected == 0)
return new TransitionResult(false, "Task is not waiting for review; cannot approve."); return new TransitionResult(false, "Task is not waiting for review; cannot approve.");
@@ -195,6 +198,9 @@ public sealed class TaskStateService : ITaskStateService
{ {
await using (var ctx = await _dbFactory.CreateDbContextAsync(ct)) await using (var ctx = await _dbFactory.CreateDbContextAsync(ct))
{ {
// Queued is intentional: OverrideSlotService dispatches RunAsync before calling
// StartRunningAsync, so a preflight failure (list not found, worktree setup) can
// reach MarkFailed while the task is still Queued in the DB.
var affected = await ctx.Tasks var affected = await ctx.Tasks
.Where(t => t.Id == taskId && .Where(t => t.Id == taskId &&
(t.Status == TaskStatus.Running || t.Status == TaskStatus.Queued)) (t.Status == TaskStatus.Running || t.Status == TaskStatus.Queued))

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

View File

@@ -10,8 +10,8 @@
<Using Include="Xunit" /> <Using Include="Xunit" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia" Version="12.0.0" /> <PackageReference Include="Avalonia" Version="12.0.4" />
<PackageReference Include="Avalonia.Headless" Version="12.0.0" /> <PackageReference Include="Avalonia.Headless" Version="12.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.11" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" /> <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.3" /> <PackageReference Include="xunit" Version="2.9.3" />

Some files were not shown because too many files have changed in this diff Show More