Compare commits

...
50 Commits
Author SHA1 Message Date
mika kuns e10634bdc4 Merge remote-tracking branch 'origin/main'
Changelog / changelog (push) Successful in 2s
Release / release (push) Successful in 40s
2026-08-08 09:28:28 +02:00
mika kuns 549aee097b feat(ui): default to the Git tab for manual and interactive tasks
A hand-driven task produces no streamed agent output, so the Output tab
opened empty. Bind now selects git for manual/interactive rows and resets
to output for autonomous ones.
2026-08-07 14:01:26 +02:00
mika kuns af0f318bf0 docs(ui): record the shared diff plumbing and the modal chrome rules
DiffEditorSetup is the place for AvaloniaEdit host boilerplate now, so note why
the diff viewer and the merge editor share it but stay separate controls, and
that ModalShell owns the titlebar drag and the OffScreenMargin inset for every
modal.
2026-08-07 13:30:48 +02:00
mika kuns b06bbc213d feat(ui): let resizable modals snap to the screen edges
ModalShell moved its window by assigning Window.Position on every pointer-move,
which bypasses the OS move loop entirely - so Windows never saw a drag gesture
and no snap was ever recognised. Dragging the diff viewer to the top edge did
nothing. The titlebar now calls Window.BeginMoveDrag, which hands the drag to the
window manager and brings back maximise-on-top-edge, half-screen side snap, snap
layouts and the preview overlay. No opt-in flag is needed: the OS only snaps
resizable windows, so the non-resizable modals stay as they are.

The stale comment claiming BeginMoveDrag does not work under Avalonia 12 was
wrong - the method is there in 12.0.4. The VisualRoot cast was the actual problem,
and the window still has to come from TopLevel.GetTopLevel.

Also inset the shell by the window's OffScreenMargin, mirroring what MainWindow
already does for itself. The modals share MainWindow's extended-client-area flags,
so without it a maximised modal overhangs the screen by the invisible resize
border and gets its edges and close button clipped.
2026-08-07 13:30:47 +02:00
mika kuns 9437121f3a fix(diff): label split panes, unify the mode switch, sync split scrolling
Three things were wrong with the fresh side-by-side viewer:

- The panes scrolled independently. The sync hunted the editors' templated
  ScrollViewers at load time, but the viewer starts collapsed (no file selected),
  so nothing was ever measured, no template existed, and the hook silently
  no-opped for the lifetime of the window. Reading now goes through
  TextView.ScrollOffsetChanged, which exists from construction; writing goes
  through a lazily resolved ScrollViewer.Offset. TextEditor.ScrollToVerticalOffset
  is NOT usable here - it is a silent no-op in AvaloniaEdit 12.0.0 even with the
  editor templated (verified headlessly).
- Neither pane said what it showed. Added a BASE | WORKTREE header row inside
  DiffTextView, sharing the pane grid's columns so each label sits over its editor.
- The layout picker was two lookalike ToggleButtons. It is now a segmented switch
  (Border.segmented + Button.segment.active), with wrap demoted to an icon toggle
  since it is orthogonal to the layout mode.

The scroll sync, TextMate setup, grammar switching, brush fallback and segment
struct were duplicated between this control and the 3-pane conflict resolver,
which the design doc had used as a copy-paste template. They now live in
DiffEditorSetup; both surfaces migrated. They stay separate controls on purpose -
a read-only two-way renderer over aligned rows with filler lines is not a variant
of a three-way editor over a writable document.

The resolver thereby also picks up the fixed scroll sync and the TryFindResource
brush lookup (its TryGetResource never resolved anything). All four merge tokens
happen to equal their hardcoded fallbacks, so nothing changes on screen.
2026-08-07 13:30:34 +02:00
mika kuns 6dff72f27c Merge branch 'main' into worktree-diff-side-by-side
# Conflicts:
#	src/ClaudeDo.Ui/CLAUDE.md
2026-08-07 11:16:49 +02:00
mika kuns 15958b992d feat(models): add fable to the cost-ascending model list 2026-08-07 11:14:21 +02:00
mika kuns df9e85a28b Merge branch 'worktree-phase1-reaktivitaet' 2026-08-07 11:01:54 +02:00
mika kuns 7eeb8f5086 feat(usage): split throttle thresholds per bucket, add draggable gauge markers 2026-08-07 11:01:45 +02:00
mika kuns b0c23a2bf6 Merge branch 'main' into worktree-phase1-reaktivitaet 2026-08-07 10:51:48 +02:00
mika kuns 4e44251d54 docs(explore-notes): pin the review-merge verified-against commit 2026-08-07 10:25:55 +02:00
mika kuns 2f3f9387c4 fix(diff): install background renderers on attach so themed brushes resolve 2026-08-07 10:25:30 +02:00
mika kuns 51371cf678 refactor(diff): drop rendering helpers orphaned by DiffLinesView 2026-08-07 10:23:51 +02:00
mika kuns 54b179fb0b fix(diff): resolve themed brushes and stop recomputing alignment on layout toggle 2026-08-07 10:23:45 +02:00
mika kuns c6d1fff8b1 fix(worker-tests): close TOCTOU race in slot-failure broadcast poll
QueueServiceSlotFailureTests's throwing-slot test broke its poll loop the
instant the DB read observed Status==Failed, but TaskStateService.FailAsync
commits the status flip before calling the broadcaster's TaskUpdated, so the
assertion could race ahead of the broadcast landing in hub.Proxy.Calls
(~1-in-5 failures in isolation). Wait for both signals before breaking.
2026-08-07 10:19:54 +02:00
mika kuns 231b063751 docs(handler): implementation plan for handler-run task links 2026-08-07 10:12:33 +02:00
mika kuns 1f8f3efc72 docs: record that the sqlite busy-timeout finding was wrong, drop stale RunCreated mention 2026-08-07 10:12:27 +02:00
mika kuns ac586797ce Revert "fix(data): give both processes a SQLite busy timeout"
This reverts commit f62dbb9239.
2026-08-07 10:11:52 +02:00
mika kuns fb29e8a871 docs(handler): design for linking a handler run to the tasks it processed 2026-08-07 09:59:52 +02:00
mika kuns f7fa8292e0 refactor(diff): render planning mode per file and retire DiffLinesView 2026-08-07 09:56:02 +02:00
mika kuns 66630d5ce2 chore(worker): drop the unsubscribed RunCreated broadcast 2026-08-07 09:55:21 +02:00
mika kuns 1e383e1c1f docs(plans): drop task 7, the handler-task broadcast already exists at the hub 2026-08-07 09:50:54 +02:00
mika kuns 3acb1cba8f fix(worker): broadcast TaskUpdated after online-inbox import 2026-08-07 09:46:43 +02:00
mika kuns cf80fe3cd6 feat(diff): add persisted side-by-side and wrap toggles to the diff viewer 2026-08-07 09:42:43 +02:00
mika kuns a7a3545e2b fix(worker): broadcast WorktreeUpdated when a worktree is created 2026-08-07 09:40:56 +02:00
mika kuns 14e0cffa25 feat(diff): sync vertical scrolling across the split panes 2026-08-07 09:37:38 +02:00
mika kuns 861ba12f7f feat(diff): tint diff rows and changed words via background renderers 2026-08-07 09:36:37 +02:00
mika kuns c1184adc92 fix(worker): fail the task when a queue slot runner throws
RunInSlotAsync only logged an unexpected exception, leaving a task stuck
Running in the DB forever with the UI never notified (the raw-SQL queue
claim that put it there never broadcasts). Cancellation is handled
separately and left alone, since the cancel path already wrote the
terminal status.
2026-08-07 09:35:26 +02:00
mika kuns e299421f76 feat(diff): draw old and new line numbers in a custom margin 2026-08-07 09:35:21 +02:00
mika kuns ffc60c4101 feat(diff): add AvaloniaEdit-based diff control with TextMate highlighting 2026-08-07 09:27:34 +02:00
mika kuns c792765ed3 refactor(mcp): rewrite external MCP tool descriptions for trigger clarity
Every tool description now leads with what the tool does AND when to reach for
it, since MCP clients rank tools by that text. Per-parameter prose moved onto
the parameters as [Description], exhaustive result-shape enumerations and
design/history rationale dropped, and the repeated boilerplate clauses
(lean-task-ref, batch cap, refused-while-Running) pulled into McpToolDocs,
which also documents the style for future tools.

Tool-level description text: 20494 -> 13605 chars (-34%); combined with the new
parameter descriptions 18517 (-10%).

Closes gaps that caused wrong calls rather than just verbose ones:
- list_task_attachments returns metadata only, no file content
- run_task_now shares continue_task's single override slot and throws when busy
- list_runs is ordered oldest-first and feeds get_run
- workingDir on create_list/update_list is an existing local git repo path,
  unvalidated until the first task run
- get_task_worktree's behind=0 also means the main ref was unreachable

Removes get_task_status_values: a whole tool entry for static reference text.
GetTask's description is now the canonical place for status meanings.
2026-08-07 09:25:45 +02:00
mika kuns e2d987e976 feat(diff): add filler, gap and word-diff brushes 2026-08-07 09:25:11 +02:00
mika kuns eeee9591aa test(ui): restore the language settings tests dropped in the previous commit 2026-08-07 09:24:43 +02:00
mika kuns 59827757f0 feat(diff): persist diff view mode and wrap preference in ui config 2026-08-07 09:22:16 +02:00
mika kuns ac099dd1a8 fix(ui): drop stale delta refreshes so the newest task state wins 2026-08-07 09:18:52 +02:00
mika kuns ebbee6005d feat(diff): highlight changed words inside paired diff lines 2026-08-07 09:17:26 +02:00
mika kuns cba7d01c31 fix(ui): retry the task delta refresh instead of swallowing the error 2026-08-07 09:16:21 +02:00
mika kuns 3cd0f0879e feat(diff): align parsed diff lines into side-by-side rows 2026-08-07 09:13:59 +02:00
mika kuns f62dbb9239 fix(data): give both processes a SQLite busy timeout 2026-08-07 09:13:21 +02:00
mika kuns 6e9a7cea87 docs(diff): add implementation plan for the side-by-side diff viewer 2026-08-07 09:07:42 +02:00
mika kuns 8acf9f8d7d docs(plans): step-by-step plan for phase 1 reactivity fixes 2026-08-07 09:02:06 +02:00
mika kuns 292d17173e docs(diff): design for side-by-side diff view with syntax and word highlighting 2026-08-07 08:49:44 +02:00
mika kuns 482d7ec332 docs(specs): design for UI reactivity and task-list performance 2026-08-07 08:49:33 +02:00
mika kuns 315bea7cf9 fix(prompts): correct five prompt claims that contradicted the tool allowlists
Audited all 12 prompt kinds against the code they drive. Every real defect sat on
the boundary between prompt text and the --allowedTools the launcher passes.

- Planning: "Use nothing else" after a six-tool list forbade the brainstorming
  Skill the same prompt demands two paragraphs earlier. WindowsTerminalLauncher
  allowlists mcp__claudedo__*,Read,Grep,Glob,WebFetch,WebSearch,Skill -- name them,
  and tell the planner to ground subtasks in the repo with Read/Grep/Glob.
- System: SuggestImprovement is only allowlisted when ParentTaskId is null and
  PlanningPhase is None, and TaskRunMcpService throws for any child, but this
  prompt reaches every run. Planning children were told to use a tool they lack.
- MergeHelperExecute: derived "effective max-turns" from task/list/preset by hand,
  which misses TaskRunner's MaxTurnsCeiling clamp. Call get_effective_run_config
  instead -- built for exactly this and reports the clamp.
- MergeHelperExecute: quoted the override-slot error as the raw lowercase throw
  rather than the string ExternalMcpService actually surfaces.
- Refine: listed Read/Grep/Glob unconditionally though RefinePrompt.BuildArgs only
  appends them when a repo is available.

Two findings deliberately left open, both needing a code decision rather than a
prompt edit: the System prompt's worktree claim is false for a list without a
WorkingDir (task runs in a plain sandbox dir), and 'fable' is missing from both the
prompt's cost ordering and ModelRegistry.ByCostAscending.
2026-08-06 22:47:41 +02:00
mika kuns c4e4e0976a fix(prompts): correct the list-handler wait cap and wait through WaitingForChildren
The execute prompt told the handler to wait with timeoutSeconds up to 170 -- a
leftover from the retired MCP_TOOL_TIMEOUT=200000ms era. The real server-side
clamp is TaskWaitMcpTools.MaxTimeoutSeconds = 900 and every launcher sets
930000ms, so the handler was making ~5x the wait_for_task_change calls it needed
and burning turns on re-waiting.

It also never passed treatWaitingForChildrenAsBusy, and only waited on ids that
were Queued or Running. A task with children reports "changed" the moment it
reaches WaitingForChildren, so such a task both dropped out of the wait set and
signalled completion early -- the handler could reach review/merge while
children were still running.
2026-08-06 21:24:03 +02:00
mika kuns 028ac57398 fix(prompts): make template token substitution order-independent
RenderTemplate replaced tokens one key at a time over a StringBuilder, so a
token appearing inside an already-substituted value got substituted again on a
later pass. The prompt briefs only escaped this because their callers happen to
pass "tasks" last -- reordering the dictionary or adding a fourth token would
have started rewriting user-authored task descriptions, which after the enhance
phase carry file paths and config snippets.

Single-pass regex over the template instead; unknown tokens still pass through.
2026-08-06 21:17:11 +02:00
mika kuns a7ff1b3a2f refactor(prompts): split the list-handler prompt per session phase
Both merge-helper ConPTY sessions loaded PromptKind.MergeHelper, so the
post-handoff session received the phase 0-2 dedupe/enhance instructions and was
told to ignore them by its brief alone.

Split into MergeHelperTriage (phases 0-2 + handoff) and MergeHelperExecute
(phases 3-5), so each session carries only its own phases. Consolidated the
generic ask-the-user rule to one place per prompt, scoped Phase 5's summary to
what the execute session actually knows, and moved the dedupe/enhance bilanz to
the triage handoff. Regression guards assert neither prompt carries the other's
phase headings and that the shared-checkout git rule stays in execute.
2026-08-06 21:00:41 +02:00
mika kuns d5730a8405 docs(explore-notes): document the ConPTY trailing-separator quoting trap 2026-08-06 15:55:16 +02:00
mika kuns 17e6cd6118 fix(app): stop a dispatcher exception from killing the whole app
Ctrl+C in a Mission Control terminal tile is bound by the terminal library to
CopyAsync, which throws IndexOutOfRangeException out of XTerm's selection buffer
on some selections. It runs from an async void key handler, so the exception
reached the dispatcher unhandled and terminated the process -- every open ConPTY
session with it. Handle it instead and surface the message in the footer error
strip. Iciclecreek.Avalonia.Terminal 2.0.3 is the newest release, so there is no
upstream fix to take.
2026-08-06 15:54:17 +02:00
mika kuns 4a7b00ed53 fix(worker): strip trailing separator from the list repo in ConPTY launch args
The ConPTY host flattens LaunchSpec.Args into one Windows command line and quotes
each token, so a list working dir stored as "C:\repo\" produced the token
"C:\repo\" -- whose trailing backslash escapes its own closing quote. Everything
after it collapsed into --add-dir's variadic list, including
--append-system-prompt-file and the positional kickoff, so "Let Claude handle it"
opened a session with no prompt at all and the CLI warned that brief.md is not a
directory. Only user-supplied working dirs can carry a trailing separator; the
session dirs the worker builds never do.
2026-08-06 15:53:46 +02:00
93 changed files with 9158 additions and 837 deletions
+17 -1
View File
@@ -46,6 +46,21 @@ except for a fresh task session with a brief, where `--add-dir <sessionDir>` mus
`--model` is deliberately **NOT** forced on an interactive session — the user can still switch
models in the TUI.
### ⚠️ Gotcha: no directory arg may end in a separator
`PtyTerminalSession` hands `Args` to `TerminalControl.Args`, which the library flattens into ONE
Windows command line with each token quoted. Windows argv rules read `\"` as an *escaped* quote,
so a token like `"C:\repo\"` never closes and every following argument is swallowed by the
preceding **variadic** flag. A list working dir stored as `C:\Dev\Repos\Bandel.Hub\` therefore
fed `--add-dir` the repo, `--append-system-prompt-file`, its value **and** the positional kickoff:
the CLI warned `brief.md is not a directory` and the session opened with no prompt at all
(2026-08-06). `BuildForMergeHelperAsync`/`BuildForMergeHelperHandoffAsync` now run the repo
through `TrimTrailingSeparator`; session dirs the worker builds never carry one.
Diagnosing this from code or a PowerShell repro is a dead end — PowerShell quotes correctly, so
every repro passes. Read the real command line instead:
`Get-CimInstance Win32_Process -Filter "Name = 'claude.exe'"`.
## Resuming a task session (`TaskEntity.InteractiveSessionId`)
`claude --session-id <uuid>` lets the caller pre-assign a conversation's session id instead of
@@ -218,7 +233,8 @@ Autonomous and interactive sessions do **not** share a system prompt. Per start
| Ad-hoc directory session | `InteractiveLaunchSpecService.BuildForDirectoryAsync` | none |
| Planning session start | `InteractiveLaunchSpecService.BuildPlanningStart``WindowsTerminalLauncher.BuildPlanningStartArgs` | `--append-system-prompt-file <path>` (`PromptKind.Planning`) |
| Planning session resume | `InteractiveLaunchSpecService.BuildPlanningResume``WindowsTerminalLauncher.BuildPlanningResumeArgs` | none — only `--permission-mode default --allowedTools <planning allowlist> --resume <id>` |
| List handler ("Let Claude handle it") | `InteractiveLaunchSpecService.BuildForMergeHelperAsync` | `--append-system-prompt-file <path>` (`PromptKind.MergeHelper`), always fresh — this path never resumes |
| List handler ("Let Claude handle it"), triage | `InteractiveLaunchSpecService.BuildForMergeHelperAsync` | `--append-system-prompt-file <path>` (`PromptKind.MergeHelperTriage` — phases 02 only), always fresh — this path never resumes |
| List handler, post-handoff | `InteractiveLaunchSpecService.BuildForMergeHelperHandoffAsync` | `--append-system-prompt-file <path>` (`PromptKind.MergeHelperExecute` — phases 35 only), fresh session dir, same handler task id |
So every interactive resume (task session and planning) drops the system prompt entirely — it's
not that they inherit the autonomous one, it's that **no** `claude` process on any resume path
+11 -3
View File
@@ -32,7 +32,14 @@ session internals, or app-settings writes. Auth via an optional `X-ClaudeDo-Key`
`ListTasksResult`/`BatchGetTaskResult`, where exactly one of the lean (`TaskRefDto`) and
full (`TaskDto`, incl. Description/Result) fields is populated per the flag — keeps a
list of verbosely-described tasks from blowing past the response size limit by default.
3. `ExternalMcpExceptionFilter.Wrap` is registered as a call-tool filter so
3. **Description style is documented in `McpToolDocs`** (same folder) and shared boilerplate
lives there as `const` strings. Rules: the first sentence says what the tool does *and* when
to reach for it (MCP clients rank tools by that text, so the trigger must not sit behind
return-shape prose); parameters are documented with `[Description]` **on the parameter**, not
in the tool description; result fields appear only where the caller must branch on them
before calling (`isEmpty`, `truncated`, `conflicts`, `available`); no design rationale or
"since this feature was introduced" history. Not test-enforced — review it in PRs.
4. `ExternalMcpExceptionFilter.Wrap` is registered as a call-tool filter so
`InvalidOperationException` / `ArgumentException` messages survive as `McpException` —
otherwise the SDK's catch-all replaces any non-`McpException` with a generic
*"An error occurred invoking 'X'."*
@@ -42,8 +49,9 @@ session internals, or app-settings writes. Auth via an optional `X-ClaudeDo-Key`
### `ExternalMcpService` — task CRUD, execution, git
Task: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `AddSubtask`, `UpdateTask`,
`UpdateTaskStatus`, `GetTaskStatusValues`, `ReviewTask`, `RunTaskNow`, `ContinueTask`,
`CancelTask`, `DeleteTask`.
`UpdateTaskStatus`, `ReviewTask`, `RunTaskNow`, `ContinueTask`, `CancelTask`, `DeleteTask`.
(`GetTaskStatusValues` was removed — a whole tool entry for static reference text. `GetTask`'s
description is now the canonical place for what each status means.)
Worktree/git: `GetTaskWorktree`, `GetTaskDiff`, `MergeTask`, `ContinueMerge`, `AbortMerge`,
`PreviewMerge`, `PreviewMergeSet`, `RevertMerge`, `ListWorktrees`, `CleanupTaskWorktree`.
+13 -7
View File
@@ -1,7 +1,8 @@
# Review, merge & conflict resolution
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
> Last verified against commit `0d1e3b9` (2026-08-06).
> Last verified against `2f3f938` (2026-08-07), which finished the diff-viewer rework:
> Planning mode renders per file and `DiffLinesView` is retired.
> Drift check: `git log --oneline 20bce9b..HEAD -- src/ClaudeDo.Worker/Lifecycle src/ClaudeDo.Worker/State src/ClaudeDo.Worker/Planning src/ClaudeDo.Ui/ViewModels/Conflicts src/ClaudeDo.Worker/External`
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
@@ -219,15 +220,20 @@ Review **Approve** on conflict, and the **Merge** button in the Diff window (a c
## Diff stack (UI)
`UnifiedDiffParser` (static) parses `git diff` output into `DiffFileViewModel`s, detecting
added/deleted/renamed/binary files and per-line numbers; `Flatten` injects file-header rows for
a combined single-pane view. `DiffModels.cs` holds the shared types (`DiffLineViewModel`,
`DiffFileViewModel`, `DiffLineKind`, `DiffFileStatus`, `SubtaskDiffRow`,
added/deleted/renamed/binary files and per-line numbers. `DiffModels.cs` holds the shared types
(`DiffLineViewModel`, `DiffFileViewModel`, `DiffLineKind`, `DiffFileStatus`, `SubtaskDiffRow`,
`DiffTreeNodeViewModel`, `DiffTree`).
`DiffViewerViewModel` is one unified read-only viewer with two modes:
- **Files** — dirty worktree / branch-vs-base / commit-range. Loads via `GitService`, folder
file-tree left + per-file diff pane right, Merge button for a live branch source.
- **Planning** — per-subtask diffs via `GetPlanningAggregateAsync`, subtask list left + flat
diff right, combined integration-branch toggle.
- **Planning** — per-subtask diffs via `GetPlanningAggregateAsync`, subtask list left + one
editor per file right (`PlanningFiles`), combined integration-branch toggle.
`DiffLinesView` renders per-file content with binary/empty placeholders.
Both modes render through `DiffAlignment` (pure — pairs diff lines into side-by-side rows and
computes word-diff spans) and `DiffTextView` (AvaloniaEdit + TextMate highlighting keyed off the
file extension, unified/split layout, optional line wrap, synced scrolling). Files mode hosts one
`DiffTextView` for the selected file; Planning mode hosts one per file in `PlanningFiles` so each
gets its own grammar (one editor can only carry one TextMate grammar). The split/wrap toggles
persist to `ui.config.json` via `AppSettings` and reach the per-file editors in Planning mode
too.
+50 -11
View File
@@ -1,7 +1,8 @@
# Usage monitoring, gate & throttle
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
> Last verified against commit `f6cb825` (2026-08-05).
> Last verified against commit `f6cb825` (2026-08-05), plus the uncommitted per-bucket-throttle /
> draggable-gauge change of 2026-08-06 (this note already describes that newer state).
> Drift check: `git log --oneline f6cb825..HEAD -- src/ClaudeDo.Worker/Usage src/ClaudeDo.Worker/Queue src/ClaudeDo.Ui/ViewModels/UsagePillViewModel.cs`
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
@@ -52,21 +53,26 @@ on block / Info on resume) exactly **once per change**, not every tick.
## The throttle (staged parallelism)
`UsageThrottle.EffectiveSlots(configuredSlots, fiveHourPct, sevenDayPct, softPct, hardPct,
gateFiveHourPct, gateSevenDayPct)` — pure static, no state.
`UsageThrottle.EffectiveSlots(configuredSlots, fiveHourPct, fiveHourThresholds, sevenDayPct,
sevenDayThresholds)` — pure static, no state. `UsageThresholds(SoftPct, HardPct, GatePct)` is the
per-bucket triple (same file).
Thresholds `usage_throttle_soft_pct` / `usage_throttle_hard_pct` (defaults 50/65).
Whichever of 5h/7d is **more utilized** decides the stage:
Thresholds are **per bucket** (`usage_throttle_five_hour_{soft,hard}_pct` /
`usage_throttle_seven_day_{soft,hard}_pct`, defaults 50/65 each) because the 5h and 7d windows fill
at very different rates. Each bucket is staged independently and the **strictest** bucket wins —
not "whichever is more utilized", so a bucket that is lower but tightly configured can be the one
that throttles:
| Utilization | Effective slots |
| Utilization (per bucket) | That bucket's slots |
|---|---|
| below soft | full configured `max_parallel_executions` |
| `>= softPct` | capped at 2 |
| `>= hardPct` | capped at 1 |
| `>=` either gate threshold | 0 — same hard block as `UsageGate` |
| `>= gatePct` | 0 — same hard block as `UsageGate` |
A threshold of `0` disables that stage. The `0` return is deliberately kept in sync with
`UsageGate`'s hard block because both read the same gate thresholds — change one, change both.
A threshold of `0` disables that stage for that bucket, and a bucket with no reading (null) never
throttles. The `0` return is deliberately kept in sync with `UsageGate`'s hard block because both
read the same gate thresholds — change one, change both.
Only **new** slot fills are affected; a run already occupying a slot when the stage tightens
runs to completion. Same fail-open policy: no snapshot means no throttling.
@@ -108,6 +114,12 @@ A missing/unreadable transcript leaves all four fields `null`; it never fails th
Reads `~/.claude/projects/**/*.jsonl`, aggregating by date / model / scope (ClaudeDo vs
Other), deduped by `requestId`, with a per-file length+mtime cache.
`ReadAsync` **skips any file whose mtime predates the window start minus one day** — it cannot hold
a record inside the range, and the full history is large (measured 2026-08-06: 501 files / 230 MB /
77k lines ≈ 1.7 s to parse cold; a 7-day range touches ~190 files / ~106 MB). The one-day slack
absorbs local-vs-UTC skew between mtime and record timestamps. `ReadSessionTotalsAsync` is
unaffected — it looks up a single `{sessionId}.jsonl`.
`<synthetic>`-model lines are skipped **everywhere** — they are not real API calls.
## UI surfaces
@@ -117,6 +129,25 @@ Other), deduped by `requestId`, with a per-file length+mtime cache.
`IWorkerClient.UsageUpdatedEvent`. Dot state priority is mutually exclusive:
**blocked > stale > warn > normal**. `IsThrottled` (effective slots below configured, and
not gate-blocked) adds a tooltip line naming effective/configured slots + decisive bucket.
The pill's click handler (`IslandsShellViewModel.OpenUsageMonitor`) **shows the window before
loading** (`BeginLoad`) — awaiting the load first made the pill feel like a dead click, because
the first `GetModelUsage` per worker process scans the whole transcript history.
- **Draggable stage markers** — each of the two real gauges carries three markers (soft/hard/gate).
`UsageGaugeBar` (`Views/Controls`) draws them against its own width and does the pointer work;
the math is a pure static, `UsageThresholdDrag` (in the modal VM's file), which keeps
soft ≤ hard ≤ gate and treats a neighbour of `0` as off. Release fires the row's
`CommitCommand` → read-modify-write via `GetAppSettings` + `UpdateAppSettings`, so only the
dragged bucket's three fields change. Plan-dependent `weekly_scoped` gauges are read-only.
- **Legend = numeric editor.** Under each adjustable bar sit three legend rows whose colour swatches
match the markers (soft `TextDimBrush`, hard `StatusReviewBrush`, gate `StatusErrorBrush`), each
with a `NumericUpDown`. `NumericUpDown` has no commit command, so the box's `Tag`
(`soft`/`hard`/`gate`) plus two code-behind handlers (`LostFocus`, Enter) call the row's
`CommitSoft`/`CommitHard`/`CommitGate` command. Those run the typed value through the **same**
`UsageThresholdDrag.Apply` clamp as a drag, so a box can't invert the order and only the edited
stage moves. ⚠️ The `KeepLastNumber` converter is mandatory on those bindings — see the
`NumericUpDown` null gotcha in `src/ClaudeDo.Ui/CLAUDE.md`.
Rows are updated **in place** on each snapshot (keyed by limit kind) so a poll landing mid-drag
doesn't replace the bound instance.
- **`UsageMonitorModalViewModel`** — opened from the pill. Renders one gauge **per row** in
`UsageSnapshotDto.Limits` — deliberately **dynamic**, because the `seven_day_opus` /
`seven_day_sonnet`-style buckets the raw API returns are plan-dependent and come back
@@ -138,5 +169,13 @@ Other), deduped by `requestId`, with a per-file length+mtime cache.
## Settings columns
`app_settings`: `usage_gate_five_hour_pct` / `usage_gate_seven_day_pct` (80/90),
`usage_throttle_soft_pct` / `usage_throttle_hard_pct` (50/65). All four clamped 0..100 by
`AppSettingsRepository.UpdateAsync`. Worker config: `usage_poll_interval_seconds`.
`usage_throttle_five_hour_{soft,hard}_pct` / `usage_throttle_seven_day_{soft,hard}_pct` (50/65 per
bucket). All six clamped 0..100 by `AppSettingsRepository.UpdateAsync`, which does **not** enforce
soft ≤ hard ≤ gate — the ordering is a UI-side drag constraint, and an out-of-order stored config
degrades instead of throwing. Worker config: `usage_poll_interval_active_seconds` /
`usage_poll_interval_idle_seconds`.
The gate percentages are editable in **two** places that both write the same `app_settings` row:
Settings → General (typed) and the usage-monitor gauges (dragged). The throttle stages are
gauge-only — `SettingsModalViewModel` therefore carries them load→save verbatim so saving Settings
can't reset a dragged value.
+1 -1
View File
@@ -124,7 +124,7 @@ read-only "## Reference files" section.
- `TaskMergeService` — conflict resolution for worktree merges.
**Hub/**
- `HubBroadcaster` — single SignalR broadcast point (TaskStarted/TaskUpdated/TaskMessage/RunCreated…).
- `HubBroadcaster` — single SignalR broadcast point (TaskStarted/TaskUpdated/TaskMessage/WorktreeUpdated…).
- `WorkerHub` — SignalR hub + client methods.
**Agents/**
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,939 @@
# Handler-Run Links Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** A "Let Claude handle it" run records which tasks it processed, shows them as a list on the handler task's detail pane, and wears a HANDLER badge instead of MANUAL.
**Architecture:** One new nullable column `TaskEntity.HandlerTaskId` (1:n, last run wins) stamped at handler-task creation from the selection the UI already passes down. The badge is a display-only computed property on `TaskRowViewModel`, driven by the existing `HandlerBaseCommit`. The panel reuses `ChildOutcomeRowViewModel` and the existing refresh path.
**Tech Stack:** .NET 8, EF Core (SQLite), Avalonia 12 + CommunityToolkit.Mvvm, xUnit.
**Spec:** `docs/superpowers/specs/2026-08-07-handler-run-links-design.md`
---
## File Structure
**Modified:**
- `src/ClaudeDo.Data/Models/TaskEntity.cs` — new `HandlerTaskId` property
- `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs` — column mapping + index
- `src/ClaudeDo.Data/Repositories/TaskRepository.cs``SetHandlerTaskIdAsync`
- `src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs` — stamp after creating the handler task
- `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs``HandlerBaseCommit`, `IsHandlerRun`, `HandlerBadge`, `ManualBadge` precedence
- `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml` — HANDLER badge border
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml``HandlerBadgeBrush` + `Border.badge.handler`
- `src/ClaudeDo.Localization/locales/en.json` + `de.json``tasks.badgeHandler`, `tasks.handlerTip`
- `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs``HandledTasks` collection, loader, clear, refresh
- `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml` — HANDLED TASKS panel
- `src/ClaudeDo.Data/CLAUDE.md`, `src/ClaudeDo.Ui/CLAUDE.md`, `docs/explore-notes/conpty-sessions.md` — docs
**Created:**
- `src/ClaudeDo.Data/Migrations/<timestamp>_AddHandlerTaskId.cs` (+ Designer, + snapshot update) — generated
- `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryHandlerLinkTests.cs`
- `tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowViewModelHandlerBadgeTests.cs`
- `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandHandledTasksTests.cs`
---
## Task 1: Data — `HandlerTaskId` column and migration
**Files:**
- Modify: `src/ClaudeDo.Data/Models/TaskEntity.cs:60-61`
- Modify: `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs:96-97` and `:127-131`
- Create: `src/ClaudeDo.Data/Migrations/<timestamp>_AddHandlerTaskId.cs` (generated)
- [ ] **Step 1: Add the property**
In `src/ClaudeDo.Data/Models/TaskEntity.cs`, directly after the existing `HandlerHeadCommit` line (`public string? HandlerHeadCommit { get; set; }`), add:
```csharp
// Id of the "list handler" run task that processed this task ("Let Claude handle it").
// 1:n and last-run-wins -- a second handler run over the same task overwrites it. Deliberately
// NOT ParentTaskId: that is the planning-child relation and drives the indented tree rendering.
// No FK: a deleted handler task must not cascade into the tasks it merely touched.
public string? HandlerTaskId { get; set; }
```
- [ ] **Step 2: Map the column and index it**
In `src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs`, after the line
`builder.Property(t => t.HandlerHeadCommit).HasColumnName("handler_head_commit");` add:
```csharp
builder.Property(t => t.HandlerTaskId).HasColumnName("handler_task_id");
```
At the end of `Configure`, after the line
`builder.HasIndex(t => t.BlockedByTaskId).HasDatabaseName("idx_tasks_blocked_by");` add:
```csharp
builder.HasIndex(t => t.HandlerTaskId).HasDatabaseName("idx_tasks_handler_task_id");
```
Do **not** add a `HasOne`/`HasForeignKey` relationship — the column is intentionally FK-less.
- [ ] **Step 3: Generate the migration**
Run from the repo root:
```bash
dotnet ef migrations add AddHandlerTaskId --project src/ClaudeDo.Data/ClaudeDo.Data.csproj --startup-project src/ClaudeDo.Worker/ClaudeDo.Worker.csproj
```
Expected: creates `src/ClaudeDo.Data/Migrations/<timestamp>_AddHandlerTaskId.cs` + `.Designer.cs` and updates `ClaudeDoDbContextModelSnapshot.cs`. The `Up` method must contain exactly one `AddColumn<string>(name: "handler_task_id", table: "tasks", nullable: true)` and one `CreateIndex(name: "idx_tasks_handler_task_id", table: "tasks", column: "handler_task_id")`. If it contains anything else, another agent's uncommitted model change leaked in — delete the migration, coordinate, retry.
If `dotnet ef` is unavailable, hand-author the migration + Designer mirroring
`src/ClaudeDo.Data/Migrations/20260806111454_AddInteractiveSessionId.cs`, and add
`Property<string>("HandlerTaskId").HasColumnType("TEXT").HasColumnName("handler_task_id");`
plus the index to the `TaskEntity` builder in `ClaudeDoDbContextModelSnapshot.cs`.
- [ ] **Step 4: Build**
Run: `dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj -c Release`
Expected: `Build succeeded`, 0 errors.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Data/Models/TaskEntity.cs src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs src/ClaudeDo.Data/Migrations
git commit -- src/ClaudeDo.Data/Models/TaskEntity.cs src/ClaudeDo.Data/Configuration/TaskEntityConfiguration.cs src/ClaudeDo.Data/Migrations -m "feat(data): add handler_task_id to link handled tasks to their handler run"
```
⚠️ Always commit with explicit paths (`git commit -- <paths>`), never a bare `git commit` — the
main checkout is shared with concurrent sessions.
---
## Task 2: Data — `SetHandlerTaskIdAsync` repository method
**Files:**
- Modify: `src/ClaudeDo.Data/Repositories/TaskRepository.cs` (after `SetHandlerHeadCommitAsync`, currently `:394-403`)
- Test: `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryHandlerLinkTests.cs` (create)
- [ ] **Step 1: Write the failing test**
Create `tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryHandlerLinkTests.cs`:
```csharp
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Tests.Infrastructure;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.Repositories;
/// Covers the handler-run link: SetHandlerTaskIdAsync stamps the tasks a "Let Claude handle it"
/// run processed, so the handler task's detail pane can list them after the run.
public sealed class TaskRepositoryHandlerLinkTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly TaskRepository _tasks;
private readonly ListRepository _lists;
public TaskRepositoryHandlerLinkTests()
{
_ctx = _db.CreateContext();
_tasks = new TaskRepository(_ctx);
_lists = new ListRepository(_ctx);
}
public void Dispose()
{
_ctx.Dispose();
_db.Dispose();
}
private async Task<string> CreateListAsync()
{
var listId = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity
{
Id = listId,
Name = "Test List",
CreatedAt = DateTime.UtcNow,
});
return listId;
}
private async Task<string> AddTaskAsync(string listId)
{
var id = Guid.NewGuid().ToString();
await _tasks.AddAsync(new TaskEntity
{
Id = id,
ListId = listId,
Title = "T",
Status = TaskStatus.Idle,
CreatedAt = DateTime.UtcNow,
});
return id;
}
[Fact]
public async Task SetHandlerTaskIdAsync_StampsAllGivenTasks()
{
var listId = await CreateListAsync();
var a = await AddTaskAsync(listId);
var b = await AddTaskAsync(listId);
var handlerId = await AddTaskAsync(listId);
var affected = await _tasks.SetHandlerTaskIdAsync(new[] { a, b }, handlerId);
Assert.Equal(2, affected);
Assert.Equal(handlerId, (await _tasks.GetByIdAsync(a))!.HandlerTaskId);
Assert.Equal(handlerId, (await _tasks.GetByIdAsync(b))!.HandlerTaskId);
Assert.Null((await _tasks.GetByIdAsync(handlerId))!.HandlerTaskId);
}
[Fact]
public async Task SetHandlerTaskIdAsync_IgnoresUnknownIds()
{
var listId = await CreateListAsync();
var a = await AddTaskAsync(listId);
var handlerId = await AddTaskAsync(listId);
var affected = await _tasks.SetHandlerTaskIdAsync(
new[] { a, "does-not-exist" }, handlerId);
Assert.Equal(1, affected);
Assert.Equal(handlerId, (await _tasks.GetByIdAsync(a))!.HandlerTaskId);
}
[Fact]
public async Task SetHandlerTaskIdAsync_SecondRunOverwrites()
{
var listId = await CreateListAsync();
var a = await AddTaskAsync(listId);
var firstHandler = await AddTaskAsync(listId);
var secondHandler = await AddTaskAsync(listId);
await _tasks.SetHandlerTaskIdAsync(new[] { a }, firstHandler);
await _tasks.SetHandlerTaskIdAsync(new[] { a }, secondHandler);
Assert.Equal(secondHandler, (await _tasks.GetByIdAsync(a))!.HandlerTaskId);
}
[Fact]
public async Task SetHandlerTaskIdAsync_EmptyList_IsNoOp()
{
var listId = await CreateListAsync();
var handlerId = await AddTaskAsync(listId);
var affected = await _tasks.SetHandlerTaskIdAsync(Array.Empty<string>(), handlerId);
Assert.Equal(0, affected);
}
}
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "FullyQualifiedName~TaskRepositoryHandlerLinkTests"`
Expected: compile error — `TaskRepository` does not contain a definition for `SetHandlerTaskIdAsync`.
- [ ] **Step 3: Implement the method**
In `src/ClaudeDo.Data/Repositories/TaskRepository.cs`, directly after `SetHandlerHeadCommitAsync`, add:
```csharp
// Links the tasks a "list handler" run processed back to the handler's own task, so the
// handler's detail pane can list them after the run. Stamped from the user's selection at
// creation time -- that way tasks the handler later cancels as duplicates stay visible.
// Unknown ids are silently skipped. Returns the number of rows actually stamped.
public async Task<int> SetHandlerTaskIdAsync(
IReadOnlyList<string> taskIds,
string handlerTaskId,
CancellationToken ct = default)
{
if (taskIds.Count == 0) return 0;
var ids = taskIds.Where(id => id != handlerTaskId).Distinct().ToList();
if (ids.Count == 0) return 0;
return await _context.Tasks
.Where(t => ids.Contains(t.Id))
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.HandlerTaskId, handlerTaskId), ct);
}
```
- [ ] **Step 4: Run the tests to verify they pass**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "FullyQualifiedName~TaskRepositoryHandlerLinkTests"`
Expected: `Passed! - Failed: 0, Passed: 4`.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Data/Repositories/TaskRepository.cs tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryHandlerLinkTests.cs
git commit -- src/ClaudeDo.Data/Repositories/TaskRepository.cs tests/ClaudeDo.Worker.Tests/Repositories/TaskRepositoryHandlerLinkTests.cs -m "feat(data): add SetHandlerTaskIdAsync to stamp handled tasks"
```
---
## Task 3: Worker — stamp the selection when the handler task is created
**Files:**
- Modify: `src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs:409-450`
- Test: `tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs` (append a `[Fact]` in the `── CreateMergeHelperTaskAsync ──` region, currently starting at `:747`)
Note: `CreateMergeHelperTaskAsync` already receives `IReadOnlyList<string> taskIds` — the UI →
`IWorkerClient``WorkerHub` chain needs **no** change.
- [ ] **Step 1: Write the failing test**
Append to `tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs` inside the same test class, after the existing `CreateMergeHelperTaskAsync_CreatesIdleManualTask_StampsHandlerBaseCommit` test:
```csharp
[Fact]
public async Task CreateMergeHelperTaskAsync_StampsHandlerTaskIdOnSelectedTasks()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var repo = CreateRepo();
var listId = await SeedListAsync(workingDir: repo.RepoDir, name: "Alpha");
var t1 = Guid.NewGuid().ToString();
var t2 = Guid.NewGuid().ToString();
await SeedTaskAsync(t1, listId, TaskStatus.WaitingForReview, title: "First task");
await SeedTaskAsync(t2, listId, TaskStatus.Idle, title: "Second task");
var svc = BuildService();
var handlerId = await svc.CreateMergeHelperTaskAsync(
new[] { t1, t2 }, listId, "List handler: Alpha", "Tasks handled by this run:", CancellationToken.None);
using var readCtx = _db.CreateContext();
var tasks = new TaskRepository(readCtx);
Assert.Equal(handlerId, (await tasks.GetByIdAsync(t1))!.HandlerTaskId);
Assert.Equal(handlerId, (await tasks.GetByIdAsync(t2))!.HandlerTaskId);
// The handler never links to itself.
Assert.Null((await tasks.GetByIdAsync(handlerId))!.HandlerTaskId);
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "FullyQualifiedName~CreateMergeHelperTaskAsync_StampsHandlerTaskIdOnSelectedTasks"`
Expected: FAIL — `Assert.Equal() Failure: Values differ … Actual: null`.
- [ ] **Step 3: Stamp the selection**
In `src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs`, in `CreateMergeHelperTaskAsync`, replace:
```csharp
await taskRepo.AddAsync(handlerTask, ct);
return handlerTask.Id;
```
with:
```csharp
await taskRepo.AddAsync(handlerTask, ct);
// Link the selection back to this run BEFORE the session starts: the handler cancels
// duplicates in phase 1, and those still belong in the "what was this run supposed to do"
// list. Stamping later (e.g. at handoff) would lose them.
await taskRepo.SetHandlerTaskIdAsync(taskIds, handlerTask.Id, ct);
return handlerTask.Id;
```
- [ ] **Step 4: Run the test to verify it passes**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "FullyQualifiedName~CreateMergeHelperTaskAsync"`
Expected: `Passed! - Failed: 0` (all five `CreateMergeHelperTaskAsync` tests).
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs
git commit -- src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs -m "feat(handler): link the selected tasks to the handler run task"
```
---
## Task 4: Ui — HANDLER badge instead of MANUAL
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs:40,51,234-240,308-329`
- Modify: `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml:141-144`
- Modify: `src/ClaudeDo.Ui/Design/IslandStyles.axaml:114-118` and `:987-990`
- Modify: `src/ClaudeDo.Localization/locales/en.json:163-164`, `src/ClaudeDo.Localization/locales/de.json:163-164`
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowViewModelHandlerBadgeTests.cs` (create)
- [ ] **Step 1: Write the failing test**
Create `tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowViewModelHandlerBadgeTests.cs`:
```csharp
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.ViewModels.Islands;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Ui.Tests.ViewModels;
/// A "list handler" host task is IsManual=true so automation skips it, but MANUAL reads wrong on
/// it -- the HANDLER badge must win and MANUAL must disappear.
public class TaskRowViewModelHandlerBadgeTests
{
[Fact]
public void HandlerTask_ShowsHandlerBadge_AndSuppressesManualBadge()
{
var row = new TaskRowViewModel { Id = "t1" };
row.IsManual = true;
row.HandlerBaseCommit = "base123";
Assert.True(row.IsHandlerRun);
Assert.NotNull(row.HandlerBadge);
Assert.Null(row.ManualBadge);
}
[Fact]
public void PlainManualTask_StillShowsManualBadge()
{
var row = new TaskRowViewModel { Id = "t2" };
row.IsManual = true;
Assert.False(row.IsHandlerRun);
Assert.Null(row.HandlerBadge);
Assert.NotNull(row.ManualBadge);
}
[Fact]
public void UpdateFromEntity_MirrorsHandlerBaseCommit()
{
var row = new TaskRowViewModel { Id = "t3" };
row.UpdateFromEntity(new TaskEntity
{
Id = "t3",
ListId = "l1",
Title = "List handler: Alpha",
Status = TaskStatus.Idle,
IsManual = true,
HandlerBaseCommit = "base123",
CreatedAt = DateTime.UtcNow,
});
Assert.Equal("base123", row.HandlerBaseCommit);
Assert.True(row.IsHandlerRun);
}
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "FullyQualifiedName~TaskRowViewModelHandlerBadgeTests"`
Expected: compile error — `TaskRowViewModel` has no `HandlerBaseCommit` / `IsHandlerRun` / `HandlerBadge`.
- [ ] **Step 3: Add the properties**
In `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs`, after the `_isManual` field
declaration (`[ObservableProperty] private bool _isManual;`), add:
```csharp
// Mirror of TaskEntity.HandlerBaseCommit -- non-null marks this row as a "list handler" run
// host task ("Let Claude handle it"), which wears HANDLER instead of MANUAL.
[ObservableProperty] private string? _handlerBaseCommit;
```
Replace the `ManualBadge` line (currently `public string? ManualBadge => IsManual ? Loc.T("tasks.badgeManual") : null;`) with:
```csharp
public bool IsHandlerRun => !string.IsNullOrEmpty(HandlerBaseCommit);
public string? HandlerBadge => IsHandlerRun ? Loc.T("tasks.badgeHandler") : null;
// HANDLER outranks MANUAL: a handler host task is IsManual only so automation skips it, and
// "MANUAL" would read as a hand-written reminder. The two badges never show together.
public bool ShowManualBadge => IsManual && !IsHandlerRun;
public string? ManualBadge => ShowManualBadge ? Loc.T("tasks.badgeManual") : null;
```
Add a change hook next to the existing `OnIsManualChanged` partial method:
```csharp
partial void OnHandlerBaseCommitChanged(string? value)
{
OnPropertyChanged(nameof(IsHandlerRun));
OnPropertyChanged(nameof(HandlerBadge));
OnPropertyChanged(nameof(ShowManualBadge));
OnPropertyChanged(nameof(ManualBadge));
}
```
Inside the existing `OnIsManualChanged`, next to the existing `OnPropertyChanged(nameof(ManualBadge));` line, add:
```csharp
OnPropertyChanged(nameof(ShowManualBadge));
```
In `UpdateFromEntity`, after the line `IsManual = t.IsManual;` add:
```csharp
HandlerBaseCommit = t.HandlerBaseCommit;
```
Also add `HandlerBadge` to `RefreshLocalized`, next to the existing `PlanningBadge` line:
```csharp
OnPropertyChanged(nameof(HandlerBadge));
OnPropertyChanged(nameof(ManualBadge));
```
- [ ] **Step 4: Add the locale keys**
In `src/ClaudeDo.Localization/locales/en.json`, after `"manualTip": ...` (line 164) add:
```json
"badgeHandler": "HANDLER",
"handlerTip": "Handler run — see the tasks it processed in the detail pane",
```
In `src/ClaudeDo.Localization/locales/de.json`, after `"manualTip": ...` (line 164) add:
```json
"badgeHandler": "HANDLER",
"handlerTip": "Handler-Run — die bearbeiteten Tasks stehen im Detailbereich",
```
- [ ] **Step 5: Add the badge style and brush**
In `src/ClaudeDo.Ui/Design/IslandStyles.axaml`, after the line
`<SolidColorBrush x:Key="ManualBadgeBrush" Color="{StaticResource TextFaintColor}"/>` add:
```xml
<SolidColorBrush x:Key="HandlerBadgeBrush" Color="{StaticResource PeatSoftColor}"/>
```
After the existing `Border.badge.manual` style block add:
```xml
<!-- handler → peat: a "Let Claude handle it" run host, not a hand-written reminder -->
<Style Selector="Border.badge.handler">
<Setter Property="Background" Value="{DynamicResource HandlerBadgeBrush}"/>
</Style>
```
- [ ] **Step 6: Render the badge**
In `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`, replace the manual badge block (lines 141-144):
```xml
<Border Classes="badge manual" IsVisible="{Binding IsManual}"
ToolTip.Tip="{loc:Tr tasks.manualTip}">
<TextBlock Text="{Binding ManualBadge}"/>
</Border>
```
with:
```xml
<Border Classes="badge manual" IsVisible="{Binding ShowManualBadge}"
ToolTip.Tip="{loc:Tr tasks.manualTip}">
<TextBlock Text="{Binding ManualBadge}"/>
</Border>
<Border Classes="badge handler" IsVisible="{Binding IsHandlerRun}"
ToolTip.Tip="{loc:Tr tasks.handlerTip}">
<TextBlock Text="{Binding HandlerBadge}"/>
</Border>
```
Only the `IsVisible` binding changed on the manual border (`IsManual``ShowManualBadge`); the
handler border is new. No converter is needed — `ShowManualBadge` is already a `bool`.
- [ ] **Step 7: Run tests to verify they pass**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "FullyQualifiedName~TaskRowViewModelHandlerBadgeTests"`
Expected: `Passed! - Failed: 0, Passed: 3`.
Run: `dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release`
Expected: `Passed! - Failed: 0` (en/de key parity).
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Expected: `Build succeeded` — this compiles the AXAML.
- [ ] **Step 8: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml src/ClaudeDo.Ui/Design/IslandStyles.axaml src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowViewModelHandlerBadgeTests.cs
git commit -- src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml src/ClaudeDo.Ui/Design/IslandStyles.axaml src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json tests/ClaudeDo.Ui.Tests/ViewModels/TaskRowViewModelHandlerBadgeTests.cs -m "feat(ui): show a HANDLER badge on list-handler run tasks"
```
---
## Task 5: Ui — "HANDLED TASKS" panel on the handler's detail pane
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs:248-255`, `:581-584`, `:685`, `:814-833`
- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml:414-434`
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandHandledTasksTests.cs` (create)
- [ ] **Step 1: Write the failing test**
Create `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandHandledTasksTests.cs`:
```csharp
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Islands;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Ui.Tests.ViewModels;
/// Covers the handler-run link: binding a "list handler" host task lists every task stamped with
/// its id, including ones the handler cancelled as duplicates.
public class DetailsIslandHandledTasksTests : IDisposable
{
private readonly string _dbPath;
public DetailsIslandHandledTasksTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_details_handled_test_{Guid.NewGuid():N}.db");
using var ctx = NewContext();
ctx.Database.EnsureCreated();
}
public void Dispose()
{
try { File.Delete(_dbPath); } catch { }
try { File.Delete(_dbPath + "-wal"); } catch { }
try { File.Delete(_dbPath + "-shm"); } catch { }
}
private ClaudeDoDbContext NewContext()
{
var opts = new DbContextOptionsBuilder<ClaudeDoDbContext>()
.UseSqlite($"Data Source={_dbPath}")
.Options;
return new ClaudeDoDbContext(opts);
}
private sealed class TestDbFactory : IDbContextFactory<ClaudeDoDbContext>
{
private readonly Func<ClaudeDoDbContext> _create;
public TestDbFactory(Func<ClaudeDoDbContext> create) => _create = create;
public ClaudeDoDbContext CreateDbContext() => _create();
}
private sealed class NullServiceProvider : IServiceProvider
{
public object? GetService(Type serviceType) => null;
}
private sealed class StubNotesApi : ClaudeDo.Ui.Services.Interfaces.INotesApi
{
public Task<List<DailyNoteDto>> ListAsync(DateOnly day) =>
Task.FromResult(new List<DailyNoteDto>());
public Task<DailyNoteDto?> AddAsync(DateOnly day, string text) =>
Task.FromResult<DailyNoteDto?>(null);
public Task UpdateAsync(string id, string text) => Task.CompletedTask;
public Task DeleteAsync(string id) => Task.CompletedTask;
}
private sealed class FakeWorkerClient : StubWorkerClient
{
public override bool IsConnected => true;
}
private DetailsIslandViewModel BuildVm()
{
var factory = new TestDbFactory(NewContext);
return new DetailsIslandViewModel(
factory, new FakeWorkerClient(), new NullServiceProvider(), new StubNotesApi(), new MergeCoordinator());
}
[Fact]
public async Task Bind_HandlerTask_ListsHandledTasksWithTheirStatus()
{
const string listId = "list-1";
const string handlerId = "handler-task-1";
await using (var ctx = NewContext())
{
ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = @"C:\repo", CreatedAt = DateTime.UtcNow });
ctx.Tasks.Add(new TaskEntity
{
Id = handlerId, ListId = listId, Title = "List handler: L",
Status = TaskStatus.WaitingForReview, IsManual = true,
HandlerBaseCommit = "base123", HandlerHeadCommit = "head456",
CreatedAt = DateTime.UtcNow,
});
ctx.Tasks.Add(new TaskEntity
{
Id = "done-1", ListId = listId, Title = "Merged task",
Status = TaskStatus.Done, HandlerTaskId = handlerId,
SortOrder = 0, CreatedAt = DateTime.UtcNow,
});
ctx.Tasks.Add(new TaskEntity
{
Id = "dupe-1", ListId = listId, Title = "Duplicate the handler cancelled",
Status = TaskStatus.Cancelled, HandlerTaskId = handlerId,
SortOrder = 1, CreatedAt = DateTime.UtcNow,
});
ctx.Tasks.Add(new TaskEntity
{
Id = "unrelated-1", ListId = listId, Title = "Not part of the run",
Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow,
});
await ctx.SaveChangesAsync();
}
var vm = BuildVm();
vm.Bind(new TaskRowViewModel { Id = handlerId, Status = TaskStatus.WaitingForReview });
var deadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < deadline && vm.HandledTasks.Count == 0)
await Task.Delay(20);
Assert.Equal(2, vm.HandledTasks.Count);
Assert.True(vm.HasHandledTasks);
Assert.Equal("Merged task", vm.HandledTasks[0].Title);
Assert.Equal(TaskStatus.Done, vm.HandledTasks[0].Status);
Assert.Equal(TaskStatus.Cancelled, vm.HandledTasks[1].Status);
Assert.DoesNotContain(vm.HandledTasks, r => r.Id == "unrelated-1");
}
[Fact]
public async Task Bind_PlainTask_HasNoHandledTasks()
{
const string listId = "list-1";
const string taskId = "plain-1";
await using (var ctx = NewContext())
{
ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
ctx.Tasks.Add(new TaskEntity
{
Id = taskId, ListId = listId, Title = "Plain",
Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow,
});
await ctx.SaveChangesAsync();
}
var vm = BuildVm();
vm.Bind(new TaskRowViewModel { Id = taskId, Status = TaskStatus.Idle });
await Task.Delay(300);
Assert.Empty(vm.HandledTasks);
Assert.False(vm.HasHandledTasks);
}
}
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "FullyQualifiedName~DetailsIslandHandledTasksTests"`
Expected: compile error — `DetailsIslandViewModel` has no `HandledTasks` / `HasHandledTasks`.
- [ ] **Step 3: Add the collection**
In `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`, after the line
`public ObservableCollection<ChildOutcomeRowViewModel> ChildOutcomes { get; } = new();` add:
```csharp
// Tasks a "list handler" run processed ("Let Claude handle it"), linked via
// TaskEntity.HandlerTaskId. Separate from ChildOutcomes on purpose: that collection is the
// planning/improvement parent's children and feeds the merge card's combined diff, which a
// handler run must not touch (it commits straight to the list's working dir).
public ObservableCollection<ChildOutcomeRowViewModel> HandledTasks { get; } = new();
```
After the line `public bool HasChildOutcomes => ChildOutcomes.Count > 0;` add:
```csharp
public bool HasHandledTasks => HandledTasks.Count > 0;
```
- [ ] **Step 4: Clear it on rebind**
In the same file, in the rebind reset block, after the line `ChildOutcomes.Clear();` add:
```csharp
HandledTasks.Clear();
```
and after `OnPropertyChanged(nameof(HasChildOutcomes));` in that same block add:
```csharp
OnPropertyChanged(nameof(HasHandledTasks));
```
- [ ] **Step 5: Load it on bind**
In the same file, directly after the line `await LoadChildOutcomesAsync(row.Id, ct);` add:
```csharp
await LoadHandledTasksAsync(row.Id, ct);
```
Then add the loader immediately after the closing brace of `LoadChildOutcomesAsync`:
```csharp
// Tasks stamped with this handler run's id. Ordered like the task list itself so the panel
// reads in the same order the user picked them.
private async System.Threading.Tasks.Task LoadHandledTasksAsync(string handlerTaskId, CancellationToken ct)
{
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var handled = await ctx.Tasks
.AsNoTracking()
.Include(t => t.Worktree)
.Where(t => t.HandlerTaskId == handlerTaskId)
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
.ToListAsync(ct);
ct.ThrowIfCancellationRequested();
if (handled.Count == 0) return;
HandledTasks.Clear();
foreach (var h in handled)
HandledTasks.Add(new ChildOutcomeRowViewModel
{
Id = h.Id,
Title = h.Title,
Status = h.Status,
RoadblockCount = h.RoadblockCount,
WorktreeState = h.Worktree?.State ?? ClaudeDo.Data.Models.WorktreeState.Active,
});
OnPropertyChanged(nameof(HasHandledTasks));
}
catch (OperationCanceledException) { }
catch { /* best-effort */ }
}
```
- [ ] **Step 6: Keep the rows live**
In the same file, in `RefreshChildOutcomeAsync`, replace:
```csharp
var row = ChildOutcomes.FirstOrDefault(c => c.Id == childTaskId);
if (row is null) return;
```
with:
```csharp
// The same refresh serves both lists: a planning parent's children and a handler run's
// handled tasks. Only one of them can hold a given id.
var row = ChildOutcomes.FirstOrDefault(c => c.Id == childTaskId)
?? HandledTasks.FirstOrDefault(c => c.Id == childTaskId);
if (row is null) return;
```
- [ ] **Step 7: Render the panel**
In `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml`, directly after the closing
`</StackPanel>` of the existing `<!-- Child outcomes -->` block, add:
```xml
<!-- Handled tasks (list handler run) -->
<StackPanel Spacing="6" IsVisible="{Binding HasHandledTasks}">
<TextBlock Classes="section-label" Text="HANDLED TASKS" />
<ItemsControl ItemsSource="{Binding HandledTasks}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ChildOutcomeRowViewModel">
<Grid ColumnDefinitions="*,Auto,Auto" Margin="0,2">
<TextBlock Grid.Column="0" Text="{Binding Title}"
TextTrimming="CharacterEllipsis"
VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Text="{Binding RoadblockText}"
IsVisible="{Binding HasRoadblock}"
Foreground="#E0A030"
Margin="8,0" VerticalAlignment="Center" />
<TextBlock Grid.Column="2" Text="{Binding StatusLabel}"
Opacity="0.75" VerticalAlignment="Center" />
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
```
- [ ] **Step 8: Run the tests to verify they pass**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "FullyQualifiedName~DetailsIslandHandledTasksTests"`
Expected: `Passed! - Failed: 0, Passed: 2`.
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Expected: `Build succeeded`.
- [ ] **Step 9: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandHandledTasksTests.cs
git commit -- src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandHandledTasksTests.cs -m "feat(ui): list the tasks a handler run processed on its detail pane"
```
---
## Task 6: Full verification and docs
**Files:**
- Modify: `src/ClaudeDo.Data/CLAUDE.md` (TaskEntity field list)
- Modify: `src/ClaudeDo.Ui/CLAUDE.md` (TaskRowViewModel + DetailsIslandViewModel bullets)
- Modify: `docs/explore-notes/conpty-sessions.md` (list handler → host task section)
- [ ] **Step 1: Run every affected test project**
```bash
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
```
Expected: `Failed: 0` in all four. If a hand-rolled fake in a test project fails to compile,
it is one of the known `IWorkerClient`/ViewModel-ctor fakes — update it; do not skip the test.
- [ ] **Step 2: Update `src/ClaudeDo.Data/CLAUDE.md`**
In the `TaskEntity` bullet, append `HandlerTaskId` to the field enumeration (after
`HandlerBaseCommit / HandlerHeadCommit`), and add a sub-bullet under the existing
`HandlerBaseCommit`/`HandlerHeadCommit` sub-bullet:
```markdown
- `HandlerTaskId` = back-link from a task to the **list handler run** that processed it (1:n, last run wins, no FK). Stamped from the user's selection when the handler task is created, so tasks the handler later cancels as duplicates stay listed. Deliberately not `ParentTaskId` — that is the planning-child relation and drives the indented tree.
```
- [ ] **Step 3: Update `src/ClaudeDo.Ui/CLAUDE.md`**
In the `DetailsIslandViewModel` bullet, after the `ChildOutcomes` mention, add
`, plus `HandledTasks` (tasks a list-handler run processed, via `HandlerTaskId`)`.
In the `TaskRowViewModel` sentence, after the `IsManual` clause, add
`, `IsHandlerRun` (→ HANDLER badge, which outranks MANUAL)`.
- [ ] **Step 4: Update `docs/explore-notes/conpty-sessions.md`**
In the "The host task and its commit range" section, add after the existing description:
```markdown
`CreateMergeHelperTaskAsync` also stamps `TaskEntity.HandlerTaskId` on every selected task
(`TaskRepository.SetHandlerTaskIdAsync`) before the session starts, so the handler task's detail
pane can list what the run was meant to process — including tasks phase 1 cancels as duplicates.
The handler never links to itself.
```
Bump that note's "verified against" commit line to the current HEAD.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Data/CLAUDE.md src/ClaudeDo.Ui/CLAUDE.md docs/explore-notes/conpty-sessions.md
git commit -- src/ClaudeDo.Data/CLAUDE.md src/ClaudeDo.Ui/CLAUDE.md docs/explore-notes/conpty-sessions.md -m "docs(handler): document the handler-run task link"
```
- [ ] **Step 6: Report the visual-verification gap**
The build and tests cannot confirm any of this renders correctly. Explicitly hand these to Mika:
1. HANDLER badge colour and legibility on a handler task row (light **and** dark theme), and that MANUAL is gone from that row while still present on a normal manual reminder.
2. The HANDLED TASKS panel on the handler task's Session tab: position relative to OUTCOMES, spacing, and behaviour with ~20 handled tasks (scroll).
3. That a real "Let Claude handle it" run over a multi-task selection produces a populated panel after the run, including a phase-1-cancelled duplicate.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,225 @@
# Diff Viewer: Side-by-Side, Syntax Highlighting, Word Diff
Date: 2026-08-07
Status: approved (design), not implemented
## Problem
The diff viewer renders every change as a flat unified stream. Reading what actually changed
inside a modified line means mentally aligning a `` row with a `+` row several rows below it.
The user reads diffs faster side by side.
Three gaps, all in the same surface:
1. No side-by-side mode.
2. No syntax highlighting — the merge editor (`ConflictResolverView`) already has it via
TextMate, the diff viewer does not.
3. No intra-line (word) highlighting, so a one-character change looks like a whole-line rewrite.
## Current state
| Concern | Where |
|---|---|
| Parsing | `src/ClaudeDo.Ui/ViewModels/Modals/UnifiedDiffParser.cs``DiffFileViewModel.Lines` |
| Models | `src/ClaudeDo.Ui/ViewModels/Modals/DiffModels.cs` (`DiffLineViewModel{Kind,OldNo,NewNo,Text}`, `DiffLineKind{Add,Del,Ctx,File}`) |
| Rendering | `src/ClaudeDo.Ui/Views/Controls/DiffLinesView.axaml` — non-virtualized `ItemsControl`, one `Border`+`Grid`+4 `TextBlock`s per line, `TextWrapping="NoWrap"` |
| Host | `src/ClaudeDo.Ui/Views/Modals/DiffViewerView.axaml` — Files mode (line 154, `SelectedFile.Lines`) and Planning mode (line 162, flattened `DiffLines` across all files) |
| Highlighting reference | `src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml.cs:80-83``RegistryOptions(ThemeName.DarkPlus)` + `InstallTextMate` + `SetGrammar` by file extension |
`Avalonia.AvaloniaEdit`, `AvaloniaEdit.TextMate` and `TextMateSharp.Grammars` are already
referenced in `ClaudeDo.Ui.csproj`.
## Decision
Replace `DiffLinesView` with an AvaloniaEdit-based control. TextMate highlighting is bound to
the `TextEditor` control; it cannot be lifted into `TextBlock` inlines without reimplementing
the scope→brush layer that `AvaloniaEdit.TextMate` already provides. Building split/wrap/word
diff on the `TextBlock` model first and swapping the renderer later would be throwaway work.
Side effect worth having: AvaloniaEdit virtualizes, which removes the current non-virtualized
`ItemsControl` as a scaling limit on large diffs.
Rejected: keeping the `ItemsControl` and hand-rolling highlighting from `TextMateSharp`
tokenization — same output, materially more code, and a second highlighting path to maintain
alongside the merge editor's.
## Layout semantics
Left pane is the **old** state, right pane is the **new** state — each side carries the complete
version of the hunk, not "removals here, additions there".
```
LEFT (old) RIGHT (new)
12 public void Save() 12 public void Save()
13 var x = 1; 13 var x = 2; ← word diff on `1` / `2`
14 Log("old"); · (filler)
· (filler) 14 Log("new");
15 } 15 }
```
## Components
### 1. `DiffAlignment` (new, pure)
`src/ClaudeDo.Ui/ViewModels/Modals/DiffAlignment.cs`
Turns `IReadOnlyList<DiffLineViewModel>` into a render-ready `AlignedDiff`. No Avalonia types,
fully unit-testable.
```csharp
public enum AlignedSide { Ctx, Del, Add, Filler, Gap }
public readonly record struct TextSpan(int Start, int Length);
public sealed record SplitRow(
AlignedSide LeftKind, int? OldNo, string LeftText, IReadOnlyList<TextSpan> LeftSpans,
AlignedSide RightKind, int? NewNo, string RightText, IReadOnlyList<TextSpan> RightSpans);
public sealed record UnifiedRow(
AlignedSide Kind, int? OldNo, int? NewNo, string Text, IReadOnlyList<TextSpan> Spans);
public sealed record AlignedDiff(
IReadOnlyList<SplitRow> SplitRows, string LeftText, string RightText,
IReadOnlyList<UnifiedRow> UnifiedRows, string UnifiedText);
```
Row index `i` maps to document line `i + 1` in the corresponding text. That mapping is the
contract the margin and both renderers depend on.
**Pairing.** Walk the lines. A `Ctx` run emits rows with the same text on both sides. A change
block (a `Del` run followed by an `Add` run) pairs index-wise up to `min(delCount, addCount)`;
the overhang gets `Filler` rows on the opposite side.
**Gaps.** The parser drops `@@` headers, so a skipped region shows up as a jump in `OldNo`/`NewNo`
between consecutive lines. `DiffAlignment` detects that jump and inserts a `Gap` row on both
sides. The parser is not touched.
**Word diff.** Only for `(Del, Add)` rows that are paired 1:1. Tokenize each side into runs of
word characters / whitespace / single punctuation, run an LCS over the tokens, and emit the
changed token runs as character spans per side.
Two guards, both `const` and both covered by tests:
- Skip when either side exceeds `MaxWordDiffChars = 2000` — LCS cost, and such lines are
unreadable as word diffs anyway.
- Skip when token similarity is below `MinWordDiffSimilarity = 0.5` (common tokens / max token
count). Below that the two lines are unrelated rewrites and per-word tinting is noise.
### 2. `DiffTextView` (new control, replaces `DiffLinesView`)
`src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml` + `.axaml.cs`
Styled properties:
| Property | Type | Meaning |
|---|---|---|
| `File` | `DiffFileViewModel?` | Source; the control aligns it and caches the `AlignedDiff` per file instance |
| `Mode` | `DiffViewMode` (`Unified`\|`Split`) | Layout |
| `WrapLines` | `bool` | Bound to each editor's `WordWrap` |
Two `TextEditor`s in a two-column grid, both `IsReadOnly=true`, `ShowLineNumbers=false`.
`Unified` mode collapses the right editor and spans the left one across both columns, feeding
it `UnifiedText`. `Split` mode shows both, fed `LeftText` / `RightText`.
Per editor:
- **TextMate**: one shared `RegistryOptions(ThemeName.DarkPlus)`; grammar resolved from
`File.Path`'s extension via `GetLanguageByExtension``GetScopeByLanguageId``SetGrammar`,
exactly as `ConflictResolverView.ApplyGrammar` does. No extension match → no grammar, plain text.
- **`DiffLineNumberMargin : AbstractMargin`** — draws line numbers from the row list. Split: old
numbers left, new numbers right. Unified: two number columns in one margin. `Filler` and `Gap`
rows draw nothing.
- **`DiffLineBackgroundRenderer : IBackgroundRenderer`** — full-width tint per visual line by
row kind: add / del / filler / gap / ctx.
- **`WordDiffRenderer : IBackgroundRenderer`** — stronger tint over the changed spans, via
`BackgroundGeometryBuilder` at `lineStartOffset + span.Start`.
Both renderers resolve rows through a single `Func<int, RowInfo?>` keyed by document line.
**Highlighting on fragments.** Only hunks are in the document, not whole files, so TextMate's
line-by-line state can be wrong at a fragment boundary (a line inside a block comment may be
highlighted as code). Accepted — the same is true of every fragment-based diff viewer.
**Colors.** Line tints stay the existing low-alpha `RunningTintBrush` / `ErrorTintBrush` so
syntax foregrounds remain legible. The per-line foreground recolor from `DiffLinesView`
(green/red text) is dropped — syntax colors take over. `Filler` gets a new dim token brush,
`Gap` renders as a dim `⋯` separator row.
**Scroll sync** (split only), modeled on `ConflictResolverView.HookScrollSync` — find each
editor's descendant `ScrollViewer`, guard re-entry with a `_syncing` flag:
- `WrapLines = false`: sync `Offset.Y` directly. Line heights match, so alignment is exact.
- `WrapLines = true`: line heights diverge. Sync on the first visible document line instead
(`ScrollToLine`), which keeps the top of the viewport aligned and lets rows drift downward.
### 3. Host changes
`DiffViewerView.axaml`:
- Header gains a segmented Unified/Split toggle and a wrap toggle.
- Files mode: `DiffLinesView Lines="{Binding SelectedFile.Lines}"``DiffTextView File="{Binding SelectedFile}"`.
- Planning mode: the flattened single-stream `DiffLines` view is replaced by an `ItemsControl`
over the subtask's parsed files, each item a file header plus its own `DiffTextView`. One
editor can only carry one grammar, so per-file editors are required for highlighting to work
at all here. `DiffViewerViewModel` exposes the parsed per-file list for the selected subtask;
`DiffLines` and `UnifiedDiffParser.Flatten` lose their last consumer and are removed.
`DiffLinesView.axaml` + `.axaml.cs` are deleted once both usages are migrated.
### 4. Persistence
`src/ClaudeDo.Ui/AppSettings.cs` (`~/.todo-app/ui.config.json`) already holds UI-only
preferences (`Language`, `AccentPreset`) with plain `Load()`/`Save()`. Two properties are added
there:
```csharp
public string DiffViewMode { get; set; } = "unified"; // "unified" | "split"
public bool DiffWrapLines { get; set; }
```
`DiffViewerViewModel` takes the injected `AppSettings`, seeds its toggles on open and calls
`Save()` when either changes. No database column, no EF migration, no hub method — a view
preference does not belong in `AppSettingsEntity`.
### 5. Localization
New keys in both `locales/en.json` and `locales/de.json` (Localization.Tests enforces parity):
`diff.view.unified`, `diff.view.split`, `diff.view.wrap`.
## Testing
`tests/ClaudeDo.Ui.Tests``DiffAlignment` is pure and carries the logic worth testing:
- Context-only diff → identical rows on both sides, no fillers.
- Equal-size change block → 1:1 pairing, no fillers.
- Unequal change block (3 del / 5 add) → 3 paired rows + 2 right-side rows with left fillers.
- Add-only and delete-only blocks → fillers on the opposite side throughout.
- Non-contiguous line numbers → exactly one `Gap` row inserted.
- Word diff: single-token change yields one span per side at the right offsets.
- Word diff skipped above `MaxWordDiffChars` and below `MinWordDiffSimilarity`.
- Row index ↔ document line mapping holds for both `SplitRows` and `UnifiedRows`.
- Binary file and empty-content file → empty `AlignedDiff`, no crash.
`AppSettings` round-trip: persisted mode and wrap survive `Save()`/`Load()`.
Rendering (margin, both renderers, scroll sync, TextMate colors) is not unit-testable here and
is an explicit manual visual pass — see Open items.
## Known limitations
1. **Wrap + split drift.** With wrap on, the two panes align at the top of the viewport but rows
drift apart further down. Per-line vertical alignment as VS Code does it is out of scope.
2. **Fragment highlighting.** See above — highlighting state can be wrong at hunk boundaries.
3. **Editors per file in Planning mode.** A subtask touching many files instantiates one editor
per file. Same cost as viewing those files individually in Files mode; not capped. If it
proves slow, the fix is lazy instantiation on expand, not a silent truncation.
## Open items (manual verification)
- Visual pass on both modes: tints legible over DarkPlus syntax colors; line numbers aligned;
filler and gap rows readable.
- Scroll sync with wrap off (exact) and wrap on (top-anchored).
- Planning mode with a multi-file subtask.
- Toggle state survives an app restart.
## Docs to update on completion
- `src/ClaudeDo.Ui/CLAUDE.md` — Views/Controls list and the "Diff & Conflicts" section still
name `DiffLinesView`.
- `docs/explore-notes/review-merge.md` — diff stack description + "verified against" commit.
@@ -0,0 +1,169 @@
# Handler-Run: Verknüpfung zu den behandelten Tasks
**Date:** 2026-08-07
**Status:** Design approved (Mika), implementation pending
**Verified against:** commit `c792765`
## Problem
Ein "Let Claude handle it"-Run besitzt seit 2026-08-05 einen echten Task (`IsManual=true`,
`HandlerBaseCommit`/`HandlerHeadCommit`, Diff über Commit-Range). Was fehlt: **welche Tasks der Run
behandelt hat, ist nirgends persistiert.** Die Auswahl lebt nur in der ConPTY-Session und im
Transcript; `HandoffMcpTools.HandoffListHandler` (`src/ClaudeDo.Worker/External/HandoffMcpTools.cs:28-45`)
bekommt `survivingTaskIds` als flüchtige Liste.
Folge: Nachdem ein Run durch ist und der Diff sichtbar wird, lässt sich nicht mehr nachvollziehen,
*was alles gemacht werden sollte* und *welcher Task was produziert hat*. Duplikate, die der Handler
in Phase 1 gecancelt hat, verschwinden vollständig aus dem Blickfeld.
Zweitens zeigt der Handler-Task in der Liste das Badge **MANUAL**, weil er `IsManual=true` setzt —
irreführend, denn es ist kein manueller Reminder.
## Ist-Zustand
### Es gibt kein Task-Kind
`TaskEntity` hat **kein `Kind`/`Type`-Enum**. Task-"Arten" sind heute Feld-Kombinationen:
| Feld | Bedeutung |
|---|---|
| `IsManual` | manueller Reminder — Queue/Daily-Prep/Refine überspringen ihn |
| `ParentTaskId` | Kind einer Planning-/Improvement-Session |
| `PlanningPhase` | Planning-Parent |
| `BlockedByTaskId` | Kettenglied, Queue-Picker überspringt es |
| `HandlerBaseCommit` | worktree-loser List-Handler-Host (`src/ClaudeDo.Data/Models/TaskEntity.cs:60-61`) |
Ein Handler-Task ist also allein durch `HandlerBaseCommit != null` identifiziert.
### `ParentTaskId` ist belegt
`TaskRepository.CreateChildAsync` (`src/ClaudeDo.Data/Repositories/TaskRepository.cs:306`) setzt es
für Planning-Kinder; `TaskRowViewModel.IsChild`/`ShowAsChild`
(`src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs:59,66`) hängen daran und rücken die Zeile
im Baum ein. Ein Recycling für Handler→behandelte Tasks würde die Auswahl optisch unter den Handler
schieben und mit echten Planning-Kindern kollidieren.
### Badge-Infrastruktur existiert
`TaskRowView.axaml:129-144` rendert DRAFT / PLANNED / PLANNING / MANUAL über
`Border Classes="badge <variant>"`. Basis-Style und Varianten liegen in
`src/ClaudeDo.Ui/Design/IslandStyles.axaml:963-990`, die Brushes als theme-fähige Tokens in
`Tokens.axaml`. Loc-Keys: `tasks.badgeManual`, `tasks.manualTip` (en.json:163-164).
### Kinder-Panel existiert
`DetailsIslandViewModel.LoadChildOutcomesAsync`
(`src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs:704-748`) lädt
`Where(t => t.ParentTaskId == parentTaskId)` in `ChildOutcomes` (`:248`) und rendert pro Zeile
Id/Titel/Status/RoadblockCount/WorktreeState via `ChildOutcomeRowViewModel`; Refresh läuft über
`TaskUpdated`/`WorktreeUpdated` (`:814-833`).
## Entscheidungen
| Frage | Entscheidung | Begründung |
|---|---|---|
| Neues `TaskKind`-Enum? | **Nein** | Es gäbe kein Enum zu erweitern — es wäre das erste überhaupt, inkl. Migration und Rückwirkung auf Queue/Filter/UI. Der Bedarf ist eine Beziehung, kein Typ. |
| `ParentTaskId` wiederverwenden? | **Nein** | belegt durch Planning-Kinder, kollidiert mit Einrückungs-Logik |
| 1:n oder n:m? | **1:n**, eine nullable Spalte | Historie "welcher Run hat den Task mal berührt" bringt nichts, wenn ohnehin der letzte Run derjenige ist, dessen Diff man ansieht. Join-Tabelle = doppelter Code für einen Randfall. |
| Wann stempeln? | **Beim Anlegen des Handler-Tasks** | Die UI kennt die Auswahl bereits. Erfasst auch die Tasks, die der Handler in Phase 1 als Duplikat cancelt — genau das "was sollte alles gemacht werden". Ein Stempeln erst in `handoff_list_handler` würde Dedupe-Verlierer verlieren und bei Abbruch vor Phase 2 gar nichts verknüpfen. |
| Umfang der Anzeige | **Nur Liste + Endstatus** | Kein Phasen-Protokoll, kein Per-Task-Diff im Panel — der Diff hängt ohnehin am jeweiligen Task. |
## Design
### 1. Daten
Neue nullable Spalte auf `TaskEntity`:
```csharp
/// <summary>Id des Handler-Task-Runs, der diesen Task behandelt hat (null = keiner).</summary>
public string? HandlerTaskId { get; set; }
```
Konfiguration in `TaskEntityConfiguration`: `HasIndex(t => t.HandlerTaskId)`, kein FK-Constraint
(konsistent mit `BlockedByTaskId`-Handhabung; ein gelöschter Handler-Task soll die behandelten Tasks
nicht kaskadierend anfassen). EF-Core-Migration `AddHandlerTaskId`.
Ein zweiter Run über dieselben Tasks überschreibt die Zuordnung — gewollt (1:n).
### 2. Schreiben
Die Auswahl wird durchgereicht: UI → `IWorkerClient.CreateMergeHelperTaskAsync`
`WorkerHub.CreateMergeHelperTask` (`src/ClaudeDo.Worker/Hub/WorkerHub.cs:827-838`) →
`InteractiveLaunchSpecService.CreateMergeHelperTaskAsync`. Nach dem Anlegen des Handler-Tasks setzt
eine neue Repository-Methode die Zuordnung in einem Batch-Update:
```csharp
Task<int> SetHandlerTaskIdAsync(IReadOnlyList<string> taskIds, string handlerTaskId, CancellationToken ct);
```
Der Handler-Task selbst bekommt **kein** `HandlerTaskId` (kein Selbstbezug). Unbekannte Ids werden
still übersprungen.
### 3. Badge
`TaskRowViewModel`:
```csharp
public bool IsHandlerRun => !string.IsNullOrEmpty(HandlerBaseCommit);
public string? HandlerBadge => IsHandlerRun ? Loc.T("tasks.badgeHandler") : null;
public string? ManualBadge => IsManual && !IsHandlerRun ? Loc.T("tasks.badgeManual") : null;
```
`HandlerBaseCommit` muss dafür auf das Row-ViewModel und in dessen Mapping aufgenommen werden.
HANDLER hat Vorrang vor MANUAL — beide Badges nie gleichzeitig.
In `TaskRowView.axaml` analog zu `:141-144` ein `Border Classes="badge handler"` mit
`ToolTip.Tip="{loc:Tr tasks.handlerTip}"`. In `IslandStyles.axaml` eine `.badge.handler`-Variante
mit `{DynamicResource HandlerBadgeBrush}`, Token in `Tokens.axaml` für Light und Dark.
Neue Loc-Keys in en.json **und** de.json (Parität ist testgeprüft):
- `tasks.badgeHandler` — "HANDLER" / "HANDLER"
- `tasks.handlerTip` — "Handler run — lists the tasks it processed" / "Handler-Run — listet die
Tasks, die er bearbeitet hat"
### 4. Anzeige
Im Detail-Bereich eines Handler-Tasks eine Liste der behandelten Tasks, parallel zum bestehenden
Kinder-Panel:
- Neue Collection `HandledTasks` auf `DetailsIslandViewModel`, befüllt von `LoadHandledTasksAsync`
mit `Where(t => t.HandlerTaskId == taskId)`, sortiert wie die Kinder-Liste.
- Zeilen wiederverwenden `ChildOutcomeRowViewModel` (Id, Titel, Status, RoadblockCount,
WorktreeState) — keine neue Row-Klasse.
- Refresh über dieselben `TaskUpdated`-Events wie `ChildOutcomes`; der bestehende
`RefreshChildOutcomeAsync`-Pfad (`:814-833`) wird um die zweite Collection erweitert.
- Sichtbar nur wenn `HandledTasks.Count > 0`.
- **Keine Klick-Interaktion** — das bestehende `ChildOutcomes`-Template ist eine reine Anzeige
(Titel / Roadblock / Status, kein Tapped-Handler). Die neue Liste bleibt identisch; "zum Task
springen" wäre neues Verhalten und ist hier nicht enthalten.
### 5. Fehlerfälle
- Handler-Task gelöscht → `HandlerTaskId` der behandelten Tasks zeigt ins Leere; die Tasks bleiben
normal nutzbar, das Panel existiert schlicht nicht mehr. Kein Cleanup nötig.
- Behandelter Task gelöscht → verschwindet aus der Liste (Query läuft live gegen die Tasks).
- Leere Auswahl → kein Stempeln, Panel bleibt unsichtbar.
## Tests
| Ebene | Test |
|---|---|
| Data | `SetHandlerTaskIdAsync` stempelt alle übergebenen Ids, ignoriert unbekannte, überschreibt eine vorhandene Zuordnung |
| Worker | `CreateMergeHelperTaskAsync` stempelt die übergebene Auswahl und **nicht** den Handler-Task selbst |
| Ui | `TaskRowViewModel`: HANDLER schlägt MANUAL (`IsManual=true` + `HandlerBaseCommit` gesetzt → nur HANDLER) |
| Ui | `DetailsIslandViewModel`: `HandledTasks` lädt nach `HandlerTaskId`, aktualisiert sich auf `TaskUpdated` |
| Localization | Parität en/de — deckt der bestehende Test automatisch ab |
## Bewusst nicht enthalten
- Kein `TaskKind`-Enum.
- Keine n:m-Historie über mehrere Runs.
- Kein Phasen-Protokoll (Dedupe-Begründungen, Umformulierungen) — nur das Ergebnis.
- Kein Per-Task-Diff im Panel; der Diff bleibt am jeweiligen Task.
- Kein Badge auf den *behandelten* Tasks.
## Offen
- **Sichtprüfung durch Mika:** Badge-Farbe im Light- und Dark-Theme, Position des Panels im
Detail-Bereich, Verhalten bei vielen behandelten Tasks (Scroll).
@@ -0,0 +1,173 @@
# UI-Reaktivität und Listen-Performance
**Datum:** 2026-08-07
**Status:** Design freigegeben, Implementierung offen
## Problem
Zwei Symptome, die als eines gemeldet wurden:
1. **Stale UI.** Ein Task bleibt in der Liste auf `Queued` stehen, obwohl der Worker ihn längst auf `Running` gesetzt hat. Ebenso tauchen extern angelegte Tasks (Online-Inbox, List-Handler) erst nach einem manuellen Neuladen auf. Modals und Overlays zeigen den Stand vom Öffnungszeitpunkt.
2. **Langsames Laden.** Eine Liste mit ~125 erledigten Tasks braucht 12 Sekunden zum Öffnen. Ziel sind Listen mit bis zu ~1000 erledigten Tasks.
## Analyse
### Reaktivität: es fehlt kein Event, es fehlt die Selbstheilung
Der Broadcast-Pfad ist im Grundsatz korrekt: der Worker schreibt in die DB, committet, und sendet danach eine ID über SignalR (`HubBroadcaster`); die UI lädt die Entity frisch nach. WAL-Sichtbarkeit und EF-Change-Tracking wurden als Ursache **ausgeschlossen** — die UI nutzt `IDbContextFactory` mit kurzlebigen Kontexten, und WAL-Reader sehen Commits sofort.
Das eigentliche Problem: **ein einziger verlorener Event ist permanent.** Der einzige Reconcile-Trigger ist heute `ConnectionRestoredEvent`, also ein Verbindungsabbruch. Es gibt drei Wege, auf denen ein Update verloren geht:
| # | Loch | Ort |
|---|---|---|
| 1 | Blankes `catch { }` um den gesamten Delta-Pfad. Eine einzige transiente Exception (z.B. `SQLITE_BUSY`) lässt die Zeile dauerhaft auf dem alten Stand — ohne Log, ohne Retry. | `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs:224` |
| 2 | `QueuePicker.ClaimNextAsync` committet `status='running'` sofort. Wirft danach etwas in `RunInSlotAsync` oder im ungeschützten Setup-Block von `TaskRunner.ContinueAsync` (Zeilen 218238 liegen außerhalb jedes `try`), fängt der Catch das ab und **loggt nur** — kein `FailAsync`, kein Broadcast. DB sagt Running, die UI erfährt es nie. | `src/ClaudeDo.Worker/Queue/QueueService.cs:349-352` |
| 3 | DB-Writes ohne Broadcast. | `src/ClaudeDo.Worker/Runner/WorktreeManager.cs:103`, `src/ClaudeDo.Worker/Online/OnlineSyncService.cs:131` |
**Korrektur (2026-08-07, bei der Umsetzung gefunden):** `InteractiveLaunchSpecService.cs:447` stand hier ursprünglich als drittes Loch. Das war falsch. Die Service-Methode broadcastet zwar selbst nicht, aber ihr einziger Produktions-Aufrufer `WorkerHub.CreateMergeHelperTask` (`src/ClaudeDo.Worker/Hub/WorkerHub.cs:818`) sendet direkt danach `TaskUpdated` — seit Commit `c07c1f7` vom 2026-08-05, abgesichert durch `MergeHelperTaskHubTests.CreateMergeHelperTask_CreatesIdleManualTask_StampsBaseCommit_Broadcasts`. Der ursprüngliche Befund hatte den DB-Write gesehen, aber den Aufrufer nicht geprüft. Ein Broadcast im Service wäre ein Duplikat gewesen.
Dazu zwei kleinere Befunde:
- **Race im Delta-Pfad.** `OnWorkerTaskUpdated` ist `async void` und hängt an *zwei* Events (`TaskUpdatedEvent` und `WorktreeUpdatedEvent`, `TasksIslandViewModel.cs:117-118`). Der Full-Reload-Zweig ist per `_loadCts` gegen Überholen abgesichert, der Delta-Zweig nicht — ein älterer Read kann einen neueren überschreiben.
- ~~**Kein Busy-Timeout konfiguriert.**~~ **Widerlegt (2026-08-07, empirisch geprüft).** Die Vermutung war, die blanken Connection-Strings (`src/ClaudeDo.App/Program.cs:95`, `src/ClaudeDo.Worker/Program.cs:61`) ließen einen `SQLITE_BUSY` sofort durchschlagen. Das stimmt nicht: Microsoft.Data.Sqlite 8.0.11 setzt `DefaultTimeout` **von sich aus auf 30 Sekunden**, mit oder ohne das Keyword — gemessen an `SqliteConnectionStringBuilder("Data Source=x.db").DefaultTimeout``30`, ebenso `SqliteConnection.DefaultTimeout` und `SqliteCommand.CommandTimeout`. Ein Contention-Test (Writer hält 2s, zweiter Writer parallel) zeigt, dass der zweite wartet und nach ~2030 ms durchkommt, statt zu werfen. Der ursprünglich dafür gemachte Commit `f62dbb9` war ein No-op mit irreführendem Kommentar und wurde mit `ac58679` zurückgenommen. Die tatsächliche Absicherung gegen transiente Lesefehler leistet der Retry im Delta-Pfad, nicht ein Timeout.
- **`RunCreated` ist ein totes Event.** Wird in `TaskRunner.cs:358` gesendet, hat aber keinen einzigen Abonnenten in der UI.
### Performance: der Engpass ist das Rendering, nicht die Datenbank
SQLite ist hier **nicht** der Engpass, und ein DB-Wechsel würde nichts verbessern. Die Kosten verteilen sich so:
| Posten | bei 125 Zeilen |
|---|---|
| SQLite-Read, 125 Zeilen × ~30 Spalten, 2 Joins | < 1 ms |
| EF-Materialisierung | ~15 ms |
| **Avalonia baut ~8.500 Controls mit Bindings** | **~1.0001.500 ms** |
Eine `TaskRowView` erzeugt **~68 Controls eager**: ~50 für Struktur und Inhalt plus 18 `MenuItem`-Deklarationen des inline deklarierten ContextMenus (`TaskRowView.axaml:35-85`). Die Liste ist **nicht virtualisiert**: drei `ItemsControl` (Overdue/Open/Completed) liegen in einem gemeinsamen `ScrollViewer` ohne `ItemsPanel`-Override (`TasksIslandView.axaml:100,119,152`). `ItemsControl` nutzt per Default ein normales `StackPanel`, und der gemeinsame `ScrollViewer` gibt allen dreien unbegrenzte Höhe — deshalb würde auch ein bloßes Setzen von `VirtualizingStackPanel` nichts bewirken.
Zeilenhöhe ~68px, verfügbare Listenhöhe auf 2560×1440 ~1250px → **~19 Zeilen gleichzeitig sichtbar**.
| | Controls | geschätzt |
|---|---|---|
| heute, 125 Tasks | ~8.500 | 12 s |
| heute, 1000 Tasks | ~68.000 | ~10 s+ |
| virtualisiert (19 sichtbar + Overscan ≈ 25 Zeilen) | ~1.700 | ~250 ms |
| + ContextMenu lazy | ~1.250 | ~180 ms |
Entscheidend: die virtualisierten Werte sind **konstant** und gelten für 125 wie für 10.000 Zeilen.
Nebenbefund: `LoadForList` (`TasksIslandViewModel.cs:305-309`) hat **kein `.Where()` vor `ToListAsync()`** — es lädt die komplette `tasks`-Tabelle aller Listen mit zwei Joins und filtert danach in C#. Die vorhandenen Indizes (`idx_tasks_list_id`, `idx_tasks_status`) werden dadurch nie genutzt. Bei der heutigen DB-Größe unkritisch, aber es skaliert mit der DB-Gesamtgröße statt mit der Listengröße.
### Drag & Drop: architektonisch virtualisierungsfähig
Die Task-Liste nutzt **kein** Avalonia-`DragDrop` pro Item, sondern ein eigenes Ghost-Drag:
- Vier Pointer-Handler hängen **zentral** an der `TasksIslandView` (`TasksIslandView.axaml.cs:40-43`, Tunnel-Routing) — keine pro-Zeile registrierten Handler, die beim Container-Recycling leaken könnten.
- Das Ziel wird per `InputHitTest` live ermittelt (`TasksIslandView.axaml.cs:304-329`) — kein Index-Hack, recycling-sicher.
- Drop-Hints sind reine ViewModel-Properties (`TaskRowViewModel.cs:25-26`, `DropHintAbove`/`DropHintBelow`).
- Der Ghost ist ein `RenderTargetBitmap`-Snapshot in einem separaten Topmost-Fenster (`Views/Controls/TaskDragController.cs`), losgelöst vom Visual Tree.
Anzupassen sind:
- `FindNextInSameSection` und `SectionFor` (`TasksIslandViewModel.cs:366-374`, `:622-628`) iterieren per `IndexOf` über die drei UI-Collections. Die flache Master-Collection `Items` existiert bereits (`TasksIslandViewModel.cs:55`) und ist korrekt sortiert — das ist der Grund, warum das Flatten bezahlbar ist.
- **Auto-Scroll beim Ziehen fehlt komplett.** Fällt heute weniger auf, weil alle Container realisiert sind; bei einer virtualisierten Liste ist es Pflicht.
- **Bestehender Bug:** Zieht man über eine Gruppengrenze, prüft `ReorderAsync` (`TasksIslandViewModel.cs:560-563`) zwar die Sektion, verschiebt bei ungleichen Sektionen aber nur `Items` ohne anschließendes `Regroup()`. `SortOrder` landet in der DB, die UI zeigt nichts.
- **Kein Präzedenzfall:** im gesamten `ClaudeDo.Ui`-Projekt existiert kein `VirtualizingStackPanel` und kein `ItemsRepeater`.
### Drag-Optik
Heute laufen zwei Darstellungen derselben Zeile gleichzeitig: der Bitmap-Ghost am Cursor **und** die Originalzeile mit `Opacity 0.55`, `scale(1.03)`, BoxShadow und Accent-Rand (`IslandStyles.axaml:441-446`). `scale(1.03)` ändert kein Layout und überlappt daher die Nachbarzeilen. Das Feedback für das Drop-Ziel ist der ganz normale `:pointerover`-Hover (`IslandStyles.axaml:433-435`, setzt nur `BorderBrush`) — dasselbe Signal wie beim harmlosen Drüberfahren; einen eigenen `drop-target`-Style gibt es nur für die Lists-Island (`Border.list-item.drop-target`). Zusätzlich laufen `BrushTransition` (0.12s) und `ThicknessTransition` auf `Margin` (0.15s) auch während des Drags mit, was die Rückmeldung verschwimmen lässt.
## Verworfene Alternativen
| Alternative | Warum verworfen |
|---|---|
| **SQLite ersetzen** | Kein Engpass. Der DB-Read liegt unter 1 ms; die Zeit steckt zu >95% im Aufbau des Visual Tree. Monatelange Arbeit für null messbaren Gewinn. |
| **Nur Task-Titel laden, Rest lazy beim Öffnen** | Richtige Intuition, falsche Ebene. Spart Bytes aus einer lokalen Datei, die in unter 1 ms gelesen wird. Die 125 Zeilen werden weiterhin als 125 vollständige `TaskRowView` gebaut. Um wirklich zu sparen, müsste das Zeilen-Template entkernt werden — also genau die Chips und Icons entfallen, wegen derer die Liste nützlich ist. Als *Zusatz* (schlanke Projektion für Speicher/Materialisierung) sinnvoll, als Hauptmaßnahme nicht. |
| **Completed einklappen + „mehr laden"** | Billig und sofort wirksam, aber aufgeklappt mit 1000 Zeilen hängt es wieder. Bleibt als **Fallback**, falls der Virtualisierungs-Spike scheitert. |
| **Completed archivieren** | Löst das Problem durch Vermeidung; der Wunsch war ausdrücklich, 1000 erledigte Tasks sehen zu können. |
| **`Revision`-Spalte / Change-Feed** | Strukturell sauber (verlorene Events wären egal, Race gelöst), aber Migration plus Anpassung jedes Schreibpfads. Für eine Single-User-Desktop-App mit lokaler DB Overkill; der Reconcile-Tick erreicht dasselbe Ziel deutlich billiger. Bleibt als Eskalation, falls Phase 3 in der Praxis nicht reicht. |
| **Nur die Löcher stopfen (ohne Reconcile)** | Behebt die bekannten Fälle, lässt die Architektur „ein verlorener Event = permanent stale" aber intakt. Das nächste Loch kommt mit dem nächsten Feature. |
## Design
### Phase 1 — Reaktivitäts-Löcher schließen
Unabhängig von Phase 2 und 3, kann sofort starten.
| Fix | Ort |
|---|---|
| `catch { }` ersetzen durch Log + einmaligen Retry. **Kein** Footer-Error — das ist ein Hintergrund-Refresh, keine Nutzeraktion. | `TasksIslandViewModel.cs:224` |
| Catch-Block ruft `_state.FailAsync` (das selbst broadcastet), statt nur zu loggen; `OperationCanceledException` bleibt ausgenommen | `QueueService.cs:349-352` |
| `WorktreeUpdated` nach dem Insert broadcasten | `Runner/WorktreeManager.cs:103` |
| `TaskUpdated` nach dem Insert broadcasten | `Online/OnlineSyncService.cs:131` |
| Monotone Sequenznummer pro TaskId im Delta-Pfad; Ergebnisse mit veralteter Sequenz verwerfen | `OnWorkerTaskUpdated` |
| `RunCreated` ersatzlos entfernen (totes Event ohne Abonnent) | `HubBroadcaster`, `TaskRunner.cs:358` |
Der ungeschützte Setup-Block in `TaskRunner.ContinueAsync` (Zeilen 218238) wird **nicht** separat umgebaut: sobald `RunInSlotAsync` im Fehlerfall `FailAsync` ruft, ist jede dort geworfene Exception abgedeckt — der Task landet auf `Failed` und der Broadcast erfolgt. Ein zweiter Schutzwall wäre doppelt.
### Phase 2 — Flache virtualisierte Liste
**Datenmodell.** `Regroup()` erzeugt statt drei Collections **eine** `Rows`-Collection vom Union-Typ (`HeaderRow` | `TaskRowViewModel`), abgeleitet aus der bereits vorhandenen flachen `Items`. Gruppenüberschriften werden zu regulären Einträgen:
```
Rows
[0] HeaderRow "Überfällig (3)"
[1] TaskRow …
[4] HeaderRow "Offen (12)"
[17] HeaderRow "Erledigt (125)"
```
**View.** Eine `ListBox` mit `VirtualizingStackPanel` und einem DataTemplate-Selector (Header / Task) ersetzt die drei `ItemsControl` und den umschließenden `ScrollViewer`.
**Drag.** `FindNextInSameSection`, `SectionFor` und `ReorderAsync` rechnen gegen `Items` statt gegen die UI-Collections. Auto-Scroll beim Ziehen an den Listenrand wird neu gebaut. Der Cross-Section-Reorder-Bug wird im selben Zug behoben, da die Sektionsgrenzen in der flachen Struktur ohnehin explizit modelliert werden müssen.
**Zeilenkosten.** Das ContextMenu wird bei `ContextRequested` im Code-Behind aufgebaut statt als 18 `MenuItem`s pro Template-Instanz.
**Query.** `.Where()` wandert vor `ToListAsync()`, damit die Query mit der Listengröße statt der DB-Gesamtgröße skaliert. Erfordert Umbau von `ITaskListFilter` von In-Memory-Prädikaten (`Matches(TaskEntity)`) auf `IQueryable`-Expressions.
**Drag-Optik (Variante A).** Ghost folgt dem Cursor; die Originalzeile kollabiert zu einer leeren, gestrichelten Lücke, die beim Ziehen an die jeweilige Zielposition mitwandert.
```
┌──────────────────────┐
│ Fix login bug │
├──────────────────────┤
│ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ │ ← leerer Slot, wandert mit
├──────────────────────┤
│ Rebase branch │
└──────────────────────┘
┌────────────────┐
│ Update deps │ ← Ghost am Cursor
└────────────────┘
```
Begleitend: `scale(1.03)` und BoxShadow auf der gezogenen Zeile entfallen; der normale `:pointerover`-Hover wird während eines aktiven Drags per `dragging`-Klasse am Container unterdrückt; Transitions sind während des Drags aus.
### Phase 3 — Reconcile-Tick
Setzt Phase 2 voraus: auf einer Liste, die 12 s zum Laden braucht, würde ein periodischer Abgleich alles verschlimmern.
Ein Timer (35 s) gleicht die **sichtbaren** Rows gegen die lokale SQLite ab und **patcht ausschließlich Properties** — er baut nie Zeilen neu und löst nie einen `LoadForList` aus. Bei einer lokalen DB und wenigen Dutzend sichtbaren Zeilen ist das ein einzelner indizierter Query.
Damit ist jeder verlorene Event nach spätestens einem Tick geheilt, unabhängig davon, wo er fehlte. Derselbe Tick versorgt zusätzlich die langlebigen Overlays: Worktrees-Overview, LogVisualizer und die MergeHelper-Auswahl.
Kurzlebige Modals (Settings, ListSettings, RepoImport, WeeklyReport, ConflictResolver) bleiben bewusst statisch — ein Dialog, der sich unter den Fingern des Nutzers ändert, ist schlechter als einer, der den Stand vom Öffnen zeigt.
## Tests
- **Worker.Tests:** Broadcast-Assertions über einen Fake-Broadcaster auf allen in Phase 1 gefixten Pfaden — insbesondere, dass der Fehlerfall in `RunInSlotAsync` einen Broadcast auslöst.
- **Ui.Tests:** Der Reconcile-Tick patcht abweichende Properties; ein Ergebnis mit veralteter Sequenznummer wird verworfen; `Regroup()` erzeugt die korrekte `Rows`-Folge inklusive Header-Positionen und -Zählern.
- **Nicht automatisiert testbar:** Ladezeit und Drag-Optik. Beides erfordert eine manuelle Gegenmessung mit einer großen Liste durch den Nutzer.
Keine Tests, die die echte `claude`-CLI starten.
## Risiken
1. **Kein Virtualisierungs-Präzedenzfall im Projekt.** Das HitTest-basierte Custom-Drag ist theoretisch tragfähig, aber nie gegen recycelte Container verifiziert. **Die erste Aufgabe in Phase 2 ist ein Spike**, kein Umbau: eine virtualisierte Liste mit dem bestehenden Drag, inklusive Recycling während eines aktiven Drags und Auto-Scroll. Scheitert der Spike, ist der Fallback „Completed einklappen + nachladen".
2. **Variable Zeilenhöhen.** 68 px im Normalfall, 90110 px bei zweizeiligem Titel oder mehreren Badges. `VirtualizingStackPanel` beherrscht das, aber die Scrollbar springt dabei gern, weil die Gesamthöhe geschätzt wird. Muss im Spike mitgeprüft werden.
3. **`ITaskListFilter`-Umbau.** Die Umstellung auf `IQueryable`-Expressions berührt die Filter-Registry (`src/ClaudeDo.Data/Filtering/`) und damit auch die virtuellen Listen. Kann bei Bedarf aus Phase 2 herausgelöst und nachgezogen werden — der Performance-Gewinn liegt heute ohnehin fast vollständig beim Rendering.
## Reihenfolge
Phase 1 → Phase 2 (Spike zuerst) → Phase 3. Phase 1 ist unabhängig und kann parallel oder vorab laufen.
+16 -1
View File
@@ -3,7 +3,9 @@ using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using ClaudeDo.Ui;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels;
using ClaudeDo.Ui.Views;
@@ -40,9 +42,22 @@ public partial class App : Application
// modeless Mission Control window is still open.
desktop.ShutdownMode = ShutdownMode.OnMainWindowClose;
var shell = services.GetRequiredService<IslandsShellViewModel>();
// Last-resort backstop: an exception escaping a dispatcher job kills the process, and
// ClaudeDo hosts third-party UI (the ConPTY terminal control) whose async void key and
// render handlers have done exactly that — one bad keystroke took the whole app down,
// losing every open session. Swallowing is the lesser evil here: the failing job is
// already dead either way, and the user still gets told via the footer error strip.
Dispatcher.UIThread.UnhandledException += (_, e) =>
{
e.Handled = true;
shell.FlashFooterError(Loc.T("vm.shell.unexpectedError", e.Exception.Message));
};
desktop.MainWindow = new MainWindow
{
DataContext = services.GetRequiredService<IslandsShellViewModel>(),
DataContext = shell,
};
// Kick off the SignalR retry loop — reconnects indefinitely if the worker
+1 -1
View File
@@ -29,7 +29,7 @@ Beyond the basics it carries:
| `MaxTurnsCeiling` | `max_turns_ceiling` | 80 | Hard ceiling every resolved max-turns value (task/list/global) is clamped to before a run. `UpdateAsync` clamps to min 1. |
| `ModelPresets` | `model_presets` | seeded | JSON array of `ModelPreset` rows. ⚠️ `AppSettingsRepository.GetAsync` **backfills shipping defaults on the first read after it's null**, so it's never null once a run has started. |
| `UsageGateFiveHourPct` / `UsageGateSevenDayPct` | `usage_gate_*_pct` | 80 / 90 | Queue pause thresholds; `0` = off. |
| `UsageThrottleSoftPct` / `UsageThrottleHardPct` | `usage_throttle_*_pct` | 50 / 65 | Staged parallelism below the hard gate; `0` = that stage off. |
| `UsageThrottle{FiveHour,SevenDay}{Soft,Hard}Pct` | `usage_throttle_{five_hour,seven_day}_{soft,hard}_pct` | 50 / 65 per bucket | Staged parallelism below the hard gate, **per bucket**; `0` = that stage off. Edited by dragging the usage-monitor gauges. |
| `DailyPrepMaxTasks` | `daily_prep_max_tasks` | 5 | Hard cap on MyDay tasks the daily prep may place. |
| `ReportExcludedPaths` | `report_excluded_paths` | null | JSON array of excluded path prefixes. |
| `StandupWeekday` | `standup_weekday` | Wednesday | int `DayOfWeek`. |
@@ -55,10 +55,14 @@ public class AppSettingsEntityConfiguration : IEntityTypeConfiguration<AppSettin
builder.Property(s => s.UsageGateSevenDayPct)
.HasColumnName("usage_gate_seven_day_pct").IsRequired().HasDefaultValue(90);
builder.Property(s => s.UsageThrottleSoftPct)
.HasColumnName("usage_throttle_soft_pct").IsRequired().HasDefaultValue(50);
builder.Property(s => s.UsageThrottleHardPct)
.HasColumnName("usage_throttle_hard_pct").IsRequired().HasDefaultValue(65);
builder.Property(s => s.UsageThrottleFiveHourSoftPct)
.HasColumnName("usage_throttle_five_hour_soft_pct").IsRequired().HasDefaultValue(50);
builder.Property(s => s.UsageThrottleFiveHourHardPct)
.HasColumnName("usage_throttle_five_hour_hard_pct").IsRequired().HasDefaultValue(65);
builder.Property(s => s.UsageThrottleSevenDaySoftPct)
.HasColumnName("usage_throttle_seven_day_soft_pct").IsRequired().HasDefaultValue(50);
builder.Property(s => s.UsageThrottleSevenDayHardPct)
.HasColumnName("usage_throttle_seven_day_hard_pct").IsRequired().HasDefaultValue(65);
builder.HasData(new AppSettingsEntity { Id = AppSettingsEntity.SingletonId });
}
@@ -0,0 +1,883 @@
// <auto-generated />
using System;
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260806141710_SplitUsageThrottlePerBucket")]
partial class SplitUsageThrottlePerBucket
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<int>("DailyPrepMaxTasks")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(5)
.HasColumnName("daily_prep_max_tasks");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(40)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sonnet")
.HasColumnName("default_model");
b.Property<string>("DefaultPermissionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<int>("MaxParallelExecutions")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1)
.HasColumnName("max_parallel_executions");
b.Property<int>("MaxTurnsCeiling")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("max_turns_ceiling");
b.Property<string>("ModelPresets")
.HasColumnType("TEXT")
.HasColumnName("model_presets");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
b.Property<string>("ReportExcludedPaths")
.HasColumnType("TEXT")
.HasColumnName("report_excluded_paths");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("StandupWeekday")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(3)
.HasColumnName("standup_weekday");
b.Property<int>("UsageGateFiveHourPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(80)
.HasColumnName("usage_gate_five_hour_pct");
b.Property<int>("UsageGateSevenDayPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(90)
.HasColumnName("usage_gate_seven_day_pct");
b.Property<int>("UsageThrottleFiveHourHardPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(65)
.HasColumnName("usage_throttle_five_hour_hard_pct");
b.Property<int>("UsageThrottleFiveHourSoftPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(50)
.HasColumnName("usage_throttle_five_hour_soft_pct");
b.Property<int>("UsageThrottleSevenDayHardPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(65)
.HasColumnName("usage_throttle_seven_day_hard_pct");
b.Property<int>("UsageThrottleSevenDaySoftPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(50)
.HasColumnName("usage_throttle_seven_day_soft_pct");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
DailyPrepMaxTasks = 5,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 40,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
MaxParallelExecutions = 1,
MaxTurnsCeiling = 80,
StandupWeekday = 3,
UsageGateFiveHourPct = 80,
UsageGateSevenDayPct = 90,
UsageThrottleFiveHourHardPct = 65,
UsageThrottleFiveHourSoftPct = 50,
UsageThrottleSevenDayHardPct = 65,
UsageThrottleSevenDaySoftPct = 50,
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT")
.HasColumnName("note_date");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER")
.HasColumnName("sort_order");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("text");
b.HasKey("Id");
b.HasIndex("Date");
b.ToTable("daily_notes", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("VerifyCommand")
.HasColumnType("TEXT")
.HasColumnName("verify_command");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.HasIndex("SortOrder")
.HasDatabaseName("idx_lists_sort");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("Days")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(31)
.HasColumnName("days_of_week");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("enabled");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT")
.HasColumnName("last_run_at");
b.Property<string>("PromptOverride")
.HasColumnType("TEXT")
.HasColumnName("prompt_override");
b.Property<TimeSpan>("TimeOfDay")
.HasColumnType("TEXT")
.HasColumnName("time_of_day");
b.HasKey("Id");
b.ToTable("prime_schedules", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b =>
{
b.Property<string>("Name")
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<DateTimeOffset>("AddedAt")
.HasColumnType("TEXT")
.HasColumnName("added_at");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<string>("PinnedRef")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("pinned_ref");
b.Property<string>("SourceUrl")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("source_url");
b.Property<string>("Subpath")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("subpath");
b.HasKey("Name");
b.ToTable("session_skills", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<long>("ByteSize")
.HasColumnType("INTEGER")
.HasColumnName("byte_size");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("file_name");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_attachments_task_id");
b.ToTable("task_attachments", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<string>("HandlerBaseCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_base_commit");
b.Property<string>("HandlerHeadCommit")
.HasColumnType("TEXT")
.HasColumnName("handler_head_commit");
b.Property<string>("InteractiveSessionId")
.HasColumnType("TEXT")
.HasColumnName("interactive_session_id");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<string>("ReviewFeedback")
.HasColumnType("TEXT")
.HasColumnName("review_feedback");
b.Property<int>("RoadblockCount")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("roadblock_count");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.HasIndex("ListId", "SortOrder")
.HasDatabaseName("idx_tasks_list_sort");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<int?>("CacheReadTokens")
.HasColumnType("INTEGER")
.HasColumnName("cache_read_tokens");
b.Property<int?>("CacheWriteTokens")
.HasColumnType("INTEGER")
.HasColumnName("cache_write_tokens");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateOnly>("EndDate")
.HasColumnType("TEXT")
.HasColumnName("end_date");
b.Property<DateTime>("GeneratedAt")
.HasColumnType("TEXT")
.HasColumnName("generated_at");
b.Property<string>("Markdown")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("markdown");
b.Property<DateOnly>("StartDate")
.HasColumnType("TEXT")
.HasColumnName("start_date");
b.HasKey("Id");
b.HasIndex("StartDate", "EndDate")
.IsUnique();
b.ToTable("week_reports", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("MergeCommit")
.HasColumnType("TEXT")
.HasColumnName("merge_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany()
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List");
b.Navigation("Parent");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Children");
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,70 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class SplitUsageThrottlePerBucket : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "usage_throttle_soft_pct",
table: "app_settings",
newName: "usage_throttle_seven_day_soft_pct");
migrationBuilder.RenameColumn(
name: "usage_throttle_hard_pct",
table: "app_settings",
newName: "usage_throttle_seven_day_hard_pct");
migrationBuilder.AddColumn<int>(
name: "usage_throttle_five_hour_hard_pct",
table: "app_settings",
type: "INTEGER",
nullable: false,
defaultValue: 65);
migrationBuilder.AddColumn<int>(
name: "usage_throttle_five_hour_soft_pct",
table: "app_settings",
type: "INTEGER",
nullable: false,
defaultValue: 50);
// The old single soft/hard pair was compared against whichever bucket was more utilized,
// so carrying it into BOTH buckets keeps an existing install behaving exactly as before
// the split — the rename above already preserved it for the 7d side.
migrationBuilder.Sql(
"""
UPDATE app_settings
SET usage_throttle_five_hour_soft_pct = usage_throttle_seven_day_soft_pct,
usage_throttle_five_hour_hard_pct = usage_throttle_seven_day_hard_pct;
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "usage_throttle_five_hour_hard_pct",
table: "app_settings");
migrationBuilder.DropColumn(
name: "usage_throttle_five_hour_soft_pct",
table: "app_settings");
migrationBuilder.RenameColumn(
name: "usage_throttle_seven_day_soft_pct",
table: "app_settings",
newName: "usage_throttle_soft_pct");
migrationBuilder.RenameColumn(
name: "usage_throttle_seven_day_hard_pct",
table: "app_settings",
newName: "usage_throttle_hard_pct");
}
}
}
@@ -106,17 +106,29 @@ namespace ClaudeDo.Data.Migrations
.HasDefaultValue(90)
.HasColumnName("usage_gate_seven_day_pct");
b.Property<int>("UsageThrottleHardPct")
b.Property<int>("UsageThrottleFiveHourHardPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(65)
.HasColumnName("usage_throttle_hard_pct");
.HasColumnName("usage_throttle_five_hour_hard_pct");
b.Property<int>("UsageThrottleSoftPct")
b.Property<int>("UsageThrottleFiveHourSoftPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(50)
.HasColumnName("usage_throttle_soft_pct");
.HasColumnName("usage_throttle_five_hour_soft_pct");
b.Property<int>("UsageThrottleSevenDayHardPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(65)
.HasColumnName("usage_throttle_seven_day_hard_pct");
b.Property<int>("UsageThrottleSevenDaySoftPct")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(50)
.HasColumnName("usage_throttle_seven_day_soft_pct");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
@@ -155,8 +167,10 @@ namespace ClaudeDo.Data.Migrations
StandupWeekday = 3,
UsageGateFiveHourPct = 80,
UsageGateSevenDayPct = 90,
UsageThrottleHardPct = 65,
UsageThrottleSoftPct = 50,
UsageThrottleFiveHourHardPct = 65,
UsageThrottleFiveHourSoftPct = 50,
UsageThrottleSevenDayHardPct = 65,
UsageThrottleSevenDaySoftPct = 50,
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
@@ -43,8 +43,11 @@ public sealed class AppSettingsEntity
public int UsageGateFiveHourPct { get; set; } = 80;
public int UsageGateSevenDayPct { get; set; } = 90;
// Percentage of the 5h/7d Claude usage window at which the queue starts throttling
// parallelism ahead of the hard gate above. 0 = that stage off.
public int UsageThrottleSoftPct { get; set; } = 50;
public int UsageThrottleHardPct { get; set; } = 65;
// Percentage at which the queue starts throttling parallelism ahead of the hard gate above.
// Tracked per bucket, because the 5h and 7d windows fill at very different rates — soft caps
// parallelism at 2 slots, hard at 1. 0 = that stage off for that bucket.
public int UsageThrottleFiveHourSoftPct { get; set; } = 50;
public int UsageThrottleFiveHourHardPct { get; set; } = 65;
public int UsageThrottleSevenDaySoftPct { get; set; } = 50;
public int UsageThrottleSevenDayHardPct { get; set; } = 65;
}
+1 -1
View File
@@ -5,7 +5,7 @@ public static class ModelRegistry
public static readonly IReadOnlyList<string> Aliases = new[] { "sonnet", "opus", "haiku", "fable" };
/// <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 static readonly IReadOnlyList<string> ByCostAscending = new[] { "haiku", "sonnet", "opus", "fable" };
public const string DefaultAlias = "sonnet";
public const string PlanningAlias = "opus";
+57 -27
View File
@@ -1,10 +1,11 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace ClaudeDo.Data;
public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport, ImprovementChild, Refine, MergeHelper, MergeHelperInitial, MergeHelperHandoff }
public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport, ImprovementChild, Refine, MergeHelperTriage, MergeHelperExecute, MergeHelperInitial, MergeHelperHandoff }
/// <summary>
/// How a prompt kind's on-disk override (if any) relates to the bundled default.
@@ -38,7 +39,8 @@ public static class PromptFiles
PromptKind.WeeklyReport => "weekly-report.md",
PromptKind.ImprovementChild => "improvement-child.md",
PromptKind.Refine => "refine.md",
PromptKind.MergeHelper => "merge-helper-system.md",
PromptKind.MergeHelperTriage => "merge-helper-triage.md",
PromptKind.MergeHelperExecute => "merge-helper-execute.md",
PromptKind.MergeHelperInitial => "merge-helper-initial.md",
PromptKind.MergeHelperHandoff => "merge-helper-handoff.md",
_ => throw new ArgumentOutOfRangeException(nameof(kind))
@@ -192,14 +194,16 @@ public static class PromptFiles
public static string Render(PromptKind kind, IReadOnlyDictionary<string, string> values)
=> RenderTemplate(ReadOrDefault(kind), values);
/// <summary>Replace only the given {name} tokens; any other braces pass through untouched.</summary>
private static readonly Regex TokenPattern = new(@"\{(\w+)\}", RegexOptions.Compiled);
/// <summary>Replace only the given {name} tokens; any other braces pass through untouched.
/// Single pass over the template, so a token appearing inside a substituted VALUE is never
/// re-substituted. That matters because the values are user-authored task titles and
/// descriptions: a sharpened description mentioning a literal {repo} must survive verbatim,
/// and it must not depend on the caller happening to order its dictionary correctly.</summary>
public static string RenderTemplate(string template, IReadOnlyDictionary<string, string> values)
{
var sb = new StringBuilder(template);
foreach (var (key, val) in values)
sb.Replace("{" + key + "}", val);
return sb.ToString();
}
=> TokenPattern.Replace(template, m =>
values.TryGetValue(m.Groups[1].Value, out var val) ? val : m.Value);
public static string DefaultFor(PromptKind kind) => kind switch
{
@@ -211,7 +215,8 @@ public static class PromptFiles
PromptKind.WeeklyReport => WeeklyReportDefault,
PromptKind.ImprovementChild => ImprovementChildDefault,
PromptKind.Refine => RefineDefault,
PromptKind.MergeHelper => MergeHelperDefault,
PromptKind.MergeHelperTriage => MergeHelperTriageDefault,
PromptKind.MergeHelperExecute => MergeHelperExecuteDefault,
PromptKind.MergeHelperInitial => MergeHelperInitialDefault,
PromptKind.MergeHelperHandoff => MergeHelperHandoffDefault,
_ => ""
@@ -238,6 +243,10 @@ public static class PromptFiles
trivial/mechanical work, 'sonnet' for normal coding, 'opus' only for genuinely
complex work (cheapest to most capable: haiku < sonnet < opus).
This tool only exists for a standalone top-level task. A child task or a task in a
planning chain does not have it improvements are one layer deep. If you don't have
it, name the follow-up in your final report instead of trying to file it.
## Working in the repo
- Read a file before editing it. Match the conventions already in this codebase
they override generic defaults.
@@ -324,8 +333,13 @@ public static class PromptFiles
until the user has approved the design.
You can ONLY shape this task's plan you cannot edit files or touch other tasks.
The tools available to you are: CreateChildTask, ListChildTasks, UpdateChildTask,
DeleteChildTask, UpdatePlanningTask, and Finalize. Use nothing else.
To shape it you have the ClaudeDo planning tools: CreateChildTask, ListChildTasks,
UpdateChildTask, DeleteChildTask, UpdatePlanningTask and Finalize. You also have
Skill (needed for the brainstorming skill above), Read, Grep, Glob, WebFetch and
WebSearch. You do NOT have Write, Edit or Bash.
Use Read/Grep/Glob to ground the plan in the actual repo a subtask that names the
real files and symbols involved runs far better than one written from guesswork.
Once the design is approved, create the child tasks with CreateChildTask, then
call Finalize. Keep each subtask concrete and self-contained with a clear
@@ -400,16 +414,18 @@ public static class PromptFiles
not already present in the current subtasks above.
Use ONLY these tools: mcp__claudedo__get_task, mcp__claudedo__update_task,
mcp__claudedo__add_subtask, and read-only Read/Grep/Glob. When you have updated the
task, stop.
mcp__claudedo__add_subtask, and only when a repository is available read-only
Read/Grep/Glob. When you have updated the task, stop.
""";
private const string MergeHelperDefault = """
You are the ClaudeDo list handler, running as an interactive session with the user watching. Work autonomously and decide things yourself by default. Ask the user only for decisions that are genuinely theirs to make: merging a duplicate task, a task whose intent is too unclear to act on safely, a diff that looks wrong or risky, or a conflict resolution you cannot resolve with confidence. Everything else, decide and keep moving.
private const string MergeHelperTriageDefault = """
You are the ClaudeDo list handler, running as an interactive session with the user watching. Work autonomously and decide things yourself by default. Ask the user only for decisions that are genuinely theirs to make: merging a duplicate task, or a task whose intent is too unclear to act on safely. Everything else, decide and keep moving.
Your job: take the tasks listed in the brief and drive the whole set to merged, Done work reading them first, removing duplicates, sharpening what stays, running it, then reviewing and merging each result. You act through the mcp__claudedo__* tools. Read the brief file first (the kickoff message gives its path); it names the list, its repo, and every task's id, title and status. All tasks belong to that one list and one repo.
Your job: take the tasks listed in the brief and get the set ready to run reading them all first, removing duplicates, then sharpening what stays. You act through the mcp__claudedo__* tools. Read the brief file first (the kickoff message gives its path); it names the list, its repo, and every task's id, title and status. All tasks belong to that one list and one repo.
Work the five phases in order. Do not start a phase before the previous one is finished.
A second session takes over after you to run, review and merge these tasks. Your deliverable is a clean, sharpened set of task descriptions you never run or merge anything yourself, and you touch no git state.
Work the three phases in order. Do not start a phase before the previous one is finished.
## Phase 0 Read everything
The brief is the primary source: it already lists every task's title, id, status and full description. Read it in full before acting. Only call batch_get_tasks if you need something the brief does not carry for a specific task, e.g. parent/child links. Do not act on any single task before you have read them all Phase 1 needs the whole set in view.
@@ -438,22 +454,37 @@ public static class PromptFiles
If a task visibly bundles several independent features, or has a blocker that is not resolved by anything in its own description, do not force it into one description. Propose splitting it to the user; if they agree, create the pieces with add_task/add_subtask and only move the pieces the user confirmed into "surviving tasks" for the phases below. Split only what the task already asks for the "do not invent requirements" rule still applies.
## Handoff
Once every surviving task is enhanced, call handoff_list_handler with this session's task id and the surviving task ids, in the order you intend to run them. That opens a fresh session to carry out phases 35 with just that list, without dragging along this session's dedupe/rewrite context. Say a short goodbye line, then stop do not continue into phase 3 yourself.
Once every surviving task is enhanced, print your triage summary one line per task from the brief:
title dedupe action (kept / merged into X / cancelled as duplicate of X) enhanced (yes/no).
Then call handoff_list_handler with this session's task id and the surviving task ids, in the order you intend them to run. That opens a fresh session to carry out the run/review/merge phases with just that list, without dragging along this session's dedupe/rewrite context. Say a short goodbye line, then stop do not continue into phase 3 yourself.
""";
private const string MergeHelperExecuteDefault = """
You are the ClaudeDo list handler, running as an interactive session with the user watching. Work autonomously and decide things yourself by default. Ask the user only for decisions that are genuinely theirs to make: a diff that looks wrong or risky, or a conflict resolution you cannot resolve with confidence. Everything else, decide and keep moving.
Your job: take the tasks listed in the brief and drive them to merged, Done work running them, then reviewing and merging each result. You act through the mcp__claudedo__* tools. Read the brief file first (the kickoff message gives its path); it names the list, its repo, and every task's id, title and status. All tasks belong to that one list and one repo.
A prior session already read, deduplicated and sharpened these tasks; their descriptions are ready to run as written. Start at Phase 3.
Work the three phases in order. Do not start a phase before the previous one is finished.
## Phase 3 Run
Do NOT use run_task_now for a batch there is a single override slot and the second call fails with "override slot busy".
Do NOT use run_task_now for a batch there is a single override slot and the second call fails with "Override slot busy. Try again later.".
Read get_app_settings and tell the user how many parallel execution slots are configured (maxParallelExecutions). If it is 1, say plainly that the tasks will execute one after another and that the value is changeable in ClaudeDo's settings.
For each surviving task, resolve the effective max-turns it will run with task.MaxTurns, else the list's get_list_config MaxTurns, else the model's preset/global default and report it. If a task looks substantial (several files, or one you just split off above) but its effective turns look low, say so and ask before queuing it; set_task_config/get_task_config let you raise it per task.
For each surviving task, call get_effective_run_config(taskId) and report the max-turns it will ACTUALLY run with. Do not derive that from task/list/preset values yourself the resolved value is clamped to a global ceiling, so a raw task or list setting can be higher than what runs. The tool reports the effective value, its source, the raw requested value and whether it was clamped. If a task looks substantial (several files, or one you just split off above) but its effective turns look low, say so and ask before queuing it; set_task_config raises it per task, though the ceiling still applies.
Then, for each surviving task:
- Idle or Failed update_task_status(id, "Queued"). For a Failed task ask first whether to reset_failed_task and re-queue it, or skip it.
- Queued leave it; it is already waiting for a slot.
- Running or WaitingForChildren leave it; only poll.
- Running or WaitingForChildren leave it; the wait below covers it.
- WaitingForReview leave it; it goes straight to Phase 4.
Call wait_for_task_change with the ids of every task still Queued or Running (timeoutSeconds up to 170) instead of sleeping and polling get_task yourself. It returns as soon as any of them leaves Queued/Running WaitingForReview on success, Failed on error or reports timedOut if none did. Report progress as tasks land, then call it again with whatever ids are still Queued/Running until none remain.
Then wait with wait_for_task_change instead of sleeping and polling get_task yourself. Pass the ids of every task not yet in WaitingForReview or a terminal status Queued, Running and WaitingForChildren alike and set treatWaitingForChildrenAsBusy=true. Without that flag a task with children returns the moment it goes Running WaitingForChildren, while its children are still working, and you would walk into Phase 4 with unfinished work. Use timeoutSeconds 900: the server clamps there anyway, and ClaudeDo's launchers already raise MCP_TOOL_TIMEOUT above it, so one long wait costs one turn where six short ones cost six.
It returns as soon as a task reaches WaitingForReview or fails, or reports timedOut if none did. Report progress as tasks land, then call it again with whatever ids are still outstanding until none remain.
## Phase 4 Review and merge
Before merging anything, call preview_merge_set with every surviving task's id (the same targetBranch you are about to merge into). It tells you, per task, whether a clean merge-tree preview is even possible (status/conflictFiles/changedFileCount/behind) and which files more than one of the tasks changed (overlaps). Read the overlaps: a file two tasks both touch is where a same-branch collision could happen. This is a HINT, not proof it only catches same-file overlap, not a cross-file break (e.g. one task deletes a symbol another task's file still references), and a clean preview never guarantees the result compiles or passes tests. Use it to decide merge order and to know which pairs to look at extra carefully in step 1 below; it does not replace reading the diffs.
@@ -478,11 +509,10 @@ public static class PromptFiles
Rules for the whole session:
- Never use raw `git merge`, `git reset`, or `git checkout` to force a merge. Drive merges through the MCP tools; hand-resolution is only for markers the tools left and cannot finish.
- Ask the user for anything ambiguous, risky, or destructive.
## Phase 5 Summary
Print one line per task from the original brief:
title dedupe action (kept / merged into X / cancelled as duplicate of X) enhanced (yes/no) final status merge commit (if any) conflicts resolved (if any).
Print one line per task from the brief:
title final status merge commit (if any) conflicts resolved (if any).
Then list anything you skipped or left for the user and why, and any follow-ups worth turning into new tasks.
""";
@@ -507,7 +537,7 @@ public static class PromptFiles
Repo: {repo}
A prior session already read, deduped and enhanced this list's tasks. Pick up at phase 3
for the tasks below their descriptions are already sharpened, so don't redo phases 02.
for the tasks below their descriptions are already sharpened.
{tasks}
@@ -88,8 +88,10 @@ public sealed class AppSettingsRepository
row.SessionSkills = string.IsNullOrWhiteSpace(updated.SessionSkills) ? null : updated.SessionSkills;
row.UsageGateFiveHourPct = Math.Clamp(updated.UsageGateFiveHourPct, 0, 100);
row.UsageGateSevenDayPct = Math.Clamp(updated.UsageGateSevenDayPct, 0, 100);
row.UsageThrottleSoftPct = Math.Clamp(updated.UsageThrottleSoftPct, 0, 100);
row.UsageThrottleHardPct = Math.Clamp(updated.UsageThrottleHardPct, 0, 100);
row.UsageThrottleFiveHourSoftPct = Math.Clamp(updated.UsageThrottleFiveHourSoftPct, 0, 100);
row.UsageThrottleFiveHourHardPct = Math.Clamp(updated.UsageThrottleFiveHourHardPct, 0, 100);
row.UsageThrottleSevenDaySoftPct = Math.Clamp(updated.UsageThrottleSevenDaySoftPct, 0, 100);
row.UsageThrottleSevenDayHardPct = Math.Clamp(updated.UsageThrottleSevenDayHardPct, 0, 100);
await _context.SaveChangesAsync(ct);
}
+13 -3
View File
@@ -357,7 +357,12 @@
"merge": "Mergen…",
"filesHeader": "Dateien",
"binary": "Binärdatei — kein Text-Diff",
"empty": "Kein Inhalt"
"empty": "Kein Inhalt",
"unifiedView": "Zusammen",
"splitView": "Nebeneinander",
"wrapLines": "Zeilenumbruch",
"paneBase": "BASIS",
"paneWorktree": "WORKTREE"
},
"worktreesOverview": {
"refresh": "Aktualisieren",
@@ -433,6 +438,10 @@
"gateBlockedFormat": "Queue pausiert — {0}",
"throttleFormat": "Queue gedrosselt: {0}/{1} Slots ({2})",
"resetIn": "Reset in {0}",
"dragHint": "Marker ziehen oder Wert unten eintragen.",
"legendSoft": "Soft · 2 Slots",
"legendHard": "Hard · 1 Slot",
"legendGate": "Gate · Pause",
"gaugeSession": "Session (5 Std.)",
"gaugeWeeklyAll": "Woche (alle Modelle)",
"gaugeWeeklyScopedFormat": "Woche ({0})",
@@ -642,7 +651,7 @@
},
"vm": {
"connection": { "online": "Online", "connecting": "Verbinden…", "offline": "Offline" },
"shell": { "restartingWorker": "Worker wird neu gestartet…" },
"shell": { "restartingWorker": "Worker wird neu gestartet…", "unexpectedError": "Unerwarteter Fehler: {0}" },
"agentStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "review": "Prüfung", "children": "Wartet auf Teilaufgaben", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen" },
"taskStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "waitingForReview": "Wartet auf Prüfung", "waitingForChildren": "Wartet auf Teilaufgaben", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen", "parked": "Geparkt", "interactive": "Interaktiv" },
"planningBadge": { "active": "PLANUNG", "finalized": "GEPLANT" },
@@ -657,7 +666,8 @@
"weeklyReport": { "invalidRange": "Ungültiger Datumsbereich.", "generating": "Bericht wird erstellt…", "error": "Fehler: {0}" },
"usageMonitor": {
"loadFailed": "Nutzungsdaten konnten nicht geladen werden: {0}",
"refreshFailed": "Nutzung konnte nicht aktualisiert werden: {0}"
"refreshFailed": "Nutzung konnte nicht aktualisiert werden: {0}",
"thresholdSaveFailed": "Grenze konnte nicht gespeichert werden: {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}", "resetToDefault": "Auf den mitgelieferten Standard zurückgesetzt." },
"sessionSkillsTab": { "installed": "Installiert: {0}", "installFailed": "Installation fehlgeschlagen: {0}", "updated": "Aktualisiert.", "updateFailed": "Aktualisierung fehlgeschlagen: {0}", "removed": "Entfernt.", "removeFailed": "Entfernen fehlgeschlagen: {0}" },
+13 -3
View File
@@ -357,7 +357,12 @@
"merge": "Merge…",
"filesHeader": "Files",
"binary": "Binary file — no text diff",
"empty": "No content"
"empty": "No content",
"unifiedView": "Unified",
"splitView": "Side by side",
"wrapLines": "Wrap lines",
"paneBase": "BASE",
"paneWorktree": "WORKTREE"
},
"worktreesOverview": {
"refresh": "Refresh",
@@ -433,6 +438,10 @@
"gateBlockedFormat": "Queue paused — {0}",
"throttleFormat": "Queue throttled: {0}/{1} slots ({2})",
"resetIn": "Reset in {0}",
"dragHint": "Drag a marker, or type the value below.",
"legendSoft": "Soft · 2 slots",
"legendHard": "Hard · 1 slot",
"legendGate": "Gate · pause",
"gaugeSession": "Session (5h)",
"gaugeWeeklyAll": "Week (all models)",
"gaugeWeeklyScopedFormat": "Week ({0})",
@@ -642,7 +651,7 @@
},
"vm": {
"connection": { "online": "Online", "connecting": "Connecting…", "offline": "Offline" },
"shell": { "restartingWorker": "Restarting worker…" },
"shell": { "restartingWorker": "Restarting worker…", "unexpectedError": "Unexpected error: {0}" },
"agentStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "review": "Review", "children": "Waiting for Subtasks", "done": "Done", "failed": "Failed", "cancelled": "Cancelled" },
"taskStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "waitingForReview": "Waiting for Review", "waitingForChildren": "Waiting for Subtasks", "done": "Done", "failed": "Failed", "cancelled": "Cancelled", "parked": "Parked", "interactive": "Interactive" },
"planningBadge": { "active": "PLANNING", "finalized": "PLANNED" },
@@ -657,7 +666,8 @@
"weeklyReport": { "invalidRange": "Invalid date range.", "generating": "Generating report…", "error": "Error: {0}" },
"usageMonitor": {
"loadFailed": "Couldn't load usage data: {0}",
"refreshFailed": "Couldn't refresh usage: {0}"
"refreshFailed": "Couldn't refresh usage: {0}",
"thresholdSaveFailed": "Couldn't save the threshold: {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}", "resetToDefault": "Reset to the bundled default." },
"sessionSkillsTab": { "installed": "Installed: {0}", "installFailed": "Install failed: {0}", "updated": "Updated.", "updateFailed": "Update failed: {0}", "removed": "Removed.", "removeFailed": "Remove failed: {0}" },
+24 -7
View File
@@ -1,4 +1,5 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using ClaudeDo.Data;
namespace ClaudeDo.Ui;
@@ -10,24 +11,40 @@ public sealed class AppSettings
public string Language { get; set; } = "";
public string AccentPreset { get; set; } = "";
private static readonly string ConfigPath = Paths.Expand("~/.todo-app/ui.config.json");
/// Diff viewer layout: "unified" or "split". A view preference, so it lives here in
/// ui.config.json rather than in the worker-owned AppSettingsEntity.
public string DiffViewMode { get; set; } = "unified";
public bool DiffWrapLines { get; set; }
public static AppSettings Load()
private static readonly string DefaultConfigPath = Paths.Expand("~/.todo-app/ui.config.json");
/// Where this instance persists. Instance-level (not static) so tests can redirect it —
/// the diff-viewer toggles call Save() on every flip.
[JsonIgnore]
public string ConfigPath { get; set; } = DefaultConfigPath;
public static AppSettings Load(string? configPath = null)
{
var path = configPath ?? DefaultConfigPath;
try
{
if (File.Exists(ConfigPath))
if (File.Exists(path))
{
var json = File.ReadAllText(ConfigPath);
return JsonSerializer.Deserialize<AppSettings>(json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true }) ?? new();
var json = File.ReadAllText(path);
var loaded = JsonSerializer.Deserialize<AppSettings>(json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true });
if (loaded is not null)
{
loaded.ConfigPath = path;
return loaded;
}
}
}
catch
{
// Fall through to defaults
}
return new();
return new AppSettings { ConfigPath = path };
}
public void Save()
+18 -4
View File
@@ -31,8 +31,8 @@ ViewModels/
Conflicts/ — ConflictResolverViewModel + ConflictModels
Views/ — mirrors the VM layout; Islands/Detail/ holds TaskHeaderBar,
DescriptionStepsCard, WorkConsole; plus SessionTerminalView
Views/Controls/ — MarkdownView, ModalShell, ThemedDatePicker, DiffLinesView, InheritedBadge,
AgentConfigEditor
Views/Controls/ — MarkdownView, ModalShell, ThemedDatePicker, DiffTextView, DiffEditorSetup,
InheritedBadge, AgentConfigEditor, UsagePill, UsageGaugeBar
Design/ — Tokens.axaml (design tokens; merged before styles)
+ IslandStyles.axaml (component styles + the filled icon geometry library)
```
@@ -58,7 +58,7 @@ Design/ — Tokens.axaml (design tokens; merged before styles)
| `ListSettingsModalViewModel` | Name, working dir, commit type, "manual list" flag, `VerifyCommand`, delete. Hosts the shared `AgentConfigEditorViewModel` as `Agent` (scope=List) — ⚠️ save delegates to `Agent.SaveAsync(verifyCommand)` because both land in the same `list_config` row via one `UpdateListConfig` call and would otherwise clobber each other. |
| `WeeklyReportModalViewModel` | Range pickers default "since last standup weekday → today", cached per range. |
| `MergeHelperSelectionModalViewModel` | "Let Claude handle it" picker → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md). |
| `UsageMonitorModalViewModel` | Opened from the usage pill; gauges are **dynamic** per `UsageSnapshotDto.Limits` row. |
| `UsageMonitorModalViewModel` | Opened from the usage pill (shown **before** the data loads via `BeginLoad`); gauges are **dynamic** per `UsageSnapshotDto.Limits` row, and the 5h/7d ones carry three draggable stage markers (soft/hard/gate) via `UsageGaugeBar` + the pure `UsageThresholdDrag`, plus a colour-matched legend with a `NumericUpDown` per stage → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md). |
Self-explanatory: `RepoImportModalViewModel` (bulk-create lists from git repos; already-wired
repos disabled), `MergeModalViewModel`, `WorktreesOverviewModalViewModel`,
@@ -68,11 +68,20 @@ warn/error filter), `WorkerConnectionModalViewModel`, `AboutModalViewModel`.
## Diff & Conflicts
`UnifiedDiffParser` (static) + `DiffModels.cs` shared types + `DiffViewerViewModel` (one unified
read-only viewer, Files and Planning modes) + `DiffLinesView`.
read-only viewer, Files and Planning modes) render through `DiffAlignment` (pure — pairs diff
lines into side-by-side rows and computes word-diff spans) and `DiffTextView` (AvaloniaEdit +
TextMate highlighting, unified/split layout, optional line wrap, synced scrolling). The
split/wrap toggles persist to `ui.config.json` via `AppSettings`.
`ConflictResolverViewModel` is an in-app Rider-style 3-pane AvaloniaEdit merge editor for both
single-task and planning unit-merge conflicts. Full detail →
[review-merge](../../docs/explore-notes/review-merge.md).
The two surfaces are **not** variants of one control (read-only 2-way renderer over aligned rows
vs. 3-way editor over a writable document), but they share their AvaloniaEdit host plumbing via
`Views/Controls/DiffEditorSetup.cs`: the process-wide TextMate `Registry`, `InstallHighlighting`,
`ApplyGrammar`, the `Brush` resource fallback, the `Seg` segment, and `VerticalScrollSync`. Put
new editor boilerplate there rather than copying it a third time.
## Services
- **WorkerClient / IWorkerClient** — SignalR client on `http://127.0.0.1:47821/hub`, auto-reconnect with exponential backoff. The surface **tracks `WorkerHub`** — treat `src/ClaudeDo.Worker/Hub/WorkerHub.cs` as the canonical method list rather than duplicating it here. Events mirror `HubBroadcaster`. Lifecycle (`StartAsync`/`StopAsync`) and a few admin methods live only on the concrete `WorkerClient`.
@@ -93,6 +102,11 @@ uppercase.
Modals use `TaskCompletionSource` results behind the reusable `ModalShell` control — the dialog
sets the result on save/cancel, the caller awaits the TCS.
`ModalShell` also owns the window chrome for every modal: the titlebar drag goes through
`Window.BeginMoveDrag` (so Windows snap works — drag to the top edge to maximise; the OS only
snaps `CanResize="True"` windows, which is the opt-in), and it insets itself by the window's
`OffScreenMargin` so a maximised extended-client-area window isn't clipped at the edges.
## Gotchas
- **`PathIcon` *fills* its geometry.** Line-art/stroke icons must be authored as filled geometry or rendered with a stroked `Path` (e.g. `Icon.PlanDay` via the `Path.plan-icon` style). A pure stroke path in a `PathIcon` is **invisible**.
+50 -10
View File
@@ -108,6 +108,9 @@
<!-- Icon.Settings (gear) -->
<StreamGeometry x:Key="Icon.Settings">M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65a.5.5 0 0 0 .12-.64l-2-3.46a.5.5 0 0 0-.61-.22l-2.49 1a7.03 7.03 0 0 0-1.69-.98l-.38-2.65a.5.5 0 0 0-.5-.42h-4a.5.5 0 0 0-.5.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1a.5.5 0 0 0-.61.22l-2 3.46a.5.5 0 0 0 .12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65a.5.5 0 0 0-.12.64l2 3.46a.5.5 0 0 0 .61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65a.5.5 0 0 0 .5.42h4a.5.5 0 0 0 .5-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1a.5.5 0 0 0 .61-.22l2-3.46a.5.5 0 0 0-.12-.64l-2.11-1.65z</StreamGeometry>
<!-- Icon.Wrap — a full-width line above a line that wraps back with an arrowhead -->
<StreamGeometry x:Key="Icon.Wrap">M3 5 H21 V7 H3 Z M3 11 H17 A4 4 0 0 1 17 19 H12 V21 L7 18 L12 15 V17 H17 A2 2 0 0 0 17 13 H3 Z</StreamGeometry>
<!-- Icon.Skull — filled silhouette: rounded cranium + eye holes (EvenOdd) + jaw -->
<StreamGeometry x:Key="Icon.Skull">F0 M12 2 C7 2 4 5.5 4 10 C4 13.5 6 16 8 17.5 L8 19 C8 20 8.9 21 10 21 L10 18.5 L14 18.5 L14 21 C15.1 21 16 20 16 19 L16 17.5 C18 16 20 13.5 20 10 C20 5.5 17 2 12 2 Z M8.5 8 L8.5 12 L11 12 L11 8 Z M13 8 L13 12 L15.5 12 L15.5 8 Z</StreamGeometry>
@@ -323,8 +326,9 @@
<Setter Property="BorderBrush" Value="{StaticResource LineBrightBrush}" />
</Style>
<!-- Icon button: 24×24 square with hover surface -->
<Style Selector="Button.icon-btn">
<!-- Icon button: 24×24 square with hover surface. The ToggleButton variant is the same
chrome for a persisted on/off control (its "on" state is the :checked rule below). -->
<Style Selector="Button.icon-btn, ToggleButton.icon-btn">
<Setter Property="Width" Value="24" />
<Setter Property="Height" Value="24" />
<Setter Property="Padding" Value="0" />
@@ -335,10 +339,53 @@
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
<Style Selector="Button.icon-btn:pointerover /template/ ContentPresenter">
<Style Selector="Button.icon-btn:pointerover /template/ ContentPresenter, ToggleButton.icon-btn:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
<Setter Property="TextElement.Foreground" Value="{StaticResource TextBrush}" />
</Style>
<Style Selector="ToggleButton.icon-btn:checked /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource Surface3Brush}" />
<Setter Property="TextElement.Foreground" Value="{StaticResource AccentBrush}" />
</Style>
<!-- PathIcon fills with its own Foreground and does not pick up TextElement.Foreground,
so the icon's three states are set on the icon itself. -->
<Style Selector="ToggleButton.icon-btn PathIcon">
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
</Style>
<Style Selector="ToggleButton.icon-btn:pointerover PathIcon">
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
</Style>
<Style Selector="ToggleButton.icon-btn:checked PathIcon">
<Setter Property="Foreground" Value="{StaticResource AccentBrush}" />
</Style>
<!-- Segmented switch: one control, mutually exclusive segments, active one highlighted.
Use for picking a mode (not for independent on/off flags — those are icon toggles). -->
<Style Selector="Border.segmented">
<Setter Property="Background" Value="{StaticResource DeepBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="{StaticResource ButtonCornerRadius}" />
<Setter Property="Padding" Value="2" />
</Style>
<Style Selector="Border.segmented Button.segment">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Padding" Value="10,4" />
<Setter Property="CornerRadius" Value="4" />
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="{StaticResource FontSizeMono}" />
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
</Style>
<Style Selector="Border.segmented Button.segment:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
<Setter Property="TextElement.Foreground" Value="{StaticResource TextBrush}" />
</Style>
<!-- Declared after :pointerover so hovering the active segment keeps the active look. -->
<Style Selector="Border.segmented Button.segment.active /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource Surface3Brush}" />
<Setter Property="TextElement.Foreground" Value="{StaticResource AccentBrush}" />
</Style>
<!-- Stroke-rendered icon (for line-art geometries that PathIcon would fill away) -->
<Style Selector="Button.icon-btn Path.plan-icon">
@@ -1111,13 +1158,6 @@
<Setter Property="Background" Value="{StaticResource DeepBrush}" />
</Style>
<!-- Diff line-number gutter column -->
<Style Selector="TextBlock.diff-lineno">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="{StaticResource FontSizeMono}" />
<Setter Property="Foreground" Value="{StaticResource TextFaintBrush}" />
</Style>
<!-- Terminal selectable log text (SelectableTextBlock doesn't inherit the TextBlock terminal style) -->
<Style Selector="Border.terminal SelectableTextBlock">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
+8
View File
@@ -111,6 +111,14 @@
<SolidColorBrush x:Key="MergeResolvedEdgeBrush" Color="#806FA86B" /> <!-- resolved conflict map tick -->
<SolidColorBrush x:Key="AmberBrush" Color="#FFD4A574" /> <!-- solid amber (theirs label) -->
<!-- Diff viewer: filler/gap rows and intra-line (word) highlighting.
Whole-line add/delete tints reuse RunningTintBrush / ErrorTintBrush, which are low
enough alpha that syntax-highlighted text stays legible on top of them. -->
<SolidColorBrush x:Key="DiffFillerBrush" Color="#0AFFFFFF" /> <!-- "no line on this side" -->
<SolidColorBrush x:Key="DiffGapBrush" Color="#14FFFFFF" /> <!-- skipped region between hunks -->
<SolidColorBrush x:Key="DiffWordAddBrush" Color="#556FA86B" /> <!-- changed words, new side -->
<SolidColorBrush x:Key="DiffWordDelBrush" Color="#55C87060" /> <!-- changed words, old side -->
<!-- Window-body gradient layers (apply as LinearGradientBrush in the main content Border) -->
<LinearGradientBrush x:Key="DesktopBackgroundBrush" StartPoint="0%,0%" EndPoint="0%,100%">
<GradientStop Offset="0" Color="#FF05070A" />
+13 -2
View File
@@ -670,7 +670,12 @@ public sealed record AppSettingsDto(
List<ModelPresetDto>? ModelPresets = null,
int UsageGateFiveHourPct = 80,
int UsageGateSevenDayPct = 90,
int MaxTurnsCeiling = 80);
int MaxTurnsCeiling = 80,
// Throttle stages per bucket — dragged on the usage-monitor gauges, not typed in Settings.
int UsageThrottleFiveHourSoftPct = 50,
int UsageThrottleFiveHourHardPct = 65,
int UsageThrottleSevenDaySoftPct = 50,
int UsageThrottleSevenDayHardPct = 65);
// Per-model run defaults (effort + turn budget) edited in Settings → General.
public sealed record ModelPresetDto(string Model, string Effort, int MaxTurns);
@@ -764,7 +769,13 @@ public sealed record UsageSnapshotDto(
string? LastError,
int ConfiguredSlots,
int EffectiveSlots,
string? ThrottleBucket);
string? ThrottleBucket,
// Throttle stages per bucket, drawn (and dragged) on the usage-monitor gauges. Defaults match
// the DB defaults so an older worker that doesn't send them yet still yields sane markers.
int ThrottleFiveHourSoftPct = 50,
int ThrottleFiveHourHardPct = 65,
int ThrottleSevenDaySoftPct = 50,
int ThrottleSevenDayHardPct = 65);
public sealed record ModelUsageRowDto(
DateOnly Date,
@@ -601,6 +601,10 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
return;
}
// A hand-driven task (manual reminder or a live ConPTY session) produces no streamed
// agent output, so the Output tab would open empty -- its work shows up as git changes.
SelectedTab = row.IsManual || row.HasInteractiveSession ? "git" : "output";
_ = BindAsync(row, ct);
}
@@ -25,6 +25,10 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
// pick the flag up (see SyncInteractiveSessions).
private readonly HashSet<string> _interactiveSessionIds = new();
private static readonly TaskListFilterRegistry _filters = new();
// Two events (TaskUpdated + WorktreeUpdated) drive the same delta refresh, so two reads for
// one task can be in flight at once. Only the newest may write to the row.
private readonly Dictionary<string, long> _deltaSeq = new();
private long _deltaCounter;
public event EventHandler? SelectionChanged;
public event EventHandler? FocusAddTaskRequested;
@@ -161,6 +165,11 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
}
private async void OnWorkerTaskUpdated(string taskId)
=> await RefreshTaskFromWorkerAsync(taskId);
// Awaitable so tests can drive it deterministically. One retry, then a full reload:
// a swallowed exception here used to leave the row on a stale status permanently.
internal async Task RefreshTaskFromWorkerAsync(string taskId)
{
var list = _currentList;
if (list is null) return;
@@ -174,54 +183,79 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
return;
}
var seq = ++_deltaCounter;
_deltaSeq[taskId] = seq;
try
{
await using var db = await _dbFactory.CreateDbContextAsync();
var entity = await db.Tasks
.Include(t => t.List)
.Include(t => t.Worktree)
.FirstOrDefaultAsync(t => t.Id == taskId);
// A parent transition (finalize/discard) broadcasts only the parent's id, but it
// changes its children's derived state — finalize flips them Draft→Planned, discard
// deletes them. The delta path below only touches the parent row and never recomputes
// the child-derived flags (ParentFinalized, HasPlanningChildren) nor drops deleted
// children, so reconcile the whole list when the updated task is (or owns) a subtree.
if (entity is not null &&
(entity.PlanningPhase != PlanningPhase.None || Items.Any(r => r.ParentTaskId == entity.Id)))
{
LoadForList(list);
return;
}
var existing = Items.FirstOrDefault(r => r.Id == taskId);
if (entity is null)
{
if (existing is not null) Items.Remove(existing);
}
else
{
var matches = TaskMatchesList(entity, list);
if (existing is not null && matches) existing.UpdateFromEntity(entity);
else if (existing is not null) Items.Remove(existing);
else if (matches) { LoadForList(list); return; }
else return;
}
// Keep the parent's HasQueuedSubtasks flag in sync when a child's status flips.
if (entity is not null && !string.IsNullOrEmpty(entity.ParentTaskId))
{
var parent = Items.FirstOrDefault(r => r.Id == entity.ParentTaskId);
if (parent is not null)
parent.HasQueuedSubtasks = Items.Any(r =>
r.ParentTaskId == parent.Id && (r.IsQueued || r.IsWaiting));
}
Regroup();
UpdateSubtitle();
await ApplyDeltaAsync(taskId, list, seq);
}
catch { }
catch (Exception first)
{
System.Diagnostics.Debug.WriteLine(
$"TasksIsland: delta refresh for {taskId} failed ({first.Message}); retrying");
try
{
await ApplyDeltaAsync(taskId, list, seq);
}
catch (Exception second)
{
System.Diagnostics.Debug.WriteLine(
$"TasksIsland: delta retry for {taskId} failed ({second.Message}); full reload");
LoadForList(list);
}
}
}
private async Task ApplyDeltaAsync(string taskId, ListNavItemViewModel list, long seq)
{
await using var db = await _dbFactory.CreateDbContextAsync();
var entity = await db.Tasks
.Include(t => t.List)
.Include(t => t.Worktree)
.FirstOrDefaultAsync(t => t.Id == taskId);
// A newer refresh for this task started while we were reading — its result is fresher.
if (_deltaSeq.TryGetValue(taskId, out var current) && current != seq) return;
// A parent transition (finalize/discard) broadcasts only the parent's id, but it
// changes its children's derived state — finalize flips them Draft→Planned, discard
// deletes them. The delta path below only touches the parent row and never recomputes
// the child-derived flags (ParentFinalized, HasPlanningChildren) nor drops deleted
// children, so reconcile the whole list when the updated task is (or owns) a subtree.
if (entity is not null &&
(entity.PlanningPhase != PlanningPhase.None || Items.Any(r => r.ParentTaskId == entity.Id)))
{
LoadForList(list);
return;
}
var existing = Items.FirstOrDefault(r => r.Id == taskId);
if (entity is null)
{
if (existing is not null) Items.Remove(existing);
}
else
{
var matches = TaskMatchesList(entity, list);
if (existing is not null && matches) existing.UpdateFromEntity(entity);
else if (existing is not null) Items.Remove(existing);
else if (matches) { LoadForList(list); return; }
else return;
}
// Keep the parent's HasQueuedSubtasks flag in sync when a child's status flips.
if (entity is not null && !string.IsNullOrEmpty(entity.ParentTaskId))
{
var parent = Items.FirstOrDefault(r => r.Id == entity.ParentTaskId);
if (parent is not null)
parent.HasQueuedSubtasks = Items.Any(r =>
r.ParentTaskId == parent.Id && (r.IsQueued || r.IsWaiting));
}
Regroup();
UpdateSubtitle();
}
// NOTE: virtual:queued/virtual:running cannot be decided by a single entity — a Planning
@@ -570,7 +570,9 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
{
var vm = _usageMonitorVmFactory();
vm.ErrorReported += FlashFooterError;
await vm.LoadAsync();
// Show first, load after: the initial transcript scan takes seconds, and awaiting it
// here left the pill looking unresponsive until the window finally appeared.
vm.BeginLoad();
await Dialogs.ShowUsageMonitorAsync(vm);
}
finally { _usageMonitorOpen = false; }
@@ -0,0 +1,231 @@
namespace ClaudeDo.Ui.ViewModels.Modals;
/// Which role a row plays on one side of the split view.
public enum AlignedSide { Ctx, Del, Add, Filler, Gap }
/// A character range inside a row's text, used for intra-line (word) highlighting.
public readonly record struct TextSpan(int Start, int Length);
/// One rendered row of the side-by-side view. Left is the old state, right the new.
public sealed record SplitRow(
AlignedSide LeftKind, int? OldNo, string LeftText, IReadOnlyList<TextSpan> LeftSpans,
AlignedSide RightKind, int? NewNo, string RightText, IReadOnlyList<TextSpan> RightSpans);
/// One rendered row of the single-pane view.
public sealed record UnifiedRow(
AlignedSide Kind, int? OldNo, int? NewNo, string Text, IReadOnlyList<TextSpan> Spans);
/// Render-ready diff. Row index <c>i</c> is document line <c>i + 1</c> in the matching text —
/// that mapping is what the line-number margin and both background renderers rely on.
public sealed record AlignedDiff(
IReadOnlyList<SplitRow> SplitRows, string LeftText, string RightText,
IReadOnlyList<UnifiedRow> UnifiedRows, string UnifiedText)
{
public static readonly AlignedDiff Empty = new(
Array.Empty<SplitRow>(), "", "", Array.Empty<UnifiedRow>(), "");
}
/// Turns a parsed unified-diff line stream into aligned rows. Pure — no Avalonia types,
/// so all of the interesting behaviour is unit-testable.
public static class DiffAlignment
{
/// Marker text for a skipped region between two hunks.
public const string GapText = "⋯";
/// Word diff is O(n·m) in tokens and is noise on lines that were rewritten wholesale,
/// so it is skipped on very long lines, on token-heavy lines, and on dissimilar pairs.
public const int MaxWordDiffChars = 2000;
public const int MaxWordDiffTokens = 400;
public const double MinWordDiffSimilarity = 0.5;
internal static readonly IReadOnlyList<TextSpan> NoSpans = Array.Empty<TextSpan>();
public static AlignedDiff Build(IReadOnlyList<DiffLineViewModel>? lines)
{
if (lines is null || lines.Count == 0) return AlignedDiff.Empty;
var split = new List<SplitRow>();
var unified = new List<UnifiedRow>();
int? prevOld = null, prevNew = null;
var i = 0;
while (i < lines.Count)
{
var line = lines[i];
// File-header rows only appear in the legacy flattened stream; alignment is per file.
if (line.Kind == DiffLineKind.File) { i++; continue; }
if (IsGap(prevOld, prevNew, line))
{
split.Add(new SplitRow(AlignedSide.Gap, null, GapText, NoSpans,
AlignedSide.Gap, null, GapText, NoSpans));
unified.Add(new UnifiedRow(AlignedSide.Gap, null, null, GapText, NoSpans));
}
if (line.Kind == DiffLineKind.Ctx)
{
split.Add(new SplitRow(AlignedSide.Ctx, line.OldNo, line.Text, NoSpans,
AlignedSide.Ctx, line.NewNo, line.Text, NoSpans));
unified.Add(new UnifiedRow(AlignedSide.Ctx, line.OldNo, line.NewNo, line.Text, NoSpans));
prevOld = line.OldNo;
prevNew = line.NewNo;
i++;
continue;
}
// A change block: the parser always emits the deletions before the additions.
var dels = new List<DiffLineViewModel>();
while (i < lines.Count && lines[i].Kind == DiffLineKind.Del) dels.Add(lines[i++]);
var adds = new List<DiffLineViewModel>();
while (i < lines.Count && lines[i].Kind == DiffLineKind.Add) adds.Add(lines[i++]);
EmitChangeBlock(dels, adds, split, unified);
if (dels.Count > 0) prevOld = dels[^1].OldNo;
if (adds.Count > 0) prevNew = adds[^1].NewNo;
}
return new AlignedDiff(
split,
string.Join('\n', split.Select(r => r.LeftText)),
string.Join('\n', split.Select(r => r.RightText)),
unified,
string.Join('\n', unified.Select(r => r.Text)));
}
/// The parser drops "@@" headers, so a skipped region is visible only as a jump in the
/// line numbers. Nothing precedes the first row, so it can never open with a gap.
private static bool IsGap(int? prevOld, int? prevNew, DiffLineViewModel next)
{
if (prevOld is { } po && next.OldNo is { } no && no > po + 1) return true;
if (prevNew is { } pn && next.NewNo is { } nn && nn > pn + 1) return true;
return false;
}
private static void EmitChangeBlock(
List<DiffLineViewModel> dels, List<DiffLineViewModel> adds,
List<SplitRow> split, List<UnifiedRow> unified)
{
var paired = Math.Min(dels.Count, adds.Count);
// Word diff is only meaningful for rows that stand 1:1 opposite each other.
var spans = new (IReadOnlyList<TextSpan> Left, IReadOnlyList<TextSpan> Right)[paired];
for (var k = 0; k < paired; k++)
spans[k] = WordDiff(dels[k].Text, adds[k].Text);
for (var k = 0; k < paired; k++)
split.Add(new SplitRow(AlignedSide.Del, dels[k].OldNo, dels[k].Text, spans[k].Left,
AlignedSide.Add, adds[k].NewNo, adds[k].Text, spans[k].Right));
for (var k = paired; k < dels.Count; k++)
split.Add(new SplitRow(AlignedSide.Del, dels[k].OldNo, dels[k].Text, NoSpans,
AlignedSide.Filler, null, "", NoSpans));
for (var k = paired; k < adds.Count; k++)
split.Add(new SplitRow(AlignedSide.Filler, null, "", NoSpans,
AlignedSide.Add, adds[k].NewNo, adds[k].Text, NoSpans));
for (var k = 0; k < dels.Count; k++)
unified.Add(new UnifiedRow(AlignedSide.Del, dels[k].OldNo, null, dels[k].Text,
k < paired ? spans[k].Left : NoSpans));
for (var k = 0; k < adds.Count; k++)
unified.Add(new UnifiedRow(AlignedSide.Add, null, adds[k].NewNo, adds[k].Text,
k < paired ? spans[k].Right : NoSpans));
}
/// Changed character ranges on each side of a 1:1 line pair, or no spans when the pair
/// is too long, too token-heavy or too dissimilar for per-word highlighting to help.
internal static (IReadOnlyList<TextSpan> Left, IReadOnlyList<TextSpan> Right) WordDiff(
string left, string right)
{
if (left.Length == 0 || right.Length == 0 || string.Equals(left, right, StringComparison.Ordinal))
return (NoSpans, NoSpans);
if (left.Length > MaxWordDiffChars || right.Length > MaxWordDiffChars)
return (NoSpans, NoSpans);
var a = Tokenize(left);
var b = Tokenize(right);
if (a.Count > MaxWordDiffTokens || b.Count > MaxWordDiffTokens)
return (NoSpans, NoSpans);
var (keepA, keepB, common) = LongestCommonSubsequence(a, b);
var similarity = common / (double)Math.Max(a.Count, b.Count);
if (similarity < MinWordDiffSimilarity) return (NoSpans, NoSpans);
return (SpansForUnmatched(a, keepA), SpansForUnmatched(b, keepB));
}
/// Splits a line into runs of word characters, runs of whitespace, and single
/// punctuation characters — the granularity that makes an identifier rename read as
/// one changed token rather than a string of changed characters.
private static List<Token> Tokenize(string s)
{
var tokens = new List<Token>();
var i = 0;
while (i < s.Length)
{
var start = i;
if (IsWordChar(s[i]))
while (i < s.Length && IsWordChar(s[i])) i++;
else if (char.IsWhiteSpace(s[i]))
while (i < s.Length && char.IsWhiteSpace(s[i])) i++;
else
i++;
tokens.Add(new Token(start, s[start..i]));
}
return tokens;
}
private static bool IsWordChar(char c) => char.IsLetterOrDigit(c) || c == '_';
private static (bool[] KeepA, bool[] KeepB, int Common) LongestCommonSubsequence(
List<Token> a, List<Token> b)
{
var n = a.Count;
var m = b.Count;
var dp = new int[n + 1, m + 1];
for (var i = n - 1; i >= 0; i--)
for (var j = m - 1; j >= 0; j--)
dp[i, j] = string.Equals(a[i].Text, b[j].Text, StringComparison.Ordinal)
? dp[i + 1, j + 1] + 1
: Math.Max(dp[i + 1, j], dp[i, j + 1]);
var keepA = new bool[n];
var keepB = new bool[m];
int x = 0, y = 0;
while (x < n && y < m)
{
if (string.Equals(a[x].Text, b[y].Text, StringComparison.Ordinal))
{
keepA[x] = keepB[y] = true;
x++; y++;
}
else if (dp[x + 1, y] >= dp[x, y + 1]) x++;
else y++;
}
return (keepA, keepB, dp[0, 0]);
}
/// Merges runs of consecutive unmatched tokens so "zz" is one span, not two.
private static IReadOnlyList<TextSpan> SpansForUnmatched(List<Token> tokens, bool[] keep)
{
var spans = new List<TextSpan>();
var i = 0;
while (i < tokens.Count)
{
if (keep[i]) { i++; continue; }
var start = tokens[i].Start;
var end = start + tokens[i].Text.Length;
i++;
while (i < tokens.Count && !keep[i])
{
end = tokens[i].Start + tokens[i].Text.Length;
i++;
}
spans.Add(new TextSpan(start, end - start));
}
return spans.Count == 0 ? NoSpans : spans;
}
/// A tokenized slice of a line, carrying its offset so spans map back to characters.
private readonly record struct Token(int Start, string Text);
}
@@ -3,7 +3,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
namespace ClaudeDo.Ui.ViewModels.Modals;
// Shared diff models used by UnifiedDiffParser, DiffLinesView and DiffViewerViewModel.
// Shared diff models used by UnifiedDiffParser, DiffTextView and DiffViewerViewModel.
public enum DiffLineKind { Add, Del, Ctx, File }
@@ -15,20 +15,6 @@ public sealed class DiffLineViewModel
public int? OldNo { get; init; }
public int? NewNo { get; init; }
public required string Text { get; init; }
public string ClassName => Kind switch
{
DiffLineKind.Add => "add",
DiffLineKind.Del => "del",
DiffLineKind.File => "file",
_ => "ctx",
};
public string Sign => Kind switch
{
DiffLineKind.Add => "+",
DiffLineKind.Del => "-",
_ => " ",
};
}
public sealed class DiffFileViewModel
@@ -1,7 +1,10 @@
using System;
using System.Collections.ObjectModel;
using System.IO;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data.Git;
using ClaudeDo.Ui;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
@@ -21,6 +24,7 @@ public sealed partial class DiffViewerViewModel : ViewModelBase
{
private readonly GitService _git;
private readonly IWorkerClient _worker;
private readonly AppSettings _settings;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsPlanning))]
@@ -52,10 +56,41 @@ public sealed partial class DiffViewerViewModel : ViewModelBase
// ── Right pane ──────────────────────────────────────────────────────────
[ObservableProperty] private DiffFileViewModel? _selectedFile; // Files mode
public ObservableCollection<DiffLineViewModel> DiffLines { get; } = new(); // Planning mode
// Planning mode: one entry per file so each gets its own editor and grammar.
public ObservableCollection<DiffFileViewModel> PlanningFiles { get; } = new();
[ObservableProperty] private string _displayedDiff = "";
[ObservableProperty] private string? _statusMessage;
// ── View toggles (persisted to ui.config.json) ──────────────────────────
[ObservableProperty] private bool _isSplitView;
[ObservableProperty] private bool _wrapLines;
// The layout picker is a segmented switch, so each segment sets its own mode rather than
// flipping a shared flag — clicking the already-active segment must be a no-op.
[RelayCommand] private void ShowUnified() => IsSplitView = false;
[RelayCommand] private void ShowSplit() => IsSplitView = true;
partial void OnIsSplitViewChanged(bool value)
{
_settings.DiffViewMode = value ? "split" : "unified";
PersistViewPreferences();
}
partial void OnWrapLinesChanged(bool value)
{
_settings.DiffWrapLines = value;
PersistViewPreferences();
}
/// A failed preference write must never take the diff viewer down with it; the toggle
/// still works for this session, it just won't survive a restart.
private void PersistViewPreferences()
{
try { _settings.Save(); }
catch (IOException) { }
catch (UnauthorizedAccessException) { }
}
// ── Planning combined toggle ────────────────────────────────────────────
[ObservableProperty] private bool _isCombinedMode;
[ObservableProperty] private string? _combinedWarning;
@@ -63,10 +98,13 @@ public sealed partial class DiffViewerViewModel : ViewModelBase
public Action? CloseAction { get; set; }
public DiffViewerViewModel(GitService git, IWorkerClient worker)
public DiffViewerViewModel(GitService git, IWorkerClient worker, AppSettings settings)
{
_git = git;
_worker = worker;
_settings = settings;
_isSplitView = string.Equals(settings.DiffViewMode, "split", StringComparison.OrdinalIgnoreCase);
_wrapLines = settings.DiffWrapLines;
}
[RelayCommand]
@@ -218,9 +256,9 @@ public sealed partial class DiffViewerViewModel : ViewModelBase
partial void OnDisplayedDiffChanged(string value)
{
DiffLines.Clear();
foreach (var line in UnifiedDiffParser.Flatten(UnifiedDiffParser.Parse(value)))
DiffLines.Add(line);
PlanningFiles.Clear();
foreach (var file in UnifiedDiffParser.Parse(value))
PlanningFiles.Add(file);
}
// ── Merge (Files mode, branch source) ───────────────────────────────────
@@ -19,6 +19,9 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
public PrimeClaudeTabViewModel Prime { get; }
public OnlineInboxSettingsViewModel OnlineInbox { get; }
public SessionSkillsSettingsTabViewModel SessionSkills { get; }
// Online Inbox ist unfertig und vor Usern verborgen — hier auf true für Reaktivierung.
public bool ShowOnlineInbox => false;
[ObservableProperty] private string _validationError = "";
[ObservableProperty] private bool _isBusy;
@@ -48,6 +51,10 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
SessionSkills = new SessionSkillsSettingsTabViewModel(worker);
}
// Throttle stages are edited by dragging the usage-monitor gauges, not on any Settings tab —
// carried through load→save verbatim so saving Settings can never reset a dragged value.
private (int FiveSoft, int FiveHard, int SevenSoft, int SevenHard) _throttleStages = (50, 65, 50, 65);
public async Task LoadAsync()
{
IsBusy = true;
@@ -64,6 +71,9 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
General.MaxParallelExecutions = dto.MaxParallelExecutions;
General.UsageGateFiveHourPct = dto.UsageGateFiveHourPct;
General.UsageGateSevenDayPct = dto.UsageGateSevenDayPct;
_throttleStages = (
dto.UsageThrottleFiveHourSoftPct, dto.UsageThrottleFiveHourHardPct,
dto.UsageThrottleSevenDaySoftPct, dto.UsageThrottleSevenDayHardPct);
Worktrees.WorktreeStrategy = dto.WorktreeStrategy ?? "sibling";
Worktrees.CentralWorktreeRoot = dto.CentralWorktreeRoot;
Worktrees.WorktreeAutoCleanupEnabled = dto.WorktreeAutoCleanupEnabled;
@@ -115,7 +125,11 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
General.ModelPresetDtos(),
General.UsageGateFiveHourPct,
General.UsageGateSevenDayPct,
General.MaxTurnsCeiling);
General.MaxTurnsCeiling,
_throttleStages.FiveSoft,
_throttleStages.FiveHard,
_throttleStages.SevenSoft,
_throttleStages.SevenHard);
await _worker.UpdateAppSettingsAsync(dto);
await Prime.SaveAsync();
await OnlineInbox.SaveAsync();
@@ -132,21 +132,6 @@ public static class UnifiedDiffParser
return files;
}
/// Flattens multiple parsed files into a single line stream, inserting a
/// file-header row before each file so boundaries are visible in a
/// single-pane (combined) view.
public static List<DiffLineViewModel> Flatten(IEnumerable<DiffFileViewModel> files)
{
var lines = new List<DiffLineViewModel>();
foreach (var file in files)
{
lines.Add(new DiffLineViewModel { Kind = DiffLineKind.File, Text = file.Path });
foreach (var line in file.Lines)
lines.Add(line);
}
return lines;
}
private static void ParseHunkHeader(string header, out int oldStart, out int newStart)
{
oldStart = 1; newStart = 1;
@@ -20,7 +20,6 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
public event Action<string>? ErrorReported;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(GaugeRows))]
[NotifyPropertyChangedFor(nameof(IsStale))]
[NotifyPropertyChangedFor(nameof(LastError))]
[NotifyPropertyChangedFor(nameof(IsGateBlocked))]
@@ -53,8 +52,8 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
public bool ModelsEmpty => !IsBusy && ModelRows.Count == 0;
public bool TasksEmpty => !IsBusy && TaskRows.Count == 0;
public IReadOnlyList<UsageGaugeRowViewModel> GaugeRows =>
Snapshot is null ? Array.Empty<UsageGaugeRowViewModel>() : Snapshot.Limits.Select(BuildGaugeRow).ToList();
[ObservableProperty]
private IReadOnlyList<UsageGaugeRowViewModel> _gaugeRows = Array.Empty<UsageGaugeRowViewModel>();
public bool IsStale => Snapshot?.IsStale == true;
public string? LastError => Snapshot?.LastError;
@@ -83,13 +82,30 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
CloseAction?.Invoke();
}
/// <summary>
/// Starts the load without blocking the caller, so the host can show the window right away and
/// let it fill in behind the busy spinner. The first load per worker process pays a full scan of
/// <c>~/.claude/projects</c> (hundreds of MB of transcripts) — awaiting it before showing the
/// window made the usage pill look like it swallowed the click.
/// </summary>
public void BeginLoad() => _ = LoadAsync();
public async Task LoadAsync()
{
Snapshot = await _worker.GetUsageSnapshotAsync();
_worker.UsageUpdatedEvent -= OnUsageUpdated;
_worker.UsageUpdatedEvent += OnUsageUpdated;
ApplyPresetRange(SelectedPresetDays);
await LoadUsageDataAsync();
IsBusy = true;
try
{
Snapshot = await _worker.GetUsageSnapshotAsync();
_worker.UsageUpdatedEvent -= OnUsageUpdated;
_worker.UsageUpdatedEvent += OnUsageUpdated;
ApplyPresetRange(SelectedPresetDays);
await LoadUsageDataAsync();
}
catch (Exception ex)
{
ErrorReported?.Invoke(Loc.T("vm.usageMonitor.loadFailed", ex.Message));
}
finally { IsBusy = false; }
}
private void OnUsageUpdated(UsageSnapshotDto snapshot) => Snapshot = snapshot;
@@ -173,6 +189,99 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
finally { IsBusy = false; }
}
partial void OnSnapshotChanged(UsageSnapshotDto? value) => SyncGaugeRows();
/// <summary>
/// Folds a fresh snapshot into the existing rows instead of rebuilding them, so a poll landing
/// while the user works the markers doesn't swap the instances out from under the drag.
/// </summary>
private void SyncGaugeRows()
{
var limits = Snapshot?.Limits ?? (IReadOnlyList<UsageLimitDto>)Array.Empty<UsageLimitDto>();
var existing = GaugeRows.ToDictionary(r => r.Key);
var rows = new List<UsageGaugeRowViewModel>(limits.Count);
foreach (var limit in limits)
{
var key = GaugeKey(limit);
var bucket = GaugeBucket(limit);
var (soft, hard, gate) = StagesFor(bucket);
var label = BuildGaugeLabel(limit);
if (existing.TryGetValue(key, out var row))
{
row.Update(label, limit.Percent, limit.Severity, limit.ResetsAt, soft, hard, gate);
rows.Add(row);
}
else
{
rows.Add(new UsageGaugeRowViewModel(
key, bucket, label, limit.Percent, limit.Severity, limit.ResetsAt,
soft, hard, gate, SaveStagesAsync));
}
}
if (!rows.SequenceEqual(GaugeRows)) GaugeRows = rows;
}
// Scoped weekly buckets are plan-dependent and share no settings row, so they stay read-only.
private static string? GaugeBucket(UsageLimitDto limit) => limit.Kind switch
{
"session" => "five_hour",
"weekly_all" => "seven_day",
_ => null,
};
private static string GaugeKey(UsageLimitDto limit) =>
limit.Kind == "weekly_scoped" ? $"weekly_scoped:{limit.ScopeModelDisplayName}" : limit.Kind;
private (int? Soft, int? Hard, int? Gate) StagesFor(string? bucket) => (bucket, Snapshot) switch
{
("five_hour", { } s) => (s.ThrottleFiveHourSoftPct, s.ThrottleFiveHourHardPct, s.FiveHourThresholdPct),
("seven_day", { } s) => (s.ThrottleSevenDaySoftPct, s.ThrottleSevenDayHardPct, s.SevenDayThresholdPct),
_ => (null, null, null),
};
/// <summary>
/// Persists one gauge's stages after a drag. Read-modify-write against the current settings, so
/// this never clobbers a field the usage monitor doesn't own.
/// </summary>
private async Task SaveStagesAsync(UsageGaugeRowViewModel row)
{
if (row.Bucket is null || row.SoftPct is not { } soft || row.HardPct is not { } hard || row.GatePct is not { } gate)
return;
try
{
var settings = await _worker.GetAppSettingsAsync();
if (settings is null)
{
ErrorReported?.Invoke(Loc.T("vm.usageMonitor.thresholdSaveFailed", Loc.T("vm.settingsModal.workerOffline")));
return;
}
var updated = row.Bucket == "five_hour"
? settings with
{
UsageThrottleFiveHourSoftPct = soft,
UsageThrottleFiveHourHardPct = hard,
UsageGateFiveHourPct = gate,
}
: settings with
{
UsageThrottleSevenDaySoftPct = soft,
UsageThrottleSevenDayHardPct = hard,
UsageGateSevenDayPct = gate,
};
await _worker.UpdateAppSettingsAsync(updated);
}
catch (Exception ex)
{
ErrorReported?.Invoke(Loc.T("vm.usageMonitor.thresholdSaveFailed", ex.Message));
}
}
private static string BuildGaugeLabel(UsageLimitDto limit) => limit.Kind switch
{
"session" => Loc.T("modals.usageMonitor.gaugeSession"),
@@ -182,17 +291,6 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
_ => limit.Kind,
};
private UsageGaugeRowViewModel BuildGaugeRow(UsageLimitDto limit)
{
int? threshold = limit.Kind switch
{
"session" => Snapshot?.FiveHourThresholdPct,
"weekly_all" => Snapshot?.SevenDayThresholdPct,
_ => null,
};
return new UsageGaugeRowViewModel(BuildGaugeLabel(limit), limit.Percent, limit.Severity, limit.ResetsAt, threshold);
}
private static IReadOnlyList<ModelUsageDisplayRow> BuildModelDisplayRows(IReadOnlyList<ModelUsageRowDto> rows)
{
var built = new List<ModelUsageDisplayRow>();
@@ -222,22 +320,115 @@ public sealed partial class UsageMonitorModalViewModel : ViewModelBase
}
}
public sealed record UsageGaugeRowViewModel(
string Label,
double Percent,
string Severity,
DateTimeOffset? ResetsAt,
int? ThresholdPercent)
/// <summary>
/// One usage gauge. The two real buckets (5h session, 7d week) carry their three stage thresholds
/// and are adjustable by dragging; plan-dependent scoped buckets render as a plain bar.
/// </summary>
public sealed partial class UsageGaugeRowViewModel : ObservableObject
{
private readonly Func<UsageGaugeRowViewModel, Task>? _commit;
public UsageGaugeRowViewModel(
string key,
string? bucket,
string label,
double percent,
string severity,
DateTimeOffset? resetsAt,
int? softPct,
int? hardPct,
int? gatePct,
Func<UsageGaugeRowViewModel, Task>? commit = null)
{
Key = key;
Bucket = bucket;
_label = label;
_percent = percent;
_severity = severity;
_resetsAt = resetsAt;
_softPct = softPct;
_hardPct = hardPct;
_gatePct = gatePct;
_commit = commit;
}
/// <summary>Identity across snapshot updates, so a live poll updates rows instead of replacing them.</summary>
public string Key { get; }
/// <summary>Which settings bucket a drag writes to: <c>five_hour</c>, <c>seven_day</c>, or null.</summary>
public string? Bucket { get; }
[ObservableProperty] private string _label;
[ObservableProperty] private double _percent;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsWarnSeverity))]
private string _severity;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ResetText))]
private DateTimeOffset? _resetsAt;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsAdjustable))]
private int? _softPct;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsAdjustable))]
private int? _hardPct;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsAdjustable))]
private int? _gatePct;
public bool IsAdjustable => Bucket is not null && SoftPct is not null && HardPct is not null && GatePct is not null;
public bool IsWarnSeverity => !string.Equals(Severity, "normal", StringComparison.OrdinalIgnoreCase);
public string ResetText => ResetsAt is { } r ? Loc.T("modals.usageMonitor.resetIn", FormatRemaining(r)) : "";
// Matches the gauge card's inner track width in the view (240 card width - 12*2 padding).
private const double GaugeTrackWidthPx = 216;
/// <summary>Live values from a fresh snapshot, without replacing the row instance mid-view.</summary>
public void Update(string label, double percent, string severity, DateTimeOffset? resetsAt,
int? softPct, int? hardPct, int? gatePct)
{
Label = label;
Percent = percent;
Severity = severity;
ResetsAt = resetsAt;
SoftPct = softPct;
HardPct = hardPct;
GatePct = gatePct;
}
public double ThresholdMarkerLeftPx =>
ThresholdPercent is { } t ? GaugeTrackWidthPx * Math.Clamp(t, 0, 100) / 100.0 : 0;
/// <summary>Raised by the gauge control when a drag ends — that is when the value is persisted.</summary>
[RelayCommand]
private Task Commit() => _commit?.Invoke(this) ?? Task.CompletedTask;
// One per legend input box. A typed value goes through the same clamp as a dragged one, so a box
// can't invert the order — and only the edited stage moves, never its neighbours.
[RelayCommand] private Task CommitSoft() => CommitStage(UsageThresholdDrag.Stage.Soft);
[RelayCommand] private Task CommitHard() => CommitStage(UsageThresholdDrag.Stage.Hard);
[RelayCommand] private Task CommitGate() => CommitStage(UsageThresholdDrag.Stage.Gate);
private Task CommitStage(UsageThresholdDrag.Stage stage)
{
if (!IsAdjustable) return Task.CompletedTask;
var edited = stage switch
{
UsageThresholdDrag.Stage.Soft => SoftPct!.Value,
UsageThresholdDrag.Stage.Hard => HardPct!.Value,
_ => GatePct!.Value,
};
var (soft, hard, gate) = UsageThresholdDrag.Apply(
SoftPct!.Value, HardPct!.Value, GatePct!.Value, stage, edited);
SoftPct = soft;
HardPct = hard;
GatePct = gate;
return Commit();
}
private static string FormatRemaining(DateTimeOffset resetsAt)
{
@@ -251,6 +442,54 @@ public sealed record UsageGaugeRowViewModel(
}
}
/// <summary>
/// Drag math for the gauge stage markers, kept out of the control so it can be tested directly:
/// every stage stays inside 0..100 and never crosses a neighbour (soft ≤ hard ≤ gate). A neighbour
/// at 0 means "that stage is off" and therefore does not constrain anything.
/// </summary>
public static class UsageThresholdDrag
{
public enum Stage { Soft, Hard, Gate }
/// <summary>Pointer reach for grabbing a marker, as a share of the bar width.</summary>
public static Stage? Nearest(int soft, int hard, int gate, double percent, double tolerancePercent)
{
Stage? best = null;
var bestDistance = double.MaxValue;
foreach (var (stage, value) in new[] { (Stage.Soft, soft), (Stage.Hard, hard), (Stage.Gate, gate) })
{
var distance = Math.Abs(percent - value);
if (distance > tolerancePercent || distance >= bestDistance) continue;
best = stage;
bestDistance = distance;
}
return best;
}
public static (int Soft, int Hard, int Gate) Apply(int soft, int hard, int gate, Stage stage, double rawPercent)
{
var value = (int)Math.Round(Math.Clamp(rawPercent, 0, 100));
return stage switch
{
Stage.Soft => (ClampRange(value, 0, UpperBound(hard, gate)), hard, gate),
Stage.Hard => (soft, ClampRange(value, soft, UpperBound(gate, 100)), gate),
Stage.Gate => (soft, hard, ClampRange(value, Math.Max(soft, hard), 100)),
_ => (soft, hard, gate),
};
}
// A neighbour of 0 is switched off and must not pin the dragged marker to 0.
private static int UpperBound(int nearest, int fallback) =>
nearest > 0 ? nearest : (fallback > 0 ? fallback : 100);
// An already-inconsistent stored config (min above max) must not throw mid-drag.
private static int ClampRange(int value, int min, int max) =>
max < min ? max : Math.Clamp(value, min, max);
}
public sealed record ModelUsageDisplayRow(
string Model,
long ClaudeDoInputTokens,
@@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.IO;
using System.Linq;
using System.Text;
using Avalonia;
@@ -9,22 +8,21 @@ using Avalonia.Controls;
using Avalonia.Controls.Shapes;
using Avalonia.Media;
using Avalonia.Threading;
using Avalonia.VisualTree;
using AvaloniaEdit;
using AvaloniaEdit.Document;
using AvaloniaEdit.Editing;
using AvaloniaEdit.Rendering;
using AvaloniaEdit.TextMate;
using ClaudeDo.Ui.ViewModels.Conflicts;
using TextMateSharp.Grammars;
using ClaudeDo.Ui.Views.Controls;
using Seg = ClaudeDo.Ui.Views.Controls.DiffEditorSetup.Seg;
namespace ClaudeDo.Ui.Views.Conflicts;
public partial class ConflictResolverView : Window
{
private ConflictResolverViewModel? _vm;
private RegistryOptions? _registry;
private TextMate.Installation? _oursTm, _resultTm, _theirsTm;
private bool _editorsReady;
// Fixed conflict spans for the read-only side panes (recomputed each rebuild).
private List<(int Offset, int Length, MergeConflictBlock Block)> _oursSpans = new();
@@ -34,11 +32,8 @@ public partial class ConflictResolverView : Window
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;
@@ -56,12 +51,6 @@ public partial class ConflictResolverView : Window
_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;
@@ -76,19 +65,24 @@ public partial class ConflictResolverView : Window
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);
if (_editorsReady) return;
_editorsReady = true;
_oursTm = DiffEditorSetup.InstallHighlighting(OursEditor);
_resultTm = DiffEditorSetup.InstallHighlighting(ResultEditor);
_theirsTm = DiffEditorSetup.InstallHighlighting(TheirsEditor);
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"));
// Panes are whole files here, not aligned rows, so raw pixel offsets line up and the
// gutter buttons only have to be repositioned once per scroll.
new VerticalScrollSync(new[] { OursEditor, ResultEditor, TheirsEditor },
afterSync: PositionGutters);
var conflict = DiffEditorSetup.Brush(this, "MergeConflictTintBrush", Color.Parse("#28C87060"));
var resolved = DiffEditorSetup.Brush(this, "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(
@@ -97,13 +91,6 @@ public partial class ConflictResolverView : Window
() => _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()
@@ -151,16 +138,11 @@ public partial class ConflictResolverView : Window
_hookedBlocks.Add(block);
}
ApplyGrammar(file.Path);
DiffEditorSetup.ApplyGrammar(file.Path, _oursTm, _resultTm, _theirsTm);
InvalidateRenderers();
}
finally { _rebuilding = false; }
if (!_wired)
{
_wired = true;
Dispatcher.UIThread.Post(HookScrollSync, DispatcherPriority.Loaded);
}
QueueGutters();
}
@@ -317,8 +299,8 @@ public partial class ConflictResolverView : Window
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"));
var unresolved = DiffEditorSetup.Brush(this, "MergeConflictEdgeBrush", Color.Parse("#80C87060"));
var resolved = DiffEditorSetup.Brush(this, "MergeResolvedEdgeBrush", Color.Parse("#806FA86B"));
foreach (var region in _resultRegions)
{
@@ -348,31 +330,6 @@ public partial class ConflictResolverView : Window
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;
@@ -390,30 +347,8 @@ public partial class ConflictResolverView : Window
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
@@ -0,0 +1,170 @@
using System;
using System.IO;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.VisualTree;
using AvaloniaEdit;
using AvaloniaEdit.Document;
using AvaloniaEdit.Rendering;
using AvaloniaEdit.TextMate;
using TextMateSharp.Grammars;
namespace ClaudeDo.Ui.Views.Controls;
/// <summary>
/// The AvaloniaEdit host plumbing shared by the two diff surfaces: the read-only side-by-side
/// <see cref="DiffTextView"/> and the 3-pane conflict resolver.
/// </summary>
/// <remarks>
/// Only the boilerplate lives here. The two surfaces are not variants of one control — one is a
/// read-only two-way renderer over aligned diff rows (with filler rows the other has no concept
/// of), the other a three-way editor over a writable result document with anchored regions. Their
/// documents, margins and background renderers therefore stay with their own views.
/// </remarks>
internal static class DiffEditorSetup
{
/// Grammars and themes are process-wide; loading a registry per view would be wasteful.
public static readonly RegistryOptions Registry = new(ThemeName.DarkPlus);
public static TextMate.Installation InstallHighlighting(TextEditor editor) =>
editor.InstallTextMate(Registry);
/// <summary>
/// Points every installation at the grammar for <paramref name="path"/>'s extension. No path,
/// no extension or no matching language leaves the editors as plain text.
/// </summary>
/// <remarks>
/// Both surfaces feed fragments (diff hunks / conflict regions) rather than whole files, so
/// TextMate's line-by-line state can be wrong at a fragment boundary — a line inside a block
/// comment may highlight as code. Accepted; every fragment-based diff viewer has this.
/// </remarks>
public static void ApplyGrammar(string? path, params TextMate.Installation?[] installations)
{
if (string.IsNullOrEmpty(path)) return;
var ext = Path.GetExtension(path);
if (string.IsNullOrEmpty(ext)) return;
var language = Registry.GetLanguageByExtension(ext);
if (language is null) return;
var scope = Registry.GetScopeByLanguageId(language.Id);
foreach (var installation in installations) installation?.SetGrammar(scope);
}
/// <summary>Resolves a themed brush, falling back to a literal when the lookup fails.</summary>
/// <remarks>
/// Two traps, both silent. It must be <c>TryFindResource</c> (the extension that walks up to
/// Application) and not the <c>TryGetResource</c> instance method, which only sees the
/// control's own Resources. And it must run while <paramref name="owner"/> is attached —
/// a detached control has no resource parent to walk, so resolving in a constructor freezes
/// every brush on its fallback for good.
/// </remarks>
public static IBrush Brush(Control owner, string key, Color fallback) =>
owner.TryFindResource(key, out var value) && value is IBrush brush
? brush
: new SolidColorBrush(fallback);
/// <summary>A minimal <see cref="ISegment"/> for geometry and read-only queries.</summary>
public 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>
/// Keeps a set of editors scrolled to the same vertical position. Construction wires the panes;
/// there is nothing to dispose, and nothing to keep a reference to — the event subscriptions on
/// the text views own the instance.
/// </summary>
/// <remarks>
/// <para>
/// Reading is driven off <see cref="TextView.ScrollOffsetChanged"/> rather than the editors'
/// templated ScrollViewers. A TextView exists from construction, whereas the ScrollViewer only
/// materialises once the editor has been measured — so subscribing to the ScrollViewer at load
/// time found nothing whenever a pane started out collapsed, and the sync then stayed dead for
/// the lifetime of the window.
/// </para>
/// <para>
/// Writing still goes through the ScrollViewer, resolved lazily on first use (by which point the
/// panes are on screen). <see cref="TextEditor.ScrollToVerticalOffset"/> looks like the obvious
/// call and is <b>silently a no-op</b> in AvaloniaEdit 12 even with the editor templated and
/// <c>TextEditor.ScrollViewer</c> non-null — verified against 12.0.0. Assigning
/// <c>ILogicalScrollable.Offset</c> on the TextView is no good either: it moves the text but
/// leaves the ScrollViewer's own offset (and therefore the scrollbar thumb) behind.
/// </para>
/// </remarks>
internal sealed class VerticalScrollSync
{
private readonly TextEditor[] _editors;
private readonly ScrollViewer?[] _viewers;
private readonly Func<bool> _isActive;
private readonly Func<bool> _anchorByLine;
private readonly Action? _afterSync;
private bool _syncing;
/// <param name="isActive">Gate for layouts where the panes are not side by side.</param>
/// <param name="anchorByLine">True while lines wrap — see <see cref="TargetOffset"/>.</param>
/// <param name="afterSync">Runs once per user scroll, not per echo.</param>
public VerticalScrollSync(TextEditor[] editors, Func<bool>? isActive = null,
Func<bool>? anchorByLine = null, Action? afterSync = null)
{
_editors = editors;
_viewers = new ScrollViewer?[editors.Length];
_isActive = isActive ?? (() => true);
_anchorByLine = anchorByLine ?? (() => false);
_afterSync = afterSync;
for (var i = 0; i < editors.Length; i++)
{
var index = i;
editors[i].TextArea.TextView.ScrollOffsetChanged += (_, _) => Sync(index);
}
}
/// Resolved on demand and cached: at construction the editor may still be collapsed and
/// therefore untemplated, but by the time anything scrolls it is on screen.
private ScrollViewer? Viewer(int index) =>
_viewers[index] ??= _editors[index].GetVisualDescendants().OfType<ScrollViewer>().FirstOrDefault();
private void Sync(int sourceIndex)
{
if (_syncing || !_isActive()) return;
_syncing = true;
try
{
for (var i = 0; i < _editors.Length; i++)
{
if (i == sourceIndex) continue;
if (Viewer(i) is not { } viewer) continue;
if (TargetOffset(_editors[sourceIndex], _editors[i]) is not { } y) continue;
// The echo from the target's own ScrollOffsetChanged lands after _syncing is
// cleared again, so this comparison — not the flag — is what ends the loop.
if (Math.Abs(viewer.Offset.Y - y) > 0.5)
viewer.Offset = new Vector(viewer.Offset.X, y);
}
}
finally { _syncing = false; }
_afterSync?.Invoke();
}
/// Where <paramref name="target"/> has to sit to show the same content as
/// <paramref name="source"/>, or null when the two cannot be related right now.
private double? TargetOffset(TextEditor source, TextEditor target)
{
if (!_anchorByLine()) return source.TextArea.TextView.ScrollOffset.Y;
// Line heights differ once lines wrap, so pixel offsets no longer correspond between the
// panes — anchor on the top visible line instead and top-align it by computing the
// target's own vertical offset for that document line. ScrollToLine would not do: it is a
// "bring into view" primitive that parks the line near mid-viewport past a threshold.
if (source.TextArea.TextView.VisualLines is not { Count: > 0 } visualLines) return null;
var line = visualLines[0].FirstDocumentLine.LineNumber;
if (line < 1 || line > target.Document.LineCount) return null;
return target.TextArea.TextView.GetVisualTopByDocumentLine(line);
}
}
@@ -1,82 +0,0 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
x:Class="ClaudeDo.Ui.Views.Controls.DiffLinesView"
x:Name="Root">
<UserControl.Styles>
<!-- diff line row tints via Tag selector (compiled-binding-friendly) -->
<Style Selector="Border.diff-line[Tag=add]">
<Setter Property="Background" Value="{StaticResource RunningTintBrush}"/>
</Style>
<Style Selector="Border.diff-line[Tag=del]">
<Setter Property="Background" Value="{StaticResource ErrorTintBrush}"/>
</Style>
<Style Selector="Border.diff-line[Tag=ctx]">
<Setter Property="Background" Value="Transparent"/>
</Style>
<Style Selector="Border.diff-line[Tag=file]">
<Setter Property="Background" Value="{StaticResource Surface3Brush}"/>
</Style>
<Style Selector="Border.diff-line[Tag=add] TextBlock.diff-sign">
<Setter Property="Foreground" Value="{StaticResource MossBrightBrush}"/>
</Style>
<Style Selector="Border.diff-line[Tag=del] TextBlock.diff-sign">
<Setter Property="Foreground" Value="{StaticResource BloodBrush}"/>
</Style>
<Style Selector="Border.diff-line[Tag=ctx] TextBlock.diff-sign">
<Setter Property="Foreground" Value="{StaticResource TextFaintBrush}"/>
</Style>
<Style Selector="Border.diff-line[Tag=add] TextBlock.diff-text">
<Setter Property="Foreground" Value="{StaticResource MossBrightBrush}"/>
</Style>
<Style Selector="Border.diff-line[Tag=del] TextBlock.diff-text">
<Setter Property="Foreground" Value="{StaticResource BloodBrush}"/>
</Style>
<Style Selector="Border.diff-line[Tag=ctx] TextBlock.diff-text">
<Setter Property="Foreground" Value="{StaticResource TextDimBrush}"/>
</Style>
<Style Selector="Border.diff-line[Tag=file] TextBlock.diff-text">
<Setter Property="Foreground" Value="{StaticResource TextBrush}"/>
<Setter Property="FontWeight" Value="SemiBold"/>
</Style>
</UserControl.Styles>
<ItemsControl ItemsSource="{Binding #Root.Lines}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:DiffLineViewModel">
<Border Classes="diff-line"
Tag="{Binding ClassName}"
Padding="4,1">
<Grid ColumnDefinitions="48,48,16,*">
<!-- Old line number -->
<TextBlock Grid.Column="0"
Text="{Binding OldNo}"
Classes="diff-lineno"
HorizontalAlignment="Right"
Margin="0,0,8,0"/>
<!-- New line number -->
<TextBlock Grid.Column="1"
Text="{Binding NewNo}"
Classes="diff-lineno"
HorizontalAlignment="Right"
Margin="0,0,8,0"/>
<!-- Sign -->
<TextBlock Grid.Column="2"
Classes="diff-sign"
Text="{Binding Sign}"
FontFamily="{DynamicResource MonoFont}"
FontSize="{StaticResource FontSizeMono}"/>
<!-- Line text -->
<TextBlock Grid.Column="3"
Classes="diff-text"
Text="{Binding Text}"
FontFamily="{DynamicResource MonoFont}"
FontSize="{StaticResource FontSizeMono}"
TextWrapping="NoWrap"/>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</UserControl>
@@ -1,19 +0,0 @@
using System.Collections;
using Avalonia;
using Avalonia.Controls;
namespace ClaudeDo.Ui.Views.Controls;
public partial class DiffLinesView : UserControl
{
public static readonly StyledProperty<IEnumerable?> LinesProperty =
AvaloniaProperty.Register<DiffLinesView, IEnumerable?>(nameof(Lines));
public IEnumerable? Lines
{
get => GetValue(LinesProperty);
set => SetValue(LinesProperty, value);
}
public DiffLinesView() => InitializeComponent();
}
@@ -0,0 +1,35 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ae="using:AvaloniaEdit"
xmlns:loc="using:ClaudeDo.Ui.Localization"
x:Class="ClaudeDo.Ui.Views.Controls.DiffTextView">
<UserControl.Styles>
<Style Selector="ae|TextEditor">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="{StaticResource FontSizeMono}" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="Padding" Value="0,2" />
</Style>
</UserControl.Styles>
<DockPanel>
<!-- Split-only: labels which side is which. Same column definitions as the pane grid
so each label sits over its own editor. Hidden in unified layout (see RefreshLayout). -->
<Grid x:Name="PaneHeader" DockPanel.Dock="Top" ColumnDefinitions="*,1,*" IsVisible="False">
<Border Grid.Column="0" Classes="island-header">
<TextBlock Classes="eyebrow" Text="{loc:Tr modals.diff.paneBase}"/>
</Border>
<Border Grid.Column="1" Background="{DynamicResource LineBrush}"/>
<Border Grid.Column="2" Classes="island-header">
<TextBlock Classes="eyebrow" Text="{loc:Tr modals.diff.paneWorktree}"/>
</Border>
</Grid>
<Grid x:Name="PaneGrid" ColumnDefinitions="*,1,*">
<ae:TextEditor Grid.Column="0" x:Name="LeftEditor" IsReadOnly="True" ShowLineNumbers="False"/>
<Border Grid.Column="1" x:Name="PaneDivider" Background="{DynamicResource LineBrush}"/>
<ae:TextEditor Grid.Column="2" x:Name="RightEditor" IsReadOnly="True" ShowLineNumbers="False"/>
</Grid>
</DockPanel>
</UserControl>
@@ -0,0 +1,352 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
using AvaloniaEdit.Editing;
using AvaloniaEdit.Rendering;
using AvaloniaEdit.TextMate;
using ClaudeDo.Ui.ViewModels.Modals;
using Seg = ClaudeDo.Ui.Views.Controls.DiffEditorSetup.Seg;
namespace ClaudeDo.Ui.Views.Controls;
/// <summary>
/// Read-only diff renderer. <see cref="IsSplit"/> false shows one editor with the unified
/// stream; true shows the old state on the left and the new state on the right, aligned by
/// <see cref="DiffAlignment"/>. Syntax highlighting comes from TextMate, keyed off the file's
/// extension — the same mechanism the 3-pane conflict resolver uses.
/// </summary>
public partial class DiffTextView : UserControl
{
public static readonly StyledProperty<DiffFileViewModel?> FileProperty =
AvaloniaProperty.Register<DiffTextView, DiffFileViewModel?>(nameof(File));
public static readonly StyledProperty<bool> IsSplitProperty =
AvaloniaProperty.Register<DiffTextView, bool>(nameof(IsSplit));
public static readonly StyledProperty<bool> WrapLinesProperty =
AvaloniaProperty.Register<DiffTextView, bool>(nameof(WrapLines));
public DiffFileViewModel? File
{
get => GetValue(FileProperty);
set => SetValue(FileProperty, value);
}
public bool IsSplit
{
get => GetValue(IsSplitProperty);
set => SetValue(IsSplitProperty, value);
}
public bool WrapLines
{
get => GetValue(WrapLinesProperty);
set => SetValue(WrapLinesProperty, value);
}
private TextMate.Installation? _leftTm, _rightTm;
// Row lookup per editor, indexed by document line number (1-based). Populated on rebuild
// and consumed by the margin and background renderers added in later tasks.
private RowInfo?[] _leftRows = Array.Empty<RowInfo?>();
private RowInfo?[] _rightRows = Array.Empty<RowInfo?>();
private AlignedDiff _aligned = AlignedDiff.Empty;
private DiffLineNumberMargin? _leftMargin, _rightMargin;
private bool _renderersInstalled;
public DiffTextView()
{
InitializeComponent();
_leftTm = DiffEditorSetup.InstallHighlighting(LeftEditor);
_rightTm = DiffEditorSetup.InstallHighlighting(RightEditor);
ReloadFile();
new VerticalScrollSync(new[] { LeftEditor, RightEditor },
isActive: () => IsSplit, anchorByLine: () => WrapLines);
}
/// Brushes and the mono typeface only resolve once the control is in the visual tree —
/// a detached control has no resource parent to walk up to, and its styles haven't been
/// applied yet. Installing the renderers in the constructor would freeze them on their
/// hardcoded fallbacks for good, since they capture their brushes once.
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnAttachedToVisualTree(e);
if (_renderersInstalled) return;
_renderersInstalled = true;
InstallRenderers();
RebuildMargins();
InvalidateRenderers();
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == FileProperty)
ReloadFile();
else if (change.Property == IsSplitProperty)
RefreshLayout();
else if (change.Property == WrapLinesProperty)
ApplyWrap();
}
/// Recomputes the alignment (word-diff LCS) for the current <see cref="File"/> and then
/// refreshes everything downstream of it. Only ever needed when the file actually changes.
private void ReloadFile()
{
_aligned = DiffAlignment.Build(File?.Lines);
RefreshLayout();
}
/// Re-applies the current <see cref="_aligned"/> to the editors for the current layout
/// (split vs unified). A single <see cref="AlignedDiff"/> already carries both row sets,
/// so toggling <see cref="IsSplit"/> never needs to re-run the alignment.
private void RefreshLayout()
{
var split = IsSplit;
RightEditor.IsVisible = split;
PaneDivider.IsVisible = split;
PaneHeader.IsVisible = split;
Grid.SetColumnSpan(LeftEditor, split ? 1 : 3);
if (split)
{
LeftEditor.Text = _aligned.LeftText;
RightEditor.Text = _aligned.RightText;
_leftRows = BuildRows(_aligned.SplitRows.Count,
i => new RowInfo(_aligned.SplitRows[i].LeftKind, _aligned.SplitRows[i].OldNo, null,
_aligned.SplitRows[i].LeftSpans));
_rightRows = BuildRows(_aligned.SplitRows.Count,
i => new RowInfo(_aligned.SplitRows[i].RightKind, null, _aligned.SplitRows[i].NewNo,
_aligned.SplitRows[i].RightSpans));
}
else
{
LeftEditor.Text = _aligned.UnifiedText;
RightEditor.Text = "";
_leftRows = BuildRows(_aligned.UnifiedRows.Count,
i => new RowInfo(_aligned.UnifiedRows[i].Kind, _aligned.UnifiedRows[i].OldNo,
_aligned.UnifiedRows[i].NewNo, _aligned.UnifiedRows[i].Spans));
_rightRows = Array.Empty<RowInfo?>();
}
RebuildMargins();
ApplyWrap();
DiffEditorSetup.ApplyGrammar(File?.Path, _leftTm, _rightTm);
InvalidateRenderers();
}
/// Row index i is document line i + 1, so slot 0 stays null and lookups can pass the
/// line number straight through.
private static RowInfo?[] BuildRows(int count, Func<int, RowInfo> project)
{
var rows = new RowInfo?[count + 1];
for (var i = 0; i < count; i++) rows[i + 1] = project(i);
return rows;
}
private RowInfo? LeftRow(int line) =>
line > 0 && line < _leftRows.Length ? _leftRows[line] : null;
private RowInfo? RightRow(int line) =>
line > 0 && line < _rightRows.Length ? _rightRows[line] : null;
private void ApplyWrap()
{
LeftEditor.WordWrap = WrapLines;
RightEditor.WordWrap = WrapLines;
}
/// The margin's column layout depends on split vs unified, so it is rebuilt rather than
/// reconfigured whenever the layout changes.
private void RebuildMargins()
{
if (_leftMargin is not null) LeftEditor.TextArea.LeftMargins.Remove(_leftMargin);
if (_rightMargin is not null) RightEditor.TextArea.LeftMargins.Remove(_rightMargin);
var foreground = Brush("TextFaintBrush", Color.Parse("#80FFFFFF"));
var typeface = new Typeface(LeftEditor.FontFamily);
_leftMargin = new DiffLineNumberMargin(LeftRow, showOld: true, showNew: !IsSplit,
foreground, typeface, LeftEditor.FontSize);
LeftEditor.TextArea.LeftMargins.Insert(0, _leftMargin);
_rightMargin = new DiffLineNumberMargin(RightRow, showOld: false, showNew: true,
foreground, typeface, RightEditor.FontSize);
RightEditor.TextArea.LeftMargins.Insert(0, _rightMargin);
}
private IBrush Brush(string key, Color fallback) => DiffEditorSetup.Brush(this, key, fallback);
private void InstallRenderers()
{
var add = Brush("RunningTintBrush", Color.Parse("#1F7C9166"));
var del = Brush("ErrorTintBrush", Color.Parse("#1FC87060"));
var filler = Brush("DiffFillerBrush", Color.Parse("#0AFFFFFF"));
var gap = Brush("DiffGapBrush", Color.Parse("#14FFFFFF"));
var wordAdd = Brush("DiffWordAddBrush", Color.Parse("#556FA86B"));
var wordDel = Brush("DiffWordDelBrush", Color.Parse("#55C87060"));
LeftEditor.TextArea.TextView.BackgroundRenderers.Add(new DiffLineRenderer(LeftRow, add, del, filler, gap));
LeftEditor.TextArea.TextView.BackgroundRenderers.Add(new WordDiffRenderer(LeftRow, wordAdd, wordDel));
RightEditor.TextArea.TextView.BackgroundRenderers.Add(new DiffLineRenderer(RightRow, add, del, filler, gap));
RightEditor.TextArea.TextView.BackgroundRenderers.Add(new WordDiffRenderer(RightRow, wordAdd, wordDel));
}
private void InvalidateRenderers()
{
LeftEditor.TextArea.TextView.InvalidateVisual();
RightEditor.TextArea.TextView.InvalidateVisual();
}
/// <summary>What one document line represents, for the margin and the background renderers.</summary>
internal sealed record RowInfo(
AlignedSide Kind, int? OldNo, int? NewNo, IReadOnlyList<TextSpan> Spans);
/// <summary>Draws one or two gutter columns of diff line numbers from the row lookup.</summary>
private sealed class DiffLineNumberMargin : AbstractMargin
{
private const double ColumnWidth = 44;
private const double Gap = 6;
private readonly Func<int, RowInfo?> _rows;
private readonly bool _showOld;
private readonly bool _showNew;
private readonly IBrush _foreground;
private readonly Typeface _typeface;
private readonly double _fontSize;
public DiffLineNumberMargin(Func<int, RowInfo?> rows, bool showOld, bool showNew,
IBrush foreground, Typeface typeface, double fontSize)
{
_rows = rows;
_showOld = showOld;
_showNew = showNew;
_foreground = foreground;
_typeface = typeface;
_fontSize = fontSize;
}
private double Columns => (_showOld ? 1 : 0) + (_showNew ? 1 : 0);
protected override Size MeasureOverride(Size availableSize) =>
new(Columns * ColumnWidth + Gap, 0);
protected override void OnTextViewChanged(TextView? oldTextView, TextView? newTextView)
{
if (oldTextView is not null) oldTextView.VisualLinesChanged -= OnVisualLinesChanged;
base.OnTextViewChanged(oldTextView, newTextView);
if (newTextView is not null) newTextView.VisualLinesChanged += OnVisualLinesChanged;
InvalidateVisual();
}
private void OnVisualLinesChanged(object? sender, EventArgs e) => InvalidateVisual();
public override void Render(DrawingContext context)
{
var textView = TextView;
if (textView is null || !textView.VisualLinesValid) return;
foreach (var visualLine in textView.VisualLines)
{
var lineNumber = visualLine.FirstDocumentLine.LineNumber;
if (_rows(lineNumber) is not { } row) continue;
var y = visualLine.VisualTop - textView.ScrollOffset.Y;
var column = 0;
if (_showOld) DrawNumber(context, row.OldNo, column++, y);
if (_showNew) DrawNumber(context, row.NewNo, column, y);
}
}
private void DrawNumber(DrawingContext context, int? value, int column, double y)
{
if (value is null) return;
var text = new FormattedText(value.Value.ToString(CultureInfo.InvariantCulture),
CultureInfo.InvariantCulture, FlowDirection.LeftToRight, _typeface, _fontSize, _foreground);
// Right-align inside the column so the digits line up across rows.
var x = (column + 1) * ColumnWidth - text.Width - Gap;
context.DrawText(text, new Point(x, y));
}
}
/// <summary>Tints whole rows by their diff role.</summary>
private sealed class DiffLineRenderer : IBackgroundRenderer
{
private readonly Func<int, RowInfo?> _rows;
private readonly IBrush _add, _del, _filler, _gap;
public DiffLineRenderer(Func<int, RowInfo?> rows, IBrush add, IBrush del, IBrush filler, IBrush gap)
{
_rows = rows; _add = add; _del = del; _filler = filler; _gap = gap;
}
public KnownLayer Layer => KnownLayer.Background;
public void Draw(TextView textView, DrawingContext drawingContext)
{
if (!textView.VisualLinesValid) return;
foreach (var visualLine in textView.VisualLines)
{
var row = _rows(visualLine.FirstDocumentLine.LineNumber);
var brush = row?.Kind switch
{
AlignedSide.Add => _add,
AlignedSide.Del => _del,
AlignedSide.Filler => _filler,
AlignedSide.Gap => _gap,
_ => null,
};
if (brush is null) continue;
var top = visualLine.VisualTop - textView.ScrollOffset.Y;
drawingContext.FillRectangle(brush, new Rect(0, top, textView.Bounds.Width, visualLine.Height));
}
}
}
/// <summary>Tints the changed character ranges inside a row, on top of the row tint.</summary>
private sealed class WordDiffRenderer : IBackgroundRenderer
{
private readonly Func<int, RowInfo?> _rows;
private readonly IBrush _add, _del;
public WordDiffRenderer(Func<int, RowInfo?> rows, IBrush add, IBrush del)
{
_rows = rows; _add = add; _del = del;
}
// Above the line tint but still behind the text.
public KnownLayer Layer => KnownLayer.Selection;
public void Draw(TextView textView, DrawingContext drawingContext)
{
if (!textView.VisualLinesValid) return;
foreach (var visualLine in textView.VisualLines)
{
var documentLine = visualLine.FirstDocumentLine;
var row = _rows(documentLine.LineNumber);
if (row is null || row.Spans.Count == 0) continue;
var brush = row.Kind == AlignedSide.Add ? _add : _del;
foreach (var span in row.Spans)
{
var offset = documentLine.Offset + span.Start;
// Spans are computed against the row text; clamp in case the document
// and the row lookup ever disagree rather than drawing past the line.
if (offset < documentLine.Offset || offset + span.Length > documentLine.EndOffset) continue;
var builder = new BackgroundGeometryBuilder { AlignToWholePixels = true, CornerRadius = 2 };
builder.AddSegment(textView, new Seg(offset, span.Length));
if (builder.CreateGeometry() is { } geometry)
drawingContext.DrawGeometry(brush, null, geometry);
}
}
}
}
}
@@ -23,48 +23,57 @@ public class ModalShell : ContentControl
public ICommand? CloseCommand { get => GetValue(CloseCommandProperty); set => SetValue(CloseCommandProperty, value); }
private Window? _window;
private PixelPoint _dragStartScreen;
private PixelPoint _dragStartPos;
private bool _dragging;
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
base.OnApplyTemplate(e);
if (e.NameScope.Find<Border>("PART_TitleBar") is { } bar)
{
bar.PointerPressed += OnTitleBarPressed;
bar.PointerMoved += OnTitleBarMoved;
bar.PointerReleased += OnTitleBarReleased;
}
}
// VisualRoot is a TopLevelHost (not the Window) in Avalonia 12, so resolve the
// owning Window via TopLevel.GetTopLevel and drive the move manually — BeginMoveDrag
// and a VisualRoot-as-Window cast both fail here.
/// Mirrors what MainWindow does for itself: with an extended client area a maximised window
/// overhangs the screen by the invisible resize border, so the chrome has to be inset by
/// OffScreenMargin or its edges (and the close button) get clipped. Doing it here covers
/// every modal at once instead of per code-behind.
protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnAttachedToVisualTree(e);
_window = TopLevel.GetTopLevel(this) as Window;
if (_window is null) return;
Margin = _window.OffScreenMargin;
_window.PropertyChanged += OnWindowPropertyChanged;
}
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
{
base.OnDetachedFromVisualTree(e);
if (_window is not null) _window.PropertyChanged -= OnWindowPropertyChanged;
_window = null;
}
private void OnWindowPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
{
if (e.Property == Window.OffScreenMarginProperty && _window is not null)
Margin = _window.OffScreenMargin;
}
/// <summary>
/// Hands the drag to the window manager instead of moving the window by hand.
/// </summary>
/// <remarks>
/// This is what makes Windows snap work — drag to the top edge to maximise, to a side for
/// half the screen, with the usual preview overlay. Assigning <c>Window.Position</c> per
/// pointer-move (what this used to do) bypasses the OS move loop entirely, so no snap
/// gesture is ever recognised. The OS only snaps resizable windows, so the non-resizable
/// modals opt out on their own without a flag here.
///
/// Note: <c>VisualRoot</c> is a TopLevelHost rather than the Window in Avalonia 12, so the
/// window has to come from <see cref="TopLevel.GetTopLevel"/>. Do not capture the pointer —
/// <see cref="Window.BeginMoveDrag"/> runs its own modal loop and needs the input.
/// </remarks>
private void OnTitleBarPressed(object? sender, PointerPressedEventArgs e)
{
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) return;
_window = TopLevel.GetTopLevel(this) as Window;
if (_window is null) return;
_dragStartScreen = _window.PointToScreen(e.GetPosition(_window));
_dragStartPos = _window.Position;
_dragging = true;
e.Pointer.Capture(sender as IInputElement);
}
private void OnTitleBarMoved(object? sender, PointerEventArgs e)
{
if (!_dragging || _window is null
|| !e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) return;
var cur = _window.PointToScreen(e.GetPosition(_window));
_window.Position = new PixelPoint(
_dragStartPos.X + (cur.X - _dragStartScreen.X),
_dragStartPos.Y + (cur.Y - _dragStartScreen.Y));
}
private void OnTitleBarReleased(object? sender, PointerReleasedEventArgs e)
{
_dragging = false;
e.Pointer.Capture(null);
(TopLevel.GetTopLevel(this) as Window)?.BeginMoveDrag(e);
}
}
@@ -0,0 +1,250 @@
using System;
using System.Windows.Input;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Data;
using Avalonia.Input;
using Avalonia.Media;
using Avalonia.Rendering;
using ClaudeDo.Ui.ViewModels.Modals;
namespace ClaudeDo.Ui.Views.Controls;
/// <summary>
/// Usage bar with three draggable stage markers: soft (throttle to 2 slots), hard (1 slot) and gate
/// (queue paused). Positions are computed against the control's real width — no hardcoded track
/// size — and the drag math lives in <see cref="UsageThresholdDrag"/> so it stays testable.
/// Values are written back through TwoWay bindings while dragging; <see cref="CommitCommand"/>
/// fires once on release, which is when the host persists them.
/// A row without thresholds (plan-dependent scoped buckets) renders as a plain read-only bar.
/// </summary>
public sealed class UsageGaugeBar : Control, ICustomHitTest
{
/// <summary>How close the pointer has to be to grab a marker.</summary>
private const double GrabRadiusPx = 12;
private const double TrackHeightPx = 10;
private const double MarkerWidthPx = 2;
public static readonly StyledProperty<double> PercentProperty =
AvaloniaProperty.Register<UsageGaugeBar, double>(nameof(Percent));
public static readonly StyledProperty<bool> IsWarnProperty =
AvaloniaProperty.Register<UsageGaugeBar, bool>(nameof(IsWarn));
public static readonly StyledProperty<int?> SoftPctProperty =
AvaloniaProperty.Register<UsageGaugeBar, int?>(
nameof(SoftPct), defaultBindingMode: BindingMode.TwoWay);
public static readonly StyledProperty<int?> HardPctProperty =
AvaloniaProperty.Register<UsageGaugeBar, int?>(
nameof(HardPct), defaultBindingMode: BindingMode.TwoWay);
public static readonly StyledProperty<int?> GatePctProperty =
AvaloniaProperty.Register<UsageGaugeBar, int?>(
nameof(GatePct), defaultBindingMode: BindingMode.TwoWay);
public static readonly StyledProperty<IBrush?> TrackBrushProperty =
AvaloniaProperty.Register<UsageGaugeBar, IBrush?>(nameof(TrackBrush));
public static readonly StyledProperty<IBrush?> FillBrushProperty =
AvaloniaProperty.Register<UsageGaugeBar, IBrush?>(nameof(FillBrush));
public static readonly StyledProperty<IBrush?> WarnFillBrushProperty =
AvaloniaProperty.Register<UsageGaugeBar, IBrush?>(nameof(WarnFillBrush));
public static readonly StyledProperty<IBrush?> SoftMarkerBrushProperty =
AvaloniaProperty.Register<UsageGaugeBar, IBrush?>(nameof(SoftMarkerBrush));
public static readonly StyledProperty<IBrush?> HardMarkerBrushProperty =
AvaloniaProperty.Register<UsageGaugeBar, IBrush?>(nameof(HardMarkerBrush));
public static readonly StyledProperty<IBrush?> GateMarkerBrushProperty =
AvaloniaProperty.Register<UsageGaugeBar, IBrush?>(nameof(GateMarkerBrush));
public static readonly StyledProperty<ICommand?> CommitCommandProperty =
AvaloniaProperty.Register<UsageGaugeBar, ICommand?>(nameof(CommitCommand));
static UsageGaugeBar()
{
AffectsRender<UsageGaugeBar>(
PercentProperty, IsWarnProperty, SoftPctProperty, HardPctProperty, GatePctProperty,
TrackBrushProperty, FillBrushProperty, WarnFillBrushProperty,
SoftMarkerBrushProperty, HardMarkerBrushProperty, GateMarkerBrushProperty);
}
private UsageThresholdDrag.Stage? _dragging;
public double Percent
{
get => GetValue(PercentProperty);
set => SetValue(PercentProperty, value);
}
public bool IsWarn
{
get => GetValue(IsWarnProperty);
set => SetValue(IsWarnProperty, value);
}
public int? SoftPct
{
get => GetValue(SoftPctProperty);
set => SetValue(SoftPctProperty, value);
}
public int? HardPct
{
get => GetValue(HardPctProperty);
set => SetValue(HardPctProperty, value);
}
public int? GatePct
{
get => GetValue(GatePctProperty);
set => SetValue(GatePctProperty, value);
}
public IBrush? TrackBrush
{
get => GetValue(TrackBrushProperty);
set => SetValue(TrackBrushProperty, value);
}
public IBrush? FillBrush
{
get => GetValue(FillBrushProperty);
set => SetValue(FillBrushProperty, value);
}
public IBrush? WarnFillBrush
{
get => GetValue(WarnFillBrushProperty);
set => SetValue(WarnFillBrushProperty, value);
}
public IBrush? SoftMarkerBrush
{
get => GetValue(SoftMarkerBrushProperty);
set => SetValue(SoftMarkerBrushProperty, value);
}
public IBrush? HardMarkerBrush
{
get => GetValue(HardMarkerBrushProperty);
set => SetValue(HardMarkerBrushProperty, value);
}
public IBrush? GateMarkerBrush
{
get => GetValue(GateMarkerBrushProperty);
set => SetValue(GateMarkerBrushProperty, value);
}
public ICommand? CommitCommand
{
get => GetValue(CommitCommandProperty);
set => SetValue(CommitCommandProperty, value);
}
private bool IsAdjustable => SoftPct is not null && HardPct is not null && GatePct is not null;
// Custom hit test (point is in local coordinates): the control draws itself, so the whole
// rectangle takes the pointer — not just the pixels the track happens to cover.
public bool HitTest(Point point) => new Rect(Bounds.Size).Contains(point);
public override void Render(DrawingContext context)
{
var width = Bounds.Width;
var height = Bounds.Height;
if (width <= 0 || height <= 0) return;
var top = Math.Max(0, (height - TrackHeightPx) / 2);
var trackHeight = Math.Min(TrackHeightPx, height);
var radius = trackHeight / 2;
// Transparent full-bounds fill keeps the grab area the whole control, not just the track.
context.FillRectangle(Brushes.Transparent, new Rect(0, 0, width, height));
if (TrackBrush is { } track)
context.DrawRectangle(track, null, new RoundedRect(new Rect(0, top, width, trackHeight), radius));
var fillWidth = width * Math.Clamp(Percent, 0, 100) / 100.0;
var fill = IsWarn ? WarnFillBrush ?? FillBrush : FillBrush;
if (fillWidth > 0 && fill is not null)
context.DrawRectangle(fill, null, new RoundedRect(new Rect(0, top, fillWidth, trackHeight), radius));
DrawMarker(context, SoftPct, SoftMarkerBrush, width, height);
DrawMarker(context, HardPct, HardMarkerBrush, width, height);
DrawMarker(context, GatePct, GateMarkerBrush, width, height);
}
private static void DrawMarker(DrawingContext context, int? percent, IBrush? brush, double width, double height)
{
if (percent is not { } value || brush is null) return;
var x = Math.Clamp(width * Math.Clamp(value, 0, 100) / 100.0 - MarkerWidthPx / 2, 0, Math.Max(0, width - MarkerWidthPx));
context.FillRectangle(brush, new Rect(x, 0, MarkerWidthPx, height));
}
protected override void OnPointerPressed(PointerPressedEventArgs e)
{
base.OnPointerPressed(e);
if (!IsAdjustable) return;
var percent = PercentAt(e.GetPosition(this).X);
_dragging = UsageThresholdDrag.Nearest(
SoftPct!.Value, HardPct!.Value, GatePct!.Value, percent, GrabTolerancePercent());
if (_dragging is null) return;
e.Pointer.Capture(this);
ApplyDrag(_dragging.Value, percent);
e.Handled = true;
}
protected override void OnPointerMoved(PointerEventArgs e)
{
base.OnPointerMoved(e);
if (!IsAdjustable) return;
var percent = PercentAt(e.GetPosition(this).X);
if (_dragging is { } stage)
{
ApplyDrag(stage, percent);
e.Handled = true;
return;
}
var hover = UsageThresholdDrag.Nearest(
SoftPct!.Value, HardPct!.Value, GatePct!.Value, percent, GrabTolerancePercent());
Cursor = new Cursor(hover is null ? StandardCursorType.Arrow : StandardCursorType.SizeWestEast);
}
protected override void OnPointerReleased(PointerReleasedEventArgs e)
{
base.OnPointerReleased(e);
if (_dragging is null) return;
_dragging = null;
e.Pointer.Capture(null);
e.Handled = true;
if (CommitCommand is { } command && command.CanExecute(null))
command.Execute(null);
}
private void ApplyDrag(UsageThresholdDrag.Stage stage, double percent)
{
var (soft, hard, gate) = UsageThresholdDrag.Apply(
SoftPct!.Value, HardPct!.Value, GatePct!.Value, stage, percent);
SoftPct = soft;
HardPct = hard;
GatePct = gate;
}
private double PercentAt(double x) => Bounds.Width <= 0 ? 0 : Math.Clamp(x / Bounds.Width * 100.0, 0, 100);
private double GrabTolerancePercent() => Bounds.Width <= 0 ? 0 : GrabRadiusPx / Bounds.Width * 100.0;
}
@@ -30,6 +30,26 @@
<DockPanel>
<!-- View toolbar: layout mode (segmented) + wrap (icon toggle), both persisted.
Wrap is orthogonal to the layout mode, so it stays a separate control. -->
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="16,8,16,0">
<Border Classes="segmented">
<StackPanel Orientation="Horizontal">
<Button Classes="segment" Classes.active="{Binding !IsSplitView}"
Content="{loc:Tr modals.diff.unifiedView}"
Command="{Binding ShowUnifiedCommand}"/>
<Button Classes="segment" Classes.active="{Binding IsSplitView}"
Content="{loc:Tr modals.diff.splitView}"
Command="{Binding ShowSplitCommand}"/>
</StackPanel>
</Border>
<ToggleButton Classes="icon-btn" IsChecked="{Binding WrapLines}"
VerticalAlignment="Center"
ToolTip.Tip="{loc:Tr modals.diff.wrapLines}">
<PathIcon Data="{StaticResource Icon.Wrap}" Width="14" Height="14"/>
</ToggleButton>
</StackPanel>
<!-- Planning toolbar: combined-mode toggle + warning/loading -->
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8" Margin="16,8,16,0"
IsVisible="{Binding IsPlanning}">
@@ -149,17 +169,31 @@
Foreground="{DynamicResource TextMuteBrush}"
IsVisible="{Binding SelectedFile.IsEmptyContent}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"
IsVisible="{Binding SelectedFile.HasLines}">
<ctl:DiffLinesView Lines="{Binding SelectedFile.Lines}"/>
</ScrollViewer>
<ctl:DiffTextView IsVisible="{Binding SelectedFile.HasLines}"
File="{Binding SelectedFile}"
IsSplit="{Binding IsSplitView}"
WrapLines="{Binding WrapLines}"/>
</Grid>
</DockPanel>
<!-- Planning mode: flat aggregate/combined diff -->
<!-- Planning mode: one editor per file so each gets its own grammar -->
<Grid Background="{DynamicResource VoidBrush}" IsVisible="{Binding IsPlanning}">
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto">
<ctl:DiffLinesView Lines="{Binding DiffLines}"/>
<ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<ItemsControl ItemsSource="{Binding PlanningFiles}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:DiffFileViewModel">
<StackPanel Margin="0,0,0,12">
<Border Classes="island-header">
<TextBlock Classes="path-mono" Text="{Binding HeaderPath}"
TextTrimming="PrefixCharacterEllipsis"/>
</Border>
<ctl:DiffTextView File="{Binding}"
IsSplit="{Binding $parent[Window].((vm:DiffViewerViewModel)DataContext).IsSplitView}"
WrapLines="{Binding $parent[Window].((vm:DiffViewerViewModel)DataContext).WrapLines}"/>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Grid>
@@ -390,7 +390,8 @@
</ScrollViewer>
</TabItem>
<TabItem Header="{loc:Tr settings.onlineInbox.tabHeader}">
<TabItem Header="{loc:Tr settings.onlineInbox.tabHeader}"
IsVisible="{Binding ShowOnlineInbox}">
<ScrollViewer>
<StackPanel Spacing="14" Margin="0,8,0,0">
@@ -8,6 +8,7 @@ public partial class SettingsModalView : Window
public SettingsModalView()
{
InitializeComponent();
}
protected override void OnDataContextChanged(EventArgs e)
@@ -18,19 +18,6 @@
<KeyBinding Gesture="Escape" Command="{Binding CloseCommand}"/>
</Window.KeyBindings>
<Window.Styles>
<Style Selector="ProgressBar.usage-gauge">
<Setter Property="Height" Value="10"/>
<Setter Property="CornerRadius" Value="4"/>
<Setter Property="Minimum" Value="0"/>
<Setter Property="Maximum" Value="100"/>
<Setter Property="Foreground" Value="{DynamicResource AccentBrush}"/>
</Style>
<Style Selector="ProgressBar.usage-gauge.warn">
<Setter Property="Foreground" Value="{DynamicResource StatusReviewBrush}"/>
</Style>
</Window.Styles>
<ctl:ModalShell Title="{loc:Tr modals.usageMonitor.title}" CloseCommand="{Binding CloseCommand}">
<DockPanel>
@@ -82,19 +69,60 @@
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:UsageGaugeRowViewModel">
<Border Background="{DynamicResource DeepBrush}" BorderBrush="{DynamicResource LineBrush}"
BorderThickness="1" CornerRadius="6" Padding="12,10" Margin="0,0,10,10" Width="240">
BorderThickness="1" CornerRadius="6" Padding="12,10" Margin="0,0,10,10" Width="270">
<StackPanel Spacing="6">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Classes="eyebrow" Text="{Binding Label}"/>
<TextBlock Classes="meta" Text="{Binding Percent, StringFormat={}{0:0}%}" HorizontalAlignment="Right"/>
</StackPanel>
<Grid Height="10">
<ProgressBar Classes="usage-gauge" Classes.warn="{Binding IsWarnSeverity}" Value="{Binding Percent}"/>
<Canvas IsHitTestVisible="False">
<Rectangle Canvas.Left="{Binding ThresholdMarkerLeftPx}" Width="2" Height="10"
Fill="{DynamicResource TextDimBrush}"
IsVisible="{Binding ThresholdPercent, Converter={x:Static conv:ObjectConverters.IsNotNull}}"/>
</Canvas>
<ctl:UsageGaugeBar Height="16"
Percent="{Binding Percent}"
IsWarn="{Binding IsWarnSeverity}"
SoftPct="{Binding SoftPct, Mode=TwoWay}"
HardPct="{Binding HardPct, Mode=TwoWay}"
GatePct="{Binding GatePct, Mode=TwoWay}"
CommitCommand="{Binding CommitCommand}"
TrackBrush="{DynamicResource LineBrush}"
FillBrush="{DynamicResource AccentBrush}"
WarnFillBrush="{DynamicResource StatusReviewBrush}"
SoftMarkerBrush="{DynamicResource TextDimBrush}"
HardMarkerBrush="{DynamicResource StatusReviewBrush}"
GateMarkerBrush="{DynamicResource StatusErrorBrush}"
ToolTip.Tip="{loc:Tr modals.usageMonitor.dragHint}"/>
<!-- Legend doubles as the numeric editor: swatch colours match the bar's markers,
and each box commits on Enter / focus loss (handlers in the code-behind). -->
<Grid ColumnDefinitions="10,*,62" RowDefinitions="Auto,Auto,Auto"
IsVisible="{Binding IsAdjustable}" Margin="0,2,0,0">
<Rectangle Grid.Row="0" Grid.Column="0" Width="10" Height="3" RadiusX="1.5" RadiusY="1.5"
VerticalAlignment="Center" Fill="{DynamicResource TextDimBrush}"/>
<TextBlock Grid.Row="0" Grid.Column="1" Classes="meta" Margin="6,0,4,0"
VerticalAlignment="Center" Text="{loc:Tr modals.usageMonitor.legendSoft}"/>
<NumericUpDown Grid.Row="0" Grid.Column="2" Tag="soft"
Value="{Binding SoftPct, Mode=TwoWay, Converter={StaticResource KeepLastNumber}}"
Minimum="0" Maximum="100" Increment="5" FormatString="0"
ShowButtonSpinner="False" FontSize="12" Padding="6,2"
LostFocus="OnStageBoxCommit" KeyDown="OnStageBoxKeyDown"/>
<Rectangle Grid.Row="1" Grid.Column="0" Width="10" Height="3" RadiusX="1.5" RadiusY="1.5"
VerticalAlignment="Center" Fill="{DynamicResource StatusReviewBrush}"/>
<TextBlock Grid.Row="1" Grid.Column="1" Classes="meta" Margin="6,0,4,0"
VerticalAlignment="Center" Text="{loc:Tr modals.usageMonitor.legendHard}"/>
<NumericUpDown Grid.Row="1" Grid.Column="2" Tag="hard"
Value="{Binding HardPct, Mode=TwoWay, Converter={StaticResource KeepLastNumber}}"
Minimum="0" Maximum="100" Increment="5" FormatString="0"
ShowButtonSpinner="False" FontSize="12" Padding="6,2"
LostFocus="OnStageBoxCommit" KeyDown="OnStageBoxKeyDown"/>
<Rectangle Grid.Row="2" Grid.Column="0" Width="10" Height="3" RadiusX="1.5" RadiusY="1.5"
VerticalAlignment="Center" Fill="{DynamicResource StatusErrorBrush}"/>
<TextBlock Grid.Row="2" Grid.Column="1" Classes="meta" Margin="6,0,4,0"
VerticalAlignment="Center" Text="{loc:Tr modals.usageMonitor.legendGate}"/>
<NumericUpDown Grid.Row="2" Grid.Column="2" Tag="gate"
Value="{Binding GatePct, Mode=TwoWay, Converter={StaticResource KeepLastNumber}}"
Minimum="0" Maximum="100" Increment="5" FormatString="0"
ShowButtonSpinner="False" FontSize="12" Padding="6,2"
LostFocus="OnStageBoxCommit" KeyDown="OnStageBoxKeyDown"/>
</Grid>
<TextBlock Classes="meta" Text="{Binding ResetText}" IsVisible="{Binding ResetText, Converter={x:Static conv:StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
@@ -1,8 +1,36 @@
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity;
using ClaudeDo.Ui.ViewModels.Modals;
namespace ClaudeDo.Ui.Views.Modals;
public partial class UsageMonitorModalView : Window
{
public UsageMonitorModalView() => InitializeComponent();
/// <summary>
/// Persists a stage typed into a gauge's legend box. `NumericUpDown` has no commit command, so
/// the box's <c>Tag</c> names the stage and the row's matching command does the clamp + save.
/// </summary>
private void OnStageBoxCommit(object? sender, RoutedEventArgs e)
{
if (sender is not Control { Tag: string stage, DataContext: UsageGaugeRowViewModel row }) return;
var command = stage switch
{
"soft" => row.CommitSoftCommand,
"hard" => row.CommitHardCommand,
_ => row.CommitGateCommand,
};
if (command.CanExecute(null)) command.Execute(null);
}
private void OnStageBoxKeyDown(object? sender, KeyEventArgs e)
{
if (e.Key != Key.Enter) return;
OnStageBoxCommit(sender, e);
e.Handled = true;
}
}
+3 -1
View File
@@ -62,7 +62,9 @@ public sealed class WindowDialogService : IDialogService
{
var dlg = new UsageMonitorModalView { DataContext = vm };
vm.CloseAction = () => dlg.Close();
await dlg.ShowDialog(_owner);
// The pill sits in both the footer and the Mission Control header, so own the dialog to
// whichever window is active — otherwise it opens behind Mission Control.
await dlg.ShowDialog(ActiveOwner());
}
public async Task ShowSettingsAsync(SettingsModalViewModel vm)
+1 -2
View File
@@ -48,7 +48,7 @@ subfolder within their area; the namespace stays the area namespace.
- **RunCancellationRegistry** — taskId → running-run CTS. Lets `TaskStateService.CancelAsync` kill a cancelled task's process without a DI cycle.
- **OverrideSlotService** — owns `RunNow` / `ContinueTask`; goes through `TaskStateService.StartRunningAsync` (caller-driven, serialized by slot lock).
- **StaleTaskRecovery** — startup-only; 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 multi-turn, planning internals, or app-settings writes). Auth via optional `X-ClaudeDo-Key`. **Two hard conventions** (both test-enforced): every optional parameter needs a C# default value, and no tool returns bare `Task`/a nullable payload. Full tool inventory + per-tool behaviour → [external-mcp](../../docs/explore-notes/external-mcp.md).
- **External/*** — always-on MCP tools for general Claude sessions, scoped to *starting* and *observing* sessions (no multi-turn, planning internals, or app-settings writes). Auth via optional `X-ClaudeDo-Key`. **Two hard conventions** (both test-enforced): every optional parameter needs a C# default value, and no tool returns bare `Task`/a nullable payload. **Tool-description style** (not test-enforced) is documented in `External/McpToolDocs.cs`, which also holds the shared boilerplate clauses — read it before adding or editing a tool description. Full tool inventory + per-tool behaviour → [external-mcp](../../docs/explore-notes/external-mcp.md).
## Status Model
@@ -159,7 +159,6 @@ launch specs · worktrees · agents/settings/lists · reports/notes/prep · diag
- `TaskMessage`
- `WorktreeUpdated`
- `TaskUpdated`
- `RunCreated`
- `ListUpdated`
- `WorkerLog`
- `PrimeFired`
+1 -1
View File
@@ -12,7 +12,7 @@ public sealed class AgentMcpTools
public AgentMcpTools(AgentFileService agents) => _agents = agents;
[McpServerTool, Description("List available agent definition files (name, description, path) for use as a task's agent path.")]
[McpServerTool, Description("List available agent definition files (name, description, path) to pick a value for a task's or list's agentPath override.")]
public async Task<IReadOnlyList<AgentInfo>> ListAgents(CancellationToken cancellationToken)
=> await _agents.ScanAsync(cancellationToken);
}
+1 -1
View File
@@ -19,7 +19,7 @@ public sealed class AppSettingsMcpTools
public AppSettingsMcpTools(IDbContextFactory<ClaudeDoDbContext> dbFactory) => _dbFactory = dbFactory;
[McpServerTool, Description("Read the worker's app-level defaults (model, max turns, permission mode, max parallel execution slots, worktree strategy). Read-only.")]
[McpServerTool, Description("Read the worker's global defaults (model, max turns, permission mode, max parallel execution slots, worktree strategy) that apply when a task/list doesn't override them. Read-only.")]
public async Task<AppSettingsReadDto> GetAppSettings(CancellationToken cancellationToken)
{
using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
+11 -12
View File
@@ -33,17 +33,15 @@ public sealed class AttachmentMcpTools
}
[McpServerTool, Description(
"Attach a read-only reference file to a task. These files are handed to the agent at run time, " +
"making them useful to prepare context for a task that will run later (e.g. plans, scripts, specs). " +
"Pass textContent for plain-text files (plans, markdown, scripts). " +
"Pass base64Content only for binary files (images, archives). Exactly one of the two must be provided. " +
"Re-attaching a file with the same fileName overwrites the previous version. " +
"Refuses if the task is currently Running — cancel it first.")]
"Attach a read-only reference file to a task so the agent receives it at run time — use to prepare " +
"context (plans, scripts, specs) for a task that will run later. Exactly one of textContent/" +
"base64Content is required. Re-attaching the same fileName overwrites the previous version." +
McpToolDocs.NotWhileRunning)]
public async Task<AttachmentDto> AddTaskAttachment(
string taskId,
string fileName,
string? textContent = null,
string? base64Content = null,
[Description("Name to store the attachment under; reusing an existing name overwrites it.")] string fileName,
[Description("Plain-text content (plans, markdown, scripts). Provide this or base64Content, not both.")] string? textContent = null,
[Description("Base64-encoded content for binary files (images, archives). Provide this or textContent, not both.")] string? base64Content = null,
CancellationToken ct = default)
{
var task = await _tasks.GetByIdAsync(taskId, ct)
@@ -94,7 +92,8 @@ public sealed class AttachmentMcpTools
return new AttachmentDto(fileName, byteSize, existing?.CreatedAt ?? DateTime.UtcNow);
}
[McpServerTool, Description("List all attachments on a task (fileName, byteSize, createdAt).")]
[McpServerTool, Description(
"List all attachments on a task — use to check what reference files are already attached before adding more.")]
public async Task<IReadOnlyList<AttachmentDto>> ListTaskAttachments(
string taskId, CancellationToken ct = default)
{
@@ -103,8 +102,8 @@ public sealed class AttachmentMcpTools
}
[McpServerTool, Description(
"Remove a single attachment from a task. Deletes both the file on disk and the database record. " +
"Refuses if the task is currently Running — cancel it first. Returns { removed: true, taskId, fileName } on success.")]
"Remove a single attachment from a task, deleting both the file on disk and its database record." +
McpToolDocs.NotWhileRunning)]
public async Task<RemoveAttachmentResult> RemoveTaskAttachment(
string taskId, string fileName, CancellationToken ct = default)
{
+39 -35
View File
@@ -3,8 +3,14 @@ using ModelContextProtocol.Server;
namespace ClaudeDo.Worker.External;
public sealed record BatchAddTaskInput(string Title, string? Description = null, string? Model = null);
public sealed record BatchSetMyDayInput(string TaskId, bool IsMyDay, int? SortOrder = null);
public sealed record BatchAddTaskInput(
string Title,
[property: Description("Task description/instructions for the agent.")] string? Description = null,
[property: Description("Model override: haiku|sonnet|opus. Blank inherits the list/global default.")] string? Model = null);
public sealed record BatchSetMyDayInput(
string TaskId,
[property: Description("true to add the task to My Day, false to remove it.")] bool IsMyDay,
[property: Description("Position within My Day; omit to append at the end.")] int? SortOrder = null);
// task is populated when found and includeDescription=false (the default, lean reference);
// taskFull is populated when found and includeDescription=true (full task incl.
@@ -34,14 +40,14 @@ public sealed class BatchMcpTools
public BatchMcpTools(ExternalMcpService svc) => _svc = svc;
[McpServerTool, Description(
"Fetch a snapshot of many tasks in one call (overview / polling a fan-out). " +
"Returns one result per id: { id, found, task, taskFull, error }. " +
"includeDescription=false (default): found tasks come back in `task` (lean reference, no " +
"Description/Result). includeDescription=true: found tasks come back in `taskFull` (incl. " +
"Description/Result) instead. A missing id is found=false (not an error; task and taskFull both null); " +
"error is only set for an unexpected failure. Max 100 ids.")]
"Fetch a snapshot of many tasks in one call — use for an overview or polling a fan-out instead of " +
"calling get_task per id. A missing id comes back as found=false, not an error; error is only set " +
"for an unexpected failure." + McpToolDocs.MaxBatch)]
public async Task<IReadOnlyList<BatchGetTaskResult>> BatchGetTasks(
string[] taskIds, bool includeDescription = false, CancellationToken cancellationToken = default)
string[] taskIds,
[Description("If true, return the full task (incl. Description/Result) in `taskFull`; if false " +
"(default), return a lean reference in `task`.")] bool includeDescription = false,
CancellationToken cancellationToken = default)
{
EnsureWithinCap(taskIds, nameof(taskIds));
@@ -75,19 +81,15 @@ public sealed class BatchMcpTools
}
[McpServerTool, Description(
"Create many tasks in one list at once. Each item: { title, description?, model? } " +
"(model: haiku|sonnet|opus, blank = inherit list/global default). " +
"queueImmediately enqueues every created task. " +
"Returns one result per item: { index, title, ok, task, possibleDuplicates, error }; task is " +
"a lean reference (id, listId, title, status, sortOrder, isMyDay), not the description you just " +
"sent. Each item is always created — possibleDuplicates is a non-blocking heads-up (up to 3 open " +
"tasks in the same list with a strongly overlapping title, id/title/status only); check it and " +
"mention any hit to the caller, but do not treat it as an error. Max 100 items.")]
"Create many tasks in one list at once — use instead of repeated add_task calls when seeding a list. " +
"Every item is still created even if it looks like a duplicate; possibleDuplicates is a non-blocking " +
"heads-up (up to 3 similar open tasks in the list) worth mentioning to the caller, not an error." +
McpToolDocs.LeanTaskRef + McpToolDocs.MaxBatch)]
public async Task<IReadOnlyList<BatchAddTaskResult>> BatchAddTasks(
string listId,
BatchAddTaskInput[] tasks,
string? createdBy = null,
bool queueImmediately = false,
[Description("If true, enqueue every created task immediately instead of leaving it Idle.")] bool queueImmediately = false,
CancellationToken cancellationToken = default)
{
EnsureWithinCap(tasks, nameof(tasks));
@@ -113,11 +115,13 @@ public sealed class BatchMcpTools
}
[McpServerTool, Description(
"Set the status of many tasks at once. status is 'Idle', 'Queued', 'Cancelled' or 'Done' only — " +
"same rule as update_task_status ('Done' is refused per-item for a task with an active worktree). " +
"Returns one result per id: { taskId, ok, error }. Max 100 ids.")]
"Set the status of many tasks at once — use for bulk queue/cancel/done actions instead of calling " +
"update_task_status per task. 'Done' is refused per-item for a task with an active worktree." +
McpToolDocs.MaxBatch)]
public async Task<IReadOnlyList<BatchTaskResult>> BatchUpdateTaskStatus(
string[] taskIds, string status, CancellationToken cancellationToken)
string[] taskIds,
[Description("One of 'Idle', 'Queued', 'Cancelled', or 'Done'.")] string status,
CancellationToken cancellationToken)
{
EnsureWithinCap(taskIds, nameof(taskIds));
return await RunPerTaskAsync(taskIds,
@@ -125,9 +129,8 @@ public sealed class BatchMcpTools
}
[McpServerTool, Description(
"Cancel many running tasks at once. Returns one result per id: " +
"{ taskId, ok, cancelled, error }. cancelled=false means the task was not running. " +
"Max 100 ids.")]
"Cancel many running tasks at once — use to bulk-stop tasks instead of calling cancel_task per id. " +
"ok=true with cancelled=false just means the task wasn't running." + McpToolDocs.MaxBatch)]
public async Task<IReadOnlyList<BatchCancelResult>> BatchCancelTasks(
string[] taskIds, CancellationToken cancellationToken)
{
@@ -151,8 +154,8 @@ public sealed class BatchMcpTools
}
[McpServerTool, Description(
"Delete many tasks at once. A Running task is refused (cancel it first) and reported " +
"as ok=false with its error. Returns one result per id: { taskId, ok, error }. Max 100 ids.")]
"Delete many tasks at once — use for bulk cleanup instead of calling delete_task per id." +
McpToolDocs.NotWhileRunning + McpToolDocs.MaxBatch)]
public async Task<IReadOnlyList<BatchTaskResult>> BatchDeleteTasks(
string[] taskIds, CancellationToken cancellationToken)
{
@@ -162,9 +165,9 @@ public sealed class BatchMcpTools
}
[McpServerTool, Description(
"Daily prep: set/clear MyDay for many tasks at once. Each item: { taskId, isMyDay, sortOrder? }. " +
"Still cap-guarded items that would exceed DailyPrepMaxTasks open MyDay tasks fail individually " +
"(ok=false) without blocking the rest. Returns one result per item: { taskId, ok, error }. Max 100 items.")]
"Set or clear MyDay (daily prep) for many tasks at once — use instead of calling set_my_day per task. " +
"Still cap-guarded: items that would exceed DailyPrepMaxTasks open MyDay tasks fail individually " +
"(ok=false) without blocking the rest." + McpToolDocs.MaxBatch)]
public async Task<IReadOnlyList<BatchTaskResult>> BatchSetMyDay(
BatchSetMyDayInput[] items, CancellationToken cancellationToken)
{
@@ -188,12 +191,13 @@ public sealed class BatchMcpTools
}
[McpServerTool, Description(
"Remove the worktrees of many tasks at once (directory + git branch). " +
"force=false refuses a dirty or Running worktree (reported ok=false); force=true removes " +
"even a dirty worktree (uncommitted changes lost), still refusing Running tasks. " +
"Returns one result per id: { taskId, ok, removed, branchDeleted, error }. Max 100 ids.")]
"Remove the worktrees (directory + git branch) of many tasks at once — use for bulk cleanup instead " +
"of calling cleanup_task_worktree per id." + McpToolDocs.NotWhileRunning + McpToolDocs.MaxBatch)]
public async Task<IReadOnlyList<BatchCleanupResult>> BatchCleanupTaskWorktrees(
string[] taskIds, bool force = false, CancellationToken cancellationToken = default)
string[] taskIds,
[Description("If true, also remove a dirty worktree, losing uncommitted changes; a Running task " +
"is still refused either way.")] bool force = false,
CancellationToken cancellationToken = default)
{
EnsureWithinCap(taskIds, nameof(taskIds));
+11 -13
View File
@@ -46,7 +46,7 @@ public sealed class ConfigMcpTools
_dbFactory = dbFactory;
}
[McpServerTool, Description("Get a list's default config (model, system prompt, agent path). Returns { found: false, config: null } if no config is set.")]
[McpServerTool, Description("Read a list's default run config — the fallback used by tasks in this list that don't set their own overrides. Returns { found: false, config: null } if none is set.")]
public async Task<TaskConfigResult> GetListConfig(string listId, CancellationToken cancellationToken)
{
var cfg = await _lists.GetConfigAsync(listId, cancellationToken);
@@ -56,9 +56,8 @@ public sealed class ConfigMcpTools
}
[McpServerTool, Description(
"Set a list's default model/system prompt/agent path/max turns. Passing all four as null clears the list " +
"config. Returns { ok, listId, config } — config is null when the config was cleared, otherwise it echoes " +
"the fields that were set (a field is null there if it was individually left unset/cleared).")]
"Set a list's default model/system prompt/agent path/max turns — the fallback for tasks in this list " +
"that don't override them. Passing all four as null clears the list config instead of setting one.")]
public async Task<SetListConfigResult> SetListConfig(
string listId, string? model = null, string? systemPrompt = null, string? agentPath = null,
int? maxTurns = null, CancellationToken cancellationToken = default)
@@ -90,9 +89,8 @@ public sealed class ConfigMcpTools
}
[McpServerTool, Description(
"Set per-task config overrides (model/system prompt/agent path/max turns). Pass null for any field to " +
"clear that override. Returns { ok, taskId, config } — config echoes the resulting overrides (a field is " +
"null there if it was cleared or never set).")]
"Set per-task overrides for model/system prompt/agent path/max turns; these take precedence over the " +
"list's default config for this one task. Pass null for any field to clear that override.")]
public async Task<SetTaskConfigResult> SetTaskConfig(
string taskId, string? model = null, string? systemPrompt = null, string? agentPath = null,
int? maxTurns = null, CancellationToken cancellationToken = default)
@@ -109,7 +107,7 @@ public sealed class ConfigMcpTools
return new SetTaskConfigResult(true, taskId, new TaskConfigDto(m, sp, ap, maxTurns));
}
[McpServerTool, Description("Get per-task config overrides (model/system prompt/agent path/max turns). Returns { found: false, config: null } if no override is set on this task.")]
[McpServerTool, Description("Read this task's per-task overrides (model/system prompt/agent path/max turns), which take precedence over the list's default config. Returns { found: false, config: null } if none is set.")]
public async Task<TaskConfigResult> GetTaskConfig(string taskId, CancellationToken cancellationToken)
{
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
@@ -120,11 +118,11 @@ public sealed class ConfigMcpTools
}
[McpServerTool, Description(
"Get the config a task will ACTUALLY run with — model, max turns, effort, permission mode, agent path, " +
"whether a system prompt is set, and skill names — with each field's source (task/list/preset/global). " +
"Uses the exact same resolution TaskRunner runs with, so this never drifts from get_app_settings/" +
"get_task_config's raw, possibly-unused values. maxTurns also reports the raw requested value and " +
"whether it was clamped to the global ceiling. Read-only, no side effects.")]
"Report the config a task will ACTUALLY run with — model, max turns, effort, permission mode, agent " +
"path, whether a system prompt is set, and skill names — each tagged with its source " +
"(task/list/preset/global). Use this over get_task_config/get_app_settings when you need resolved " +
"values, not raw overrides. maxTurns also reports the raw requested value and whether it was clamped " +
"to the global ceiling.")]
public async Task<EffectiveRunConfigDto> GetEffectiveRunConfig(string taskId, CancellationToken cancellationToken)
{
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
+134 -160
View File
@@ -26,7 +26,6 @@ public sealed record CancelTaskResult(bool Cancelled, string Id);
// review range (worktree ahead, or HandlerBaseCommit..HandlerHeadCommit for a worktree-less
// child) contributed nothing, so a reviewer sees them before approving instead of after.
public sealed record ReviewTaskResult(TaskRefDto Task, string? MergeStatus, IReadOnlyList<string> MergeConflicts, string? MergeMessage, string? RepoPath = null, IReadOnlyList<TaskRefDto>? EmptyChildren = null);
public sealed record StatusValueDto(string Status, string Meaning);
public sealed record RunTaskNowResult(bool Started, string TaskId);
public sealed record TaskDto(
@@ -161,7 +160,8 @@ public sealed class ExternalMcpService
_planningMerge = planningMerge;
}
[McpServerTool, Description("List all task lists available in ClaudeDo.")]
[McpServerTool, Description(
"List all task lists available in ClaudeDo. Start here — every task tool needs a listId from this call.")]
public async Task<IReadOnlyList<TaskListDto>> ListTaskLists(CancellationToken cancellationToken)
{
var lists = await _lists.GetAllAsync(cancellationToken);
@@ -169,17 +169,17 @@ public sealed class ExternalMcpService
}
[McpServerTool, Description(
"List tasks in a given list. Optionally filter by creator (createdBy) and/or status. " +
"Valid status values: Idle, Queued, Running, WaitingForReview, WaitingForChildren, Done, Failed, Cancelled. " +
"includeDescription=false (default): returns lean task references in `tasks` (no Description/Result) — " +
"use this unless you actually need the description text, since a list of verbosely-described tasks can " +
"otherwise blow past the response size limit. " +
"includeDescription=true: returns full tasks (incl. Description/Result) in `tasksFull` instead; `tasks` is " +
"null in that case.")]
"List the tasks in one list — the usual way to find a taskId. Optionally filter by creator and/or status.")]
public async Task<ListTasksResult> ListTasks(
string listId,
[Description("Only return tasks with this CreatedBy value.")]
string? createdBy = null,
[Description("Only return tasks in this status: Idle, Queued, Running, WaitingForReview, " +
"WaitingForChildren, Done, Failed or Cancelled.")]
string? status = null,
[Description("false (default): lean references in `tasks`, no Description/Result — keep this unless you " +
"need the description text, since verbosely-described tasks can blow past the response size " +
"limit. true: full tasks in `tasksFull` instead (`tasks` is then null).")]
bool includeDescription = false,
CancellationToken cancellationToken = default)
{
@@ -206,10 +206,12 @@ public sealed class ExternalMcpService
}
[McpServerTool, Description(
"Get a single task by id, including its current status and result. " +
"Status lifecycle: Idle → Queued → Running → WaitingForReview → Done | Failed | Cancelled. " +
"A successful run lands in WaitingForReview; use review_task to approve, reject, or cancel. " +
"Done/Failed/Cancelled tasks can be reset to Idle for re-execution.")]
"Get a single task by id, including its current status and result — the canonical reference for what a " +
"status means. Lifecycle: Idle → Queued → Running → WaitingForReview → Done | Failed | Cancelled. " +
"A successful run lands in WaitingForReview; use review_task to approve, reject or cancel it. " +
"Done/Failed/Cancelled tasks can be reset to Idle for re-execution. A Queued task with a blocker waits " +
"for its predecessor before the picker will claim it, and WaitingForChildren is a parent whose own work " +
"is done but whose children are still running.")]
public async Task<TaskDto> GetTask(string taskId, CancellationToken cancellationToken)
{
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
@@ -227,21 +229,19 @@ public sealed class ExternalMcpService
}
[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. " +
"Returns { task, possibleDuplicates }: task is a lean reference (id, listId, title, status, " +
"sortOrder, isMyDay) — not the description you just sent. The task is always created — " +
"possibleDuplicates is a non-blocking heads-up (up to 3 open tasks in the same list with a " +
"strongly overlapping title, id/title/status only); check it and mention any hit to the caller, " +
"but do not treat it as an error.")]
"Create a new task in the given list. The task is always created — possibleDuplicates is a non-blocking " +
"heads-up (up to 3 open tasks in the same list with a strongly overlapping title); check it and mention " +
"any hit to the caller, but do not treat it as an error." + McpToolDocs.LeanTaskRef)]
public async Task<AddTaskResult> AddTask(
string listId,
string title,
string? description = null,
string? createdBy = null,
[Description("true: enqueue the task for agent execution right away.")]
bool queueImmediately = false,
[Description("Cheapest model that can do the task well: 'haiku' for trivial/mechanical work, 'sonnet' " +
"for normal coding, 'opus' only for complex or cross-cutting work. null inherits the " +
"list/global default (normally sonnet).")]
string? model = null,
CancellationToken cancellationToken = default)
{
@@ -354,9 +354,8 @@ public sealed class ExternalMcpService
}
[McpServerTool, Description(
"Update an existing task's title, description, and/or commit type. Pass null to leave a field unchanged. " +
"Refuses if the task is currently Running. Returns a lean task reference (id, listId, title, status, " +
"sortOrder, isMyDay) — not the description you just sent.")]
"Update an existing task's title, description, and/or commit type. Pass null to leave a field unchanged." +
McpToolDocs.NotWhileRunning + McpToolDocs.LeanTaskRef)]
public async Task<TaskRefDto> UpdateTask(
string taskId,
string? title = null,
@@ -380,12 +379,12 @@ public sealed class ExternalMcpService
}
[McpServerTool, Description(
"Append a subtask (step) to a task. orderNum defaults to the end. " +
"Refuses if the task is currently Running. Subtasks are surfaced to the agent at run time and shown in the task's Steps list. " +
"Returns a lean task reference (id, listId, title, status, sortOrder, isMyDay), not the task's description.")]
"Append a subtask (step) to a task. Subtasks are surfaced to the agent at run time and shown in the " +
"task's Steps list." + McpToolDocs.NotWhileRunning + McpToolDocs.LeanTaskRef)]
public async Task<TaskRefDto> AddSubtask(
string taskId,
string title,
[Description("Position among the existing steps; defaults to the end.")]
int? orderNum = null,
CancellationToken cancellationToken = default)
{
@@ -419,16 +418,14 @@ public sealed class ExternalMcpService
}
[McpServerTool, Description(
"Update a task's status. Only 'Idle', 'Queued', 'Cancelled' and 'Done' are permitted externally — " +
"use run_task_now for execution control, and review_task to act on a WaitingForReview task. " +
"Settable: Idle (reset to editable), Queued (enqueue for execution), " +
"Cancelled (retire the task without deleting it; it can be reset to Idle later), " +
"Done (mark complete; refused if the task has an active worktree — use review_task to approve " +
"and merge that worktree instead). " +
"Full lifecycle: Idle → Queued → Running → WaitingForReview → Done | Failed | Cancelled. " +
"Returns a lean task reference (id, listId, title, status, sortOrder, isMyDay), not the task's description.")]
"Move a task between the statuses a caller may set directly. Use run_task_now for execution control and " +
"review_task to act on a WaitingForReview task — neither is reachable from here." + McpToolDocs.LeanTaskRef)]
public async Task<TaskRefDto> UpdateTaskStatus(
string taskId,
[Description("'Idle' (reset to editable), 'Queued' (enqueue for execution), 'Cancelled' (retire without " +
"deleting; can be reset to Idle later) or 'Done' (mark complete; refused if the task has an " +
"active worktree — use review_task to approve and merge that worktree instead). No other " +
"value is settable externally.")]
string status,
CancellationToken cancellationToken)
{
@@ -482,27 +479,29 @@ public sealed class ExternalMcpService
}
[McpServerTool, Description(
"Review a task that is WaitingForReview. " +
"decision='approve' review+merge, exactly like the UI's Approve: a childless task merges its worktree into " +
"targetBranch (default: the repo's current branch) then goes Done; a task with children drives the unit merge " +
"(parent worktree if active + each Done child in order); a task without an active worktree approves straight to Done. " +
"mergeStatus 'conflict' means the merge stopped on conflicts (files listed) — by default the merge is cleanly " +
"aborted and you resolve in the ClaudeDo UI; pass leaveConflictsInTree=true to instead leave the conflict " +
"markers in the working tree (repoPath in the result) so you can resolve them and call continue_merge, " +
"or abort_merge to cancel. " +
"decision='reject_rerun' → Queued and re-runs, resuming the agent's session with your feedback as the next turn (feedback is required). " +
"decision='reject_park' → Idle for manual editing (feedback ignored). " +
"decision='cancel' → Cancelled. " +
"Fails if the task is not currently WaitingForReview (except cancel, which also works while Running/Queued). " +
"The result's task field is a lean reference (id, listId, title, status, sortOrder, isMyDay), not the task's description. " +
"emptyChildren (parent approve only) lists the Done children about to be unit-merged whose own review range " +
"contributed nothing (e.g. a child that reported CLAUDEDO_BLOCKED and committed no code) — check it before " +
"trusting that every child actually delivered something.")]
"Act on a task that is WaitingForReview — the only way to approve, reject or retire a reviewed run. " +
"'approve' is review+merge, exactly like the UI's Approve: a childless task merges its worktree into " +
"targetBranch then goes Done; a task with children drives the unit merge (parent worktree if active + each " +
"Done child in order); a task without an active worktree approves straight to Done. Fails if the task is " +
"not WaitingForReview (except 'cancel', which also works while Running/Queued). mergeStatus 'conflict' " +
"means the merge stopped on conflicts, with the files listed. emptyChildren (parent approve only) lists " +
"the Done children about to be unit-merged whose own review range contributed nothing (e.g. a child that " +
"reported CLAUDEDO_BLOCKED and committed no code) — check it before trusting that every child actually " +
"delivered something." + McpToolDocs.LeanTaskRef)]
public async Task<ReviewTaskResult> ReviewTask(
string taskId,
[Description("'approve', 'reject_rerun', 'reject_park' or 'cancel'.")]
string decision,
[Description("Rejection comment. Required for 'reject_rerun', where the task goes Queued and re-runs with " +
"this text as the next turn of the agent's resumed session; ignored for 'reject_park', which " +
"just returns the task to Idle for manual editing.")]
string? feedback = null,
[Description("Branch an approve merges into; defaults to the repo's current branch.")]
string? targetBranch = null,
[Description("What an approve does when the merge hits conflicts. false (default): abort cleanly, leaving " +
"no half-merged state, and you resolve in the ClaudeDo UI. true: leave the conflict markers " +
"in the working tree (repoPath in the result) so you can resolve them and call continue_merge, " +
"or abort_merge to cancel.")]
bool leaveConflictsInTree = false,
CancellationToken cancellationToken = default)
{
@@ -634,7 +633,10 @@ public sealed class ExternalMcpService
}
}
[McpServerTool, Description("Immediately run a task in the override execution slot (bypasses the agent queue). Returns { started: true, taskId } on success.")]
[McpServerTool, Description(
"Run a task immediately in the override execution slot, bypassing the agent queue. That slot is single-" +
"occupancy and shared with continue_task — throws \"Override slot busy\" if something else holds it; " +
"enqueue via update_task_status instead of retrying in a loop.")]
public async Task<RunTaskNowResult> RunTaskNow(string taskId, CancellationToken cancellationToken)
{
try
@@ -653,7 +655,8 @@ public sealed class ExternalMcpService
return new RunTaskNowResult(true, taskId);
}
[McpServerTool, Description("Cancel a running task. Returns { cancelled: true, id } if the task was running and cancellation was requested; cancelled is false if the task was not running.")]
[McpServerTool, Description(
"Cancel a running task, killing its agent process. cancelled=false means the task was not running.")]
public async Task<CancelTaskResult> CancelTask(string taskId, CancellationToken cancellationToken)
{
var cancelled = _queue.CancelTask(taskId);
@@ -661,7 +664,9 @@ public sealed class ExternalMcpService
return new CancelTaskResult(cancelled, taskId);
}
[McpServerTool, Description("Delete a task. Returns { deleted: true, id } on success. Throws if the task is not found or is currently Running — cancel it first.")]
[McpServerTool, Description(
"Delete a task permanently. Prefer update_task_status 'Cancelled' to retire a task you may want back." +
McpToolDocs.NotWhileRunning)]
public async Task<DeleteTaskResult> DeleteTask(string taskId, CancellationToken cancellationToken)
{
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
@@ -676,31 +681,12 @@ public sealed class ExternalMcpService
return new DeleteTaskResult(true, taskId);
}
// ── Status reference ─────────────────────────────────────────────────────
[McpServerTool, Description("Returns all valid task status values and their meanings. Use before filtering by status or interpreting task state.")]
public Task<IReadOnlyList<StatusValueDto>> GetTaskStatusValues() =>
Task.FromResult<IReadOnlyList<StatusValueDto>>([
new("Idle", "Not yet queued; task is editable and will not run until enqueued."),
new("Queued", "Waiting for an agent execution slot. Tasks with a blocker (BlockedByTaskId) are skipped by the queue picker until their predecessor finishes."),
new("Running", "Agent is actively executing the task; cannot be edited or deleted until cancelled."),
new("WaitingForReview", "Run finished successfully and awaits review. Use review_task: approve (→ Done), reject_rerun (→ Queued, resumes the session with feedback), reject_park (→ Idle), or cancel (→ Cancelled)."),
new("WaitingForChildren", "Planning parent whose child tasks are still running. The parent resumes once all children reach a terminal state."),
new("Done", "Completed successfully and approved; result text is available in the result field. Can be reset to Idle for re-execution."),
new("Failed", "Execution ended with an error; task can be reset to Idle or re-queued directly."),
new("Cancelled", "Cancelled by the user; task can be reset to Idle or re-queued directly."),
]);
// ── Worktree / git tools ──────────────────────────────────────────────────
[McpServerTool, Description(
"Get git worktree details for a task: path, branch, headCommit (current HEAD SHA), " +
"baseCommit (SHA where the branch was created), ahead (commits on branch since base), " +
"behind (commits on main not yet on this branch; 0 if 'main' ref is unreachable), " +
"isDirty (has uncommitted changes in the worktree directory), " +
"mergeCommit (SHA of the merge commit this worktree's branch produced on the target branch, " +
"if it has been merged and that succeeded after this field was introduced; null otherwise — " +
"required by revert_merge). " +
"Get a task's git worktree state — path, branch, base/head commit, ahead/behind counts, isDirty, and the " +
"mergeCommit its branch produced once merged. behind is 0 when the 'main' ref is unreachable, so do not " +
"read 0 as \"up to date\" without checking. A null mergeCommit means revert_merge cannot act on this task. " +
"Throws if the task or its worktree does not exist.")]
public async Task<WorktreeInfoDto> GetTaskWorktree(string taskId, CancellationToken cancellationToken)
{
@@ -718,16 +704,17 @@ public sealed class ExternalMcpService
}
[McpServerTool, Description(
"Get the diff for a task's worktree relative to its base commit. For a worktree-less " +
"list-handler host task (Mission Control's \"Let Claude handle it\"), returns the fixed " +
"HandlerBaseCommit..HandlerHeadCommit range over the list's working dir instead. " +
"stat=false (default): returns the full unified diff, capped at 200 KB (truncated=true when larger). " +
"stat=true: returns a --stat summary (changed files with insertion/deletion counts). " +
"files always lists the changed file paths regardless of stat mode. " +
"totalBytes is the uncapped diff size (useful when truncated=true). " +
"Throws if the task has no worktree/review range, or the relevant directory is missing from disk.")]
"Read what a task actually changed — the diff of its worktree against its base commit (for a worktree-less " +
"list-handler host task, the fixed HandlerBaseCommit..HandlerHeadCommit range over the list's working dir " +
"instead). files lists the changed paths in either mode; truncated=true means the diff was capped and " +
"totalBytes holds its real size. Throws if the task has no worktree/review range, or the relevant " +
"directory is missing from disk.")]
public async Task<TaskDiffDto> GetTaskDiff(
string taskId, bool stat = false, CancellationToken cancellationToken = default)
string taskId,
[Description("false (default): the full unified diff, capped at 200 KB. true: a --stat summary with " +
"per-file insertion/deletion counts — start here when the diff may be large.")]
bool stat = false,
CancellationToken cancellationToken = default)
{
var (repoPath, baseCommit, headCommit) = await LoadDiffRangeAsync(taskId, cancellationToken);
@@ -782,21 +769,21 @@ public sealed class ExternalMcpService
}
[McpServerTool, Description(
"Merge a task's worktree branch into targetBranch (default: main). " +
"noFf=true (default): always creates a merge commit (--no-ff). " +
"dryRun=true: validates preconditions only, does not perform the merge; merged=false in the result means 'not actually merged'. " +
"allowWaitingForReview=true: also allows merging a task in WaitingForReview (default false, which only allows Done). " +
"On success: merged=true, mergeCommit contains the new merge commit SHA. " +
"On conflict: by default the merge is cleanly aborted (no half-merged state left); merged=false and conflicts lists the affected files. " +
"leaveConflictsInTree=true: on conflict the merge is NOT aborted — conflict markers are left in the working " +
"tree at repoPath (conflictsInTree=true in the result) so you can resolve them there and call continue_merge, " +
"or abort_merge to cancel.")]
"Merge a Done task's worktree branch into targetBranch. For a task still in WaitingForReview prefer " +
"review_task, which merges as part of approving. merged=true carries the new mergeCommit SHA; on conflict " +
"merged=false and conflicts lists the affected files.")]
public async Task<MergeTaskResultDto> MergeTask(
string taskId,
string targetBranch = "main",
[Description("true (default): always create a merge commit (--no-ff).")]
bool noFf = true,
[Description("true: validate preconditions only and do not merge — merged=false then means \"not attempted\".")]
bool dryRun = false,
[Description("true: also allow merging a task in WaitingForReview; false (default) allows Done only.")]
bool allowWaitingForReview = false,
[Description("What to do on conflict. false (default): abort cleanly, leaving no half-merged state. true: " +
"leave the conflict markers in the working tree at repoPath (conflictsInTree=true) so you can " +
"resolve them there and call continue_merge, or abort_merge to cancel.")]
bool leaveConflictsInTree = false,
CancellationToken cancellationToken = default)
{
@@ -848,11 +835,9 @@ public sealed class ExternalMcpService
}
[McpServerTool, Description(
"Finish an in-progress conflicted merge after the conflict markers in the working tree (repoPath from " +
"merge_task/review_task) have been resolved. Handles both a single task's merge and a parent/children unit " +
"merge — pass the PARENT task id to continue a unit merge. On success merged=true and the task reaches its " +
"post-merge status (Done when approving). If conflict markers are still present, merged=false and conflicts " +
"lists the affected files — resolve them and call continue_merge again. " +
"Finish an in-progress conflicted merge once you have resolved the conflict markers in the working tree " +
"(repoPath from merge_task/review_task). Pass the PARENT task id to continue a parent/children unit merge. " +
"merged=false with conflicts listed means markers are still present — resolve them and call again. " +
"Throws if there is no in-progress merge for the task; use abort_merge to cancel a paused merge instead.")]
public async Task<MergeContinuationResultDto> ContinueMerge(string taskId, CancellationToken cancellationToken)
{
@@ -920,10 +905,8 @@ public sealed class ExternalMcpService
[McpServerTool, Description(
"Abort an in-progress conflicted merge, discarding the conflict markers and restoring a clean working tree. " +
"Handles both a single task's merge and a parent/children unit merge — pass the PARENT task id to abort a " +
"unit merge. The task keeps its pre-merge status (e.g. WaitingForReview). " +
"Throws if there is no in-progress merge for the task. " +
"Returns a lean task reference (id, listId, title, status, sortOrder, isMyDay), not the task's description.")]
"Pass the PARENT task id to abort a parent/children unit merge. The task keeps its pre-merge status " +
"(e.g. WaitingForReview). Throws if there is no in-progress merge for the task." + McpToolDocs.LeanTaskRef)]
public async Task<TaskRefDto> AbortMerge(string taskId, CancellationToken cancellationToken)
{
_ = await _tasks.GetByIdAsync(taskId, cancellationToken)
@@ -945,21 +928,17 @@ public sealed class ExternalMcpService
}
[McpServerTool, Description(
"Non-destructive merge preview for a task's worktree branch against targetBranch (default: the repo's " +
"current branch), via `git merge-tree --write-tree` — does NOT touch the working tree, index, or HEAD. " +
"status: 'clean' (mergeable; changedFileCount is the size of that merge) or 'conflict' (conflictFiles " +
"lists the paths git would stop on). behind = commits on targetBranch not yet on this task's branch, so " +
"you can spot a stale branch even when the preview itself is clean. " +
"IMPORTANT: a clean preview says nothing about whether the merged result compiles or passes tests — git " +
"can merge two changes cleanly (e.g. one file deletes a symbol another file still references) and still " +
"break the build. " +
"isEmpty=true means the task's review range contributed nothing (no commits ahead of base, or — for a " +
"worktree-less list-handler host task — HandlerBaseCommit == HandlerHeadCommit); do not mistake a small " +
"changedFileCount for an empty one, check isEmpty instead. " +
"Throws a clear error if the task has neither an active worktree nor a handler commit range, or the " +
"list's working directory is missing from disk.")]
"Check whether a task would merge cleanly before touching anything — `git merge-tree --write-tree`, so the " +
"working tree, index and HEAD are untouched. status is 'clean' or 'conflict' (conflictFiles lists where git " +
"would stop); behind counts commits on targetBranch not yet on this branch, which flags a stale branch even " +
"when the preview is clean. IMPORTANT: a clean preview says nothing about whether the result compiles or " +
"passes tests — git can merge two changes cleanly (one file deleting a symbol another still references) and " +
"still break the build. isEmpty=true means the task's review range contributed nothing; check that flag " +
"rather than reading a small changedFileCount as empty. Throws if the task has neither an active worktree " +
"nor a handler commit range, or the list's working directory is missing from disk.")]
public async Task<MergePreviewToolDto> PreviewMerge(
string taskId,
[Description("Branch to preview against; defaults to the repo's current branch.")]
string? targetBranch = null,
CancellationToken cancellationToken = default)
{
@@ -968,18 +947,16 @@ public sealed class ExternalMcpService
}
[McpServerTool, Description(
"Merge preview plus file-overlap check across several tasks at once, all previewed against the same " +
"targetBranch (default: the repo's current branch). For each taskId returns the same fields as " +
"preview_merge (status/conflictFiles/changedFileCount/behind; error is set instead if that task could not " +
"be previewed, and it is then excluded from the overlap computation). overlaps lists, for each file " +
"touched by MORE THAN ONE of the given tasks (via each task's own diff, not the merge preview itself), " +
"which tasks touch it — passing a single taskId always yields an empty overlaps list. " +
"IMPORTANT: file-name overlap is a HINT, not a guarantee of a real collision, and its absence is not a " +
"guarantee of safety — two tasks touching different files entirely (e.g. one deletes a symbol, another " +
"still references it elsewhere) can still collide, and this tool will not flag that case. " +
"isEmpty=true (per entry) means that task's review range contributed nothing — see preview_merge.")]
"Plan a batch merge: preview_merge for several tasks against the same targetBranch, plus a file-overlap " +
"check between them. Per entry you get preview_merge's fields, or error instead when that task could not " +
"be previewed (it is then left out of the overlap computation). overlaps names, for each file touched by " +
"MORE THAN ONE of the given tasks, which tasks touch it — a single taskId always yields no overlaps. " +
"IMPORTANT: overlap is a HINT and its absence is not safety — two tasks touching entirely different files " +
"(one deleting a symbol, another still referencing it) can still collide unflagged, and as with " +
"preview_merge a clean result does not mean the merge builds.")]
public async Task<MergePreviewSetResultDto> PreviewMergeSet(
IReadOnlyList<string> taskIds,
[Description("Branch to preview every task against; defaults to the repo's current branch.")]
string? targetBranch = null,
CancellationToken cancellationToken = default)
{
@@ -1076,18 +1053,17 @@ public sealed class ExternalMcpService
}
[McpServerTool, Description(
"Revert a previously merged task's merge commit on targetBranch (default: main), via `git revert -m 1` — " +
"a new commit, never a reset/rewrite (the target working directory is shared with other sessions). " +
"Requires the task to be Done with a Merged worktree that has a recorded merge commit; tasks merged " +
"before this feature existed have no recorded commit and are refused rather than guessed via git log. " +
"On success: reverted=true, revertCommit is the new commit's SHA, and the task returns to " +
"WaitingForReview so it can be reconsidered. " +
"On a conflicting revert: reverted=false, the revert is aborted immediately (no half-resolved state " +
"left in the tree) and conflicts lists the files that would have conflicted. " +
"Throws if there is no recorded merge commit, the repo is mid-merge/mid-revert, or the target working " +
"tree has uncommitted changes from another session.")]
"Undo a merged task by reverting its merge commit — `git revert -m 1`, always a new commit and never a " +
"reset/rewrite, since the target working directory is shared with other sessions. Requires the task to be " +
"Done with a Merged worktree that has a recorded merge commit (check get_task_worktree's mergeCommit " +
"first). On success the task returns to WaitingForReview so it can be reconsidered. On conflict the revert " +
"is aborted immediately and conflicts lists the files. Throws if there is no recorded merge commit, the " +
"repo is mid-merge/mid-revert, or the target working tree has uncommitted changes from another session.")]
public async Task<RevertMergeResultDto> RevertMerge(
string taskId, string targetBranch = "main", CancellationToken cancellationToken = default)
string taskId,
[Description("Branch carrying the merge commit; defaults to main.")]
string targetBranch = "main",
CancellationToken cancellationToken = default)
{
var result = await _merge.RevertMergeAsync(taskId, targetBranch, cancellationToken);
@@ -1104,10 +1080,8 @@ public sealed class ExternalMcpService
}
[McpServerTool, Description(
"List all ClaudeDo-tracked worktrees. " +
"Each entry: taskId, path, branch, headCommit (empty if path missing on disk), " +
"isDirty (has uncommitted changes), mergedIntoMain (worktree state is Merged). " +
"Only worktrees recorded in the ClaudeDo database are returned.")]
"Survey every worktree ClaudeDo tracks — use it to find leftovers to clean up. Only worktrees recorded in " +
"the ClaudeDo database appear here, and headCommit is empty when the path is missing from disk.")]
public async Task<IReadOnlyList<WorktreeListItemDto>> ListWorktrees(CancellationToken cancellationToken)
{
var rows = await _maintenance.GetOverviewAsync(null, cancellationToken);
@@ -1125,12 +1099,14 @@ public sealed class ExternalMcpService
}
[McpServerTool, Description(
"Remove a task's worktree directory and delete its git branch. " +
"force=false (default): refuses if the worktree has uncommitted changes or the task is Running. " +
"force=true: removes even a dirty worktree (uncommitted changes are lost); task must not be Running. " +
"Returns removed=true on success; branchDeleted reflects whether the branch was also removed.")]
"Remove a task's worktree directory and delete its git branch. branchDeleted reports whether the branch " +
"went too." + McpToolDocs.NotWhileRunning)]
public async Task<CleanupWorktreeResult> CleanupTaskWorktree(
string taskId, bool force = false, CancellationToken cancellationToken = default)
string taskId,
[Description("false (default): refuse a worktree with uncommitted changes. true: remove it anyway, losing " +
"those changes.")]
bool force = false,
CancellationToken cancellationToken = default)
{
using var ctx = _dbFactory.CreateDbContext();
var task = await new TaskRepository(ctx).GetByIdAsync(taskId, cancellationToken)
@@ -1155,10 +1131,9 @@ public sealed class ExternalMcpService
}
[McpServerTool, Description(
"Send a follow-up prompt to an existing Claude session (multi-turn continuation). " +
"The agent resumes using --resume with the session ID from the task's last run. " +
"Runs in the override execution slot; throws if the slot is busy — try again later. " +
"Returns a status string from the execution slot.")]
"Send a follow-up prompt to a task's existing Claude session instead of starting a fresh run — the agent " +
"resumes via --resume with the session id from the task's last run, so its prior context is kept. Uses the " +
"same single-occupancy override slot as run_task_now and throws \"Override slot busy\" when that is taken.")]
public async Task<string> ContinueTask(
string taskId,
string followUpPrompt,
@@ -1187,10 +1162,10 @@ public sealed class ExternalMcpService
// ── Daily prep ───────────────────────────────────────────────────────────
[McpServerTool, Description(
"Daily prep: returns the open tasks eligible for today's MyDay selection. " +
"candidates = Idle, not blocked, in a git repo not excluded from the weekly report, and not already in MyDay. " +
"currentMyDay = Idle tasks already flagged IsMyDay (count them toward the cap). " +
"maxTasks = the hard cap on total open MyDay tasks. Use set_my_day to add tasks (never exceed maxTasks).")]
"Daily prep: the open tasks eligible for today's MyDay selection. candidates are Idle, unblocked, " +
"non-manual and in a git repo not excluded from the weekly report; currentMyDay are Idle tasks already " +
"flagged and count toward maxTasks, the hard cap on open MyDay tasks. Add your picks with set_my_day and " +
"never exceed maxTasks.")]
public async Task<DailyPrepDataDto> GetDailyPrepCandidates(CancellationToken cancellationToken)
{
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
@@ -1225,14 +1200,13 @@ public sealed class ExternalMcpService
}
[McpServerTool, Description(
"Daily prep: set or clear a task's MyDay flag, optionally setting its sortOrder " +
"(use consecutive sortOrder values to keep related tasks together). " +
"Setting isMyDay=true is rejected if it would exceed the MyDay cap (DailyPrepMaxTasks open MyDay tasks); " +
"clearing (isMyDay=false) is always allowed. " +
"Returns a lean task reference (id, listId, title, status, sortOrder, isMyDay), not the task's description.")]
"Daily prep: set or clear a task's MyDay flag. Setting it is rejected once the MyDay cap " +
"(DailyPrepMaxTasks open MyDay tasks) would be exceeded; clearing is always allowed." +
McpToolDocs.LeanTaskRef)]
public async Task<TaskRefDto> SetMyDay(
string taskId,
bool isMyDay,
[Description("Position in the MyDay list; use consecutive values to keep related tasks together.")]
int? sortOrder = null,
CancellationToken cancellationToken = default)
{
+8 -6
View File
@@ -20,13 +20,15 @@ public sealed class HandoffMcpTools
}
[McpServerTool, Description(
"End of Phase 2 for the list handler (\"Let Claude handle it\"): hand this run off to a fresh " +
"ConPTY session that carries out Phases 3-5, without dragging along this session's dedupe/rewrite " +
"context. taskId is this session's own handler task id; survivingTaskIds are the tasks that made " +
"it past dedupe, in the order to run them. Reuses the SAME handler task -- no new task is created, " +
"and HandlerBaseCommit is untouched. The current tile stays open; end your own turn after calling this.")]
"Call at the end of Phase 2 of the list handler (\"Let Claude handle it\") to hand this run off " +
"to a fresh ConPTY session that carries out Phases 3-5, without dragging along this session's " +
"dedupe/rewrite context. Reuses the SAME handler task -- no new task is created, and " +
"HandlerBaseCommit is untouched. The current tile stays open; you must end your own turn " +
"immediately after calling this.")]
public async Task<HandoffListHandlerResult> HandoffListHandler(
string taskId, IReadOnlyList<string> survivingTaskIds, CancellationToken cancellationToken)
[Description("This session's own handler task id.")] string taskId,
[Description("The tasks that made it past dedupe, in the order to run them.")] IReadOnlyList<string> survivingTaskIds,
CancellationToken cancellationToken)
{
if (survivingTaskIds.Count == 0)
throw new InvalidOperationException("survivingTaskIds must contain at least one task id.");
+1 -1
View File
@@ -20,7 +20,7 @@ public sealed class LifecycleMcpTools
_reset = reset;
}
[McpServerTool, Description("Reset a failed task: discards its worktree and returns it to Idle so it can be run again. Only Failed tasks are accepted. Returns { reset: true, taskId } on success.")]
[McpServerTool, Description("Reset a failed task back to Idle so it can be run again, discarding its now-stale worktree. Only tasks with Status=Failed are accepted; other statuses throw.")]
public async Task<ResetFailedTaskResult> ResetFailedTask(string taskId, CancellationToken cancellationToken)
{
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
+15 -5
View File
@@ -21,9 +21,14 @@ public sealed class ListMcpTools
_broadcaster = broadcaster;
}
[McpServerTool, Description("Create a new task list. workingDir sets the git repo tasks run against; commitType defaults to 'chore'.")]
[McpServerTool, Description("Create a new task list — the top-level grouping tasks belong to, with its own working dir, commit type, and default run config.")]
public async Task<ListSummaryDto> CreateList(
string name, string? workingDir = null, string? commitType = null, CancellationToken cancellationToken = default)
string name,
[Description("Absolute local path to an existing git repository this list's tasks will run against. Not validated here — the first task run fails if the path isn't an actual git repo. Omit to run this list's tasks in a throwaway sandbox with no worktree.")]
string? workingDir = null,
[Description("Conventional-commit-style type prefix for this list's task commits (e.g. 'feat', 'fix'). Defaults to 'chore'.")]
string? commitType = null,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(name))
throw new InvalidOperationException("name is required.");
@@ -41,9 +46,14 @@ public sealed class ListMcpTools
return ToDto(entity);
}
[McpServerTool, Description("Rename a list and/or change its working dir and default commit type. Pass null to leave a field unchanged.")]
[McpServerTool, Description("Rename a list, or change its working dir / default commit type without recreating it. Pass null for any field to leave it unchanged.")]
public async Task<ListSummaryDto> UpdateList(
string listId, string? name = null, string? workingDir = null, string? commitType = null,
string listId,
string? name = null,
[Description("Absolute local path to an existing git repository this list's tasks will run against; not validated until the next task runs. Null leaves it unchanged; pass an empty string to clear it and switch this list to sandbox-only task runs.")]
string? workingDir = null,
[Description("New default commit type prefix for this list's task commits. Null leaves it unchanged.")]
string? commitType = null,
CancellationToken cancellationToken = default)
{
var entity = await _lists.GetByIdAsync(listId, cancellationToken)
@@ -62,7 +72,7 @@ public sealed class ListMcpTools
return ToDto(entity);
}
[McpServerTool, Description("Delete a list and its tasks. Irreversible. Returns { deleted: true, id } on success.")]
[McpServerTool, Description("Permanently delete a list and all its tasks — no undo. Only for removing the whole list, not a single task within it.")]
public async Task<DeleteListResult> DeleteList(string listId, CancellationToken cancellationToken)
{
_ = await _lists.GetByIdAsync(listId, cancellationToken)
+28
View File
@@ -0,0 +1,28 @@
namespace ClaudeDo.Worker.External;
/// <summary>
/// Boilerplate clauses shared by several external MCP tool descriptions. Every tool description is
/// still emitted in full to the client — these constants only stop the wording from drifting apart
/// across ~50 attributes.
///
/// Description style (keep new tools in line with it):
/// 1. First sentence says what the tool does AND when to reach for it — MCP clients rank tools by
/// this text, so the trigger must not be buried behind return-shape prose.
/// 2. Then only non-obvious preconditions and refusals.
/// 3. Document parameters with [Description] on the parameter, not in the tool description.
/// 4. Describe result fields only where the caller must branch on them (isEmpty, truncated,
/// conflicts, …). Everything else is visible in the first actual response.
/// 5. No design rationale or "since this feature was introduced" history.
/// Budget: ~400 chars for a simple tool, ~800 for the merge/review family.
/// </summary>
internal static class McpToolDocs
{
/// <summary>Warns that the payload is the lean reference, not the task's description/result.</summary>
public const string LeanTaskRef = " Returns a lean task reference, not the task's description.";
/// <summary>Batch-size cap shared by every BatchMcpTools entry point.</summary>
public const string MaxBatch = " Max 100 per call.";
/// <summary>Mutations that refuse to touch a task while its agent is running.</summary>
public const string NotWhileRunning = " Refused while the task is Running — cancel it first.";
}
+6 -9
View File
@@ -28,15 +28,12 @@ public sealed class QueueStateMcpTools
}
[McpServerTool, Description(
"Read-only snapshot of the execution queue -- observe slot occupancy instead of inferring " +
"it from maxParallelExecutions. Result: { configuredSlots, effectiveSlots, activeSlots: " +
"[{ slot, taskId, startedAt }], waitingTaskIds }. configuredSlots is Settings -> " +
"MaxParallelExecutions; effectiveSlots is that value stepped down by the usage throttle " +
"(lower when the 5h/7d usage window is filling up) -- compare the two to see whether " +
"throttling is currently active. activeSlots lists every task presently holding an " +
"execution slot, with slot \"queue\" for a normal queue slot or \"override\" for the single " +
"run_task_now/continue_task slot. waitingTaskIds lists queued, unblocked, non-manual, due " +
"tasks in the order the queue would pick them next.")]
"Read-only snapshot of the execution queue -- call this to observe slot occupancy instead " +
"of inferring it from maxParallelExecutions. effectiveSlots is configuredSlots stepped down " +
"by the usage throttle (lower when the 5h/7d usage window fills up), so comparing the two " +
"shows whether throttling is currently active. Each active slot is \"queue\" (a normal " +
"queue slot) or \"override\" (the single run_task_now/continue_task slot). waitingTaskIds " +
"lists queued, unblocked, non-manual, due tasks in the order the queue would pick them next.")]
public async Task<GetQueueStateResult> GetQueueState(CancellationToken cancellationToken = default)
{
var (configured, effective) = await _queue.GetSlotCountsAsync(cancellationToken);
+11 -10
View File
@@ -24,14 +24,17 @@ public sealed class RunHistoryMcpTools
public RunHistoryMcpTools(TaskRunRepository runs) => _runs = runs;
[McpServerTool, Description("List all execution runs for a task (newest run metadata, tokens, turns, result, error).")]
[McpServerTool, Description(
"List all execution runs for a task — metadata, tokens, turns, result, and error per run — ordered " +
"oldest to newest by run number, so the last entry is the most recent. Use a run's id from here with " +
"get_run to fetch it individually.")]
public async Task<IReadOnlyList<RunDto>> ListRuns(string taskId, CancellationToken cancellationToken)
{
var runs = await _runs.GetByTaskIdAsync(taskId, cancellationToken);
return runs.Select(ToDto).ToList();
}
[McpServerTool, Description("Get a single execution run by its run id.")]
[McpServerTool, Description("Get one execution run's full detail by its run id, obtained from list_runs.")]
public async Task<RunDto> GetRun(string runId, CancellationToken cancellationToken)
{
var run = await _runs.GetByIdAsync(runId, cancellationToken)
@@ -40,18 +43,16 @@ public sealed class RunHistoryMcpTools
}
[McpServerTool, Description(
"Fetch log entries from a task's latest run. " +
"Returns { available, entries, totalLines, truncated }. " +
"available=false means no log exists yet (task is queued or just started — not an error). " +
"entries are the individual lines (NDJSON messages) from Claude's streaming output. " +
"Default: returns the last 50 entries (tail=50). " +
"tail: override the number of trailing entries to return. " +
"offset+limit: return entries starting at position offset (0-based); overrides tail when provided. " +
"truncated=true when fewer entries are returned than totalLines.")]
"Fetch NDJSON log lines from a task's latest run — use this to check progress or debug a task without " +
"opening the log file. Defaults to the last 50 lines. available=false means no log exists yet (queued " +
"or just started — not an error); truncated=true when fewer entries are returned than totalLines.")]
public async Task<TaskLogResult> GetTaskLog(
string taskId,
[Description("Number of trailing entries to return; ignored if offset or limit is set. Default 50.")]
int? tail = null,
[Description("0-based entry index to start from; overrides tail when set. Combine with limit to page through the log.")]
int? offset = null,
[Description("Max entries to return starting at offset. Omit to return everything from offset to the end.")]
int? limit = null,
CancellationToken cancellationToken = default)
{
+17 -12
View File
@@ -29,19 +29,24 @@ public sealed class TaskWaitMcpTools
}
[McpServerTool, Description(
"Blocks until at least one of the given tasks leaves Queued/Running, or until timeoutSeconds elapses " +
"(clamped server-side to 900s). Returns immediately if any task is already outside Queued/Running " +
"when called (an unknown id is reported as status \"NotFound\" and counts as changed). Use this instead " +
"of polling get_task in a loop. Pitfall: a planning parent with children goes Running -> " +
"WaitingForChildren while its children are still working, and by default that counts as \"changed\" -- " +
"so waiting on a parent returns immediately even though the work isn't done. Set " +
"treatWaitingForChildrenAsBusy=true to keep waiting through WaitingForChildren; the call then only " +
"returns once the parent reaches WaitingForReview or a terminal status (default: false, unchanged " +
"legacy behavior). Requires the calling claude process to run with MCP_TOOL_TIMEOUT >= 930000 (ms) for " +
"a long wait to actually be held open -- ClaudeDo's own launchers already set this. " +
"Result: { changed: [{ taskId, status }], timedOut }.")]
"Blocks until at least one of the given tasks leaves Queued/Running -- use this instead of " +
"polling get_task in a loop. Returns immediately if a task is already outside Queued/Running " +
"(an unknown id reports status \"NotFound\" and counts as changed). Pitfall: a planning parent " +
"goes Running -> WaitingForChildren while its children are still working, so by default " +
"waiting on a parent returns early; see treatWaitingForChildrenAsBusy. Requires the calling " +
"claude process to run with MCP_TOOL_TIMEOUT >= 930000 (ms) for a long wait to actually be " +
"held open -- ClaudeDo's own launchers already set this.")]
public async Task<WaitForTaskChangeResult> WaitForTaskChange(
string[] taskIds, int timeoutSeconds = 60, bool treatWaitingForChildrenAsBusy = false,
string[] taskIds,
[Description(
"How long to wait, in seconds, before giving up. Clamped server-side to 900s (15 min) " +
"regardless of what's passed.")]
int timeoutSeconds = 60,
[Description(
"When true, WaitingForChildren still counts as busy, so waiting on a planning parent " +
"continues until it reaches WaitingForReview or a terminal status instead of returning " +
"as soon as it leaves Running.")]
bool treatWaitingForChildrenAsBusy = false,
CancellationToken cancellationToken = default)
{
if (taskIds.Length == 0)
@@ -40,9 +40,6 @@ public sealed class HubBroadcaster : IPrimeBroadcaster, IRefineBroadcaster
public Task ListUpdated(string listId) =>
_hub.Clients.All.SendAsync("ListUpdated", listId);
public Task RunCreated(string taskId, int runNumber, bool isRetry) =>
_hub.Clients.All.SendAsync("RunCreated", taskId, runNumber, isRetry);
public Task UsageUpdated(UsageSnapshotDto snapshot) =>
_hub.Clients.All.SendAsync("UsageUpdated", snapshot);
+21 -3
View File
@@ -50,7 +50,12 @@ public record AppSettingsDto(
List<ModelPresetDto>? ModelPresets = null,
int UsageGateFiveHourPct = 80,
int UsageGateSevenDayPct = 90,
int MaxTurnsCeiling = 80);
int MaxTurnsCeiling = 80,
// Throttle stages per bucket — dragged on the usage-monitor gauges, not typed in Settings.
int UsageThrottleFiveHourSoftPct = 50,
int UsageThrottleFiveHourHardPct = 65,
int UsageThrottleSevenDaySoftPct = 50,
int UsageThrottleSevenDayHardPct = 65);
// Per-model run defaults (effort + turn budget) edited in Settings -> General.
public record ModelPresetDto(string Model, string Effort, int MaxTurns);
@@ -137,7 +142,12 @@ public record UsageSnapshotDto(
string? LastError,
int ConfiguredSlots,
int EffectiveSlots,
string? ThrottleBucket);
string? ThrottleBucket,
// Throttle stages per bucket, so the usage monitor can draw (and drag) them on each gauge.
int ThrottleFiveHourSoftPct,
int ThrottleFiveHourHardPct,
int ThrottleSevenDaySoftPct,
int ThrottleSevenDayHardPct);
public record ModelUsageRowDto(
DateOnly Date,
@@ -446,7 +456,11 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
.Select(p => new ModelPresetDto(p.Model, p.Effort, p.MaxTurns)).ToList(),
row.UsageGateFiveHourPct,
row.UsageGateSevenDayPct,
row.MaxTurnsCeiling);
row.MaxTurnsCeiling,
row.UsageThrottleFiveHourSoftPct,
row.UsageThrottleFiveHourHardPct,
row.UsageThrottleSevenDaySoftPct,
row.UsageThrottleSevenDayHardPct);
}
public async Task UpdateAppSettings(AppSettingsDto dto)
@@ -477,6 +491,10 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
UsageGateFiveHourPct = dto.UsageGateFiveHourPct,
UsageGateSevenDayPct = dto.UsageGateSevenDayPct,
MaxTurnsCeiling = dto.MaxTurnsCeiling,
UsageThrottleFiveHourSoftPct = dto.UsageThrottleFiveHourSoftPct,
UsageThrottleFiveHourHardPct = dto.UsageThrottleFiveHourHardPct,
UsageThrottleSevenDaySoftPct = dto.UsageThrottleSevenDaySoftPct,
UsageThrottleSevenDayHardPct = dto.UsageThrottleSevenDayHardPct,
});
}
@@ -1,6 +1,7 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Online.Interfaces;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Hosting;
@@ -15,19 +16,22 @@ public sealed class OnlineSyncService : BackgroundService
private readonly IOnlineAuthProvider _auth;
private readonly OnlineInboxConfig _config;
private readonly ILogger<OnlineSyncService> _logger;
private readonly HubBroadcaster _broadcaster;
public OnlineSyncService(
IDbContextFactory<ClaudeDoDbContext> dbFactory,
IOnlineInboxApi api,
IOnlineAuthProvider auth,
OnlineInboxConfig config,
ILogger<OnlineSyncService> logger)
ILogger<OnlineSyncService> logger,
HubBroadcaster broadcaster)
{
_dbFactory = dbFactory;
_api = api;
_auth = auth;
_config = config;
_logger = logger;
_broadcaster = broadcaster;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
@@ -129,6 +133,8 @@ public sealed class OnlineSyncService : BackgroundService
CommitType = CommitTypeRegistry.DefaultType,
};
await tasks.AddAsync(entity, ct);
// Without this the imported task only shows up after a manual reload.
await _broadcaster.TaskUpdated(entity.Id);
await _api.MarkImportedAsync(remote.Id, ct);
_logger.LogInformation("OnlineSyncService: imported task {Id} ('{Title}')", remote.Id, remote.Title);
+29 -7
View File
@@ -242,16 +242,18 @@ public sealed class QueueService : BackgroundService
public async Task<(int Configured, int Effective)> GetSlotCountsAsync(CancellationToken ct)
{
int configured;
int softPct, hardPct, gateFivePct, gateSevenPct;
UsageThresholds fiveHour, sevenDay;
try
{
using var context = _dbFactory.CreateDbContext();
var settings = await new AppSettingsRepository(context).GetAsync(ct);
configured = Math.Max(1, settings.MaxParallelExecutions);
softPct = settings.UsageThrottleSoftPct;
hardPct = settings.UsageThrottleHardPct;
gateFivePct = settings.UsageGateFiveHourPct;
gateSevenPct = settings.UsageGateSevenDayPct;
fiveHour = new UsageThresholds(
settings.UsageThrottleFiveHourSoftPct, settings.UsageThrottleFiveHourHardPct,
settings.UsageGateFiveHourPct);
sevenDay = new UsageThresholds(
settings.UsageThrottleSevenDaySoftPct, settings.UsageThrottleSevenDayHardPct,
settings.UsageGateSevenDayPct);
}
catch (Exception ex)
{
@@ -267,8 +269,9 @@ public sealed class QueueService : BackgroundService
}
var effective = UsageThrottle.EffectiveSlots(
configured, snapshot.FiveHour?.Utilization, snapshot.SevenDay?.Utilization,
softPct, hardPct, gateFivePct, gateSevenPct);
configured,
snapshot.FiveHour?.Utilization, fiveHour,
snapshot.SevenDay?.Utilization, sevenDay);
ReportThrottleTransition(configured, effective, snapshot);
return (configured, effective);
@@ -343,9 +346,28 @@ public sealed class QueueService : BackgroundService
await _runner.RunAsync(task, "queue", ct, alreadyClaimed: true);
}
catch (OperationCanceledException)
{
// Cancellation is driven by the cancel path, which already wrote the terminal status.
// Marking the task Failed here would be a regression (it would stomp Cancelled).
_logger.LogInformation("Slot runner cancelled for task {TaskId}", taskId);
}
catch (Exception ex)
{
_logger.LogError(ex, "Slot runner error for task {TaskId}", taskId);
// The picker already committed status='running' before this ran. Without this the
// task stays Running forever and the UI never hears about it — it keeps showing the
// pre-claim status because the raw-SQL claim itself never broadcasts.
try
{
await _state.FailAsync(taskId, DateTime.UtcNow,
$"Slot runner error: {ex.Message}", CancellationToken.None);
}
catch (Exception failEx)
{
_logger.LogError(failEx, "Could not mark task {TaskId} as failed after a slot error", taskId);
}
}
}
}
@@ -223,6 +223,7 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
var repoDir = list.WorkingDir;
if (string.IsNullOrEmpty(repoDir) || !Directory.Exists(repoDir))
throw new InvalidOperationException($"list '{list.Name}' has no existing working directory");
repoDir = TrimTrailingSeparator(repoDir);
var briefLines = new List<string>();
foreach (var id in taskIds)
@@ -236,7 +237,7 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
Directory.CreateDirectory(sessionDir);
var systemPromptPath = Path.Combine(sessionDir, "system-prompt.md");
await File.WriteAllTextAsync(systemPromptPath, PromptFiles.ReadOrDefault(PromptKind.MergeHelper), ct);
await File.WriteAllTextAsync(systemPromptPath, PromptFiles.ReadOrDefault(PromptKind.MergeHelperTriage), ct);
var briefPath = Path.Combine(sessionDir, "brief.md");
await File.WriteAllTextAsync(briefPath, PromptFiles.Render(PromptKind.MergeHelperInitial,
@@ -279,8 +280,9 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
// Builds the LaunchSpec for the fresh session a merge-helper run hands off to once Phase 2
// (enhance) is done -- SAME handler task id as the run that called handoff_list_handler, so
// HandlerBaseCommit/HandlerHeadCommit and the review range stay untouched; this never creates
// a task. Reuses the merge-helper system prompt unchanged (the phase 3-5 instructions already
// live there) and only writes a fresh handoff kickoff file, in a NEW session dir -- the old
// a task. Uses the MergeHelperExecute system prompt (phases 3-5 only) rather than the Triage
// one this run started with, so the handoff session carries no dedupe/enhance instructions it
// would have to ignore. Writes a fresh handoff kickoff file in a NEW session dir -- the old
// ConPTY tile keeps running against its own session-dir files untouched.
public async Task<LaunchSpec> BuildForMergeHelperHandoffAsync(
string taskId, IReadOnlyList<string> survivingTaskIds, CancellationToken ct)
@@ -300,6 +302,7 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
var repoDir = list.WorkingDir;
if (string.IsNullOrEmpty(repoDir) || !Directory.Exists(repoDir))
throw new InvalidOperationException($"list '{list.Name}' has no existing working directory");
repoDir = TrimTrailingSeparator(repoDir);
var briefLines = new List<string>();
foreach (var id in survivingTaskIds)
@@ -313,7 +316,7 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
Directory.CreateDirectory(sessionDir);
var systemPromptPath = Path.Combine(sessionDir, "system-prompt.md");
await File.WriteAllTextAsync(systemPromptPath, PromptFiles.ReadOrDefault(PromptKind.MergeHelper), ct);
await File.WriteAllTextAsync(systemPromptPath, PromptFiles.ReadOrDefault(PromptKind.MergeHelperExecute), ct);
var briefPath = Path.Combine(sessionDir, "handoff.md");
await File.WriteAllTextAsync(briefPath, PromptFiles.Render(PromptKind.MergeHelperHandoff,
@@ -349,6 +352,19 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
return new LaunchSpec(repoDir, resolvedClaude, args, env);
}
// Strips a trailing directory separator off a path bound for the CLI argument list. The ConPTY
// host flattens Args into ONE Windows command line and quotes each token, so a token ending in
// '\' escapes its own closing quote ("C:\repo\" parses as C:\repo" ...) and every following
// argument is absorbed into the preceding variadic flag -- for a list handler that means
// --add-dir swallows --append-system-prompt-file AND the positional kickoff, and the session
// opens with no prompt at all. Only user-supplied list working dirs can carry one; the session
// dirs we build never do. A bare root ("C:\", "/") is all separator and is left untouched.
private static string TrimTrailingSeparator(string dir)
{
var trimmed = dir.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
return trimmed.Length == 0 || trimmed.EndsWith(':') ? dir : trimmed;
}
// Renders one task as a brief list item. A description can itself be arbitrary Markdown
// (headings, lists, fenced code) — those must not merge into the brief's own task list, so
// the description is placed in a fenced code block indented to the list item's continuation
+3 -2
View File
@@ -311,6 +311,9 @@ public sealed class TaskRunner
{
var wtCtx = await _wtManager.CreateAsync(task, list, ct);
await _broadcaster.WorkerLog($"Created worktree for \"{task.Title}\"", WorkerLogLevel.Info, DateTime.UtcNow);
// The worktrees row was just inserted; without this the UI keeps showing the task
// as having no worktree until some unrelated event happens to refresh it.
await _broadcaster.WorktreeUpdated(task.Id);
return new RunDirResult(wtCtx.WorktreePath, wtCtx, null);
}
catch (Exception ex)
@@ -355,8 +358,6 @@ public sealed class TaskRunner
await taskRepo.SetLogPathAsync(taskId, logPath, ct);
}
await _broadcaster.RunCreated(taskId, runNumber, isRetry);
var arguments = _argsBuilder.Build(config);
await using var logWriter = new LogWriter(logPath);
@@ -28,9 +28,15 @@ public sealed class TranscriptUsageReader : ITranscriptUsageReader
if (Directory.Exists(_projectsRoot))
{
foreach (var file in Directory.EnumerateFiles(_projectsRoot, "*.jsonl", SearchOption.AllDirectories))
// A transcript last written before the window began cannot hold a record inside it, so
// it is skipped unread — that is what keeps a 7-day range off the full history (hundreds
// of MB). One day of slack absorbs local-vs-UTC skew between mtime and record stamps.
var mtimeCutoff = start.ToDateTime(TimeOnly.MinValue).AddDays(-1);
foreach (var file in new DirectoryInfo(_projectsRoot).EnumerateFiles("*.jsonl", SearchOption.AllDirectories))
{
ct.ThrowIfCancellationRequested();
if (file.LastWriteTime < mtimeCutoff) continue;
foreach (var record in GetOrReadFile(file))
{
@@ -67,8 +73,8 @@ public sealed class TranscriptUsageReader : ITranscriptUsageReader
if (string.IsNullOrWhiteSpace(sessionId) || !Directory.Exists(_projectsRoot))
return Task.FromResult<SessionUsageTotals?>(null);
var file = Directory
.EnumerateFiles(_projectsRoot, $"{sessionId}.jsonl", SearchOption.AllDirectories)
var file = new DirectoryInfo(_projectsRoot)
.EnumerateFiles($"{sessionId}.jsonl", SearchOption.AllDirectories)
.FirstOrDefault();
if (file is null) return Task.FromResult<SessionUsageTotals?>(null);
@@ -89,17 +95,16 @@ public sealed class TranscriptUsageReader : ITranscriptUsageReader
new SessionUsageTotals(input, output, cacheRead, cacheCreation));
}
private List<UsageMessageRecord> GetOrReadFile(string file)
private List<UsageMessageRecord> GetOrReadFile(FileInfo info)
{
var info = new FileInfo(file);
if (_cache.TryGetValue(file, out var cached) &&
if (_cache.TryGetValue(info.FullName, out var cached) &&
cached.Length == info.Length && cached.LastWriteUtc == info.LastWriteTimeUtc)
{
return cached.Records;
}
var records = ReadFile(file);
_cache[file] = new FileCacheEntry(info.Length, info.LastWriteTimeUtc, records);
var records = ReadFile(info.FullName);
_cache[info.FullName] = new FileCacheEntry(info.Length, info.LastWriteTimeUtc, records);
return records;
}
@@ -49,13 +49,18 @@ public sealed class UsageSnapshotBuilder
.Select(l => new UsageLimitDto(l.Kind, l.Group, l.Percent, l.Severity, l.ResetsAt, l.ScopeModelDisplayName, l.IsActive))
.ToList();
var fiveHourThresholds = new UsageThresholds(
settings.UsageThrottleFiveHourSoftPct, settings.UsageThrottleFiveHourHardPct, settings.UsageGateFiveHourPct);
var sevenDayThresholds = new UsageThresholds(
settings.UsageThrottleSevenDaySoftPct, settings.UsageThrottleSevenDayHardPct, settings.UsageGateSevenDayPct);
var configuredSlots = Math.Max(1, settings.MaxParallelExecutions);
var effectiveSlots = snapshot is null || lastError is not null
? configuredSlots
: UsageThrottle.EffectiveSlots(
configuredSlots, snapshot.FiveHour?.Utilization, snapshot.SevenDay?.Utilization,
settings.UsageThrottleSoftPct, settings.UsageThrottleHardPct,
settings.UsageGateFiveHourPct, settings.UsageGateSevenDayPct);
configuredSlots,
snapshot.FiveHour?.Utilization, fiveHourThresholds,
snapshot.SevenDay?.Utilization, sevenDayThresholds);
var throttleBucket = effectiveSlots < configuredSlots
? DecisiveBucket(snapshot?.FiveHour?.Utilization, snapshot?.SevenDay?.Utilization)
: null;
@@ -75,7 +80,11 @@ public sealed class UsageSnapshotBuilder
lastError,
configuredSlots,
effectiveSlots,
throttleBucket);
throttleBucket,
fiveHourThresholds.SoftPct,
fiveHourThresholds.HardPct,
sevenDayThresholds.SoftPct,
sevenDayThresholds.HardPct);
}
private static string? DecisiveBucket(double? fiveHourPct, double? sevenDayPct)
+24 -19
View File
@@ -1,37 +1,42 @@
namespace ClaudeDo.Worker.Usage;
/// <summary>
/// Pure staged throttle sitting ahead of <see cref="UsageGate"/>'s hard pause: as the 5h/7d usage
/// window fills up, the queue's effective parallelism steps down before it hits zero, instead of
/// running at full tilt right up to the gate threshold. Whichever of the two buckets is more
/// utilized decides the stage. A missing bucket (null) is treated as 0% for that bucket only —
/// callers with no snapshot at all should skip this and use <paramref name="configuredSlots"/>
/// directly (fail-open), same policy as <see cref="UsageGate"/>.
/// The soft/hard/gate percentages of a single usage bucket. Soft caps parallelism at 2 slots, hard
/// at 1, gate blocks the queue refill entirely. A threshold of 0 disables that stage.
/// </summary>
public readonly record struct UsageThresholds(int SoftPct, int HardPct, int GatePct);
/// <summary>
/// Pure staged throttle sitting ahead of <see cref="UsageGate"/>'s hard pause: as a usage window
/// fills up, the queue's effective parallelism steps down before it hits zero, instead of running
/// at full tilt right up to the gate threshold. Each bucket carries its own thresholds (the 5h and
/// 7d windows fill at very different rates) and the strictest bucket decides. A missing bucket
/// (null) never throttles — callers with no snapshot at all should skip this and use
/// <paramref name="configuredSlots"/> directly (fail-open), same policy as <see cref="UsageGate"/>.
/// </summary>
public static class UsageThrottle
{
public static int EffectiveSlots(
int configuredSlots,
double? fiveHourPct,
UsageThresholds fiveHour,
double? sevenDayPct,
int softPct,
int hardPct,
int gateFiveHourPct,
int gateSevenDayPct)
UsageThresholds sevenDay)
{
var slots = Math.Max(1, configuredSlots);
if (gateFiveHourPct > 0 && fiveHourPct is { } five && five >= gateFiveHourPct)
return 0;
if (gateSevenDayPct > 0 && sevenDayPct is { } seven && seven >= gateSevenDayPct)
return 0;
return Math.Min(
BucketSlots(slots, fiveHourPct, fiveHour),
BucketSlots(slots, sevenDayPct, sevenDay));
}
var worst = Math.Max(fiveHourPct ?? 0, sevenDayPct ?? 0);
private static int BucketSlots(int slots, double? pct, UsageThresholds thresholds)
{
if (pct is not { } utilization) return slots;
if (hardPct > 0 && worst >= hardPct)
return Math.Min(slots, 1);
if (softPct > 0 && worst >= softPct)
return Math.Min(slots, 2);
if (thresholds.GatePct > 0 && utilization >= thresholds.GatePct) return 0;
if (thresholds.HardPct > 0 && utilization >= thresholds.HardPct) return Math.Min(slots, 1);
if (thresholds.SoftPct > 0 && utilization >= thresholds.SoftPct) return Math.Min(slots, 2);
return slots;
}
@@ -29,9 +29,9 @@ public class ModelRegistryTests
}
[Fact]
public void ByCostAscending_is_haiku_sonnet_opus()
public void ByCostAscending_is_haiku_sonnet_opus_fable()
{
Assert.Equal(new[] { "haiku", "sonnet", "opus" }, ModelRegistry.ByCostAscending);
Assert.Equal(new[] { "haiku", "sonnet", "opus", "fable" }, ModelRegistry.ByCostAscending);
}
[Theory]
+162 -20
View File
@@ -22,6 +22,42 @@ public class PromptFilesTests
Assert.Equal("## {Wochentag}, {dd.MM.yyyy} — 01.06.2026", outp);
}
[Fact]
public void RenderTemplate_does_not_substitute_tokens_that_appear_inside_a_value()
{
// A sharpened task description can legitimately contain "{repo}" (a config snippet, a
// path placeholder). Substitution must be a single pass over the TEMPLATE, so injected
// values are never rescanned.
var outp = PromptFiles.RenderTemplate(
"Repo: {repo}\n\n{tasks}",
new Dictionary<string, string>
{
["repo"] = "C:\\real\\repo",
["tasks"] = "- Fix the {repo} placeholder in the config template",
});
Assert.Equal("Repo: C:\\real\\repo\n\n- Fix the {repo} placeholder in the config template", outp);
}
[Fact]
public void RenderTemplate_result_is_independent_of_dictionary_order()
{
const string template = "Scope: {scope}\nRepo: {repo}\n\n{tasks}";
var task = "- Document {scope} and {repo} tokens";
var tasksLast = PromptFiles.RenderTemplate(template, new Dictionary<string, string>
{
["scope"] = "List: Bugs", ["repo"] = "C:\\repo", ["tasks"] = task,
});
var tasksFirst = PromptFiles.RenderTemplate(template, new Dictionary<string, string>
{
["tasks"] = task, ["repo"] = "C:\\repo", ["scope"] = "List: Bugs",
});
Assert.Equal(tasksLast, tasksFirst);
Assert.Contains("- Document {scope} and {repo} tokens", tasksLast);
}
[Fact]
public void DefaultFor_system_mentions_blocked_marker_and_scope()
{
@@ -47,41 +83,102 @@ public class PromptFilesTests
[Fact]
public void PathFor_merge_helper_kinds_map_to_their_files()
{
Assert.EndsWith("merge-helper-system.md", PromptFiles.PathFor(PromptKind.MergeHelper));
Assert.EndsWith("merge-helper-triage.md", PromptFiles.PathFor(PromptKind.MergeHelperTriage));
Assert.EndsWith("merge-helper-execute.md", PromptFiles.PathFor(PromptKind.MergeHelperExecute));
Assert.EndsWith("merge-helper-initial.md", PromptFiles.PathFor(PromptKind.MergeHelperInitial));
Assert.EndsWith("merge-helper-handoff.md", PromptFiles.PathFor(PromptKind.MergeHelperHandoff));
}
[Fact]
public void DefaultFor_merge_helper_covers_all_five_phases()
public void DefaultFor_merge_helper_triage_covers_phases_0_to_2_only()
{
var d = PromptFiles.DefaultFor(PromptKind.MergeHelper);
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperTriage);
Assert.False(string.IsNullOrWhiteSpace(d));
Assert.Contains("Phase 0", d);
Assert.Contains("Phase 1", d);
Assert.Contains("Phase 2", d);
Assert.Contains("Phase 3", d);
Assert.Contains("Phase 4", d);
Assert.Contains("Phase 5", d);
Assert.Contains("## Phase 0", d);
Assert.Contains("## Phase 1", d);
Assert.Contains("## Phase 2", d);
// The run/review/merge phases belong to the handoff session's prompt, not this one —
// carrying them here is what made the handoff session redo dedupe work.
Assert.DoesNotContain("## Phase 3", d);
Assert.DoesNotContain("## Phase 4", d);
Assert.DoesNotContain("## Phase 5", d);
}
[Fact]
public void DefaultFor_merge_helper_names_the_tools_each_phase_needs()
public void DefaultFor_merge_helper_execute_covers_phases_3_to_5_only()
{
var d = PromptFiles.DefaultFor(PromptKind.MergeHelper);
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperExecute);
Assert.False(string.IsNullOrWhiteSpace(d));
Assert.Contains("## Phase 3", d);
Assert.Contains("## Phase 4", d);
Assert.Contains("## Phase 5", d);
Assert.DoesNotContain("## Phase 0", d);
Assert.DoesNotContain("## Phase 1", d);
Assert.DoesNotContain("## Phase 2", d);
}
[Fact]
public void DefaultFor_merge_helper_triage_names_the_tools_its_phases_need()
{
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperTriage);
Assert.Contains("batch_get_tasks", d); // phase 0
Assert.Contains("update_task", d); // phase 1 + 2
Assert.Contains("handoff_list_handler", d); // handoff
// Triage never runs or merges anything.
Assert.DoesNotContain("review_task", d);
Assert.DoesNotContain("continue_merge", d);
Assert.DoesNotContain("preview_merge_set", d);
}
[Fact]
public void DefaultFor_merge_helper_execute_names_the_tools_its_phases_need()
{
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperExecute);
Assert.Contains("get_app_settings", d); // phase 3
Assert.Contains("update_task_status", d); // phase 3
Assert.Contains("wait_for_task_change", d); // phase 3
Assert.Contains("preview_merge_set", d); // phase 4
Assert.Contains("review_task", d); // phase 4
Assert.Contains("continue_merge", d); // phase 4
Assert.DoesNotContain("run_task_now(", d); // single override slot — must not batch-start
}
[Fact]
public void DefaultFor_merge_helper_execute_waits_with_the_real_server_side_cap()
{
// TaskWaitMcpTools.MaxTimeoutSeconds is 900 and every ClaudeDo launcher sets
// MCP_TOOL_TIMEOUT=930000ms. The prompt used to say 170 -- a leftover from the retired
// 200000ms era -- which burned ~5x the turns on re-waiting.
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperExecute);
Assert.Contains("timeoutSeconds 900", d);
Assert.DoesNotContain("170", d);
}
[Fact]
public void DefaultFor_merge_helper_execute_waits_through_waiting_for_children()
{
// Without the flag, a parent with children reports "changed" as soon as it reaches
// WaitingForChildren, so the handler would advance to review/merge while children run.
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperExecute);
Assert.Contains("treatWaitingForChildrenAsBusy=true", d);
Assert.Contains("WaitingForChildren", d);
}
[Fact]
public void DefaultFor_merge_helper_execute_keeps_the_shared_checkout_git_guard()
{
// The handler session never gets PromptKind.System, so this is the ONLY place the
// never-`git add -A`-in-a-shared-checkout rule reaches a list-handler run.
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperExecute);
Assert.Contains("git add -- <the resolved paths>", d);
Assert.Contains("NEVER `git add -A`", d);
Assert.Contains("Never use raw `git merge`", d);
}
[Fact]
public void DefaultFor_merge_helper_only_asks_dedupe_questions_when_a_candidate_exists()
{
var d = PromptFiles.DefaultFor(PromptKind.MergeHelper);
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperTriage);
Assert.Contains("move straight to Phase 2", d);
Assert.Contains("do not ask the user to confirm the absence of duplicates", d);
Assert.Contains("Cancel nothing without an explicit answer", d);
@@ -91,7 +188,7 @@ public class PromptFilesTests
[Fact]
public void DefaultFor_merge_helper_phase0_treats_brief_as_primary_source()
{
var d = PromptFiles.DefaultFor(PromptKind.MergeHelper);
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperTriage);
Assert.Contains("primary source", d, StringComparison.OrdinalIgnoreCase);
Assert.Contains("batch_get_tasks", d);
Assert.Contains("Do not act on any single task before you have read", d);
@@ -100,7 +197,7 @@ public class PromptFilesTests
[Fact]
public void DefaultFor_merge_helper_allows_splitting_bundled_tasks_in_phase_2()
{
var d = PromptFiles.DefaultFor(PromptKind.MergeHelper);
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperTriage);
Assert.Contains("Propose splitting it to the user", d);
Assert.Contains("add_task/add_subtask", d);
Assert.Contains("do not invent requirements", d);
@@ -109,17 +206,62 @@ public class PromptFilesTests
[Fact]
public void DefaultFor_merge_helper_checks_effective_max_turns_before_queuing()
{
var d = PromptFiles.DefaultFor(PromptKind.MergeHelper);
Assert.Contains("effective max-turns", d);
Assert.Contains("get_list_config", d);
// Must delegate to get_effective_run_config rather than re-deriving the
// task/list/preset chain: TaskRunner.ResolveMaxTurns clamps the result to
// AppSettings.MaxTurnsCeiling, so a hand-derived number can exceed what actually runs.
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperExecute);
Assert.Contains("get_effective_run_config", d);
Assert.Contains("clamped", d);
Assert.Contains("set_task_config", d);
Assert.Contains("get_task_config", d);
Assert.DoesNotContain("get_list_config", d);
}
[Fact]
public void DefaultFor_merge_helper_execute_quotes_the_override_slot_error_verbatim()
{
// ExternalMcpService.RunTaskNow rewraps OverrideSlotService's raw lowercase throw.
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperExecute);
Assert.Contains("Override slot busy. Try again later.", d);
}
[Fact]
public void DefaultFor_system_scopes_suggest_improvement_to_top_level_tasks()
{
// TaskRunner only allowlists SuggestImprovement when ParentTaskId is null AND
// PlanningPhase is None, and TaskRunMcpService throws for any child -- but this
// prompt reaches every run, including planning children.
var d = PromptFiles.DefaultFor(PromptKind.System);
Assert.Contains("SuggestImprovement", d);
Assert.Contains("standalone top-level task", d);
Assert.Contains("improvements are one layer deep", d);
}
[Fact]
public void DefaultFor_planning_names_every_allowlisted_tool_and_claims_no_exclusivity()
{
// WindowsTerminalLauncher allowlists mcp__claudedo__*,Read,Grep,Glob,WebFetch,
// WebSearch,Skill. The prompt used to say "Use nothing else" right after telling the
// session to invoke the brainstorming skill -- forbidding its own first instruction.
var d = PromptFiles.DefaultFor(PromptKind.Planning);
Assert.DoesNotContain("Use nothing else", d);
Assert.Contains("Skill", d);
Assert.Contains("Grep", d);
Assert.Contains("WebSearch", d);
Assert.Contains("Write, Edit or Bash", d);
}
[Fact]
public void DefaultFor_refine_gates_the_read_tools_on_an_available_repo()
{
// RefinePrompt.BuildArgs only appends Read/Grep/Glob when canReadRepo is true.
var d = PromptFiles.DefaultFor(PromptKind.Refine);
Assert.Contains("only when a repository is available", d);
}
[Fact]
public void DefaultFor_merge_helper_checks_file_collisions_before_merge_order()
{
var d = PromptFiles.DefaultFor(PromptKind.MergeHelper);
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperExecute);
Assert.Contains("Default to the order the brief lists them", d);
Assert.Contains("tell the user which tasks collide", d);
Assert.Contains("NORMAL case, not a failure", d);
@@ -192,7 +334,7 @@ public class PromptFilesTests
[Fact]
public void DefaultFor_merge_helper_tells_the_session_to_hand_off_after_phase_2()
{
var d = PromptFiles.DefaultFor(PromptKind.MergeHelper);
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperTriage);
Assert.Contains("handoff_list_handler", d);
Assert.Contains("do not continue into phase 3 yourself", d, StringComparison.OrdinalIgnoreCase);
}
@@ -1,10 +1,14 @@
using System.Text.Json;
using ClaudeDo.Ui;
using Xunit;
namespace ClaudeDo.Ui.Tests;
public class AppSettingsTests
{
private static string TempConfigPath() =>
Path.Combine(Path.GetTempPath(), $"claudedo-uicfg-{Guid.NewGuid():N}.json");
[Fact]
public void Language_defaults_to_empty()
{
@@ -15,8 +19,67 @@ public class AppSettingsTests
public void Language_round_trips_through_json()
{
var json = JsonSerializer.Serialize(new AppSettings { Language = "de" });
var back = JsonSerializer.Deserialize<AppSettings>(json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
Assert.Equal("de", back.Language);
}
[Fact]
public void DiffPreferences_DefaultToUnifiedAndNoWrap()
{
var settings = new AppSettings();
Assert.Equal("unified", settings.DiffViewMode);
Assert.False(settings.DiffWrapLines);
}
[Fact]
public void DiffPreferences_SurviveSaveAndLoad()
{
var path = TempConfigPath();
try
{
new AppSettings { ConfigPath = path, DiffViewMode = "split", DiffWrapLines = true }.Save();
var restored = AppSettings.Load(path);
Assert.Equal("split", restored.DiffViewMode);
Assert.True(restored.DiffWrapLines);
Assert.Equal(path, restored.ConfigPath);
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Fact]
public void ConfigPath_IsNotWrittenIntoTheConfigFile()
{
var path = TempConfigPath();
try
{
new AppSettings { ConfigPath = path }.Save();
Assert.DoesNotContain("ConfigPath", File.ReadAllText(path), StringComparison.OrdinalIgnoreCase);
}
finally
{
if (File.Exists(path)) File.Delete(path);
}
}
[Fact]
public void DiffPreferences_ReadFromCamelCasedConfig()
{
const string json = """{"diffViewMode":"split","diffWrapLines":true}""";
var restored = JsonSerializer.Deserialize<AppSettings>(json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
Assert.Equal("split", restored.DiffViewMode);
Assert.True(restored.DiffWrapLines);
}
}
@@ -80,4 +80,37 @@ public class DetailsIslandTabsTests : IDisposable
Assert.True(vm.IsOutputTab);
Assert.False(vm.IsGitTab);
}
[Fact]
public void Bind_manual_task_defaults_to_git_tab()
{
var vm = NewVm();
vm.Bind(new TaskRowViewModel { Id = Guid.NewGuid().ToString("N"), IsManual = true });
Assert.True(vm.IsGitTab);
Assert.False(vm.IsOutputTab);
}
[Fact]
public void Bind_interactive_task_defaults_to_git_tab()
{
var vm = NewVm();
vm.Bind(new TaskRowViewModel { Id = Guid.NewGuid().ToString("N"), HasInteractiveSession = true });
Assert.True(vm.IsGitTab);
}
[Fact]
public void Bind_autonomous_task_defaults_to_output_tab()
{
var vm = NewVm();
vm.SelectTabCommand.Execute("git");
vm.Bind(new TaskRowViewModel { Id = Guid.NewGuid().ToString("N") });
Assert.True(vm.IsOutputTab);
Assert.False(vm.IsGitTab);
}
}
@@ -0,0 +1,258 @@
using ClaudeDo.Ui.ViewModels.Modals;
using Xunit;
namespace ClaudeDo.Ui.Tests.ViewModels;
public class DiffAlignmentTests
{
private static DiffLineViewModel Ctx(int oldNo, int newNo, string text) =>
new() { Kind = DiffLineKind.Ctx, OldNo = oldNo, NewNo = newNo, Text = text };
private static DiffLineViewModel Del(int oldNo, string text) =>
new() { Kind = DiffLineKind.Del, OldNo = oldNo, Text = text };
private static DiffLineViewModel Add(int newNo, string text) =>
new() { Kind = DiffLineKind.Add, NewNo = newNo, Text = text };
[Fact]
public void NullOrEmpty_YieldsEmptyDiff()
{
Assert.Empty(DiffAlignment.Build(null).SplitRows);
Assert.Empty(DiffAlignment.Build(Array.Empty<DiffLineViewModel>()).SplitRows);
}
[Fact]
public void ContextOnly_MirrorsBothSides_WithNoFillers()
{
var result = DiffAlignment.Build(new[] { Ctx(1, 1, "a"), Ctx(2, 2, "b") });
Assert.Equal(2, result.SplitRows.Count);
Assert.All(result.SplitRows, r =>
{
Assert.Equal(AlignedSide.Ctx, r.LeftKind);
Assert.Equal(AlignedSide.Ctx, r.RightKind);
Assert.Equal(r.LeftText, r.RightText);
});
Assert.Equal("a\nb", result.LeftText);
Assert.Equal("a\nb", result.RightText);
}
[Fact]
public void EqualSizedChangeBlock_PairsOneToOne()
{
var result = DiffAlignment.Build(new[]
{
Del(1, "old one"), Del(2, "old two"),
Add(1, "new one"), Add(2, "new two"),
});
Assert.Equal(2, result.SplitRows.Count);
Assert.Equal(AlignedSide.Del, result.SplitRows[0].LeftKind);
Assert.Equal(AlignedSide.Add, result.SplitRows[0].RightKind);
Assert.Equal("old one", result.SplitRows[0].LeftText);
Assert.Equal("new one", result.SplitRows[0].RightText);
Assert.Equal(1, result.SplitRows[0].OldNo!.Value);
Assert.Equal(1, result.SplitRows[0].NewNo!.Value);
}
[Fact]
public void MoreAddsThanDels_PadsTheLeftSideWithFillers()
{
var result = DiffAlignment.Build(new[]
{
Del(1, "d1"),
Add(1, "a1"), Add(2, "a2"), Add(3, "a3"),
});
Assert.Equal(3, result.SplitRows.Count);
Assert.Equal(AlignedSide.Del, result.SplitRows[0].LeftKind);
Assert.Equal(AlignedSide.Filler, result.SplitRows[1].LeftKind);
Assert.Equal(AlignedSide.Filler, result.SplitRows[2].LeftKind);
Assert.Equal("", result.SplitRows[1].LeftText);
Assert.Null(result.SplitRows[1].OldNo);
Assert.All(result.SplitRows, r => Assert.Equal(AlignedSide.Add, r.RightKind));
}
[Fact]
public void MoreDelsThanAdds_PadsTheRightSideWithFillers()
{
var result = DiffAlignment.Build(new[]
{
Del(1, "d1"), Del(2, "d2"), Del(3, "d3"),
Add(1, "a1"),
});
Assert.Equal(3, result.SplitRows.Count);
Assert.Equal(AlignedSide.Add, result.SplitRows[0].RightKind);
Assert.Equal(AlignedSide.Filler, result.SplitRows[1].RightKind);
Assert.Equal(AlignedSide.Filler, result.SplitRows[2].RightKind);
Assert.All(result.SplitRows, r => Assert.Equal(AlignedSide.Del, r.LeftKind));
}
[Fact]
public void NonContiguousLineNumbers_InsertOneGapRow()
{
var result = DiffAlignment.Build(new[] { Ctx(1, 1, "a"), Ctx(40, 40, "b") });
Assert.Equal(3, result.SplitRows.Count);
Assert.Equal(AlignedSide.Gap, result.SplitRows[1].LeftKind);
Assert.Equal(AlignedSide.Gap, result.SplitRows[1].RightKind);
Assert.Null(result.SplitRows[1].OldNo);
Assert.Null(result.SplitRows[1].NewNo);
}
[Fact]
public void FirstLineNeverProducesALeadingGap()
{
var result = DiffAlignment.Build(new[] { Ctx(120, 118, "a") });
Assert.Single(result.SplitRows);
Assert.Equal(AlignedSide.Ctx, result.SplitRows[0].LeftKind);
}
[Fact]
public void UnifiedRows_KeepGitOrder_DeletionsThenAdditions()
{
var result = DiffAlignment.Build(new[]
{
Ctx(1, 1, "keep"),
Del(2, "d1"), Del(3, "d2"),
Add(2, "a1"),
});
Assert.Equal(4, result.UnifiedRows.Count);
Assert.Equal(AlignedSide.Ctx, result.UnifiedRows[0].Kind);
Assert.Equal(AlignedSide.Del, result.UnifiedRows[1].Kind);
Assert.Equal(AlignedSide.Del, result.UnifiedRows[2].Kind);
Assert.Equal(AlignedSide.Add, result.UnifiedRows[3].Kind);
Assert.Equal("keep\nd1\nd2\na1", result.UnifiedText);
}
[Fact]
public void RowIndex_MapsToDocumentLine_OnBothSides()
{
var result = DiffAlignment.Build(new[]
{
Ctx(1, 1, "a"),
Del(2, "d"), Add(2, "x"), Add(3, "y"),
Ctx(3, 4, "b"),
});
var leftLines = result.LeftText.Split('\n');
var rightLines = result.RightText.Split('\n');
Assert.Equal(result.SplitRows.Count, leftLines.Length);
Assert.Equal(result.SplitRows.Count, rightLines.Length);
for (var i = 0; i < result.SplitRows.Count; i++)
{
Assert.Equal(result.SplitRows[i].LeftText, leftLines[i]);
Assert.Equal(result.SplitRows[i].RightText, rightLines[i]);
}
var unifiedLines = result.UnifiedText.Split('\n');
Assert.Equal(result.UnifiedRows.Count, unifiedLines.Length);
}
[Fact]
public void FileHeaderRows_AreIgnored()
{
var result = DiffAlignment.Build(new[]
{
new DiffLineViewModel { Kind = DiffLineKind.File, Text = "src/Foo.cs" },
Ctx(1, 1, "a"),
});
Assert.Single(result.SplitRows);
Assert.Equal("a", result.SplitRows[0].LeftText);
}
[Fact]
public void WordDiff_HighlightsOnlyTheChangedToken()
{
var result = DiffAlignment.Build(new[]
{
Del(1, " var x = 1;"),
Add(1, " var x = 2;"),
});
var row = Assert.Single(result.SplitRows);
var left = Assert.Single(row.LeftSpans);
var right = Assert.Single(row.RightSpans);
Assert.Equal(new TextSpan(12, 1), left);
Assert.Equal(new TextSpan(12, 1), right);
Assert.Equal("1", row.LeftText.Substring(left.Start, left.Length));
Assert.Equal("2", row.RightText.Substring(right.Start, right.Length));
}
[Fact]
public void WordDiff_MergesAdjacentChangedTokensIntoOneSpan()
{
var result = DiffAlignment.Build(new[]
{
Del(1, "call(a, b);"),
Add(1, "call(zz, b);"),
});
var row = Assert.Single(result.SplitRows);
var right = Assert.Single(row.RightSpans);
Assert.Equal("zz", row.RightText.Substring(right.Start, right.Length));
}
[Fact]
public void WordDiff_SkippedWhenTheLinesAreUnrelated()
{
var result = DiffAlignment.Build(new[]
{
Del(1, "public void Alpha()"),
Add(1, "return 42;"),
});
var row = Assert.Single(result.SplitRows);
Assert.Empty(row.LeftSpans);
Assert.Empty(row.RightSpans);
}
[Fact]
public void WordDiff_SkippedOnVeryLongLines()
{
var left = new string('a', DiffAlignment.MaxWordDiffChars + 1);
var result = DiffAlignment.Build(new[] { Del(1, left), Add(1, left + "b") });
var row = Assert.Single(result.SplitRows);
Assert.Empty(row.LeftSpans);
Assert.Empty(row.RightSpans);
}
[Fact]
public void WordDiff_SkippedWhenTokenCountExceedsTheCap()
{
var many = string.Join(" ", Enumerable.Range(0, DiffAlignment.MaxWordDiffTokens + 10).Select(n => $"t{n}"));
var result = DiffAlignment.Build(new[] { Del(1, many), Add(1, many + " x") });
var row = Assert.Single(result.SplitRows);
Assert.Empty(row.LeftSpans);
Assert.Empty(row.RightSpans);
}
[Fact]
public void WordDiff_IdenticalTextYieldsNoSpans()
{
var result = DiffAlignment.Build(new[] { Del(1, "same"), Add(1, "same") });
var row = Assert.Single(result.SplitRows);
Assert.Empty(row.LeftSpans);
Assert.Empty(row.RightSpans);
}
[Fact]
public void WordDiff_UnpairedOverhangRowsHaveNoSpans()
{
var result = DiffAlignment.Build(new[]
{
Del(1, "var x = 1;"),
Add(1, "var x = 2;"), Add(2, "var y = 3;"),
});
Assert.NotEmpty(result.SplitRows[0].RightSpans);
Assert.Empty(result.SplitRows[1].RightSpans);
}
}
@@ -17,6 +17,13 @@ public class DiffViewerViewModelTests
LocaleStore.Load(Path.Combine(dir!, "src", "ClaudeDo.Localization", "locales")), "en");
}
// A throwaway config path — the view toggles call Save(), and a test must never
// overwrite the developer's real ~/.todo-app/ui.config.json.
private static AppSettings TestSettings() => new()
{
ConfigPath = Path.Combine(Path.GetTempPath(), $"claudedo-uicfg-{Guid.NewGuid():N}.json"),
};
private sealed class FakePlanningWorker : StubWorkerClient
{
public IReadOnlyList<SubtaskDiffDto> AggregateResult { get; set; } = Array.Empty<SubtaskDiffDto>();
@@ -36,7 +43,7 @@ public class DiffViewerViewModelTests
[Fact]
public async Task CommitRange_NullHeadCommit_ShowsUnavailable()
{
var vm = new DiffViewerViewModel(null!, new FakePlanningWorker());
var vm = new DiffViewerViewModel(null!, new FakePlanningWorker(), TestSettings());
vm.ConfigureCommitRange("/some/repo", "abc123", null);
await vm.LoadAsync();
@@ -49,7 +56,7 @@ public class DiffViewerViewModelTests
[Fact]
public async Task CommitRange_NullBaseRef_ShowsUnavailable()
{
var vm = new DiffViewerViewModel(null!, new FakePlanningWorker());
var vm = new DiffViewerViewModel(null!, new FakePlanningWorker(), TestSettings());
vm.ConfigureCommitRange("/some/repo", null, "def456");
await vm.LoadAsync();
@@ -104,7 +111,7 @@ public class DiffViewerViewModelTests
new SubtaskDiffDto("s2", "Second", "branch-2", "base2", "head2", "+2 -1", "diff2"),
}
};
var vm = new DiffViewerViewModel(null!, fake);
var vm = new DiffViewerViewModel(null!, fake, TestSettings());
vm.ConfigurePlanning("plan-1", "main");
await vm.LoadAsync();
@@ -124,7 +131,7 @@ public class DiffViewerViewModelTests
new SubtaskDiffDto("s2", "Second", "b2", "base2", "head2", null, "DIFF-B"),
}
};
var vm = new DiffViewerViewModel(null!, fake);
var vm = new DiffViewerViewModel(null!, fake, TestSettings());
vm.ConfigurePlanning("plan-1", "main");
await vm.LoadAsync();
@@ -141,7 +148,7 @@ public class DiffViewerViewModelTests
AggregateResult = new[] { new SubtaskDiffDto("s1", "First", "b1", "base1", "head1", null, "DIFF-A") },
CombinedResult = new CombinedDiffResultDto(true, "integration-branch", "COMBINED-DIFF", null, null),
};
var vm = new DiffViewerViewModel(null!, fake);
var vm = new DiffViewerViewModel(null!, fake, TestSettings());
vm.ConfigurePlanning("plan-1", "main");
await vm.LoadAsync();
@@ -162,7 +169,7 @@ public class DiffViewerViewModelTests
AggregateResult = new[] { new SubtaskDiffDto("s1", "First", "b1", "base1", "head1", null, "DIFF-A") },
CombinedResult = new CombinedDiffResultDto(false, null, null, "subtask-42", new[] { "a.cs", "b.cs" }),
};
var vm = new DiffViewerViewModel(null!, fake);
var vm = new DiffViewerViewModel(null!, fake, TestSettings());
vm.ConfigurePlanning("plan-1", "main");
await vm.LoadAsync();
@@ -184,7 +191,7 @@ public class DiffViewerViewModelTests
AggregateResult = new[] { new SubtaskDiffDto("s1", "First", "b1", "base1", "head1", null, "DIFF-A") },
CombinedResult = null,
};
var vm = new DiffViewerViewModel(null!, fake);
var vm = new DiffViewerViewModel(null!, fake, TestSettings());
vm.ConfigurePlanning("plan-1", "main");
await vm.LoadAsync();
@@ -205,7 +212,7 @@ public class DiffViewerViewModelTests
AggregateResult = new[] { new SubtaskDiffDto("s1", "First", "b1", "base1", "head1", null, "DIFF-A") },
CombinedException = "planning task not found",
};
var vm = new DiffViewerViewModel(null!, fake);
var vm = new DiffViewerViewModel(null!, fake, TestSettings());
vm.ConfigurePlanning("plan-1", "main");
await vm.LoadAsync();
@@ -43,6 +43,33 @@ public class SettingsModalViewModelTests
SessionSkills: null, ModelPresets: null,
UsageGateFiveHourPct: fiveHourPct, UsageGateSevenDayPct: sevenDayPct);
[Fact]
public async Task Save_carries_dragged_throttle_stages_through_untouched()
{
// The throttle stages are only editable by dragging the usage-monitor gauges. Saving the
// Settings modal rebuilds the whole DTO, so it must not reset them to the defaults.
var worker = new FakeWorker
{
AppToReturn = DtoWith(65, 95) with
{
UsageThrottleFiveHourSoftPct = 42,
UsageThrottleFiveHourHardPct = 58,
UsageThrottleSevenDaySoftPct = 71,
UsageThrottleSevenDayHardPct = 88,
},
};
var vm = MakeVm(worker);
await vm.LoadAsync();
await vm.SaveCommand.ExecuteAsync(null);
Assert.NotNull(worker.Saved);
Assert.Equal(42, worker.Saved!.UsageThrottleFiveHourSoftPct);
Assert.Equal(58, worker.Saved.UsageThrottleFiveHourHardPct);
Assert.Equal(71, worker.Saved.UsageThrottleSevenDaySoftPct);
Assert.Equal(88, worker.Saved.UsageThrottleSevenDayHardPct);
}
private static SettingsModalViewModel MakeVm(FakeWorker worker) =>
new(worker, new PrimeClaudeTabViewModel(new FakePrimeApi()), new OnlineLoginService(),
MakeLocalizer(), new AppSettings());
@@ -0,0 +1,153 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.ViewModels.Islands;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Ui.Tests.ViewModels;
// The delta path in OnWorkerTaskUpdated used to be wrapped in a blank `catch { }`. A single
// transient DB error therefore left the row on its old status forever — the "task stuck on
// Queued although it is running" bug. It must retry, and fall back to a full reload.
public class TasksIslandDeltaResilienceTests : IDisposable
{
private readonly string _dbPath;
public TasksIslandDeltaResilienceTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_ui_delta_{Guid.NewGuid():N}.db");
using var ctx = NewContext();
ctx.Database.EnsureCreated();
}
public void Dispose()
{
try { File.Delete(_dbPath); } catch { }
try { File.Delete(_dbPath + "-wal"); } catch { }
try { File.Delete(_dbPath + "-shm"); } catch { }
}
private ClaudeDoDbContext NewContext()
{
var opts = new DbContextOptionsBuilder<ClaudeDoDbContext>()
.UseSqlite($"Data Source={_dbPath}")
.Options;
return new ClaudeDoDbContext(opts);
}
// Throws on the first N CreateDbContext calls, then behaves normally.
private sealed class FlakyDbFactory : IDbContextFactory<ClaudeDoDbContext>
{
private readonly Func<ClaudeDoDbContext> _create;
private int _failuresLeft;
public int CreateCalls { get; private set; }
public FlakyDbFactory(Func<ClaudeDoDbContext> create, int failuresLeft)
{
_create = create;
_failuresLeft = failuresLeft;
}
public ClaudeDoDbContext CreateDbContext()
{
CreateCalls++;
if (_failuresLeft > 0)
{
_failuresLeft--;
throw new InvalidOperationException("simulated transient DB failure");
}
return _create();
}
public void FailNext() => _failuresLeft++;
}
private sealed class FakeWorker : StubWorkerClient
{
}
// A user list's nav id is prefixed — see TasksIslandRegroupTests.UserList.
private static ListNavItemViewModel UserList(string listEntityId, string name) =>
new() { Id = $"user:{listEntityId}", Kind = ListKind.User, Name = name };
// LoadForList is void and fires a background task; this is the wait idiom the other
// TasksIsland test files use.
private static async Task LoadAndWaitAsync(TasksIslandViewModel vm, ListNavItemViewModel list)
{
vm.LoadForList(list);
var deadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < deadline)
{
await Task.Delay(25);
if (vm.Items.Count > 0) break;
}
await Task.Delay(50);
}
private async Task SeedAsync()
{
await using var db = NewContext();
db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow });
db.Tasks.Add(new TaskEntity
{
Id = "T1", ListId = "L1", Title = "Task one",
Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow, SortOrder = 0,
});
await db.SaveChangesAsync();
}
[Fact]
public async Task Delta_refresh_retries_after_a_transient_failure_and_still_applies_the_new_status()
{
await SeedAsync();
var flaky = new FlakyDbFactory(NewContext, failuresLeft: 0);
var vm = new TasksIslandViewModel(flaky, new FakeWorker());
var list = UserList("L1", "Work");
await LoadAndWaitAsync(vm, list);
Assert.Equal(TaskStatus.Queued, vm.Items.Single(r => r.Id == "T1").Status);
// Worker flips the task to Running.
await using (var db = NewContext())
{
var t = await db.Tasks.FirstAsync(x => x.Id == "T1");
t.Status = TaskStatus.Running;
await db.SaveChangesAsync();
}
// The next delta read fails once; the retry must still land the new status.
flaky.FailNext();
await vm.RefreshTaskFromWorkerAsync("T1");
Assert.Equal(TaskStatus.Running, vm.Items.Single(r => r.Id == "T1").Status);
}
[Fact]
public async Task A_stale_delta_result_does_not_overwrite_a_newer_one()
{
await SeedAsync();
var factory = new FlakyDbFactory(NewContext, failuresLeft: 0);
var vm = new TasksIslandViewModel(factory, new FakeWorker());
var list = UserList("L1", "Work");
await LoadAndWaitAsync(vm, list);
// Start refresh #1 while the DB still says Queued, but do not await it yet.
var first = vm.RefreshTaskFromWorkerAsync("T1");
await using (var db = NewContext())
{
var t = await db.Tasks.FirstAsync(x => x.Id == "T1");
t.Status = TaskStatus.Running;
await db.SaveChangesAsync();
}
// Refresh #2 sees Running and must win, regardless of completion order.
var second = vm.RefreshTaskFromWorkerAsync("T1");
await Task.WhenAll(first, second);
Assert.Equal(TaskStatus.Running, vm.Items.Single(r => r.Id == "T1").Status);
}
}
@@ -33,7 +33,16 @@ public class UsageMonitorModalViewModelTests
public int RefreshCalls;
public Exception? RefreshThrows;
public override Task<UsageSnapshotDto?> GetUsageSnapshotAsync() => Task.FromResult(Snapshot);
/// <summary>When set, the snapshot fetch never completes — stands in for the slow first
/// transcript scan on the worker side.</summary>
public TaskCompletionSource<UsageSnapshotDto?>? SnapshotGate;
public Exception? SnapshotThrows;
public override Task<UsageSnapshotDto?> GetUsageSnapshotAsync()
{
if (SnapshotThrows is not null) throw SnapshotThrows;
return SnapshotGate?.Task ?? Task.FromResult(Snapshot);
}
public override Task<UsageSnapshotDto?> RefreshUsageAsync()
{
@@ -53,8 +62,25 @@ public class UsageMonitorModalViewModelTests
TaskUsageCalls++;
return Task.FromResult(TaskRows);
}
public AppSettingsDto? AppSettings;
public AppSettingsDto? SavedSettings;
public override Task<AppSettingsDto?> GetAppSettingsAsync() => Task.FromResult(AppSettings);
public override Task UpdateAppSettingsAsync(AppSettingsDto dto)
{
SavedSettings = dto;
return Task.CompletedTask;
}
}
private static AppSettingsDto AppSettings() =>
new(DefaultClaudeInstructions: "", DefaultModel: "sonnet", DefaultMaxTurns: 30,
DefaultPermissionMode: "auto", MaxParallelExecutions: 3, WorktreeStrategy: "sibling",
CentralWorktreeRoot: null, WorktreeAutoCleanupEnabled: false, WorktreeAutoCleanupDays: 7,
ReportExcludedPaths: null, StandupWeekday: 3, DailyPrepMaxTasks: 5);
private static UsageLimitDto Limit(
string kind, double percent = 10, string severity = "normal",
DateTimeOffset? resetsAt = null, string? scopeModelDisplayName = null, bool isActive = true)
@@ -71,13 +97,63 @@ public class UsageMonitorModalViewModelTests
DateTime? fetchedAtUtc = null,
int configuredSlots = 1,
int effectiveSlots = 1,
string? throttleBucket = null)
string? throttleBucket = null,
int throttleFiveHourSoftPct = 50,
int throttleFiveHourHardPct = 65,
int throttleSevenDaySoftPct = 50,
int throttleSevenDayHardPct = 65)
=> new(
null, null, null, null,
limits ?? Array.Empty<UsageLimitDto>(),
fiveHourThresholdPct, sevenDayThresholdPct,
isGateBlocked, gateReason, fetchedAtUtc ?? DateTime.UtcNow, isStale, lastError,
configuredSlots, effectiveSlots, throttleBucket);
configuredSlots, effectiveSlots, throttleBucket,
throttleFiveHourSoftPct, throttleFiveHourHardPct,
throttleSevenDaySoftPct, throttleSevenDayHardPct);
// ── BeginLoad: the modal must open before the data lands ────────────────
[Fact]
public void BeginLoad_ReturnsWhileWorkerStillPending_AndShowsBusy()
{
var worker = new FakeWorker { SnapshotGate = new TaskCompletionSource<UsageSnapshotDto?>() };
var vm = new UsageMonitorModalViewModel(worker);
vm.BeginLoad();
Assert.True(vm.IsBusy);
Assert.False(vm.ModelsEmpty);
Assert.False(vm.TasksEmpty);
}
[Fact]
public void BeginLoad_WorkerThrows_ReportsErrorInsteadOfCrashing()
{
var worker = new FakeWorker { SnapshotThrows = new InvalidOperationException("worker offline") };
var vm = new UsageMonitorModalViewModel(worker);
string? reported = null;
vm.ErrorReported += m => reported = m;
vm.BeginLoad();
Assert.NotNull(reported);
Assert.Contains("worker offline", reported);
Assert.False(vm.IsBusy);
}
[Fact]
public async Task LoadAsync_WorkerThrows_ReportsErrorAndClearsBusy()
{
var worker = new FakeWorker { SnapshotThrows = new InvalidOperationException("worker offline") };
var vm = new UsageMonitorModalViewModel(worker);
string? reported = null;
vm.ErrorReported += m => reported = m;
await vm.LoadAsync();
Assert.NotNull(reported);
Assert.False(vm.IsBusy);
}
// ── Manual refresh ──────────────────────────────────────────────────────
@@ -210,7 +286,7 @@ public class UsageMonitorModalViewModelTests
var vm = new UsageMonitorModalViewModel(worker);
await vm.LoadAsync();
Assert.Equal(80, vm.GaugeRows[0].ThresholdPercent);
Assert.Equal(80, vm.GaugeRows[0].GatePct);
}
[Fact]
@@ -220,7 +296,276 @@ public class UsageMonitorModalViewModelTests
var vm = new UsageMonitorModalViewModel(worker);
await vm.LoadAsync();
Assert.Null(vm.GaugeRows[0].ThresholdPercent);
Assert.Null(vm.GaugeRows[0].GatePct);
Assert.False(vm.GaugeRows[0].IsAdjustable);
}
// ── Draggable stage markers ─────────────────────────────────────────────
[Fact]
public async Task GaugeRow_Session_CarriesPerBucketThrottleStages()
{
var worker = new FakeWorker
{
Snapshot = Snapshot(new[] { Limit("session") }, fiveHourThresholdPct: 80,
throttleFiveHourSoftPct: 45, throttleFiveHourHardPct: 60),
};
var vm = new UsageMonitorModalViewModel(worker);
await vm.LoadAsync();
var row = vm.GaugeRows[0];
Assert.Equal("five_hour", row.Bucket);
Assert.Equal(45, row.SoftPct);
Assert.Equal(60, row.HardPct);
Assert.Equal(80, row.GatePct);
Assert.True(row.IsAdjustable);
}
[Fact]
public async Task GaugeRow_WeeklyAll_CarriesSevenDayStages()
{
var worker = new FakeWorker
{
Snapshot = Snapshot(new[] { Limit("weekly_all") }, sevenDayThresholdPct: 90,
throttleSevenDaySoftPct: 70, throttleSevenDayHardPct: 85),
};
var vm = new UsageMonitorModalViewModel(worker);
await vm.LoadAsync();
var row = vm.GaugeRows[0];
Assert.Equal("seven_day", row.Bucket);
Assert.Equal(70, row.SoftPct);
Assert.Equal(85, row.HardPct);
Assert.Equal(90, row.GatePct);
}
[Fact]
public async Task Commit_WritesOnlyTheDraggedBucket_AndKeepsEverythingElse()
{
var worker = new FakeWorker
{
Snapshot = Snapshot(new[] { Limit("session") }, fiveHourThresholdPct: 80,
throttleFiveHourSoftPct: 50, throttleFiveHourHardPct: 65),
AppSettings = AppSettings(),
};
var vm = new UsageMonitorModalViewModel(worker);
await vm.LoadAsync();
var row = vm.GaugeRows[0];
row.SoftPct = 40;
row.HardPct = 55;
row.GatePct = 75;
await row.CommitCommand.ExecuteAsync(null);
Assert.NotNull(worker.SavedSettings);
Assert.Equal(40, worker.SavedSettings!.UsageThrottleFiveHourSoftPct);
Assert.Equal(55, worker.SavedSettings.UsageThrottleFiveHourHardPct);
Assert.Equal(75, worker.SavedSettings.UsageGateFiveHourPct);
// The 7d bucket and unrelated settings ride along untouched.
Assert.Equal(50, worker.SavedSettings.UsageThrottleSevenDaySoftPct);
Assert.Equal(65, worker.SavedSettings.UsageThrottleSevenDayHardPct);
Assert.Equal(90, worker.SavedSettings.UsageGateSevenDayPct);
Assert.Equal(3, worker.SavedSettings.MaxParallelExecutions);
}
[Fact]
public async Task Commit_WorkerOffline_ReportsErrorAndSavesNothing()
{
var worker = new FakeWorker
{
Snapshot = Snapshot(new[] { Limit("session") }),
AppSettings = null,
};
var vm = new UsageMonitorModalViewModel(worker);
await vm.LoadAsync();
string? reported = null;
vm.ErrorReported += m => reported = m;
await vm.GaugeRows[0].CommitCommand.ExecuteAsync(null);
Assert.NotNull(reported);
Assert.Null(worker.SavedSettings);
}
[Fact]
public async Task Commit_NonAdjustableRow_SavesNothing()
{
var worker = new FakeWorker
{
Snapshot = Snapshot(new[] { Limit("weekly_scoped", scopeModelDisplayName: "Opus") }),
AppSettings = AppSettings(),
};
var vm = new UsageMonitorModalViewModel(worker);
await vm.LoadAsync();
await vm.GaugeRows[0].CommitCommand.ExecuteAsync(null);
Assert.Null(worker.SavedSettings);
}
[Fact]
public async Task LiveSnapshot_UpdatesRowsInPlace_WithoutReplacingInstances()
{
// A poll landing mid-interaction must not swap the row the gauge is bound to.
var worker = new FakeWorker { Snapshot = Snapshot(new[] { Limit("session", percent: 20) }) };
var vm = new UsageMonitorModalViewModel(worker);
await vm.LoadAsync();
var before = vm.GaugeRows[0];
vm.Snapshot = Snapshot(new[] { Limit("session", percent: 55) }, throttleFiveHourSoftPct: 44);
Assert.Same(before, vm.GaugeRows[0]);
Assert.Equal(55, vm.GaugeRows[0].Percent);
Assert.Equal(44, vm.GaugeRows[0].SoftPct);
}
[Fact]
public async Task LiveSnapshot_NewLimitKind_AddsARow()
{
var worker = new FakeWorker { Snapshot = Snapshot(new[] { Limit("session") }) };
var vm = new UsageMonitorModalViewModel(worker);
await vm.LoadAsync();
vm.Snapshot = Snapshot(new[] { Limit("session"), Limit("weekly_all") });
Assert.Equal(2, vm.GaugeRows.Count);
}
// ── Legend input boxes ───────────────────────────────────────────────────
[Fact]
public async Task TypedStage_SavesTheEditedBucket()
{
var worker = new FakeWorker
{
Snapshot = Snapshot(new[] { Limit("session") }, fiveHourThresholdPct: 80),
AppSettings = AppSettings(),
};
var vm = new UsageMonitorModalViewModel(worker);
await vm.LoadAsync();
var row = vm.GaugeRows[0];
row.HardPct = 58;
await row.CommitHardCommand.ExecuteAsync(null);
Assert.Equal(58, worker.SavedSettings!.UsageThrottleFiveHourHardPct);
Assert.Equal(50, worker.SavedSettings.UsageThrottleFiveHourSoftPct);
Assert.Equal(80, worker.SavedSettings.UsageGateFiveHourPct);
}
[Fact]
public async Task TypedStage_OutOfOrder_IsPinned_AndLeavesNeighboursAlone()
{
var worker = new FakeWorker
{
Snapshot = Snapshot(new[] { Limit("session") }, fiveHourThresholdPct: 80,
throttleFiveHourSoftPct: 50, throttleFiveHourHardPct: 65),
AppSettings = AppSettings(),
};
var vm = new UsageMonitorModalViewModel(worker);
await vm.LoadAsync();
// Typing 95 into the soft box may not push past hard — and must not drag hard along.
var row = vm.GaugeRows[0];
row.SoftPct = 95;
await row.CommitSoftCommand.ExecuteAsync(null);
Assert.Equal(65, row.SoftPct);
Assert.Equal(65, row.HardPct);
Assert.Equal(80, row.GatePct);
Assert.Equal(65, worker.SavedSettings!.UsageThrottleFiveHourSoftPct);
}
[Fact]
public async Task TypedGate_BelowHard_IsPinnedToHard()
{
var worker = new FakeWorker
{
Snapshot = Snapshot(new[] { Limit("weekly_all") }, sevenDayThresholdPct: 90,
throttleSevenDaySoftPct: 50, throttleSevenDayHardPct: 65),
AppSettings = AppSettings(),
};
var vm = new UsageMonitorModalViewModel(worker);
await vm.LoadAsync();
var row = vm.GaugeRows[0];
row.GatePct = 20;
await row.CommitGateCommand.ExecuteAsync(null);
Assert.Equal(65, row.GatePct);
Assert.Equal(65, worker.SavedSettings!.UsageGateSevenDayPct);
}
[Fact]
public async Task TypedStage_OnNonAdjustableRow_SavesNothing()
{
var worker = new FakeWorker
{
Snapshot = Snapshot(new[] { Limit("weekly_scoped", scopeModelDisplayName: "Opus") }),
AppSettings = AppSettings(),
};
var vm = new UsageMonitorModalViewModel(worker);
await vm.LoadAsync();
await vm.GaugeRows[0].CommitSoftCommand.ExecuteAsync(null);
Assert.Null(worker.SavedSettings);
}
// ── Drag math ────────────────────────────────────────────────────────────
[Theory]
[InlineData(30, UsageThresholdDrag.Stage.Soft, 30, 65, 80)] // free move below hard
[InlineData(90, UsageThresholdDrag.Stage.Soft, 65, 65, 80)] // pinned to hard
[InlineData(-5, UsageThresholdDrag.Stage.Soft, 0, 65, 80)] // clamped at 0
[InlineData(70, UsageThresholdDrag.Stage.Hard, 50, 70, 80)] // free move between soft and gate
[InlineData(10, UsageThresholdDrag.Stage.Hard, 50, 50, 80)] // pinned to soft
[InlineData(95, UsageThresholdDrag.Stage.Hard, 50, 80, 80)] // pinned to gate
[InlineData(120, UsageThresholdDrag.Stage.Gate, 50, 65, 100)] // clamped at 100
[InlineData(20, UsageThresholdDrag.Stage.Gate, 50, 65, 65)] // pinned to hard
public void Drag_KeepsStagesOrderedAndInRange(
double dragTo, UsageThresholdDrag.Stage stage, int expectedSoft, int expectedHard, int expectedGate)
{
var result = UsageThresholdDrag.Apply(50, 65, 80, stage, dragTo);
Assert.Equal((expectedSoft, expectedHard, expectedGate), result);
}
[Fact]
public void Drag_RoundsToWholePercent()
{
Assert.Equal((37, 65, 80), UsageThresholdDrag.Apply(50, 65, 80, UsageThresholdDrag.Stage.Soft, 36.7));
}
[Fact]
public void Drag_NeighbourAtZeroIsOff_AndDoesNotPinTheMarker()
{
// hard = 0 means "hard stage off" — soft must still be draggable up to the gate.
Assert.Equal((70, 0, 80), UsageThresholdDrag.Apply(50, 0, 80, UsageThresholdDrag.Stage.Soft, 70));
}
[Fact]
public void Drag_InconsistentStoredConfig_DoesNotThrow()
{
// soft above gate (only reachable by hand-editing the DB) must degrade, not crash.
var result = UsageThresholdDrag.Apply(90, 95, 50, UsageThresholdDrag.Stage.Hard, 60);
Assert.Equal(50, result.Hard);
}
[Theory]
[InlineData(50, UsageThresholdDrag.Stage.Soft)]
[InlineData(63, UsageThresholdDrag.Stage.Hard)]
[InlineData(82, UsageThresholdDrag.Stage.Gate)]
public void Nearest_PicksTheClosestMarkerInReach(double percent, UsageThresholdDrag.Stage expected)
{
Assert.Equal(expected, UsageThresholdDrag.Nearest(50, 65, 80, percent, tolerancePercent: 5));
}
[Fact]
public void Nearest_OutOfReach_GrabsNothing()
{
Assert.Null(UsageThresholdDrag.Nearest(50, 65, 80, percent: 20, tolerancePercent: 5));
}
// ── Stale / gate bands ───────────────────────────────────────────────────
@@ -989,19 +989,6 @@ public sealed class ExternalMcpServiceTests : IDisposable
Assert.Equal(10, result.Config.MaxTurns);
}
// ── GetTaskStatusValues ───────────────────────────────────────────────────
[Fact]
public async Task GetTaskStatusValues_ContainsAllStatuses()
{
var sut = NewService();
var values = await sut.GetTaskStatusValues();
var names = values.Select(v => v.Status).ToHashSet();
foreach (var status in Enum.GetValues<TaskStatus>())
Assert.Contains(status.ToString(), names);
}
// ── ListTasks status filter ───────────────────────────────────────────────
[Fact]
@@ -100,8 +100,10 @@ public sealed class QueueStateMcpToolsTests : IDisposable
var repo = new AppSettingsRepository(ctx);
var settings = await repo.GetAsync();
settings.MaxParallelExecutions = maxParallel;
settings.UsageThrottleSoftPct = softPct;
settings.UsageThrottleHardPct = hardPct;
settings.UsageThrottleFiveHourSoftPct = softPct;
settings.UsageThrottleFiveHourHardPct = hardPct;
settings.UsageThrottleSevenDaySoftPct = softPct;
settings.UsageThrottleSevenDayHardPct = hardPct;
await repo.UpdateAsync(settings);
}
@@ -1,6 +1,7 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Online;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
@@ -55,7 +56,8 @@ public sealed class OnlineSyncServiceTests : IDisposable
}
}
private OnlineSyncService BuildService(FakeApi api, string? token = "test-token", bool enabled = true)
private OnlineSyncService BuildService(
FakeApi api, string? token = "test-token", bool enabled = true, HubBroadcaster? broadcaster = null)
{
var config = new OnlineInboxConfig { Enabled = enabled, PollIntervalSeconds = 60 };
var auth = new StaticTokenAuthProvider(token);
@@ -64,7 +66,8 @@ public sealed class OnlineSyncServiceTests : IDisposable
api,
auth,
config,
NullLogger<OnlineSyncService>.Instance);
NullLogger<OnlineSyncService>.Instance,
broadcaster ?? new HubBroadcaster(new CapturingHubContext()));
}
private async Task<(string ListId, ClaudeDoDbContext Ctx, TaskRepository Tasks, ListRepository Lists)> SeedAsync()
@@ -103,6 +106,26 @@ public sealed class OnlineSyncServiceTests : IDisposable
Assert.Contains(remoteId, api.MarkedImported);
}
[Fact]
public async Task Tick_Imports_RemoteTask_BroadcastsTaskUpdated()
{
var (listId, ctx, _, _) = await SeedAsync();
using var _ = ctx;
var remoteId = Guid.NewGuid().ToString();
var api = new FakeApi
{
UnimportedTasks = [new RemoteTask(remoteId, listId, "From Web", "desc", DateTimeOffset.UtcNow)],
};
var hubContext = new CapturingHubContext();
var svc = BuildService(api, broadcaster: new HubBroadcaster(hubContext));
await svc.TickAsync(CancellationToken.None);
Assert.Contains(hubContext.Proxy.Calls,
c => c.Method == "TaskUpdated" && (string)c.Args[0]! == remoteId);
}
[Fact]
public async Task Tick_UnknownList_Skips_And_DoesNotMark()
{
@@ -618,6 +618,31 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
Assert.Equal(InteractiveLaunchSpecService.McpToolTimeoutMs, spec.Env["MCP_TOOL_TIMEOUT"]);
}
// A directory argument that keeps its trailing separator escapes its own closing quote once the
// ConPTY host flattens Args into a single Windows command line ("C:\repo\" -> \" is a literal
// quote), so --add-dir's variadic list swallows every following argument -- including the
// positional kickoff, leaving the session with no prompt at all.
[Fact]
public async Task BuildForMergeHelperAsync_WorkingDirWithTrailingSeparator_EmitsNoArgEndingInSeparator()
{
var repo = Path.Combine(_tempDir, "repoTrailingSep");
Directory.CreateDirectory(repo);
var listId = await SeedListAsync(workingDir: repo + Path.DirectorySeparatorChar, name: "Trailing");
var t1 = Guid.NewGuid().ToString();
await SeedTaskAsync(t1, listId, TaskStatus.WaitingForReview, title: "First task");
var svc = BuildService();
var spec = await svc.BuildForMergeHelperAsync(new[] { t1 }, listId, CancellationToken.None);
var sessionDir = TrackSessionDir(spec);
var args = spec.Args.ToList();
var addIdx = args.IndexOf("--add-dir");
var appendIdx = args.IndexOf("--append-system-prompt-file");
Assert.Equal(new[] { sessionDir, repo }, args.GetRange(addIdx + 1, appendIdx - addIdx - 1));
Assert.DoesNotContain(args, a => a.EndsWith('\\') || a.EndsWith('/'));
}
[Fact]
public async Task BuildForMergeHelperAsync_BriefNamesListRepoAndEveryTask()
{
@@ -826,6 +851,29 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
Assert.Contains("working directory", ex.Message);
}
[Fact]
public async Task BuildForMergeHelperHandoffAsync_WorkingDirWithTrailingSeparator_EmitsNoArgEndingInSeparator()
{
var repo = Path.Combine(_tempDir, "repoHandoffTrailingSep");
Directory.CreateDirectory(repo);
var listId = await SeedListAsync(workingDir: repo + Path.DirectorySeparatorChar, name: "Trailing");
var handlerTaskId = Guid.NewGuid().ToString();
await SeedTaskAsync(handlerTaskId, listId, TaskStatus.Idle, title: "List handler: Trailing");
var survivor = Guid.NewGuid().ToString();
await SeedTaskAsync(survivor, listId, TaskStatus.WaitingForReview, title: "Survivor");
var svc = BuildService();
var spec = await svc.BuildForMergeHelperHandoffAsync(handlerTaskId, new[] { survivor }, CancellationToken.None);
var sessionDir = TrackSessionDir(spec);
var args = spec.Args.ToList();
var addIdx = args.IndexOf("--add-dir");
var appendIdx = args.IndexOf("--append-system-prompt-file");
Assert.Equal(new[] { sessionDir, repo }, args.GetRange(addIdx + 1, appendIdx - addIdx - 1));
Assert.DoesNotContain(args, a => a.EndsWith('\\') || a.EndsWith('/'));
}
[Fact]
public async Task BuildForMergeHelperHandoffAsync_ReusesHandlerTaskId_NoNewTaskCreated()
{
@@ -857,7 +905,9 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
var appendIdx = args.IndexOf("--append-system-prompt-file");
var systemPromptPath = args[appendIdx + 1];
Assert.Equal(Path.Combine(sessionDir, "system-prompt.md"), systemPromptPath);
Assert.Equal(PromptFiles.ReadOrDefault(PromptKind.MergeHelper), File.ReadAllText(systemPromptPath));
// The handoff session gets the Execute prompt (phases 3-5), NOT the Triage prompt the
// first session ran with — otherwise it carries dedupe/enhance instructions to ignore.
Assert.Equal(PromptFiles.ReadOrDefault(PromptKind.MergeHelperExecute), File.ReadAllText(systemPromptPath));
var kickoff = args[^1];
var handoffPath = Path.Combine(sessionDir, "handoff.md");
@@ -922,6 +972,40 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
Assert.Equal(_worktreeDir, spec.Cwd);
}
// Regression guard for the bug where both merge-helper builders wrote the SAME system prompt,
// so the handoff session received the phase 0-2 (dedupe/enhance) instructions and was told to
// ignore them by its brief alone. Each session must carry only its own phases.
[Fact]
public async Task MergeHelperBuilders_WriteDifferentSystemPrompts_EachScopedToItsOwnPhases()
{
var repo = Path.Combine(_tempDir, "repoPromptSplit");
Directory.CreateDirectory(repo);
var listId = await SeedListAsync(workingDir: repo, name: "Alpha");
var handlerTaskId = Guid.NewGuid().ToString();
await SeedTaskAsync(handlerTaskId, listId, TaskStatus.Idle, title: "Handler");
var survivor = Guid.NewGuid().ToString();
await SeedTaskAsync(survivor, listId, TaskStatus.WaitingForReview, title: "Survivor");
var svc = BuildService();
var triageSpec = await svc.BuildForMergeHelperAsync(new[] { survivor }, listId, CancellationToken.None);
var triageDir = TrackSessionDir(triageSpec);
var executeSpec = await svc.BuildForMergeHelperHandoffAsync(handlerTaskId, new[] { survivor }, CancellationToken.None);
var executeDir = TrackSessionDir(executeSpec);
var triagePrompt = File.ReadAllText(Path.Combine(triageDir, "system-prompt.md"));
var executePrompt = File.ReadAllText(Path.Combine(executeDir, "system-prompt.md"));
Assert.NotEqual(triagePrompt, executePrompt);
Assert.Contains("## Phase 1", triagePrompt);
Assert.DoesNotContain("## Phase 4", triagePrompt);
Assert.Contains("## Phase 4", executePrompt);
Assert.DoesNotContain("## Phase 1", executePrompt);
}
// Regression guard for the bug where BuildForMergeHelperHandoffAsync set MCP_TOOL_TIMEOUT to
// an older, shorter value (200000) than every other ConPTY spec (930000) after a parallel
// merge landed the two changes independently. Every spec this service builds must carry the
@@ -121,4 +121,52 @@ public sealed class QueueClaimTaskUpdatedBroadcastTests : IDisposable
releaseProcess.TrySetResult();
await runTask;
}
[Fact]
public async Task Creating_a_worktree_broadcasts_WorktreeUpdated()
{
string listId = Guid.NewGuid().ToString(), taskId = Guid.NewGuid().ToString();
var repoDir = Path.Combine(_tempDir, "repo");
Directory.CreateDirectory(repoDir);
// A real git repo — Worker.Tests run real git by design.
await RunGitAsync(repoDir, "init");
await RunGitAsync(repoDir, "config user.email t@t.t");
await RunGitAsync(repoDir, "config user.name t");
await File.WriteAllTextAsync(Path.Combine(repoDir, "a.txt"), "hi");
await RunGitAsync(repoDir, "add a.txt");
await RunGitAsync(repoDir, "commit -m init");
using (var ctx = _db.CreateContext())
{
ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = repoDir, CreatedAt = DateTime.UtcNow });
ctx.Tasks.Add(new TaskEntity
{
Id = taskId, ListId = listId, Title = "T", Status = TaskStatus.Running,
StartedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
});
await ctx.SaveChangesAsync();
}
var fake = new FakeClaudeProcess((_, _, _, _, _) =>
Task.FromResult(new RunResult { ExitCode = 0, ResultMarkdown = "ok" }));
var runner = BuildRunner(fake);
using (var ctx = _db.CreateContext())
await runner.RunAsync((await new TaskRepository(ctx).GetByIdAsync(taskId))!, "queue",
CancellationToken.None, alreadyClaimed: true);
Assert.Contains(_hubContext.Proxy.Calls,
c => c.Method == "WorktreeUpdated" && (string)c.Args[0]! == taskId);
}
private static async Task RunGitAsync(string dir, string args)
{
var psi = new System.Diagnostics.ProcessStartInfo("git", args)
{
WorkingDirectory = dir, RedirectStandardOutput = true, RedirectStandardError = true,
};
using var p = System.Diagnostics.Process.Start(psi)!;
await p.WaitForExitAsync();
}
}
@@ -0,0 +1,223 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Config;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Queue;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Tests.Infrastructure;
using ClaudeDo.Worker.Usage;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Logging.Abstractions;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.Services;
// The queue picker's raw-SQL claim commits status='running' before the runner starts. If
// anything then throws before the runner's own terminal-status write, the task used to stay
// Running forever with the UI never notified (RunInSlotAsync's catch only logged the error).
// It must now mark the task Failed for a real exception (which broadcasts TaskUpdated), but
// must NOT do so for a cancellation — the cancel path already wrote the terminal status.
//
// These drive the real QueueService end to end (StartAsync + the waker), not just the
// FailAsync contract, so they actually exercise the fixed catch block.
public sealed class QueueServiceSlotFailureTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly string _tempDir;
private readonly WorkerConfig _cfg;
public QueueServiceSlotFailureTests()
{
_tempDir = Path.Combine(Path.GetTempPath(), $"claudedo_slotfail_{Guid.NewGuid():N}");
Directory.CreateDirectory(_tempDir);
_cfg = new WorkerConfig
{
SandboxRoot = Path.Combine(_tempDir, "sandbox"),
LogRoot = Path.Combine(_tempDir, "logs"),
QueueBackstopIntervalMs = 50, // fast for tests
};
}
public void Dispose()
{
_db.Dispose();
try { Directory.Delete(_tempDir, true); } catch { }
}
// Mirrors QueueServiceTests.CreateService but takes the picker as a parameter so each test
// can engineer the exact failure path it needs to exercise.
// Build() wires its own CapturingHubContext internally and hands it back as .Hub — the
// broadcaster inside TaskStateService (and therefore FailAsync's TaskUpdated) uses that
// exact instance, so everything else here must share it too rather than constructing a
// second CapturingHubContext that would silently miss FailAsync's broadcast.
private (QueueService service, CapturingHubContext hub, QueueWaker waker) CreateService(IQueuePicker picker)
{
var dbFactory = _db.CreateFactory();
var built = TaskStateServiceBuilder.Build(dbFactory);
var broadcaster = new HubBroadcaster(built.Hub);
var wtManager = new WorktreeManager(new GitService(), dbFactory, _cfg, NullLogger<WorktreeManager>.Instance);
var argsBuilder = new ClaudeArgsBuilder();
var runner = new TaskRunner(new FakeClaudeProcess(), dbFactory, broadcaster, wtManager, argsBuilder, _cfg,
NullLogger<TaskRunner>.Instance, built.State, new TaskRunTokenRegistry(), new AttachmentStore(),
new FakeSessionSkillSeeder(), new FakeTranscriptUsageReader());
var waker = new QueueWaker();
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance, built.RunCancels);
var service = new QueueService(dbFactory, runner, _cfg, NullLogger<QueueService>.Instance, waker, picker,
overrideSlot, built.State, built.RunCancels, new FakeUsageGate(), new UsageState(), broadcaster);
return (service, built.Hub, waker);
}
private async Task<string> SeedListAsync()
{
var listId = Guid.NewGuid().ToString();
using var ctx = _db.CreateContext();
ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
await ctx.SaveChangesAsync();
return listId;
}
// Directly rewrites the task's list_id via a raw connection with FK enforcement off,
// reproducing "the list vanished between the queue claim and the run" without going
// through EF's foreign-key-checked connections (which would reject the write).
private void OrphanTaskListId(string taskId)
{
using var conn = new SqliteConnection($"Data Source={_db.DbPath}");
conn.Open();
using (var pragmaCmd = conn.CreateCommand())
{
pragmaCmd.CommandText = "PRAGMA foreign_keys=OFF;";
pragmaCmd.ExecuteNonQuery();
}
using var cmd = conn.CreateCommand();
cmd.CommandText = "UPDATE tasks SET list_id = 'orphaned-missing-list' WHERE id = $id;";
cmd.Parameters.AddWithValue("$id", taskId);
cmd.ExecuteNonQuery();
}
[Fact]
public async Task A_throwing_slot_run_marks_the_task_Failed_and_broadcasts_TaskUpdated()
{
var listId = await SeedListAsync();
var taskId = Guid.NewGuid().ToString();
using (var ctx = _db.CreateContext())
{
ctx.Tasks.Add(new TaskEntity
{
Id = taskId, ListId = listId, Title = "T", Status = TaskStatus.Queued,
ReviewFeedback = "please fix", CreatedAt = DateTime.UtcNow,
});
await ctx.SaveChangesAsync();
// A prior run with a session id routes RunInSlotAsync into TaskRunner.ContinueAsync
// instead of RunAsync.
await new TaskRunRepository(ctx).AddAsync(new TaskRunEntity
{
Id = Guid.NewGuid().ToString(), TaskId = taskId, RunNumber = 1, IsRetry = false,
Prompt = "original", SessionId = "sess-1", StartedAt = DateTime.UtcNow.AddMinutes(-5),
});
}
// ContinueAsync's setup block reads the list *before* its own try/catch starts
// (TaskRunner.cs, ContinueAsync ~line 232-234) and throws InvalidOperationException
// ("List not found.") straight past TaskRunner's own protection. That's the exact gap
// QueueService.RunInSlotAsync's own catch now has to cover.
OrphanTaskListId(taskId);
var (service, hub, waker) = CreateService(new QueuePicker(_db.CreateFactory()));
using var cts = new CancellationTokenSource();
await service.StartAsync(cts.Token);
waker.Wake();
// FailAsync (TaskStateService.cs:236-249) commits the DB status flip via
// ExecuteUpdateAsync *before* it calls the broadcaster's TaskUpdated — so a poll that
// breaks the instant it observes Status==Failed can race ahead of the broadcast still
// landing in hub.Proxy.Calls. Wait for both signals together so the assertions below
// never sample a genuinely-not-yet-broadcast window as a failure.
TaskEntity? reloaded = null;
var deadline = DateTime.UtcNow.AddSeconds(10);
while (DateTime.UtcNow < deadline)
{
using var verify = _db.CreateContext();
reloaded = await new TaskRepository(verify).GetByIdAsync(taskId);
var broadcastSeen = hub.Proxy.Calls.Any(
c => c.Method == "TaskUpdated" && (string)c.Args[0]! == taskId);
if (reloaded!.Status == TaskStatus.Failed && broadcastSeen) break;
await Task.Delay(25);
}
cts.Cancel();
Assert.Equal(TaskStatus.Failed, reloaded!.Status);
Assert.Contains(hub.Proxy.Calls,
c => c.Method == "TaskUpdated" && (string)c.Args[0]! == taskId);
}
// A fake IQueuePicker that performs the real atomic claim (so the DB row transitions
// Queued->Running exactly like production) and then, synchronously before returning,
// cancels the token QueueService's per-slot CTS is linked from. By the time
// QueueService.ExecuteAsync creates that linked CTS and dispatches RunInSlotAsync, the
// token is already cancelled — deterministic, no timing race required.
private sealed class ClaimThenCancelPicker : IQueuePicker
{
private readonly IQueuePicker _inner;
private readonly CancellationTokenSource _cancelAfterClaim;
public ClaimThenCancelPicker(IQueuePicker inner, CancellationTokenSource cancelAfterClaim)
{
_inner = inner;
_cancelAfterClaim = cancelAfterClaim;
}
public async Task<TaskEntity?> ClaimNextAsync(DateTime now, CancellationToken ct)
{
var claimed = await _inner.ClaimNextAsync(now, ct);
if (claimed is not null) _cancelAfterClaim.Cancel();
return claimed;
}
}
[Fact]
public async Task A_cancelled_slot_run_does_not_mark_the_task_Failed()
{
var listId = await SeedListAsync();
var taskId = Guid.NewGuid().ToString();
using (var ctx = _db.CreateContext())
{
ctx.Tasks.Add(new TaskEntity
{
Id = taskId, ListId = listId, Title = "T", Status = TaskStatus.Queued,
CreatedAt = DateTime.UtcNow,
});
await ctx.SaveChangesAsync();
}
var outerCts = new CancellationTokenSource();
var realPicker = new QueuePicker(_db.CreateFactory());
var picker = new ClaimThenCancelPicker(realPicker, outerCts);
var (service, hub, waker) = CreateService(picker);
await service.StartAsync(outerCts.Token);
waker.Wake();
// Wait for the slot to be claimed and then released again (RunInSlotAsync's
// ContinueWith removes it once the catch block — ours or a stray one — finishes).
var deadline = DateTime.UtcNow.AddSeconds(10);
while (service.GetActive().Any(a => a.taskId == taskId) && DateTime.UtcNow < deadline)
await Task.Delay(25);
await Task.Delay(100); // let the fire-and-forget continuation fully settle
TaskEntity? reloaded;
using (var verify = _db.CreateContext())
reloaded = await new TaskRepository(verify).GetByIdAsync(taskId);
// The picker's atomic claim already flipped it to Running; the cancelled slot run must
// leave it there rather than flipping it to Failed.
Assert.Equal(TaskStatus.Running, reloaded!.Status);
Assert.DoesNotContain(hub.Proxy.Calls,
c => c.Method == "TaskUpdated" && (string)c.Args[0]! == taskId);
}
}
@@ -81,8 +81,10 @@ public sealed class QueueServiceTests : IDisposable
var repo = new AppSettingsRepository(ctx);
var settings = await repo.GetAsync();
settings.MaxParallelExecutions = maxParallel;
settings.UsageThrottleSoftPct = softPct;
settings.UsageThrottleHardPct = hardPct;
settings.UsageThrottleFiveHourSoftPct = softPct;
settings.UsageThrottleFiveHourHardPct = hardPct;
settings.UsageThrottleSevenDaySoftPct = softPct;
settings.UsageThrottleSevenDayHardPct = hardPct;
settings.UsageGateFiveHourPct = gateFive;
settings.UsageGateSevenDayPct = gateSeven;
await repo.UpdateAsync(settings);
@@ -139,6 +139,35 @@ public class TranscriptUsageReaderTests : IDisposable
Assert.Equal(1, row.Messages);
}
[Fact]
public async Task Files_Last_Written_Before_The_Window_Are_Not_Read()
{
// Deliberate heuristic: a transcript whose mtime predates the window cannot contain a
// record inside it, so it is skipped unread. Here the content would match the window —
// proving the file was never opened, which is what keeps a 7-day range off the full history.
var path = WriteSession("proj", "old.jsonl",
AssistantLine(@"C:\Dev\App", "2026-06-02T08:00:00Z", "claude-sonnet-5", 5, 5, 0, 0));
File.SetLastWriteTime(path, new DateTime(2026, 5, 1, 12, 0, 0));
var reader = MakeReader();
var result = await reader.ReadAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 3));
Assert.Empty(result);
}
[Fact]
public async Task File_Written_On_The_Window_Start_Day_Is_Still_Read()
{
var path = WriteSession("proj", "edge.jsonl",
AssistantLine(@"C:\Dev\App", "2026-06-01T08:00:00Z", "claude-sonnet-5", 5, 5, 0, 0));
File.SetLastWriteTime(path, new DateTime(2026, 6, 1, 0, 5, 0));
var reader = MakeReader();
var result = await reader.ReadAsync(new DateOnly(2026, 6, 1), new DateOnly(2026, 6, 3));
Assert.Single(result);
}
[Fact]
public async Task Malformed_Line_Does_Not_Abort_The_Run()
{
@@ -149,11 +149,61 @@ public sealed class UsageSnapshotBuilderTests : IDisposable
var repo = new AppSettingsRepository(ctx);
var settings = await repo.GetAsync();
settings.MaxParallelExecutions = maxParallel;
settings.UsageThrottleSoftPct = softPct;
settings.UsageThrottleHardPct = hardPct;
settings.UsageThrottleFiveHourSoftPct = softPct;
settings.UsageThrottleFiveHourHardPct = hardPct;
settings.UsageThrottleSevenDaySoftPct = softPct;
settings.UsageThrottleSevenDayHardPct = hardPct;
await repo.UpdateAsync(settings);
}
private async Task SetPerBucketThrottleAsync(
int maxParallel, int fiveSoft, int fiveHard, int sevenSoft, int sevenHard)
{
using var ctx = _db.CreateContext();
var repo = new AppSettingsRepository(ctx);
var settings = await repo.GetAsync();
settings.MaxParallelExecutions = maxParallel;
settings.UsageThrottleFiveHourSoftPct = fiveSoft;
settings.UsageThrottleFiveHourHardPct = fiveHard;
settings.UsageThrottleSevenDaySoftPct = sevenSoft;
settings.UsageThrottleSevenDayHardPct = sevenHard;
await repo.UpdateAsync(settings);
}
[Fact]
public async Task Per_bucket_throttle_stages_are_reported_for_the_gauges()
{
await SetThresholdsAsync(80, 90);
await SetPerBucketThrottleAsync(maxParallel: 3, fiveSoft: 45, fiveHard: 60, sevenSoft: 70, sevenHard: 85);
var state = new UsageState();
state.ReportSuccess(new UsageSnapshot(
new UsageBucket(10, null), new UsageBucket(10, null), Array.Empty<UsageLimitRow>(), DateTime.UtcNow));
var dto = await CreateBuilder(state, new UsageGateDecision(false, null)).BuildAsync();
Assert.Equal(45, dto.ThrottleFiveHourSoftPct);
Assert.Equal(60, dto.ThrottleFiveHourHardPct);
Assert.Equal(70, dto.ThrottleSevenDaySoftPct);
Assert.Equal(85, dto.ThrottleSevenDayHardPct);
}
[Fact]
public async Task Per_bucket_stages_apply_independently_to_effective_slots()
{
await SetThresholdsAsync(80, 90);
// Both buckets sit at 60%: past the 5h soft stage (45) but below every 7d stage (70/85).
await SetPerBucketThrottleAsync(maxParallel: 3, fiveSoft: 45, fiveHard: 90, sevenSoft: 70, sevenHard: 85);
var state = new UsageState();
state.ReportSuccess(new UsageSnapshot(
new UsageBucket(60, null), new UsageBucket(60, null), Array.Empty<UsageLimitRow>(), DateTime.UtcNow));
var dto = await CreateBuilder(state, new UsageGateDecision(false, null)).BuildAsync();
Assert.Equal(2, dto.EffectiveSlots);
}
[Fact]
public async Task Throttled_slots_and_decisive_bucket_reported()
{
@@ -4,13 +4,11 @@ namespace ClaudeDo.Worker.Tests.Usage;
public sealed class UsageThrottleTests
{
private const int Soft = 50;
private const int Hard = 65;
private const int GateFive = 80;
private const int GateSeven = 90;
private static readonly UsageThresholds FiveHour = new(SoftPct: 50, HardPct: 65, GatePct: 80);
private static readonly UsageThresholds SevenDay = new(SoftPct: 50, HardPct: 65, GatePct: 90);
private static int Effective(double? five, double? seven, int configured = 3) =>
UsageThrottle.EffectiveSlots(configured, five, seven, Soft, Hard, GateFive, GateSeven);
UsageThrottle.EffectiveSlots(configured, five, FiveHour, seven, SevenDay);
[Fact]
public void BelowSoftThreshold_ReturnsFullConfiguredSlots()
@@ -94,13 +92,19 @@ public sealed class UsageThrottleTests
[Fact]
public void ZeroSoftAndHardThresholds_NeverThrottleBelowGate()
{
Assert.Equal(3, UsageThrottle.EffectiveSlots(3, 79, 89, softPct: 0, hardPct: 0, gateFiveHourPct: GateFive, gateSevenDayPct: GateSeven));
var five = new UsageThresholds(0, 0, 80);
var seven = new UsageThresholds(0, 0, 90);
Assert.Equal(3, UsageThrottle.EffectiveSlots(3, 79, five, 89, seven));
}
[Fact]
public void ZeroGateThresholds_NeverHardBlock()
{
Assert.Equal(1, UsageThrottle.EffectiveSlots(3, 99, 99, softPct: Soft, hardPct: Hard, gateFiveHourPct: 0, gateSevenDayPct: 0));
var five = new UsageThresholds(50, 65, 0);
var seven = new UsageThresholds(50, 65, 0);
Assert.Equal(1, UsageThrottle.EffectiveSlots(3, 99, five, 99, seven));
}
[Fact]
@@ -108,4 +112,46 @@ public sealed class UsageThrottleTests
{
Assert.Equal(1, Effective(10, 10, configured: 0));
}
// ── Per-bucket thresholds are independent ───────────────────────────────
[Fact]
public void PerBucket_SameUtilization_DifferentStagesPerBucket()
{
// 60% is past the 5h soft (50) but still under the 7d soft (70): the 5h bucket decides.
var five = new UsageThresholds(50, 65, 80);
var seven = new UsageThresholds(70, 85, 90);
Assert.Equal(2, UsageThrottle.EffectiveSlots(3, 60, five, 60, seven));
}
[Fact]
public void PerBucket_LessUtilizedBucketCanStillBeTheStricterOne()
{
// 7d sits lower (40%) but has the tighter thresholds, so it — not the busier 5h — throttles.
var five = new UsageThresholds(90, 95, 99);
var seven = new UsageThresholds(20, 35, 90);
Assert.Equal(1, UsageThrottle.EffectiveSlots(3, 80, five, 40, seven));
}
[Fact]
public void PerBucket_StrictestStageWins()
{
// 5h is only in its soft stage (2 slots), 7d is past its hard stage (1 slot) → 1 wins.
var five = new UsageThresholds(50, 65, 80);
var seven = new UsageThresholds(30, 40, 90);
Assert.Equal(1, UsageThrottle.EffectiveSlots(3, 55, five, 45, seven));
}
[Fact]
public void PerBucket_MissingBucketNeverThrottles()
{
// No 7d reading at all: only the 5h bucket may step parallelism down.
var five = new UsageThresholds(50, 65, 80);
var seven = new UsageThresholds(1, 2, 3);
Assert.Equal(2, UsageThrottle.EffectiveSlots(3, 55, five, null, seven));
}
}