Compare commits

..
166 Commits
Author SHA1 Message Date
mika kuns f9a8ed761a docs: record the operation-feedback state before the v2.10.0 cut
Changelog / changelog (push) Successful in 2s
Release / release (push) Successful in 43s
Plan checkboxes stood at zero although P0, A, B, C1 and D are all merged --
anyone reading it would have assumed nothing was built. Ticks the merged
packages, records A5's cancellation and the visual pass, and names the two
leftovers (the three unused ops.* keys from A3/A4, C2-C5 as the only open
group). Also corrects the batch_* tool count from eight to seven and puts the
OperationTiming kill-switch note in the Ui CLAUDE.md back into English.
2026-08-17 11:45:03 +02:00
mika kuns 0e314e44dd Merge remote-tracking branch 'origin/main' 2026-08-17 11:35:02 +02:00
Mika Kuns 9c4f0d1872 feat(claude-do): merge [D4] Restliche MCP-Long-Runner versorgen + Progress-Regel im
ClaudeDo-Task: 8e7f70d8-e821-45c8-835b-7743c201f599
2026-08-17 10:00:35 +02:00
Mika Kuns 23aab26daf feat(worker): report progress for continue_merge and unit-merge verify gate
continue_merge and the parent/children unit-merge drain (PlanningMergeOrchestrator)
re-run the post-merge verify gate but never forwarded their IProgress token into it,
so a slow verify command on either path went silent past Claude Code's 300s MCP
idle-abort even though D1-D3 already fixed this for merge_task/review_task's
childless path. list_worktrees also gets elapsed-time progress: many tracked
worktrees means many concurrent git subprocess spawns.

Worker CLAUDE.md's existing progress rule now points at ProgressReporter as the
one implementation instead of a fresh polling loop.
2026-08-17 09:56:59 +02:00
Mika Kuns 1341c5f4f3 feat(claude-do): merge [D3] Worktree- und Diff-MCP-Tools mit Progress (3 Tools in E
ClaudeDo-Task: cc93dd2d-f870-4820-9f69-5388372ee73b
2026-08-17 09:32:40 +02:00
Mika Kuns aeb1a5eb82 feat(worker): report progress for cleanup_task_worktree, get_task_diff, preview_merge_set
Wraps the single-element git/worktree long-runners in ProgressReporter.RunAsync
(time-based, ExternalMcpService.ProgressReportInterval) and adds i/n reporting
to preview_merge_set's per-task loop, so these MCP calls survive Claude Code's
~300s idle-silence abort instead of leaving the caller with no signal that work
is still happening.
2026-08-17 09:29:51 +02:00
Mika Kuns 0de3816fd0 feat(claude-do): merge [D2] Die 7 batch_*-MCP-Tools mit Element-Fortschritt (i/n)
ClaudeDo-Task: 286efaaf-b38e-47d4-9655-0c2d87e4b329
2026-08-17 09:06:27 +02:00
Mika Kuns 53e26f0340 feat(worker): report per-item progress on all 7 batch_* MCP tools
Each batch tool now sends an i/n progress ping via ProgressReporter.ReportItem
after processing every item, so a waiting agent doesn't see the MCP client's
300s idle-silence abort while the worker keeps looping. Covers BatchGetTasks,
BatchAddTasks, BatchUpdateTaskStatus, BatchCancelTasks, BatchDeleteTasks,
BatchSetMyDay, and BatchCleanupTaskWorktrees (the slowest of the seven, since
it does git work per task).
2026-08-17 08:56:32 +02:00
Mika Kuns 9099b22eea refactor(claude-do): merge OperationTiming hinter Kill-Switch, Default aus (vor dem näc
ClaudeDo-Task: ae170c8b-33cf-4cf3-932e-865fbdd2afa6
2026-08-17 08:52:57 +02:00
Mika Kuns ce1a26d407 refactor(claude-do): merge [D1] ProgressReporter aus TaskMergeService extrahieren (+ i/
ClaudeDo-Task: 47c76093-633e-4f3b-b49b-b34873de0469
2026-08-17 08:39:06 +02:00
Mika Kuns 555933a93c refactor(worker): extract ProgressReporter from TaskMergeService
Pulls the MCP idle-timeout progress loop out of TaskMergeService into a
standalone ProgressReporter (Lifecycle namespace) shared by the verify
gate and preview-verify call sites, and adds a per-item i/n overload
(ReportItem) for upcoming batch progress in D2/D3.
2026-08-17 08:36:21 +02:00
Mika Kuns 43fbfd3fa2 feat(ui): gate OperationTiming.Shared behind a kill switch, off by default
Its job (surfacing the DetailsIsland.BindAsync churn) is done; leave the
InvokeTimedAsync wrappers and Island Record call sites in place as a
chokepoint for next time, but stop writing by default. Shared now only
writes when CLAUDEDO_OP_TIMING=1 is set; the constructor keeps enabled=true
so both TestSetup redirects and OperationTimingTests are unaffected.
2026-08-17 08:30:02 +02:00
Mika Kuns ec9ad1b0e1 chore(claude-do): merge Bind-Churn: DetailsIsland.BindAsync feuert ~1900x/Tag, 91-96
ClaudeDo-Task: 351cacc4-3e11-4b3e-a89e-4efc4bd0448e
2026-08-13 09:00:32 +02:00
Mika Kuns 8a1e2c9698 chore(claude-do): merge [C1] Generischer OperationProgress-Kanal, MergeProgress dara
ClaudeDo-Task: 543947ed-b501-4df8-b30d-1c14804244fd
2026-08-13 08:53:17 +02:00
Mika Kuns c1ec05a437 fix(ui): stop tests from polluting the live operation-timing.ndjson
DetailsIsland.BindAsync churn (~1900 binds/day, 91-96% cancelled) was not a
UI reactivity bug: OperationTiming.Shared is a static singleton hardcoded to
~/.todo-app/logs/operation-timing.ndjson, and Ui.Tests/Worker.Tests both
construct real DetailsIslandViewModel/TasksIslandViewModel instances that
call Shared.Record directly. Every dotnet test run appended 50-100 lines
straight into the live app's log — pid-burst analysis showed 13 distinct
test-run pids in the ":?" bucket (tests never pass a source) plus a
no-suffix bucket with no pid field at all (pre-dating the pid-per-line
feature). Real user-driven binds that day: 4.

Made Shared settable and added a [ModuleInitializer]-based TestSetup in
both test projects that redirects it to a per-process temp file before any
test runs.
2026-08-13 08:35:09 +02:00
Mika Kuns b8caa27027 feat(worker): add generic OperationProgress channel, port MergeProgress onto it
Merge/verify phases now broadcast over a generic (opKey, phase, current,
total) Hub event instead of a merge-specific one, so future producers
(worktree cleanup, startup recovery, planning integration) can reuse it.
IWorkerClient.MergeProgressEvent stays as a thin forwarder for existing
consumers (elapsed seconds riding in the generic "current" slot).
2026-08-13 08:31:23 +02:00
Mika Kuns db32e5307f chore(claude-do): merge [B3] Guard-Test: großer Diff blockiert den Dispatcher nicht
ClaudeDo-Task: e1edd8cd-9392-4616-b2e2-e0511ff59568
2026-08-12 15:00:14 +02:00
Mika Kuns c795827162 test(ui): [B3] guard against a synchronous diff parse blocking the dispatcher
Adds DiffDispatcherGuardTests, the one blockade-regression test in the
operation-feedback effort. Uses a queueing SynchronizationContext (Option B —
this project has no bootstrapped Avalonia dispatcher anywhere) to prove
DiffViewerViewModel's UnifiedDiffParser.Parse offload ([B1]) truly defers past
the calling thread rather than racing on timing. [B2]'s DiffAlignment.Build
offload in DiffTextView is deliberately not covered — three escalating
attempts to construct that control safely in-process each broke under the
full test suite (dispatcher thread-affinity crashes, then process-wide
Application state polluting an unrelated test), and Avalonia.Headless.XUnit
collides with this project's xUnit v2 stack. Verified the test fails against
the pre-[B1] code and passes with it; full suite green aside from a
pre-existing, unrelated flaky test.
2026-08-12 14:55:37 +02:00
Mika Kuns a9af650407 chore(claude-do): merge [B2] DiffAlignment.Build — begründete Grenze gegen unbegrenz
ClaudeDo-Task: ee8117fa-283a-4470-a823-ce57f52a40be
2026-08-12 14:11:41 +02:00
Mika Kuns c9afc81f0a fix(ui): offload DiffAlignment.Build off the UI thread above a line threshold
DiffTextView.ReloadFile ran DiffAlignment.Build synchronously for both the
constructor and the File-property path, so a large diff (or several at once
in Planning mode's per-file ItemsControl) could freeze the UI thread.
Diffs at or under 500 raw lines still build inline (no placeholder frame);
larger ones build via Task.Run and a generation counter discards the result
if File changed again before the build finished.
2026-08-12 14:07:06 +02:00
Mika Kuns ea4db9d0bd chore(claude-do): merge [B1] UnifiedDiffParser.Parse vom UI-Thread nehmen (DiffViewe
ClaudeDo-Task: e2003fc8-f029-44d0-a66a-51b57a95583f
2026-08-12 13:51:18 +02:00
mika kuns 589b9e75f3 fix(ui): unfreeze the operation spinner and attribute rebind churn
Three causes behind the stuck indicator:
- OperationStatus.End did not retire the generation, so tick work posted
  while the dispatcher was blocked drained afterwards and set
  ShowIndicator back to true on a finished operation.
- The startup update check ran in Task.Run; OperationStatus writes its
  observable properties on the calling thread, so the final
  ShowIndicator=false never reached the binding.
- OperationIndicator overwrote its own DataContext from the Status
  property, which re-targeted the call-site binding. It now scopes the
  DataContext to an inner panel and collapses itself when Status is
  null; every call site switched from DataContext= to Status=.

Also gates the footer connection pill in the command body instead of
CanExecute (a disabled command greyed out the ONLINE chip), and extends
OperationTiming: pid per line, 4 MB rollover, fast successes dropped
(failures always kept), and DetailsIsland.BindAsync now carries the
selection trigger via TasksIslandViewModel.SelectFrom.
2026-08-12 13:50:05 +02:00
Mika Kuns f6994f32d5 fix(ui): show ParseOp indicator on the planning-mode diff parse too
ParseOp only covered LoadFilesAsync; OnDisplayedDiffChanged's parse (driven
by ToggleCombinedAsync and OnSelectedSubtaskChanged) had no visible
feedback even though the offload was already correct. Both call sites now
go through one ParseOffUiThreadAsync helper (Begin on the UI thread before
Task.Run, End via `using` regardless of a stale generation), so the
already-visible toolbar indicator covers the planning path without
duplicating the begin/offload/end ceremony.
2026-08-12 13:41:05 +02:00
Mika Kuns 9fc8ed391e fix(ui): offload UnifiedDiffParser.Parse off the UI thread in DiffViewerViewModel
Both call sites (LoadFilesAsync, OnDisplayedDiffChanged) ran the parser
synchronously on the dispatcher, freezing the window on a large diff. The
Files-mode parse+tree-build now runs via Task.Run behind a ParseOp
OperationStatus wired to the toolbar; the planning-mode parse is
fire-and-forget with a generation counter so a fast second DisplayedDiff
change can't write stale PlanningFiles.
2026-08-12 13:19:20 +02:00
mika kuns b9827eac01 fix(ui): connection pill opens worker help only when disconnected
The footer pill fired OpenWorkerConnectionHelpCommand unconditionally, so
clicking it on a connected worker showed the "WORKER NOT REACHABLE" dialog.
Gate the command on !IsConnected (not IsOffline — the retry loop stays in
"connecting" forever while the worker is down, which is exactly when the
dialog's "Start Worker" is needed) and re-evaluate CanExecute on connection
state changes.
2026-08-12 10:58:10 +02:00
Mika Kuns 69b07fff26 chore(claude-do): merge [A4] WeeklyReport, DailyPrep und Planning-Aktionen auf Opera
ClaudeDo-Task: 7c6f2f66-8cd7-4abb-8151-aed42ae739b5
2026-08-12 10:10:57 +02:00
Mika Kuns 030b3ceb6c chore(claude-do): merge [A3] Settings + Update-Check: OperationStatus für Skill-Inst
ClaudeDo-Task: 2356806f-f16e-4652-b615-4562e95c3a65
2026-08-12 10:02:35 +02:00
Mika Kuns ec4cbcbaf9 chore(claude-do): [A3] Settings + Update-Check: OperationStatus für Skill-Inst
Vorgaben: `docs/superpowers/plans/2026-08-11-operation-feedback.md`, Gruppe A, Entwurf A3. P0-1 ist gemergt: `src/ClaudeDo.Ui/Services/OperationStatus.cs` + `src/ClaudeDo.Ui/Views/Controls/OperationIndicator.axaml`.

SCOPE-ÄNDERUNG (Nutzer, 2026-08-12): OnlineInbox Sign-In/Sign-Out ist AUS DEM SCOPE GENOMMEN — vorerst unwichtig. `ViewModels/Modals/Settings/OnlineInboxSettingsViewModel.cs` und die

ClaudeDo-Task: 2356806f-f16e-4652-b615-4562e95c3a65
2026-08-12 09:55:04 +02:00
Mika Kuns 5fca459579 chore(claude-do): merge [A2] WorktreesOverview + Reset-All: getrennte OperationStatu
ClaudeDo-Task: cec07e2c-2814-4a43-898c-a6be07a13f01
2026-08-12 09:54:58 +02:00
Mika Kuns 5a74a67660 chore(claude-do): merge [A1] Detail-Pane: Approve, Submit, Reject, Park, Merge-Previ
ClaudeDo-Task: 120cb6a4-ad0d-426a-bb72-e3c508698b8f
2026-08-12 09:46:14 +02:00
Mika Kuns bea85a9ee5 feat(ui): route detail-pane review actions through OperationStatus
Approve, Submit, Reject, and Park each get their own OperationStatus so the
button locks and shows an OperationIndicator for the duration of the call;
Approve additionally scopes a MergeProgressEvent subscription to its own
task id to sharpen the label from "merging" to "verifying" mid-flight.
MergeSectionViewModel's preview refresh gets the same treatment for display
only, since it has no command button to gate.
2026-08-12 09:42:15 +02:00
Mika Kuns bbd6151ba6 feat(claude-do): [A4] WeeklyReport, DailyPrep und Planning-Aktionen auf OperationStatus umstellen
WeeklyReportModalViewModel.Generate, PrepPanelViewModel.PlanDayAsync und die beiden
Planning-Aktionen (QueuePlanningSubtasksAsync, FinalizePlanningSessionAsync) laufen jetzt
durch je eine OperationStatus + OperationIndicator statt handgebauter Spinner. Die beiden
Planning-Aktionen teilen sich eine Instanz, angezeigt im Tasks-Island-Header, weil sie aus
einem sofort schliessenden Kontextmenue ausgeloest werden und es keine dauerhafte
Pro-Zeilen-Flaeche gibt, an die ein Indikator gehaengt werden koennte.
2026-08-12 09:39:42 +02:00
Mika Kuns a0d5db0db0 feat(ui): separate OperationStatus per worktree action (refresh/cleanup/reset/force-remove/batch-merge)
Replaces the shared IsBusy/IsMerging flags in WorktreesOverviewModalViewModel and the
reset flow in WorktreesSettingsTabViewModel with dedicated OperationStatus instances
shown via OperationIndicator, so a running Refresh no longer blocks the Cleanup
indicator. ForceRemove gains a CanExecute guard against re-entrancy while it's running,
and the reconcile tick's busy guard now checks all four action statuses instead of the
old IsBusy||IsMerging pair.
2026-08-12 09:39:10 +02:00
Mika Kuns d3abd4b88b docs(claude-do): merge [P0-3] Feedback-Regel in Ui- und Worker-CLAUDE.md
ClaudeDo-Task: 771e3484-cd9f-4d81-a890-82c336d1fee4
2026-08-12 08:50:59 +02:00
Mika Kuns 8098b7f4ec docs(claude-do): document OperationStatus and MCP progress-reporting rules 2026-08-12 08:44:30 +02:00
Mika Kuns 3e3f44bfb9 feat(claude-do): merge [P0-2] Timing-Hook für Hub-Invokes und Island-DB-Pfad
ClaudeDo-Task: a96c2c16-62e0-48f2-b80d-d98b113c7583
2026-08-12 08:43:09 +02:00
Mika Kuns 5353e5c39c feat(claude-do): merge [P0-1] OperationStatus + OperationIndicator + ops-Locale-Key
ClaudeDo-Task: 8e3167b1-dc9d-4f65-bbfb-90a3b181ddcc
2026-08-12 08:36:20 +02:00
Mika Kuns ca65849280 feat(ui): add OperationStatus feedback primitive and OperationIndicator control
Foundation for long-running-operation feedback (P0-1). OperationStatus tracks
IsRunning/ShowIndicator(300ms grace)/Elapsed/IsStalled(60s since last Report)
via an injectable TimeProvider, no static timer. OperationIndicator binds a
status to the shared spinner style. Pre-provisions ops.* locale keys for
groups A and C; no ViewModel wired up yet.
2026-08-12 08:31:38 +02:00
Mika Kuns a614feb96d feat(ui): add operation-timing NDJSON sink for hub invokes and bulk DB paths
Two chokepoints per P0-2: WorkerClient.InvokeTimedAsync(<T>) wraps all 63
_hub.InvokeAsync call sites (plus TryInvokeAsync), and Stopwatch blocks
around the Island VMs' bulk DB load/write paths (list load, task list load,
reorders, batch delete, reconcile tick, count refresh). Single-row reads
stay untouched per the plan's rule of thumb. No display, no behavior change.
2026-08-12 08:31:35 +02:00
mika kuns eca16a3506 docs(plans): grobe Task-Entwürfe je Gruppe und Test-Kommandos ergänzen 2026-08-11 19:32:41 +02:00
mika kuns 260cb5a89c docs(plans): Vorgaben je Merge-Helper-Gruppe für Operations-Feedback 2026-08-11 19:29:04 +02:00
mika kuns 72d46af6e5 docs(specs): Peer-Commit als aufgelöste Abhängigkeit festhalten 2026-08-11 19:17:28 +02:00
mika kuns ba1a745e09 docs(specs): Design für Feedback bei langlaufenden Operationen 2026-08-11 19:16:55 +02:00
mika kuns cad0582b37 fix(merge): conventional merge-commit default and live verify progress
The merge commit message was hand-rolled per caller ("Merge task: <title>",
"Merge <branch>", "Merge subtask") and ignored the task's commit type. Every
caller now passes a blank message and TaskMergeService fills in
CommitMessageBuilder.BuildMerge -> {commitType}(list-slug): merge <title> plus the
ClaudeDo-Task trailer; the merge modal prefills it from GetMergeTargets.

A merge whose list has a verify command holds the MergeTask call for minutes (5m46s
on this repo), during which the modal only disabled its button - no spinner, no
message, so a landed merge looked like a dead app. TaskMergeService now broadcasts
MergeProgress(taskId, phase, elapsedSeconds) for the merging and verifying phases
(re-reported every 30s) plus a WorkerLog line when verify starts; the modal shows a
spinner and the localized phase.
2026-08-11 19:14:36 +02:00
mika kuns fc9df7f9ac Merge task: UI polish: ListBox Padding scroll clip, 4s log rebuild, ClassifyItems side effects 2026-08-11 18:34:36 +02:00
mika kuns e55c84cda4 fix(ui): stop ClearCompletedAsync from mutating chain-display fields
ClassifyItems() layers ApplyChainGrouping on top of partitioning, which
writes ShowAsChainMember/ChainStep/ChainAfterLabel on every row.
ClearCompletedAsync only needed the completed-section count/rows, so it
got that mutation as an unwanted side effect of counting.

Split the pure overdue/open/completed partition into PartitionItems();
ClassifyItems() now composes it with the chain-grouping mutation for
Regroup's actual render path. ClearCompletedAsync uses PartitionItems
directly. Regroup's behavior is unchanged.
2026-08-11 18:32:59 +02:00
mika kuns d327a1007e fix(ui): reconcile the log visualizer tick instead of clearing Rows
The 4s reconcile tick called Apply(), which did Rows.Clear() + refill on
every tick -- resetting the user's scroll position and selection while
they're mid-read, exactly what the overlay exists to avoid.

Reconcile granularly instead: find the overlap between the previous and
latest snapshot (the ring buffer is append-mostly, evicting from the
front as entries age out of the 30-min window), trim rows for evicted
entries off the tail, and insert new entries at the head. A tick with no
change now leaves Rows untouched. The WarnErrorOnly toggle still goes
through the full Apply() rebuild, unchanged.
2026-08-11 18:32:39 +02:00
mika kuns 1861832b99 fix(ui): move the tasks list inset off the ListBox onto the panel
Padding on RowsListBox mirrored the ScrollViewer.Padding-excluded-from-
Extent trap fixed elsewhere today, so move the 10,4 inset onto the
VirtualizingStackPanel's Margin instead, matching the other scrollables.

Headless measurement showed Avalonia's Fluent ListBox template actually
forwards Padding onto PART_ItemsPresenter's margin already (content
side), so this particular control was never affected -- both variants
produce an identical Extent and a fully visible last row at max scroll.
Kept the change for consistency and corrected the CLAUDE.md gotcha to
reflect the verified behavior instead of assuming the same trap applied.
2026-08-11 18:32:30 +02:00
mika kuns c35fabe0a2 Merge claudedo/d8199f1f3df3447f8de2e7aca9ec5064 2026-08-11 17:48:21 +02:00
mika kuns ad6af68895 chore(claude-do): MCP: review_task/merge_task senden Progress während des Veri
## Symptom (real aufgetreten, 2026-08-11)

Ein `review_task(decision="approve")` über MCP lief >5 Min. Claude Codes MCP-Client brach den Call ab mit:

> MCP server "claudedo" tool "review_task" sent no response or progress for 300s; aborting.

Ergebnis: der Merge war **schon gelaufen und comitted** (`0e12be4`, `mergeCommit` am Worktree gesetzt), aber der Task blieb auf `WaitingForReview` hängen —

ClaudeDo-Task: d8199f1f-3df3-447f-8de2-e7aca9ec5064
2026-08-11 17:45:51 +02:00
mika kuns fb9fc6e9b5 Merge claudedo/9ec7d4ea272b46458da441ff3621f2f0 2026-08-11 17:24:35 +02:00
mika kuns 1a0cb97325 Merge claudedo/e39b952576f5469a9a3d821bdc110853 2026-08-11 17:19:23 +02:00
mika kuns 340f2c9e14 Merge claudedo/dc5d5776339b4da6a5fb628a5c2ed648 2026-08-11 17:14:07 +02:00
mika kuns 4ce5ae7189 docs(worker): drop the nameof leak from the handoff tool description 2026-08-11 17:14:01 +02:00
mika kuns f6926f08a6 Merge claudedo/eb5e618d1b844ff7b60d434c4f7ba2a0 2026-08-11 17:08:10 +02:00
mika kuns b656a241a2 fix(worker,data): honor wait_for_task_change's NotFound contract, harden TaskNumberAllocator
wait_for_task_change resolved #<number>/bare-number ids up front via TaskIdResolver, which
throws for an unknown number -- breaking the tool's own documented promise that an unknown
id reports status "NotFound" instead of failing the whole call. Resolve per id and fall back
to the original id on a resolution failure so CheckOnceAsync can still report it.

TaskNumberAllocator.AddWithNumberAsync indexed into the app_settings UPDATE...RETURNING result
without checking for an empty result, throwing on a missing singleton row; it also caught any
DbUpdateException as a number collision, burning up to 5 numbers on an unrelated failure (e.g.
FK violation) before the real error surfaced. Now recreates the missing row and only retries on
the actual unique-index collision (SQLite error 19 on tasks.number), rethrowing everything else
immediately.

Audited the other TaskIdResolver.ResolveAsync/ResolveManyAsync call sites (ExternalMcpService,
HandoffMcpTools, ConfigMcpTools, RunHistoryMcpTools, AttachmentMcpTools, LifecycleMcpTools,
BatchMcpTools): none of their tool descriptions promise a found/NotFound flag for the id itself
(BatchMcpTools.BatchGetTasks already handles this correctly via its own per-id try/catch;
PreviewMergeSet promises a per-task "error" field, not a found/NotFound flag; the rest are
single-id tools that already throw on a missing task downstream) -- left throwing behavior as-is.
2026-08-11 16:57:23 +02:00
mika kuns 1003fdbf46 fix(worker): name quarantined prompt files and their replacements in the recovery Warn
PromptFileRecovery's orphan-quarantine log only reported a bare count, so a customized
merge-helper-execute.md silently stopped applying with no pointer to where it went. Log each
quarantined file's destination path, and for merge-helper-execute.md specifically call out that
it was split into merge-helper-wait.md and merge-helper-merge.md with no content migration.
2026-08-11 16:55:32 +02:00
mika kuns e343baf21a fix(ui): close the outgoing ConPTY pane on merge-helper handoff
Each handoff tile is a live claude process with the full mcp__claudedo__* surface; leaving the
outgoing phase's pane open leaked one process per phase. Close it via CloseConPtySession before
opening the next phase's tile, restoring the one-pane-per-TaskId invariant so
OpenConPtySessionAsync's dedupe and OnPaneSubmitForReview can go back to FirstOrDefault.
2026-08-11 16:55:25 +02:00
mika kuns 39b24c2f74 fix(worker): cap the list-handler wait/merge handoff chain server-side
HandoffMcpTools.HandoffListHandler only validated the phase name and
broadcast it -- nothing bounded how many times a ConPTY session could
hand off wait<->merge, so a model that skimmed past the prompt's
"final round" line could loop indefinitely. Add HandoffRoundTracker,
an in-memory per-handler-task counter (a list-handler run always
creates a fresh handler task, so no reset logic is needed): past 4
handoffs (two full wait/merge cycles) a non-final nextPhase is
coerced to its "_final" counterpart, and any handoff after a
merge_final round for that task is rejected outright.
2026-08-11 16:52:49 +02:00
mika kuns 6c4fa7b1a5 chore(claude-do): Task-row visual fixes: chain step badge z-order + hide "#0"
From the 2026-08-11 unpushed-commit review (Low). Both in `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`. **Visual verification by the user is required — do not claim either is fixed without a screenshot; list both as open visual checks in the result.**

## A) Chain step badge is drawn under the task card

Commit `eb66ae7` moved `Border.chain-step-badge` from column 0 into column 1 with `Margin

ClaudeDo-Task: 9ec7d4ea-272b-4645-8da4-41ff3621f2f0
2026-08-11 16:46:21 +02:00
mika kuns 79b35801ae fix(ui): stop the reconcile ticks from destroying user state
The 4s reconcile tick was added to three long-lived surfaces. On two of
them it reloads by rebuilding every row instance, which silently threw
away whatever the user had built up since the overlay opened; on the
third it healed a row's data but left it in the wrong section.

- WorktreesOverview: LoadAsync clears Rows, SelectedCount, ConflictRows
  and BatchProgress, so batch-merge ticks, the highlighted row and a
  finished batch's outcome badges were wiped every four seconds --
  assembling a multi-worktree selection was impossible. Carry that state
  across the reload, keyed by task id, and re-point SelectedRow at the
  fresh instance (or clear it when the worktree is gone).
- MergeHelperSelection: the remember/restore had no re-entrancy guard, so
  a second tick entering between the first one's reload and its restore
  snapshotted rows already back at IsTickedByDefault and wrote that
  default back, re-ticking what the user had unticked. One tick at a time,
  and hold the row instances instead of a value snapshot so a tick landed
  during the await survives.
- TasksIsland: the tick deliberately never called Regroup because Phase 2b
  owned Rows in parallel. 2b has landed, so a healed task that went Done
  stayed in the Open section under a stale count, and a healed depends-on
  link never pulled its dependent under the chain head. Regroup when a
  patch moved a grouping input, gated on a cheap key so an idle tick stays
  free.
2026-08-11 16:39:20 +02:00
mika kuns eb66ae7b8b fix(ui): keep task rows without a chain relation flat
The indent column was hardcoded to 24px, so every row reserved the
gutter even with no child/chain relation. Restore Auto sizing with the
width back on the Border, which collapses to 0 while it is invisible.
Move the step badge into column 1 with a negative margin so its desired
width can never widen the Auto column (same on-screen position).
2026-08-11 15:27:35 +02:00
mika kuns 3c7871afe2 Merge claudedo/e908c51dc7c547f99ed99c491f286eeb 2026-08-11 15:23:12 +02:00
mika kuns b81d6e0b4f feat(ui): render dependency-chain rail, step badge, and after-chip in TaskRowView
Reuses the existing 24px indent track (now gated on ShowAsChild OR
ShowAsChainMember) instead of a second mechanism, adds a round step-number
badge centered on the rail line, and a dimmed "after X" chip for chain
members whose head isn't in view or that render as a planning child.
2026-08-11 15:14:39 +02:00
mika kuns 9977fe4c3e Merge claudedo/573224c1c8a74f62860c32331c234d1e 2026-08-11 14:49:08 +02:00
mika kuns 42279e0806 Merge claudedo/7ecf4a3b2149418390c8c69d8a52e5d1 2026-08-11 14:43:44 +02:00
mika kuns 18babe3dee feat(ui): compute dependency-chain step/grouping in TasksIslandViewModel
Slice 1 of dependency-chain display: TaskRowViewModel gains DependsOnTaskId
plus the extrinsic ShowAsChainMember/ChainStep/ChainAfterLabel contract for
Slice 2's rail/badge rendering. Regroup's ClassifyItems walks each row's
DependsOnTaskId chain per section, pulls dependents directly under their
head regardless of SortOrder, and falls back to a flat row + label when the
head isn't in the same section (mirrors the ParentInView precedent).
Planning children never join a chain group - parent indent wins - and only
ever carry the label.

No AXAML changes; that's Slice 2.
2026-08-11 14:38:16 +02:00
mika kuns e8e2fcec3f docs: update for task-numbers features (Slice 5)
Document task-number allocation in Data layer (TaskEntity.Number,
next_task_number counter, invariants, insert paths).

Update Worker docs to clarify TaskIdResolver wiring (#123 → GUID lookup),
Number in MCP payloads, and correct the 'Two hard conventions' statement
(only the first is test-enforced).

Bump external-mcp.md verified-against commit to 38af549 (Slice 4 merge)
and add new sections on task ID resolution and numbering.

Add Slice 4 visual verification items to open.md (row/detail number display,
worker-log messages).

Verified against:
- src/ClaudeDo.Data/TaskNumberAllocator.cs
- src/ClaudeDo.Data/Repositories/TaskRepository.cs (AddAsync line 20, CreateChildAsync line 276)
- src/ClaudeDo.Worker/External/TaskIdResolver.cs
- src/ClaudeDo.Worker/External/ExternalMcpService.cs (TaskDto/TaskRefDto DTOs)
- tests/ClaudeDo.Worker.Tests/External/ExternalMcpToolSchemaTests.cs (only first convention test-enforced)
2026-08-11 14:26:22 +02:00
mika kuns 0e12be480b Merge claudedo/b8004a65aba245bfbce192d10d90bb36 2026-08-11 13:54:17 +02:00
mika kuns 38af549a80 feat(ui,worker): surface task numbers in row/detail UI and worker log
Slice 4/5 of task-numbers: TaskRowViewModel.Number renders as a dimmed
"#123" before the row title; DetailsIslandViewModel.TaskIdBadge now
shows "#123" instead of the unusable "#T<guid-prefix>" handle; and the
curated WorkerLog business events in TaskRunner, TaskMergeService, and
TaskResetService prefix their quoted title with "#<Number>".
2026-08-11 13:51:20 +02:00
mika kuns a1aeb21481 Merge claudedo/3fe19f438f4446818304a3ae190b8763 2026-08-11 13:14:41 +02:00
mika kuns 2a3133efad feat(worker): accept #123 task numbers as MCP tool input
TaskIdResolver resolves a #123/bare-123 taskId parameter to its GUID
before any lookup, across every External/ MCP tool that takes a task
id, including the batch tools' id arrays (via delegation to the
already-resolving single-entity methods) and update_task's
dependsOnTaskId (empty string still passes through unchanged as the
clear-link sentinel). An unknown number throws a clear error instead
of a silent null. McpToolDocs.TaskNumberHint tells the agent to refer
to tasks as #<number> when reporting to the user, added to the
description of get_task, list_tasks, add_task, update_task_status and
review_task.
2026-08-11 13:10:49 +02:00
mika kuns 9660a29da4 Merge claudedo/42d038e771e249e790f38087cbf2be6f 2026-08-11 12:04:42 +02:00
mika kuns 106c964410 feat(worker): surface task numbers in MCP tool return payloads
Adds Number alongside every task id in External/'s DTOs -- the two
central mappers (ToDto/ToRefDto -> TaskDto/TaskRefDto) plus every
DTO that carries a bare task id and bypasses them (batch results,
queue state, wait-for-change, config, attachments, handoff,
lifecycle, merge-preview-set, worktree list). Input resolution
(#123 as an argument) stays for slice 3.
2026-08-11 11:59:51 +02:00
mika kuns 8da1a12389 fix(test): give Ui.Tests task seeds a unique Number
The task-numbers slice added a UNIQUE index on tasks.number. Ui.Tests seed
TaskEntity rows directly instead of going through TaskRepository, so every row
kept the default Number = 0 and 31 tests failed with
'UNIQUE constraint failed: tasks.number'.
2026-08-11 11:32:01 +02:00
mika kuns 5ab5a6c0a8 Merge claudedo/11da6ebbedbc4737b34878d77722106c 2026-08-11 10:52:33 +02:00
mika kuns 9e46c96b24 feat(data): add task numbers schema, allocator, and backfill migration
TaskEntity.Number is a global, monotonically increasing, never-reused
integer (displayed as #123), allocated from AppSettingsEntity.NextTaskNumber
via a single UPDATE...RETURNING statement rather than MAX(number)+1, which
would reissue a deleted task's number. Both insert paths (TaskRepository.
AddAsync and CreateChildAsync) route through the new TaskNumberAllocator,
with a bounded retry on a unique-index collision. One migration adds the
columns, backfills existing rows in creation order, and creates the unique
index afterwards. Data-layer only; MCP/UI wiring is later slices.
2026-08-11 10:49:06 +02:00
mika kuns 31a9e87b56 docs(specs): add task-numbers and dependency-chain display designs 2026-08-11 09:56:26 +02:00
mika kuns d885b4eb41 Merge claudedo/5e2a441113a34c2a9257b56abb0a605c 2026-08-11 09:41:19 +02:00
mika kuns c8244c67f3 feat(ui): wire list-handler nextPhase through the handoff hub event
HandoffRequested now carries nextPhase end to end (IWorkerClient ->
WorkerClient -> MissionControlViewModel -> GetMergeHelperHandoffLaunchSpecAsync),
and the outgoing tile is left open on handoff instead of being closed --
lookups that need the active pane for a task now use LastOrDefault since a
handler task's ConPtySessions can hold more than one pane.
2026-08-11 09:40:14 +02:00
mika kuns ee7003d964 Merge claudedo/a4834991067a479fabfb3226abcd4b9f 2026-08-11 09:28:22 +02:00
mika kuns f2609d186a feat(worker): wire list-handler phase parameter through handoff chain
Threads a nextPhase parameter (wait/merge/wait_final/merge_final, validated
by the new MergeHelperPhase) from handoff_list_handler through
HubBroadcaster/WorkerHub into InteractiveLaunchSpecService, which now picks
the next session's system prompt (MergeHelperWait/MergeHelperMerge) and
model (HandlerWaitAlias/HandlerMergeAlias) from it instead of hardcoding the
old two-phase Execute prompt -- this also fixes a build break left by the
prior prompt-split task, which removed PromptKind.MergeHelperExecute without
updating its only caller.

Also sets --model/--effort/--permission-mode explicitly for every list-handler
session (Triage included) via PermissionModeResolver instead of inheriting the
CLI's ambient model and hardcoding "auto", and adds Task to the merge-helper
allowlist so the Merge phase can delegate diff reviews to subagents.
2026-08-11 09:27:16 +02:00
mika kuns 6927805600 fix(ui): stop clipping the last log line in scrollable output
Avalonia 12 leaves ScrollViewer.Padding out of the scroll Extent, so at
maximum offset the content still overhangs the viewport by the padding
height — the last lines were cut off and unreachable even after
ScrollToEnd(). Move the inset onto the content (Margin on the inner
ItemsControl/StackPanel) and leave the ScrollViewer padding-free.

Affects the work console Output/Git/Session tabs, the reusable session
terminal (task + prep log) and the log visualizer.
2026-08-11 09:26:06 +02:00
mika kuns 1657a70962 Merge claudedo/46c16cb0e9934697b0865700baf0f244 2026-08-11 09:02:48 +02:00
mika kuns a311152b25 Merge claudedo/204a03ab377147be847c19bb2ce5b834 2026-08-11 08:54:13 +02:00
mika kuns 1203323102 Merge claudedo/033f104f2a0f44b1aac8ca54a73a8866 2026-08-11 08:54:05 +02:00
mika kuns e555bdb8a4 Merge claudedo/cda10d515837448c8a20d608dfe5e599 2026-08-11 08:53:57 +02:00
mika kuns d3b91b5214 feat(data): split list-handler prompt into triage/wait/merge roles
Adds HandlerTriageAlias/HandlerWaitAlias/HandlerMergeAlias to ModelRegistry and
replaces PromptKind.MergeHelperExecute with MergeHelperWait (phase 3 only) and
MergeHelperMerge (phases 4-5), so the list handler can run as three
cost-scoped sessions instead of two. Triage's Phase 2 now checks/sets a wide
verify command via get_list_config/set_list_config once per run. Merge now
merges before reruns, delegates diff review to a sonnet subagent, rejects
0-file diffs, tracks the merged-but-not-Done verify-gate outcome, and caps
reruns at one per task via handoff_list_handler's new nextPhase parameter.

InteractiveLaunchSpecService.cs still references the removed
PromptKind.MergeHelperExecute and fails to build -- wiring the Worker/UI side
onto the new roles is a follow-up task.
2026-08-11 08:52:28 +02:00
mika kuns 095d216bda feat(external-mcp): let set_list_config write the verify command
SetListConfig previously could only pass VerifyCommand through unchanged;
only the UI hub could set it. Add an optional verifyCommand parameter with
matching clear/merge/create-vs-delete handling, and split the list result
into ListConfigDto so the task-only SetTaskConfig/GetTaskConfig DTOs stay
untouched. Also corrects Worker/CLAUDE.md's claim that tasks can override
verify_command — it's list-only.
2026-08-11 08:52:03 +02:00
mika kuns d60ecb7f1b fix(ui): stop review-feedback field clipping placeholder and typed text
Shorten the review-prompt placeholder and localize it (session.reviewFeedbackPlaceholder,
en+de) instead of a hardcoded string that overran the narrow column. Switch the growing
TextBox's VerticalContentAlignment from Center to Top so multi-line input grows downward
instead of being clipped top/bottom, add an auto scrollbar past MaxHeight, and top-align
the prompt glyph and Resume button to match.
2026-08-11 08:50:45 +02:00
mika kuns 6a731beb53 fix(ui): add vertical spacing between task rows
Cards touched with Margin=0. Add margin on the inner task-row border
instead of the ListBoxItem style, so the drag/drop hit-test container
still covers the row and the drop-hint doesn't flicker between items.
2026-08-11 08:47:00 +02:00
mika kuns 11103d4d3e fix(runner): only fail a run on denied write tools with a worktree 2026-08-11 08:32:53 +02:00
mika kuns 1acd4c1734 fix(git): carry the pathspec into GetBranchDiffAsync's fallback 2026-08-11 08:32:53 +02:00
mika kuns d3ba279b54 fix(ui): stop the merge-helper tick from re-ticking unticked candidates 2026-08-11 08:32:52 +02:00
mika kuns 7106bf754f fix(mcp): keep wait_for_task_change waiting through a planning-chain block 2026-08-11 08:32:52 +02:00
mika kuns f31b1b4fb2 fix(mcp): stop set_list_config from clearing settings it doesn't expose 2026-08-11 08:32:52 +02:00
mika kuns d451edd367 fix(data): normalize a list working dir's trailing separator on write
A list working_dir stored as "C:\Dev\Tests\StaplerTracking\" broke every consumer
that puts it on a Windows command line: argv rules read \" as an escaped quote, so
the token never closes. "Open in terminal" passed wt.exe a starting directory of
C:\Dev\Tests\StaplerTracking" and it failed with 0x8007010b; the same data had
already corrupted the ConPTY list handler's arg list in August.

Paths.TrimTrailingSeparator is now the single helper (replacing the copies in
InteractiveLaunchSpecService and ClaudeHelpLauncher) and ListRepository applies it
on Add/Update, which covers every writer: UI create, repo import, hub UpdateList,
and MCP CreateList/UpdateList. OpenInTerminal also switches to ArgumentList so its
quoting is correct regardless of what's stored.
2026-08-10 17:12:46 +02:00
mika kuns 6d8cbacc9b fix(data): stop AddTaskDependency's table rebuild from dropping scope_globs
AddScopeOverlapFields, AddTaskDependency and AddFailureReason were each
generated off the same parent migration, so none of their Designer
snapshots knows the others' columns. AddTaskDependency calls AddForeignKey,
which on SQLite forces a full `tasks` table rebuild generated from that
migration's own frozen snapshot -- silently recreating the table without
`scope_globs`.

Result: the Worker died at startup with "no such column: t.scope_globs"
(Hosting failed to start), so it never bound the SignalR port and the App
could not connect. Fresh installs were affected too, not just existing DBs.

Add the missing ScopeGlobs property to the stale Designer snapshot so the
rebuild preserves the column. Verified by migrating a fresh temp DB and
diffing pragma_table_info('tasks') against the model snapshot: 39/39
columns.

Note: the test suite cannot catch this class of bug -- every fixture uses
Database.EnsureCreated(), which builds the schema from the model and never
applies the migration chain.
2026-08-10 17:00:24 +02:00
mika kuns bbfe40242c fix(ui): re-emit the flat list's section headers on a language change
Phase 2b-1 moved the group headers from {loc:Tr} bindings in TasksIslandView
into HeaderRow.Label, resolved once in Regroup(). RefreshLocalizedText only
updated CompletedHeader and the task rows, so after switching the language the
three section headers kept the old one. Re-run Regroup() there.

Not unit-tested: the only seam is the global static Loc.Current, and mutating
it would bleed into test classes running in parallel.
2026-08-10 16:26:16 +02:00
mika kuns 2fe40f3d65 Merge claudedo/923d3b07183c4ca8bcd15aed12f9df39 2026-08-10 16:21:01 +02:00
mika kuns ec6c230822 refactor(ui): flatten TasksIsland's task list into one virtualized ListBox
Replace the three ItemsControl sections (Overdue/Open/Completed) with a single
flat Rows collection (HeaderRow | TaskRowViewModel) bound to a ListBox +
VirtualizingStackPanel, so the realized container count stays bounded instead
of growing with the list (measured: ~7-8 realized containers at any scroll
position with 1000 source rows). Group headers become regular row entries,
each section is simply omitted from Rows when empty, and the Completed
section's Clear-completed action moves onto the header row itself.

SectionFor/ReorderAsync/FindNextInSameSection now read a row's section off
the nearest preceding HeaderRow in Rows instead of three separate bound
collections, and ScrollSelectedIntoView goes through ListBox.ScrollIntoView so
it still works for rows outside the realized window. Drag/reorder behavior,
grouping/counts, and the Clear-completed button are unchanged.

Auto-scroll-while-dragging and the cross-section reorder bug are explicitly
out of scope (follow-up tasks).
2026-08-10 16:18:55 +02:00
mika kuns 82881acc70 fix(ui): stop the reconcile tick on native close, index rows in the tick
Two follow-ups to the Phase 3 reconcile tick:

- LogVisualizer and WorktreesOverview only stopped their timer in the Close
  command, but ShowLogVisualizerAsync/ShowWorktreesOverviewAsync had no
  dlg.Closed fallback (unlike the MergeHelper picker). An Alt+F4 or owner
  close left a 4s timer polling the worker / re-running LoadAsync on an
  orphaned VM, accumulating one per open. Extracted StopReconcileTick on both
  VMs and wired it from Closed.
- The tick scanned Items linearly per id; at the 500-row cap that is ~250k
  string comparisons every few seconds. Index the rows once instead.
2026-08-10 16:05:37 +02:00
mika kuns 1b7b18fdfc Merge claudedo/45142ae6a226488192290835d6336c5a 2026-08-10 16:03:06 +02:00
mika kuns 1cad264967 feat(ui): add reconcile tick to self-heal lost worker broadcasts
TasksIslandViewModel now runs a periodic tick that diffs the flat Items
collection against SQLite and patches properties in place (capped at 500
rows, sharing the Phase 1 delta sequence guard so it never overtakes a
fresher broadcast). Never rebuilds rows or triggers Regroup/LoadForList, so
it stays decoupled from the parallel Phase 2b virtualization work. The same
cadence now refreshes the long-lived Worktrees Overview, Log Visualizer, and
Merge Helper selection overlays; short-lived modals are untouched.
2026-08-10 15:56:05 +02:00
mika kuns 514d6111fe fix(test): name the cancellationToken arg in the preview_merge_set subset test
Two branches merged into main independently: one added `runVerify` to
PreviewMergeSet ahead of `cancellationToken`, the other added the subset
test that passed the token positionally. Neither conflicted textually, so
the mismatch only surfaced as CS1503 at test-compile time.
2026-08-10 15:26:07 +02:00
mika kuns 66f7a8ae99 Merge branch 'claudedo/9f963a215bb34ca4a28050d4f39178d3' 2026-08-10 15:19:57 +02:00
mika kuns fb12d965fc Merge subtask
# Conflicts:
#	src/ClaudeDo.Data/Git/GitService.cs
#	src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs
2026-08-10 15:18:30 +02:00
mika kuns 8b75a92794 Merge branch 'claudedo/5c80a0aba5d9468a96cf809fc7a6e7a1' 2026-08-10 15:15:26 +02:00
mika kuns 333ce89eef Merge branch 'claudedo/16172942c1424a67a0499e01cefb7d60' 2026-08-10 15:08:51 +02:00
mika kuns a9396c1a99 Merge claudedo/89f989e04fcf43f5a80211d63df7a6d1 2026-08-10 15:07:05 +02:00
mika kuns f41a3e9083 Merge claudedo/1891227787ae4085a52c112bd1dcd75a 2026-08-10 15:05:53 +02:00
mika kuns 88207ee2a2 Merge subtask 2026-08-10 15:05:14 +02:00
mika kuns 85c8c36f2c Merge subtask 2026-08-10 15:05:11 +02:00
mika kuns 6db3f0f537 Merge branch 'claudedo/295d5d409c194ba28dbb95ab13630832' 2026-08-10 15:04:46 +02:00
mika kuns 21c9c3b3b9 Merge claudedo/107e62d083e44436b9074c16d14e2edc 2026-08-10 15:02:52 +02:00
mika kuns f614323fa9 Merge claudedo/1bbc77a12e0844b2a037bf44cfb47eae 2026-08-10 15:01:59 +02:00
mika kuns 2fd7f829bd Merge branch 'claudedo/340b4a2df0f14235a34fe0d02b2dc06f' 2026-08-10 15:01:33 +02:00
mika kuns d95ae55415 Merge claudedo/2a790a4416924ed6beda0880dcf16836 2026-08-10 15:00:04 +02:00
mika kuns 474c7201c9 Merge claudedo/f319b602707f4dfdb6727e9fae5ac757 2026-08-10 14:58:58 +02:00
mika kuns 19b31450b1 Merge claudedo/e5aeaf59fae840adaea3f1e912e203e1 2026-08-10 14:58:35 +02:00
mika kuns df021a6cb8 Merge claudedo/850c2e490711461a87467ca6743052e0 2026-08-10 14:58:14 +02:00
mika kuns 3fc67fb422 Merge claudedo/42d103cfae72447aa048e28121d6e733 2026-08-10 14:57:37 +02:00
mika kuns 52b42c0a35 Merge claudedo/fc88143c0c704feab4ba6806cec49f4f 2026-08-10 14:56:55 +02:00
mika kuns 31a89bd93f Merge claudedo/21d704131b2d486cb1d103134e5ea5e8 2026-08-10 14:56:37 +02:00
mika kuns 9ee6defd69 Merge claudedo/02f6746fd67b4217a581f2f911f6ff0d 2026-08-10 14:56:10 +02:00
mika kuns 5a5c95f1c8 Merge claudedo/3511e9d2d7b44a9aa018739c83143b6f 2026-08-10 14:55:53 +02:00
mika kuns a1c90041c6 Merge claudedo/d86e63f916af4946bbc25098449669ac 2026-08-10 14:55:33 +02:00
Mika Kuns 0bfb1f5657 !WIP: General changes 2026-08-10 14:52:44 +02:00
mika kuns 47918869e9 fix(worker): wire mcp config into resumed runs
TaskRunner.ContinueAsync resolved a ClaudeRunConfig but never attached
a per-run MCP token/config or AllowedTools the way RunAsync does, so a
--resume continuation (e.g. reject-rerun with feedback) lost every
mcp__claudedo_run__* tool, including AskUser. Extracted the setup into
SetupMcpConfigAsync and call it from both paths, with matching cleanup
in ContinueAsync's finally block.
2026-08-10 14:48:04 +02:00
mika kuns 2662e7bd98 feat(worker): auto-rebase overlapping WaitingForReview branches after a merge
After a merge lands on the target branch, TaskMergeService now rebases every
other WaitingForReview task's active worktree onto the new tip -- but only
when that branch actually touches a file the merge just changed, computed via
GitService.GetChangedFileNamesAsync (both the merge's landed files and each
candidate's own diff). A branch merely behind is left alone. On conflict or
any other failure the rebase is aborted and the branch is left exactly as it
was, with a WorkerLog warning naming the task; a successful rebase updates the
worktree's recorded BaseCommit/HeadCommit and broadcasts WorktreeUpdated.
2026-08-10 14:36:55 +02:00
mika kuns db50e710b9 fix(runner): substitute acceptEdits for haiku's silent auto-mode downgrade, fail permission-denial-only runs 2026-08-10 14:35:24 +02:00
mika kuns d3155e6868 feat(queue): warn when the base branch has uncommitted changes
Queuing (update_task_status, batch_update_task_status) and run_task_now
now surface a non-blocking baseDirty warning (separate modified/untracked
counts) when the list's working dir has uncommitted changes at enqueue
time, since a new worktree forks from the commit tip and silently misses
them. BaseDirtyChecker caches per working dir for a few seconds so a
batch queue over many tasks in one list only shells out to git once. The
UI surfaces the same warning via the footer error strip on queue actions.
2026-08-10 14:30:33 +02:00
mika kuns 84715665c2 fix(releases): don't drop the prerelease marker when comparing versions
A MinVer dev build on main computes a prerelease of its own guessed next
version (e.g. 2.9.1-alpha.0.14 after tag v2.9.0), which already sorts
above the last tag by numeric core, so the update banner correctly
stays quiet there. But the reduction to a bare numeric core meant a
genuine new release landing at exactly that guessed version (v2.9.1
real) compared equal to the still-running prerelease and the banner
never fired. A prerelease now only loses to a real release of the same
core, not to another prerelease of the same core.

Verified against the live repo: latest published tag is v2.9.0; a
Release build of this worktree (14 commits ahead) reports
AssemblyInformationalVersion 2.9.1-alpha.0.14+<sha>.
2026-08-10 14:27:23 +02:00
mika kuns 09add29994 fix(mcp): keep wait_for_task_change alive past Claude Code's idle timeout
MCP_TOOL_TIMEOUT (raised by ClaudeDo's launchers) is a wall-clock cap unrelated
to Claude Code's idle-silence abort (default 300s for HTTP-transport MCP
servers), which no launcher raises. A wait near the 900s recommendation was
silently killed at ~300s in any session, launcher or not.

WaitForTaskChange now reports MCP progress every 30s while polling, which
resets that idle timer. Verified against a real claude -p call (no launcher
env overrides) surviving a 341s wait via 60s pings -- the same silence that
previously aborted at 300s. Tool description and the list-handler prompt no
longer claim ClaudeDo's launchers guarantee a long wait survives.
2026-08-10 14:21:18 +02:00
mika kuns a067a7bfff fix(mcp): report a Failed task's failureReason instead of a bare status
get_task/batch_get_tasks now return failureReason (max_turns|timeout|error|
cancelled|unknown) plus failureTurnsUsed/failureMaxTurns on a Failed task, so
max_turns (worktree usually fine, continue_task) is distinguishable from a
real error (reset_failed_task) without pulling get_task_log's raw NDJSON.
Classified and stamped onto TaskEntity by TaskRunner.MarkFailed via
TaskStateService.FailAsync; TaskRunEntity also keeps the CLI's raw
terminal_reason/result_subtype/errors for deeper diagnosis. reset_failed_task's
description now warns explicitly that it discards the worktree and points at
continue_task for max_turns. Surfaced on the task card's status-chip tooltip.
2026-08-10 14:20:00 +02:00
mika kuns ca6eb62b5c feat(mcp): truncate/select batch_get_tasks payload, expose roadblockText
batch_get_tasks with includeDescription=true had no size guard: 8 tasks'
full Description/Result serialized to a single 51k-char line, blowing past
the tool-result token budget and forcing a file/PowerShell workaround to
read it back. Roadblock text was also only reachable via a second get_task
call per task, since roadblockCount (in the lean ref) has no text sibling.

- descriptionMaxChars (default 1500, was unlimited) truncates Description
  and Result independently, flagged via *Truncated/*FullLength so a caller
  never silently works off a cut string.
- fields narrows taskFull to just the named properties, including the new
  roadblockText (the tail of Result after TaskRunner's roadblock marker) —
  reachable without pulling in the rest of Result/Description.
- taskFull is now BatchTaskDetailDto, a batch-only shape decoupled from
  TaskDto so get_task's own contract is untouched.
- The whole response is capped (MaxResponseChars); over that, the call
  throws naming which parameter (descriptionMaxChars/fields/taskIds) to
  adjust instead of shipping an oversized payload.

10 tasks with 5000-char descriptions (default settings) serialize to
~13.8k chars, comfortably under the 25k-char cap.
2026-08-10 14:19:16 +02:00
mika kuns 6c8ec245b3 feat(worker): dependsOn via MCP + honest staleness signal in merge preview
Adds a user/MCP-declared task dependency (DependsOnTaskId) distinct from the
planning chain's internal BlockedByTaskId: add_task/update_task can set it,
the queue picker skips a Queued task until the dependency reaches Done, a
Failed/Cancelled dependency leaves the dependent blocked instead of starving
silently, and setting a link rejects self-reference/unknown-id/cycles.
get_task/list_tasks/batch_get_tasks now report blocked/blockedReason, and
wait_for_task_change reports "Blocked" immediately instead of running out its
timeout on a task the picker will never claim.

preview_merge/preview_merge_set gain staleFiles: files a branch touches that
the target branch also changed since the branch's fork point, a more honest
staleness signal than `behind` alone.
2026-08-10 14:18:55 +02:00
mika kuns 359bb3a439 refactor(ui): clean up task-row drag visuals
Collapse the grabbed row out of layout instead of overlapping neighbors
with scale/opacity/shadow (the ghost window already shows the moving
snapshot). Replace the thin moss drop-hint lines with a dashed
placeholder gap sized like a collapsed row. Suppress the ordinary hover
highlight and transitions on whatever row is currently the drop target
so it doesn't read as "clickable" while dragging.
2026-08-10 13:58:40 +02:00
mika kuns 5eea439129 fix(mcp): make set_task_config/set_list_config patch instead of overwrite
Omitted fields on set_task_config/set_list_config used to be indistinguishable
from an explicit clear, so any single-field update silently wiped every other
override (including SessionSkills, which wasn't even a tool parameter). Now
only the fields you pass are changed; clearing an override requires naming it
in the new clearFields array (a sentinel string doesn't work uniformly since
maxTurns is an int). set_list_config's "all four null deletes the config" case
is preserved but only reachable via clearFields. Return values now reflect the
full resulting config.
2026-08-10 13:55:58 +02:00
mika kuns e98cf46097 perf(ui): build task row context menu lazily on right-click 2026-08-10 13:53:36 +02:00
mika kuns 7f10d45898 perf(ui): push task-list filtering into SQL
LoadForListAsync loaded the entire tasks table (all lists, two Include
joins) then filtered to the selected list in C#, never using
idx_tasks_list_id/idx_tasks_status. ITaskListFilter now exposes
MatchExpression, an Expression<Func<TaskEntity,bool>> that EF Core can
translate to SQL; a new TaskListFilterBase compiles it on demand for the
existing in-memory Matches(). The primary match query now runs as
db.Tasks.Where(filter.MatchExpression), with two small follow-up queries
(scoped by parent-id IN-lists) for contextual planning-parent rows and
pulled-in children, followed by an explicit re-sort to restore the global
SortOrder/CreatedAt ordering Regroup() depends on.
2026-08-10 13:49:09 +02:00
mika kuns 5653daca6e feat(mcp): expose the conflict resolver's hunk API over MCP
get_merge_conflicts and resolve_conflict_hunk let an MCP caller resolve a
leaveConflictsInTree merge hunk-by-hunk (ours/theirs/base/literal text)
instead of grep-parsing diff3 markers itself. Reuses the existing
TaskMergeService/ConflictMarkerParser machinery (adds MergeSegment.StartLine
and a non-staging TaskMergeService.WriteConflictFileAsync) rather than a
second parser. A partially resolved file is written without git add so
continue_merge keeps refusing while any hunk still has markers.
2026-08-10 13:49:07 +02:00
mika kuns 659bf3e743 feat(ui): show child tasks as children in the merge-helper picker
MergeHelperSelectionModalViewModel now loads ParentTaskId and orders
rows hierarchy-aware (parent immediately followed by its children,
mirroring TasksIslandViewModel.Regroup). Children render indented with
the same indent-track visual as the main task list; ticking a parent
cascades selection to its children, while a child can still be
deselected on its own. A child whose parent got filtered out (e.g.
already Done) renders top-level with an explicit hint instead of
disappearing. Confirm keeps returning parents ahead of their children.
2026-08-10 13:47:55 +02:00
mika kuns f74c7dd70b fix(ui): diff viewer opens non-modal and maximizable
The diff viewer used ShowDialog at both call sites (task details, worktrees
overview), blocking navigation of the rest of the app while open. Switch both
to Window.Show(owner) and reuse the one open window (swap DataContext,
Activate()) instead of stacking a second one on re-open.

Add an opt-in maximize/restore button + double-click-titlebar toggle to
ModalShell (ShowMaximizeButton, default false so other modals are untouched),
wired on for DiffViewerView only. Reuses the existing Icon.WinMax/WinRestore
geometry and OffScreenMargin inset from MainWindow's own maximize handling.

The review gate (Approve & Merge locked until the diff is opened) already
fires DiffViewed before the dialog/window is shown, so it is unaffected by
the modal-to-non-modal change.
2026-08-10 13:46:56 +02:00
mika kuns 07ee535b7d feat(claude-do): feat(queue): Option pro Liste — Tasks mit überlappendem Date
## Befund (Batch-Lauf 2026-08-06, Liste "Bandel.Hub")
maxParallelExecutions = 5, 23 Geschwister-Tasks auf demselben Plugin. Die Parallelitaet hat die
Ausfuehrung verkuerzt, aber die Konfliktlast erhoeht: eine CSS-Datei wurde von 10 Tasks
angefasst, drei Razor-Dateien von je 6. Der Engpass des Gesamtdurchlaufs war nicht die
Ausfuehrung, sondern Review und Merge — die sind zwingend seriell. Netto wa

ClaudeDo-Task: 89f989e0-4fcf-43f5-a802-11d63df7a6d1
2026-08-10 13:41:38 +02:00
mika kuns c715c96af9 feat(mcp): paths filter for get_task_diff, subset hint for preview_merge_set
get_task_diff gained an optional paths param (git pathspec, both stat and
full-diff modes) so a large multi-file task can be narrowed to the files
actually in question instead of shipping the whole diff.

preview_merge_set now also reports Subsets: pairs where one task's changed
files are a proper subset of another's in the same set, the strongest
available post-hoc signal that a task may be redundant with another.
2026-08-10 13:39:55 +02:00
mika kuns 2a8991496f docs(explore-notes): report Phase 2a virtualization+drag gate spike
Headless Avalonia ListBox+VirtualizingStackPanel spike answers all five
questions from the Phase 2a gate task: containers stay bounded at 1000
items, the existing InputHitTest ghost-drag model survives recycling,
variable row heights don't drift the Extent (only naive index*height
math would), auto-scroll via Offset mutation is mechanically feasible,
and mixed header/task rows template correctly via implicit DataTemplates.
Recommendation: proceed to Phase 2b. Spike code itself is throwaway and
not checked in.
2026-08-10 13:39:03 +02:00
mika kuns afe1b68f46 fix(merge): refuse a merge that would overwrite an untracked file in the target
Before merging (or staging a conflict resolution's git add -A), compare the
branch's newly-added paths against what's currently untracked in the target
working directory. A collision aborts with a new untracked_collision status
naming the path and size, surfaced through merge_task/review_task,
preview_merge/preview_merge_set (which merge-tree alone can't see), and the
UI merge paths via FlashFooterError/ShowErrorAsync instead of a silent
catch{}. git's own preflight already refuses this while the path stays
untracked at merge time; this closes the gap once a path becomes trackable
in between (e.g. an unrelated conflict resolution's git add -A) or across
the continue_merge TOCTOU window.
2026-08-10 12:15:41 +02:00
mika kuns effece3a41 feat(worker): build/test the merge preview, not just the post-merge merge
preview_merge could only see file-overlap cleanliness via git merge-tree, never
whether the result compiles -- the two costliest findings of the 2026-08-06 batch
run were both merge-tree-clean but build-broken. When a list has a verify command
configured, a clean preview is now additionally materialized (via a commit-tree +
detached scratch worktree, outside the real repo, always cleaned up) and
built/tested there, without ever touching the real working tree.

preview_merge always attempts a verify run when a command is configured;
preview_merge_set only does when its new runVerify parameter is set (default off),
so a set preview never starts N builds unasked. The post-merge verify gate is
unchanged.
2026-08-10 12:10:17 +02:00
mika kuns 5d362b6973 fix(worker): stop sibling worktrees from landing inside the repo tree
Path.GetDirectoryName returned workingDir itself instead of its parent
whenever list.WorkingDir ended in a directory separator, placing
.claudedo-worktrees inside the target repo's own working tree. Add
WorktreeRootResolver to normalize the trailing separator before deriving
the sibling root and to guard that the resulting worktree path never
lands inside workingDir, used by both WorktreeManager and
PlanningSessionManager. Add a startup sweep (LegacyWorktreeFolderRecovery)
that warns when a list's working dir already contains a leftover
.claudedo-worktrees folder from before the fix.
2026-08-10 11:58:34 +02:00
mika kuns 1b7f7f38ae feat(prime): wire prompt_override into the daily prep prompt
The prime_schedules.prompt_override column existed end-to-end but was
never populated or read. DailyPrepPrompt.BuildPrompt now takes an
optional override and appends it as an extra paragraph after the fixed
prompt (additive, never a replacement, so a user can't disable the
required get_daily_prep_candidates/set_my_day flow). PrimeRunner passes
schedule.PromptOverride through. The Prime tab in Settings now has a
multiline field per schedule wired to a new PromptOverride property on
PrimeScheduleRowViewModel (blank persists as null).
2026-08-10 11:57:54 +02:00
mika kuns 29c79e8b8c fix(worker): stage only resolved conflict paths in continue_merge
git add -A ran against the shared target checkout, sweeping in
whatever untracked or unrelated modified files a concurrent session
left there and committing them into an unrelated merge. Stage exactly
the paths that were reported conflicted and resolved instead.
2026-08-10 11:56:37 +02:00
mika kuns 6a2a19cc9e docs(worker): record the findings store and fix the git-test skip convention 2026-08-10 10:47:17 +02:00
mika kuns 0568f003d9 feat(ui): open a list's findings folder 2026-08-10 10:45:51 +02:00
mika kuns 696abd0a7a feat(ui): toggle whether the findings folder is committed 2026-08-10 10:43:12 +02:00
mika kuns f717742379 docs(runner): correct the findings-pointer comment 2026-08-10 10:33:58 +02:00
mika kuns 87397ecfa9 feat(runner): point autonomous runs at the findings index 2026-08-10 10:32:46 +02:00
mika kuns 519f64c02e feat(mcp): add save_finding to the in-task server 2026-08-10 10:27:13 +02:00
mika kuns b312095fb2 refactor(mcp): trim the save_finding description to the style budget 2026-08-10 10:21:30 +02:00
mika kuns 2d4288abca feat(mcp): add save_finding to the external server 2026-08-10 10:19:45 +02:00
mika kuns 9a09dd6186 feat(findings): resolve the target store from task or list 2026-08-10 10:13:29 +02:00
mika kuns 389892d277 feat(data): add findings_tracked to lists 2026-08-10 10:06:50 +02:00
mika kuns 4d997a5f99 refactor(git): share the info/exclude writer with the findings store 2026-08-10 09:59:42 +02:00
mika kuns d3c2e2e7c8 feat(findings): add the .claudedo findings store 2026-08-10 09:55:54 +02:00
mika kuns 37c3b476cf docs(findings): plan the findings store implementation 2026-08-10 09:51:55 +02:00
mika kuns ae1f6fc78a docs(findings): spec the .claudedo findings store 2026-08-10 09:36:10 +02:00
267 changed files with 21852 additions and 1450 deletions
+3
View File
@@ -1,4 +1,7 @@
{
"worktree": {
"baseRef": "head"
},
"permissions": {
"allow": [
"Bash(git add:*)",
+1
View File
@@ -20,6 +20,7 @@ These sit **between** the CLAUDE.md files and the code:
| [review-merge](review-merge.md) | Approve=merge-unit, verify gate, `MergeCommit`/revert, diff stack, conflict resolver |
| [conpty-sessions](conpty-sessions.md) | Interactive/planning/list-handler launch specs + the arg-flattening gotcha |
| [installer-preflight](installer-preflight.md) | CLI version/login/auto-mode research, the `ExecutableResolver`/shim root cause, and the Installer's `Checks/`+`SystemCheckPage` implementation status |
| [list-virtualization-spike](list-virtualization-spike.md) | Phase 2a gate spike: virtualized `ListBox` + the existing ghost-drag `InputHitTest` model, verified headless |
## Rules
+38 -8
View File
@@ -1,7 +1,7 @@
# ConPTY interactive sessions & launch specs
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
> Last verified against commit `8dbdfb3` (2026-08-06).
> Last verified against commit `79b3580` (2026-08-11).
> Drift check: `git log --oneline bdee731..HEAD -- src/ClaudeDo.Worker/Planning src/ClaudeDo.Worker/Hub src/ClaudeDo.Worker/Runner/ClaudeArgsBuilder.cs src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs src/ClaudeDo.Ui/Views/InteractiveTerminalView.axaml`
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
@@ -18,7 +18,7 @@ Autonomous queue tasks are **not** covered here — they stay on the stream-json
| Task session | `GetInteractiveLaunchSpec` | Effort from the task/list model preset |
| Ad-hoc | `GetAdHocLaunchSpec` | Effort from the global default |
| Planning | (planning start/resume) | Effort from `PlanningAlias`; uses `--permission-mode default`, **not** `plan` |
| List handler | `GetMergeHelperLaunchSpec` | Effort from list config; `--permission-mode auto` (unattended) |
| List handler | `GetMergeHelperLaunchSpec` / `GetMergeHelperHandoffLaunchSpec` | Model + effort fixed per role (`ModelRegistry.HandlerTriageAlias`/`HandlerWaitAlias`/`HandlerMergeAlias`), **not** from list config; `--permission-mode` via `PermissionModeResolver` (unattended) |
## ⚠️ Gotcha: never pass task free-text as a CLI argument
@@ -54,8 +54,16 @@ so a token like `"C:\repo\"` never closes and every following argument is swallo
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.
(2026-08-06). `BuildForMergeHelperAsync`/`BuildForMergeHelperHandoffAsync` run the repo through
`TrimTrailingSeparator`; session dirs the worker builds never carry one.
The same data bit the UI's "Open in terminal" on 2026-08-10 (`wt -d "C:\…\StaplerTracking\"`
`Could not access starting directory "C:\…\StaplerTracking""`), so the fix moved to the write side:
**`ListRepository.AddAsync`/`UpdateAsync` normalize `WorkingDir` via `Paths.TrimTrailingSeparator`**,
which covers every writer (UI create, repo import, hub `UpdateList`, MCP `CreateList`/`UpdateList`) —
the UI and worker keep their own defensive trim for rows written before that. Anything new that puts
a **user-supplied** path on a command line should use `Paths.TrimTrailingSeparator` and, on the
`ProcessStartInfo` side, `ArgumentList` rather than an interpolated `Arguments` string.
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:
@@ -91,9 +99,30 @@ launch even started.
## List handler ("Let Claude handle it")
`BuildForMergeHelperAsync` uses `--permission-mode auto` so it runs unattended. The
`--allowedTools` allowlist is the security boundary:
`mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill`.
`BuildForMergeHelperAsync`/`BuildForMergeHelperHandoffAsync` resolve `--permission-mode` via
`PermissionModeResolver` (currently always `auto`, since none of the handler roles run on haiku)
so the session runs unattended. The `--allowedTools` allowlist is the security boundary:
`mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill,Task` (`Task` lets the Merge
role delegate diff reviews to sonnet subagents).
The run now spans up to **five** phase-scoped sessions instead of two, chained via
`handoff_list_handler(taskId, survivingTaskIds, nextPhase)`: opus Triage → sonnet Wait → opus
Merge → (only if Merge started reruns) sonnet Wait(final) → opus Merge(final). `nextPhase` (`wait`
| `merge` | `wait_final` | `merge_final`, validated by `MergeHelperPhase.Validate`) picks both the
system prompt (`PromptKind.MergeHelperWait`/`MergeHelperMerge`) and the model
(`ModelRegistry.HandlerWaitAlias`/`HandlerMergeAlias`) for the next session; the `_final` variants
share the same prompt/model as their non-final counterpart and differ only in one extra line
rendered into the handoff kickoff file (`PromptKind.MergeHelperHandoff`) marking the final round
and forbidding further reruns.
Each handoff hands the SAME handler task id to a NEW tile, and `MissionControlViewModel.
OpenMergeHelperHandoffConPtySessionAsync` closes the outgoing phase's pane (`CloseConPtySession`,
which disposes its `PtyTerminalSession` and kills the underlying `claude` process) before opening
the next one — every tile is a live process with the full `mcp__claudedo__*` surface, and the
outgoing session is only ever told to end its turn, never to exit. This keeps the one-pane-per-
`TaskId` invariant that `ConPtySessions` relies on elsewhere (`OpenConPtySessionAsync`'s dedupe,
`OnPaneSubmitForReview`) — both use a plain `FirstOrDefault(s => s.TaskId == taskId)`, not a
"newest wins" lookup.
`MCP_TOOL_TIMEOUT` is 200 s here — `TaskWaitMcpTools` clamps its own timeout to 170 s to stay
comfortably under it (see [external-mcp.md](external-mcp.md)).
@@ -234,7 +263,8 @@ Autonomous and interactive sessions do **not** share a system prompt. Per start
| 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"), 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 |
| List handler, post-handoff (wait/wait_final) | `InteractiveLaunchSpecService.BuildForMergeHelperHandoffAsync` | `--append-system-prompt-file <path>` (`PromptKind.MergeHelperWait` — phase 3 only), fresh session dir, same handler task id |
| List handler, post-handoff (merge/merge_final) | `InteractiveLaunchSpecService.BuildForMergeHelperHandoffAsync` | `--append-system-prompt-file <path>` (`PromptKind.MergeHelperMerge` — phases 45 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
+39 -5
View File
@@ -1,8 +1,8 @@
# External MCP tool surface
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
> Last verified against commit `20bce9b` (2026-08-06).
> Drift check: `git log --oneline 20bce9b..HEAD -- src/ClaudeDo.Worker/External`
> Last verified against commit `38af549` (2026-08-11).
> Drift check: `git log --oneline 38af549..HEAD -- src/ClaudeDo.Worker/External`
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
Covers `src/ClaudeDo.Worker/External/` — the always-on MCP tools ClaudeDo exposes to general
@@ -14,11 +14,34 @@ need no `--mcp-config`.
and git/merge operations. They deliberately do **not** expose multi-turn control, planning
session internals, or app-settings writes. Auth via an optional `X-ClaudeDo-Key` header.
## Hard conventions (enforced by tests)
## Task ID resolution and numbering
Every tool parameter accepting a task id — `taskId`, `parentId`, `taskIds` arrays, and similar —
is wired through `TaskIdResolver`, which resolves:
- `#123` (with or without the `#` prefix) → look up by `TaskEntity.Number`
- Bare GUID string → use as-is
- Unknown number → `InvalidOperationException` (`no task with number 123`)
This is **not** ambiguous: a GUID is never all-digits, so a pure-integer parameter is always a
number, not a partial GUID. All task-returning tools (`GetTask`, `ListTasks`, `AddTask`,
`BatchGetTasks`, etc.) stamp the resolved task's `Number` in the DTO — both `TaskDto` and the
lean `TaskRefDto` carry an `int Number` field. **Branch names and worktree paths** (`claudedo/{id}`)
continue to use the GUID; the number is a display alias, never the identity.
Every tool description carries a shared boilerplate clause (defined in `McpToolDocs.TaskNumberHint`)
instructing the agent to **refer to tasks as `#<number>` when reporting results to the user**
without this the agent sees the number in every payload but never learns to speak it.
## Conventions
### Test-enforced
1. **Every optional/filter parameter needs a C# default value** (e.g. `string? status = null`).
The MCP schema only marks a parameter optional when it has one — nullability alone does
not do it. `ExternalMcpToolSchemaTests` guards this by reflection.
### Not test-enforced, but strongly observed
2. **No tool returns bare `Task` or a nullable payload directly.** An MCP client cannot tell
an empty/omitted response apart from a dropped one.
- *Write* tools return a small confirmation record — `{ ok/deleted/removed/reset/started:
@@ -32,13 +55,15 @@ 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. **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.
"since this feature was introduced" history.
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
@@ -145,7 +170,16 @@ directory is shared with other concurrent sessions.
single-task. Every tool returns a per-item result array (`{ id/index, ok, error?, … }`) — a
failing item never aborts the rest — and rejects batches over **100 items**.
`BatchGetTasks` mirrors `ListTasks`'s `includeDescription` flag (default `false`): a found item's
`BatchGetTaskResult` carries `task` (lean) or `taskFull` (full), never both.
`BatchGetTaskResult` carries `task` (lean `TaskRefDto`) or `taskFull`, never both. `taskFull` is
`BatchTaskDetailDto` — a batch-only shape, **not** `TaskDto` (`get_task` is untouched) — whose
Description/Result are cut to `descriptionMaxChars` (default 1500, was unlimited) with
`*Truncated`/`*FullLength` flagging it, and which `fields` (an optional name allow-list, e.g.
`['title','roadblockText']`) can narrow further; unrequested fields come back `null`.
`roadblockText` pulls just the bullet lines after `TaskRunner.ComposeReviewResult`'s roadblock
marker out of `Result`, without needing the rest of it. The whole per-call response is also
capped (`BatchMcpTools.MaxResponseChars`) — over that, the call throws naming which parameter to
adjust instead of shipping an oversized payload (the incident that prompted this: 8 tasks'
full Description/Result serialized to a single 51k-char line).
**`GetTaskLog`** — latest run's log, tail-capped at 256 KB.
@@ -0,0 +1,114 @@
# List virtualization spike (Phase 2a gate)
> **Explore-note — verify before trusting.** Distilled map of a subsystem, not authoritative.
> Last verified against commit `6a2a19c` (2026-08-10).
> Drift check: `git log --oneline 6a2a19c..HEAD -- src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml src/ClaudeDo.Ui/Views/Islands/TasksIslandView.axaml.cs src/ClaudeDo.Ui/Views/Controls/TaskDragController.cs`
> Stable structure only (no line numbers). See docs/explore-notes/README.md.
Gate task for [ui-reaktivitaet-und-listen-performance-design](../superpowers/specs/2026-08-07-ui-reaktivitaet-und-listen-performance-design.md)
Phase 2, Risk 1. Answers the five open questions before any rework of the real
`TasksIslandView`/`TasksIslandViewModel`. No production code changed for this spike.
## Method
A throwaway Avalonia-headless xUnit test (`Avalonia.Headless` + `Avalonia.Themes.Fluent`,
temporarily added to `ClaudeDo.Ui.Tests`, removed again after this report was written — not
committed) built a plain `ListBox` with an explicit `VirtualizingStackPanel` and 1000 mixed
items (≈14% "complex" two-line/badge rows, mirroring the 68px/90110px split from the design
doc), then drove it programmatically: forced layout/render passes, scrolled the `ScrollViewer`,
called the same `TopLevel.InputHitTest` the production drag code uses, and counted realized
`ListBoxItem` containers via the visual tree. No screenshots — every number below came from an
actual headless run of that harness on 2026-08-10.
One harness-only wrinkle: `InputHitTest` walks the **compositor's rendered scene**, not just the
layout tree, so the test had to call `window.CaptureRenderedFrame()` after each layout-affecting
change before hit-testing. A real windowed app renders every frame continuously, so this isn't a
production concern — noted here only so the method is reproducible.
## Q1 — Does a virtualized `ListBox` actually bound the number of realized containers at 1000 items?
**Geht.** Realized `ListBoxItem` count stayed at 2223 across the whole scroll range (top, 25%,
50%, 75%, bottom) with 1000 source items in the list:
```
realized containers @ top: 22
realized containers @ 25% scroll: 22
realized containers @ 50% scroll: 22
realized containers @ 75% scroll: 23
realized containers @ 100% scroll: 22
```
Matches the design doc's estimate (~19 visible + overscan ≈ 2225) almost exactly. The
`ListBox`/`VirtualizingStackPanel` combination genuinely recycles containers instead of building
one per item.
## Q2 — Does the existing ghost-drag `InputHitTest` model survive container recycling?
**Geht.** Scrolled from top to 60% (forcing recycling), then called `TopLevel.InputHitTest` at the
*same screen point* both times — exactly what `RowButtonAt`/`ListItemUnder` in
`TasksIslandView.axaml.cs` do live during a drag:
```
hit-test @ top, screen point (450,600): Task[8]
hit-test @ 60% scroll, same screen point: Task[577]
expected DataContext at that point (geometry): Task[577]
```
The hit-test result matches the geometrically-correct row after recycling, and correctly changed
(it did *not* keep returning the stale top-of-list row). The production pattern —
`topLevel.InputHitTest(pt)` then walk ancestors to the row `Button`/`ListBoxItem` — is
recycling-safe: it's a live geometric query against whatever is currently realized, with no
per-row state to go stale.
## Q3 — Variable row heights (68px vs 90110px): does the scrollbar/`Extent` estimate drift?
**Geht, mit einer Einschränkung.** Measured actual template heights in this harness: header
57px, simple task row 53px, complex (two-line + badges) row 90px. Hand-summing all 1000 rows by
their real measured height gives 58275px; `VirtualizingStackPanel`'s reported `Extent.Height` was
58228px — **0.1% error**. Avalonia's own extent bookkeeping for variable-height virtualized rows
is not the "gross estimate that drifts" risk the design doc worried about.
The real risk is a **hand-rolled** pixel↔index conversion. Scrolling to `index * 68px` (a naive
"assume every row is 68px" offset) while targeting logical row #700 actually landed on row
**#790** — a 90-row drift, because ~14% of the preceding rows were taller. Anything that
computes a scroll offset from `index * fixedRowHeight` (a natural shortcut for a "scroll to
selected row" or a custom auto-scroll speed curve) will misfire. The existing
`ScrollSelectedIntoView` in `TasksIslandView.axaml.cs` already avoids this trap — it finds the
realized control by `DataContext` and calls `BringIntoView()`, not index-times-height math. Phase
2b must keep using `ListBox.ScrollIntoView(item)` / `Control.BringIntoView()`, never index
arithmetic, for any "jump to row" behavior.
## Q4 — Is auto-scroll-while-dragging feasible with the existing central-pointer-handler model?
**Geht.** Auto-scroll doesn't exist today and has to be built, but the mechanism it needs — the
same UI-thread pointer-moved handler (or a `DispatcherTimer` it starts) mutating
`ScrollViewer.Offset` directly while a drag is in progress — was exercised directly: nudging
`Offset` by 400px in one synchronous step kept the realized-container count constant (22 before,
22 after) and an immediate subsequent `InputHitTest` at the same screen point resolved correctly
to the new row under the cursor (`Task[388]`), with no async delay or stale state. Since
`TasksIslandView`'s drag handlers already run on the UI thread via tunnelled pointer events, a
`DispatcherTimer` (or a direct `Offset` nudge inside `OnPointerMovedDrag` when the cursor is near
the list's top/bottom edge) has no threading obstacle. This still needs to be built in Phase 2b;
this spike only confirms the approach isn't blocked by virtualization/recycling.
## Q5 — Mixed item types (header rows vs. task rows) via a template selector, under virtualization
**Geht.** Used `ListBox.DataTemplates` with two `FuncDataTemplate<T>` entries keyed by type
(`HeaderRow` / `TaskRow`) instead of an explicit `IDataTemplate.Match` selector — Avalonia's
implicit per-type template lookup is the idiomatic equivalent and needs no selector class. Both
types showed up among the realized containers throughout scrolling
(`Q5 distinct realized DataContext types: HeaderRow, TaskRow`), confirming the design's flat
`Rows` (`HeaderRow | TaskRowViewModel`) collection will virtualize and template correctly.
## Recommendation for Phase 2b
**Geht — proceed**, with two carry-overs into the implementation:
1. Any "scroll to row" logic (selection scroll, auto-scroll speed/targeting) must go through
`ScrollIntoView`/`BringIntoView` against the realized control, never `index * rowHeight` math
(Q3).
2. Auto-scroll is new work, not a retrofit risk — build it as an `Offset` nudge from the existing
UI-thread drag handlers (Q4).
No evidence surfaced against the Phase 2 design as written. The `ITaskListFilter` risk (#3 in the
design doc) is unrelated to virtualization and out of this spike's scope.
+42 -1
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 `2f3f938` (2026-08-07), which finished the diff-viewer rework:
> Last verified against `fc9df7f` (2026-08-11), which added the conventional merge-commit default
> and the `MergeProgress` broadcast.
> 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.
@@ -82,6 +83,17 @@ gating `CanMergeAll`. The worker-side guard remains the actual correctness fix;
just avoids inviting a click the worker would reject. `CancelReviewAsync`'s catch now raises
`ErrorReported` (→ shell `FlashFooterError`) instead of swallowing the rejection silently.
## Merge commit message
`MergeAsync` takes a `commitMessage`, but **every caller passes it blank** and lets
`TaskMergeService.DefaultMergeMessage` build `CommitMessageBuilder.BuildMerge(task.CommitType,
list.Name, task.Title, task.Id)``fix(my-list): merge <title>` + `ClaudeDo-Task:` trailer. Same
`type(scope)` shape as the task's own worktree commits, so merges stay Conventional-Commits-clean.
The only non-blank caller is the user editing the field in the merge modal, which is prefilled from
`GetMergeTargets().DefaultCommitMessage` (the UI can't build it — it knows neither the commit type
nor the list name). Callers used to hand-roll `"Merge task: {title}"` / `"Merge {branch}"` /
`"Merge subtask"`; don't add a fourth.
## Post-merge verify gate
A list can set `ListConfigEntity.VerifyCommand` (List Settings modal → Verification).
@@ -110,11 +122,40 @@ but `ApproveAndMergeAsync` still runs the verify command (same per-repo gate, wo
`list.WorkingDir`) before the task may reach `Done`. Without that, the run that lands the most on
the target branch at once would be the one run nothing checks.
**The gate is what makes a merge look broken.** A verify command that builds and tests a solution
occupies the whole `MergeTask` hub call — measured at ~5m 46s on this repo. `TaskMergeService`
therefore broadcasts `MergeProgress(taskId, phase, elapsedSeconds)`: `PhaseMerging` before the
per-repo gate wait, `PhaseVerifying` when the verify starts and again on every
`ProgressReportInterval` tick (30 s, shared with the MCP progress reports), plus one
`WorkerLog` Info line so the footer strip shows it too. The merge modal renders that as a spinner
plus phase text; without it the only feedback was the disabled Submit button, which reads as a
dead app for minutes while the merge has in fact already landed.
**Serialization:** a process-wide `ConcurrentDictionary<string, SemaphoreSlim>` keyed by
`list.WorkingDir` serializes `MergeAsync` / `ContinueMergeAsync` (git ops + verify) per repo,
so a verify run can't be interrupted by a second merge landing in the same working dir
mid-build.
### Verify in the preview, not just post-merge
`TaskMergeService.PreviewAsync(taskId, targetBranch, runVerify, ct)` can additionally build/test a
clean `git merge-tree` result *before* anything is merged. It never touches the real working tree:
the tree `PreviewMergeAsync` would write is wrapped in a throwaway commit
(`GitService.CommitTreeAsync`, parent = the target branch's current tip) and checked out into a
detached-HEAD scratch worktree under the OS temp dir (`GitService.WorktreeAddDetachedAsync`), which
is always removed afterward (`finally`, non-cancellable cleanup). No verify command configured, or
`runVerify=false`, reproduces the pre-existing preview exactly — no delay, no scratch worktree.
Wired into `ExternalMcpService`: `preview_merge` (single task) always requests a verify run when the
list has a command configured; `preview_merge_set` only does when its `runVerify` parameter is
explicitly set (default `false`) — a set preview never starts N builds unasked. The Hub's
`PreviewMerge` (the UI's live mergeability indicator) always passes `runVerify: false`, since
running a build on every poll would be a bad regression; `MergePreviewDto` carries the
verify fields anyway so a future UI entry point can opt in. Result fields: `VerifyExitCode` (null =
not run; `-1` = timed out or failed to start; mirrors the post-merge gate's convention),
`VerifyDurationMs`, `VerifyOutputTail` (tail of output, only populated on non-zero exit, via the
same `TailOutput` helper the post-merge gate uses).
## `MergeCommit` and revert
`WorktreeEntity.MergeCommit` (nullable) is the SHA of the merge commit this worktree's branch
+15
View File
@@ -81,6 +81,21 @@ verifiziert und deshalb hier entfernt. Es bleiben die Entscheidungen, die daran
`dotnet test` invocation as the configured command) — only fast synthetic commands (`exit N`,
`ping` for timeout) were exercised.
## Offene Verifikation (2026-08-11, Task numbers in UI)
Slice 4 der Task-Numbers-Features (UI-Display) ist gemerged (commit 38af549); Build + Tests grün,
aber **nicht visuell verifiziert**:
- **Task Row Display:** `TaskRowViewModel.Number` (Zeile 39, TaskRowViewModel.cs) zeigt die
Nummer als `#<number>` dimmed vor dem Titel in der Row an (`TaskRowView.axaml` Zeile 45+). Prüfen:
offene Task in der Übersicht hat sichtbar `#<number>` vor dem Titel.
- **Detail Pane Header:** `DetailsIslandViewModel.TaskIdBadge` (Zeile 77, DetailsIslandViewModel.cs)
zeigt jetzt `#<number>` statt des alten GUID-Präfix. Prüfen: Task-Detail-Chip zeigt
`#<number>`.
- **Worker Log Messages:** Geschäftsereignisse in `TaskRunner` (Zeile 199+), `TaskMergeService`
(Zeile 174+), und `TaskResetService` (Zeile 84+) prefixen Task-Titel mit `#<number>`. Prüfen:
Footer Worker-Log zeigt Task-Messages wie „`#42 finished`" statt nur dem GUID.
## Offene Verifikation (2026-08-06, Fix-Batch aus der Sichtprüfung)
Fünf Findings der Sichtprüfung sind gefixt, Build + Tests grün, aber **noch nicht in der App
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,515 @@
# Feedback für langlaufende Operationen — Vorgaben für die Umsetzung
> **Dieses Dokument ist absichtlich kein Task-Skript.** Es enthält die Vorgaben pro Gruppe.
> Die konkreten Tasks schreibt der **Merge-Helper je Gruppe** beim Ausführen — er kennt dann
> den tatsächlichen Codestand. Was hier steht, ist bindend; was hier nicht steht, entscheidet
> er. Checkboxen stehen auf Paketebene, damit der Fortschritt sichtbar bleibt.
**Design:** `docs/superpowers/specs/2026-08-11-operation-feedback-design.md` — dort stehen die
Belege (Datei:Zeile) und die Begründungen. Dieses Dokument wiederholt sie nicht.
**Ziel:** Jede Operation, die länger als ~300 ms dauern kann, zeigt an, dass sie läuft, was sie
tut und wie lange sie schon läuft — über **einen** Mechanismus statt pro Fall neu gebaut.
---
## Ausführungsmodell
```
P0 (Fundament) ── muss ALLEIN und ZUERST landen
├── Gruppe A UI-Stille Merge-Helper 1
├── Gruppe B UI-Freeze Merge-Helper 2
└── Gruppe C Worker-Stille Merge-Helper 3
Gruppe D MCP-Stille Merge-Helper 4 ── unabhängig von P0, kann sofort starten
```
**Ein Merge-Helper pro Gruppe.** Nach P0 laufen A, B, C und D parallel. Jede Gruppe hält sich
strikt an ihr Datei-Eigentum — das ist die einzige Absicherung gegen gegenseitiges
Überschreiben, weil alle im gemeinsamen `main`-Checkout arbeiten.
### Datei-Eigentum (bindend)
| Gruppe | Besitzt exklusiv | Darf **nicht** anfassen |
|---|---|---|
| **P0** | `Ui/Services/OperationStatus.cs` (neu), `Ui/Views/Controls/OperationIndicator.axaml(.cs)` (neu), **beide `locales/*.json`**, `Ui/Services/WorkerClient.cs` (nur Timing-Hook) | alles andere |
| **A** | Island-VMs, Modal-VMs, deren AXAML, `Ui/Services/UpdateCheckService.cs` | `locales/*`, `WorkerClient`, `IWorkerClient`, Worker-Projekt |
| **B** | `Ui/ViewModels/Modals/DiffViewerViewModel.cs`, `Ui/Views/Controls/DiffTextView.axaml.cs` | `locales/*`, Island-VMs, Worker-Projekt |
| **C** | `Worker/Hub/HubBroadcaster.cs`, `Worker/Hub/WorkerHub.cs`, `Ui/Services/WorkerClient.cs`, `IWorkerClient.cs`, `StubWorkerClient.cs`, `Worker/Lifecycle/*Recovery.cs`, `Worker/Runner/WorktreeManager.cs`, `Worker/Worktrees/WorktreeMaintenanceService.cs` | `locales/*`, Island-VMs, `Worker/External/*` |
| **D** | `Worker/External/*McpTools.cs`, `Worker/External/ExternalMcpService.cs`, `Worker/Lifecycle/TaskMergeService.cs` (nur die Progress-Schleife) | `locales/*`, alles im Ui-Projekt |
**Locale-Regel:** Nur P0 schreibt in `en.json`/`de.json`. Braucht eine Gruppe doch einen Key,
den P0 nicht vorgesehen hat: als **letzte** Änderung des Pakets anhängen und im Merge-Helper
als bekannte Konfliktstelle behandeln. Nie mitten in der Gruppe.
---
## Vorgaben für den Merge-Helper selbst
Gilt für alle vier Gruppen:
- **Agent-Modell:** `sonnet` für Implementierer und Reviewer. Nie haiku, nie opus, nie Fable.
- **`maxTurns` explizit auf 200 setzen.** `model_presets` ist NULL, sonst bekommt ein
sonnet-Task 30 Turns und stirbt mit „exited with code 1 and no result".
- **`serializeOnFileOverlap` auf der Gruppenliste einschalten.** Innerhalb einer Gruppe
fassen mehrere Pakete dieselben Dateien an.
- **Bauen:** `dotnet build ClaudeDo.slnx` schlägt auf .NET 8 fehl. Einzelprojekte mit
`-c Release` bauen (ein laufender Worker sperrt `Debug`):
```
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
```
- **Testen — pro Gruppe die relevanten Projekte:**
```
A, B dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Localization.Tests/... -c Release (nur wenn Keys berührt)
C Ui.Tests + ClaudeDo.Worker.Tests (Worker.Tests enthält auch UiVm-Tests)
D dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
P0 Ui.Tests + Localization.Tests
```
Fällt ein Test innerhalb der Verify-Kette, erst die Suite **allein** laufen lassen — die
`Ui.Tests` haben eine bekannte reihenfolgen-abhängige Flakiness, die nichts mit dem Merge zu
tun hat.
- **Nie `git add -A`.** Immer explizit nach Pfad stagen und `git commit -- <pfade>` — der
`main`-Checkout ist von parallelen Sessions geteilt, ein blankes Commit fegt fremde WIP mit.
- **Nach dem Batch-Merge `main` selbst prüfen.** Approve baut und testet nicht. Neun grüne
Branches haben `main` schon zweimal zerlegt, davon einmal durch eine Test-Kollision, die ein
reiner src-Build nicht sieht. Also: Build **und** die betroffenen Testprojekte.
- **Vor Approve prüfen, ob der Branch überhaupt etwas enthält** (`changedFileCount`). Ein
blockierter Task wird sonst `Done` mit leerem Branch.
- **Keine EF-Migration** in diesem Vorhaben. Parallele Migrationen aus Geschwister-Branches
löschen sich beim SQLite-Table-Rebuild gegenseitig die Spalten, und die Tests bemerken es
nicht (`EnsureCreated`).
- **Keine Tests, die die echte `claude`-CLI starten.**
- **Visuelle Prüfung ist nicht durch Tests ersetzbar.** Jedes Paket in A und B endet mit einer
offenen visuellen Prüfung für den Nutzer. Nie behaupten, die UI funktioniere.
---
## P0 — Fundament
- [x] **P0-1 — `OperationStatus` + `OperationIndicator` + Locale-Keys**
- [x] **P0-2 — Timing-Hook für die Messung**
- [x] **P0-3 — Regel in den CLAUDE.md-Dateien**
> **Stand 2026-08-17.** P0, A (außer A5), B, C1 und D sind gemergt; der visuelle Pass über A und B
> ist durch, ohne Fund. Nachtrag zu P0-2: der Timing-Sink liegt seit dem Release-Vorlauf hinter
> `CLAUDEDO_OP_TIMING=1` und ist **standardmäßig aus** — die 63 `InvokeTimedAsync`-Call-Sites
> bleiben bewusst stehen. Offen: C2C5.
**Vorbedingung:** keine. Muss allein landen, bevor A/B/C starten.
### Der Vertrag (bindend — 19 Pakete hängen daran)
`src/ClaudeDo.Ui/Services/OperationStatus.cs`, eine `ObservableObject`-Klasse:
| Member | Verhalten |
|---|---|
| `bool IsRunning` | **sofort** `true` bei `Begin`. Treibt `CanExecute`, verhindert Doppelklick |
| `bool ShowIndicator` | erst nach **300 ms** `true`. Kein Flackern bei schnellen Calls |
| `string? Label` | lokalisierter Text |
| `string Elapsed` | `mm:ss`, **lokal getickt** |
| `bool IsStalled` | `true` nach **60 s ohne `Report`** |
| `IDisposable Begin(string label)` | startet; `Dispose` beendet **auch im Exception-Fall** |
| `void Report(string label)` | überschreibt `Label` mid-flight, setzt die Stall-Uhr zurück |
**Nachbesserung 1 (bindend):** `IsStalled` bemisst sich an der Zeit **seit dem letzten
`Report`**, nicht an der Gesamtdauer. Ein regulär mehrminütiges Verify-Gate darf sich nicht
selbst als hängend melden. Eine Operation ohne jeden `Report` gilt nach 60 s als stalled — das
ist genau der Fall, der heute wie ein Absturz aussieht.
Benutzung im ViewModel:
```csharp
using var op = Approve.Begin(Loc.T("ops.merge.merging"));
var result = await _worker.ApproveReviewAsync(...);
```
### Weitere Vorgaben
- **Zeitquelle injizierbar** über `TimeProvider` (in .NET 8 vorhanden). **Kein statischer
`DispatcherTimer`** — geteilter statischer State ist die Ursache der reihenfolgen-abhängigen
Flakiness in den `Ui.Tests`. Timer-Callbacks kommen vom Threadpool und müssen auf den
Dispatcher gepostet werden.
- **Mehrere `OperationStatus` pro VM sind erwünscht.** `WorktreesOverviewModalViewModel`
braucht getrennte für Refresh, Cleanup und Merge — sonst blockiert ein laufender Refresh die
Cleanup-Anzeige.
- **`OperationStatus` transportiert keine Fehler.** Fehlerbehandlung bleibt unverändert über
`ShowErrorAsync` / `ErrorReported` / `FlashFooterError`.
- **`OperationIndicator`** nutzt `Ellipse.spinner` aus `Design/IslandStyles.axaml` (14×14,
Accent, 0.9 s Rotation) plus Label und Elapsed. Ohne dieses Control wird die
Spinner-StackPanel aus `MergeModalView.axaml` acht Mal von Hand nachgebaut. Werte aus
`Tokens.axaml` verwenden, keine Inline-Zahlen.
- **Locale-Keys:** neuer Top-Level-Namespace `ops` in `en.json` und `de.json`, Parität wird von
`Localization.Tests` erzwungen. P0 legt die Keys für **A und C** vorab an — abgeleitet aus den
Paketlisten unten.
**Nachbesserung 3 (bindend):** **Gruppe D braucht keine Locale-Keys.**
MCP-Progress-Meldungen gehen an Agenten, nicht an den Nutzer, und bleiben englische
Klartext-Strings.
### P0-2: Messung an genau einer Stelle
Zwei Hooks, nicht zwanzig:
1. In `WorkerClient` jeden Hub-Invoke mit Dauer loggen.
2. Im DB-Pfad der Islands dasselbe.
Ein Tag Nutzung liefert eine sortierte Liste echter Ausreißer. **A5 und C werten sie aus**,
statt zu raten. Bild 2 und 4 brauchen keine Messung.
Faustregel für den Zweifelsfall: instrumentiert wird, was git aufruft, Netz nutzt, einen
Prozess startet, oder O(n) über unbegrenzt viele DB-Zeilen läuft. Einzelzeilen-Reads nicht.
### Definition of Done für P0
`OperationStatus` hat Unit-Tests mit einem Fake-`TimeProvider` für: 300-ms-Grace, 60-s-Stall
**ab letztem Report**, Elapsed-Formatierung, `Dispose` nach Exception, `Report` überschreibt
Label und setzt die Stall-Uhr zurück. `Localization.Tests` grün. Kein VM ist umgestellt — P0
liefert nur das Werkzeug.
### Task-Entwürfe (grob)
**P0-1 · `OperationStatus` + `OperationIndicator` + `ops`-Locale-Keys**
Baue das Primitive nach dem Vertrag oben, dazu das Anzeige-Control und den neuen
Locale-Namespace `ops` in beiden Sprachdateien. Keys für Gruppe A und C vorab anlegen, für D
keine. Zeitquelle über `TimeProvider`, kein statischer Timer.
*Dateien:* `Ui/Services/OperationStatus.cs` (neu), `Ui/Views/Controls/OperationIndicator.axaml(.cs)` (neu), `Localization/locales/{en,de}.json`, `tests/ClaudeDo.Ui.Tests/`
*Fertig wenn:* Unit-Tests für Grace, Stall-ab-Report, Elapsed, Dispose-nach-Exception, Report-Reset. Localization.Tests grün. Kein ViewModel angefasst.
**P0-2 · Timing-Hook für die Messung**
Zwei Hooks: Dauer jedes Hub-Invokes in `WorkerClient`, Dauer des DB-Pfads der Islands. Ziel
sind auswertbare Zahlen, keine Anzeige. Ausgabe so, dass sie nach einem Tag Nutzung sortierbar
ist.
*Dateien:* `Ui/Services/WorkerClient.cs`, der gemeinsame DB-Zugriffspfad der Island-VMs
*Fertig wenn:* Beide Hooks aktiv, Overhead vernachlässigbar, das Format ist dokumentiert.
**P0-3 · Feedback-Regel in den CLAUDE.md-Dateien**
`src/ClaudeDo.Ui/CLAUDE.md`: jeder `IWorkerClient`-Call in einem `[RelayCommand]` läuft durch
eine `OperationStatus`, Anzeige über `OperationIndicator`. `src/ClaudeDo.Worker/CLAUDE.md`: ein
MCP-Tool, das über ~5 s laufen kann, reportet Progress.
*Fertig wenn:* Beide Regeln stehen, jeweils mit einem Satz Begründung.
---
## Gruppe A — Bild 1: UI-Stille
- [x] **A1 — Detail-Pane** ⚠️ *der gemeldete Fall*
- [x] **A2 — WorktreesOverview**
- [x] **A3 — Settings-Tabs** — ohne OnlineInbox-SignIn, siehe Nachzügler unten
- [x] **A4 — Reports und Planning-UI** — ohne die beiden Planning-Calls im `DiffViewerViewModel`
- [x] ~~**A5 — Island-DB-Pfade nach Messdaten**~~ — **abgesagt** (2026-08-13): kein DB-Pfad über
500 ms (höchster p95 372 ms), die Ausreißer saßen alle auf der Hub-Seite. Wie vorgesehen
geschlossen, ohne Code.
**Nachzügler (kein Task):** drei Locale-Keys aus P0 sind ungenutzt geblieben —
`ops.onlineInbox.signingIn` (A3), `ops.planning.buildingIntegrationBranch` und
`ops.planning.loadingAggregate` (A4). Die beiden Planning-Calls liegen in
`DiffViewerViewModel.cs`, einer Datei aus Gruppe B, die A4 nicht anfassen durfte.
**Vorbedingung:** P0 gemergt.
### Vorgaben
- **A1 zuerst.** `DetailsIslandViewModel.ApproveReviewAsync` ist der gemeldete Schmerz. Dazu im
selben Paket: `SubmitForReviewAsync`, `RejectReviewAsync`, `ParkReviewAsync` und
`MergeSectionViewModel.PreviewMergeAsync`.
- **A1 abonniert `IWorkerClient.MergeProgressEvent`**, um das Label mid-flight von „merging" auf
„verifying" zu schärfen. Das Event existiert seit `cad0582`.
**Nachbesserung 2 (bindend):** A verlässt sich darauf, dass dieses Event **bestehen bleibt**.
C1 baut den generischen Kanal darunter, muss `MergeProgressEvent` aber als dünnen Forwarder
erhalten. Andernfalls müsste C `DetailsIslandViewModel` ändern — eine Datei aus Gruppe A —
und die Parallelität wäre zerstört. **A darf keinen anderen Kanal verwenden.**
- **Abo nur für die Dauer des Calls.** Der Worker broadcastet an alle Clients; ein dauerhaft
registriertes VM bekommt Events für fremde Tasks. `MergeModalViewModel` macht es richtig
vor: `+=` im `try`, `-=` im `finally`, plus `if (taskId != TaskId) return;`.
- **A2:** `IsBusy` existiert dort schon, schaltet aber nur `IsEnabled`. Die Anzeige fehlt
komplett. Betrifft Refresh, Cleanup, Reset, ForceRemove und Batch-Merge — Batch-Merge
zusätzlich mit Zeilen-Status, weil dort N Tasks sequenziell durchlaufen.
- **A3:** SessionSkill-Install ist ein `git clone` — der offensichtlichste Kandidat. Dazu
Update/Restore-Defaults, OnlineInbox-SignIn, RepoImport-Scan, Update-Check.
- **A4:** GenerateWeekReport, RunDailyPrepNow, BuildPlanningIntegrationBranch,
GetPlanningAggregate, Finalize/QueuePlanningSubtasks. `DiffViewerViewModel.IsLoadingCombined`
existiert bereits und bleibt — nicht doppeln.
- **A5 erst nach Auswertung von P0-2.** Nur Stellen umstellen, die die Messung als Ausreißer
zeigt. Kein Umstellen auf Verdacht.
- **`DetailsIslandViewModel` ist groß.** Nicht umstrukturieren, nur die Commands anfassen.
- **Bereits saubere Flächen nicht anfassen:** `MergeModalViewModel`,
`ConflictResolverViewModel`, `DiffViewerViewModel.IsLoadingCombined`,
`UsageMonitorModalViewModel`.
### Definition of Done pro Paket
Jeder umgestellte Command: `IsRunning` während des Laufs gesetzt, `CanExecute` gesperrt,
Zustand nach einer Exception zurückgesetzt — als Test in `ClaudeDo.Ui.Tests`. Kein
handgebauter Spinner, immer `OperationIndicator`. **Visuelle Prüfung offen und explizit
benannt** (Grace-Periode und Layout kann kein Test bestätigen).
### Task-Entwürfe (grob)
**A1 · Detail-Pane: Approve, Submit, Reject, Park, Preview** ⚠️ *der gemeldete Fall*
Fünf Commands im Detail-Pane bekommen je eine `OperationStatus` und einen `OperationIndicator`
neben dem auslösenden Button. Approve abonniert zusätzlich `MergeProgressEvent` **nur für die
Dauer des Calls** und filtert auf die eigene TaskId, um das Label von „merging" auf „verifying"
zu schärfen. `DetailsIslandViewModel` nicht umstrukturieren.
*Dateien:* `Ui/ViewModels/Islands/DetailsIslandViewModel.cs`, `MergeSectionViewModel.cs`, `Ui/Views/Islands/Detail/*`
*Fertig wenn:* Approve zeigt während des Verify-Gates Phase und Elapsed, Button ist gesperrt, Zustand nach Fehler zurückgesetzt. Visuelle Prüfung offen.
**A2 · WorktreesOverview: Anzeige für alle fünf Aktionen**
`IsBusy` existiert, schaltet aber nur `IsEnabled`. Getrennte `OperationStatus` für Refresh,
Cleanup, Reset, ForceRemove und Batch-Merge — der Batch-Merge zusätzlich mit Zeilen-Status, weil
er N Tasks sequenziell durchläuft.
*Dateien:* `Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs`, `Ui/Views/Modals/WorktreesOverviewModalView.axaml`
*Fertig wenn:* Jede der fünf Aktionen zeigt sichtbar, dass sie läuft; ein laufender Refresh blockiert die Cleanup-Anzeige nicht. Visuelle Prüfung offen.
**A3 · Settings-Tabs: Skill-Install, Restore-Defaults, OnlineInbox, RepoImport, Update-Check**
Fünf Flächen, bei denen `IsBusy` heute nur den Button sperrt. Der Skill-Install ist ein
`git clone` und der offensichtlichste Kandidat.
*Dateien:* `Ui/ViewModels/Modals/Settings/*`, `Ui/ViewModels/Modals/RepoImportModalViewModel.cs`, `Ui/Services/UpdateCheckService.cs`, `Ui/Views/Modals/SettingsModalView.axaml`
*Fertig wenn:* Alle fünf zeigen Aktivität; der Skill-Install zeigt zusätzlich, dass geklont wird. Visuelle Prüfung offen.
**A4 · Reports und Planning-UI**
GenerateWeekReport, RunDailyPrepNow, BuildPlanningIntegrationBranch, GetPlanningAggregate,
Finalize/QueuePlanningSubtasks. `DiffViewerViewModel.IsLoadingCombined` existiert bereits und
bleibt unangetastet.
*Dateien:* `Ui/ViewModels/Modals/WeeklyReportModalViewModel.cs`, Planning-Pfade in `DetailsIslandViewModel`/`TasksIslandViewModel`, zugehörige AXAML
*Fertig wenn:* Report-Generierung und Planning-Integration zeigen Aktivität; kein Doppel-Indikator im Combined-Diff. Visuelle Prüfung offen.
**A5 · Island-DB-Pfade nach Messdaten**
**Erst die Zahlen aus P0-2 auswerten**, dann nur die Ausreißer umstellen. Kandidaten:
`ClearCompleted`, Drag-Reorder über viele Zeilen, Laden großer Listen. Nichts auf Verdacht.
*Dateien:* nach Messergebnis
*Fertig wenn:* Die Auswertung ist im Task dokumentiert, und jede umgestellte Stelle ist durch eine Messung begründet. Wenn nichts über der Schwelle liegt: Task mit Begründung schließen, nichts bauen.
---
## Gruppe B — Bild 2: UI-Freeze
- [x] **B1 — `UnifiedDiffParser.Parse` auslagern**
- [x] **B2 — `DiffAlignment.Build` Grenze**
- [x] **B3 — Guard-Test gegen Dispatcher-Blockade**
**Vorbedingung:** P0 gemergt (für den Indikator in B1).
### Vorgaben
- **Reihenfolge ist zwingend: erst auslagern, dann anzeigen.** Ein Spinner auf einem
blockierten UI-Thread wird nicht gezeichnet. B1 verschiebt `UnifiedDiffParser.Parse` nach
`Task.Run` und setzt danach den Indikator.
- **`UnifiedDiffParser` ist statisch und rein** — thread-safe, `Task.Run` ist unkritisch. Das
Zurückschreiben der Ergebnisse in Observable-Collections muss auf dem UI-Thread passieren.
- **B2 braucht eine Entscheidung, die der Merge-Helper trifft:** `DiffAlignment.Build` läuft in
`DiffTextView.axaml.cs` — in einem Control, nicht in einem VM. Entweder Grenze („ab N Zeilen
auslagern") oder inkrementeller Aufbau. Die Wahl gehört ins Paket, nicht hierher; die
Vorgabe ist nur: **eine sichtbare Grenze definieren, keine unbegrenzte Synchron-Arbeit.**
- **AvaloniaEdit-Fallen** (belegt, nicht neu ausprobieren): `this.TryGetResource` in einem
Control findet Brushes aus `Tokens.axaml` **nie** und scheitert lautlos; Brushes und Typeface
nicht im Konstruktor auflösen. `ScrollToVerticalOffset` ist in v12 ein No-op — schreiben über
`ScrollViewer.Offset`, lesen über `TextView.ScrollOffsetChanged`.
- Gemeinsames Editor-Boilerplate gehört in `Views/Controls/DiffEditorSetup.cs`, nicht ein
drittes Mal kopiert.
### Definition of Done
B3 ist der einzige Test im Vorhaben, der Blockade prüft: großes Diff-Fixture, Nachweis, dass
der Dispatcher weiter Nachrichten verarbeitet. Visuelle Prüfung offen (Verhalten bei großem
Diff, Indikator während des Parsens).
### Task-Entwürfe (grob)
**B1 · `UnifiedDiffParser.Parse` vom UI-Thread nehmen**
Beide Aufrufstellen im `DiffViewerViewModel` nach `Task.Run` verschieben, danach den
`OperationIndicator` setzen. Reihenfolge ist zwingend: erst auslagern, dann anzeigen — ein
Spinner auf einem blockierten Dispatcher wird nicht gezeichnet. Das Zurückschreiben in
Observable-Collections muss auf dem UI-Thread passieren.
*Dateien:* `Ui/ViewModels/Modals/DiffViewerViewModel.cs`
*Fertig wenn:* Kein synchroner Parse mehr im VM, Indikator während des Parsens sichtbar. Visuelle Prüfung mit einem großen Diff offen.
**B2 · `DiffAlignment.Build` — Grenze gegen unbegrenzte Synchron-Arbeit**
`Build` läuft in einem Control, nicht in einem VM. Der ausführende Agent entscheidet zwischen
Auslagern ab einer Zeilenzahl und inkrementellem Aufbau — die Vorgabe ist nur: **eine
sichtbare, begründete Grenze**. AvaloniaEdit-Fallen aus dem Vorgaben-Abschnitt beachten
(`TryGetResource` scheitert lautlos, `ScrollToVerticalOffset` ist ein No-op).
*Dateien:* `Ui/Views/Controls/DiffTextView.axaml.cs`, gemeinsames Boilerplate nach `DiffEditorSetup.cs`
*Fertig wenn:* Die gewählte Grenze ist im Code kommentiert und begründet; große Diffs blockieren nicht mehr unbegrenzt. Visuelle Prüfung offen.
**B3 · Guard-Test: großer Diff blockiert den Dispatcher nicht**
Headless-Test mit großem Diff-Fixture, der nachweist, dass der Dispatcher während Parse und
Build weiter Nachrichten verarbeitet. Der einzige Blockade-Test im Vorhaben — soll auch
zukünftige Regressionen fangen.
*Dateien:* `tests/ClaudeDo.Ui.Tests/`, Fixture im Testprojekt
*Fertig wenn:* Der Test fällt gegen den Stand **vor** B1/B2 und ist danach grün.
---
## Gruppe C — Bild 3: Worker-Stille
- [x] **C1 — generischer `OperationProgress`-Kanal**
- [ ] **C2 — Startup-Recovery sichtbar**
- [ ] **C3 — Worktree-Anlage sichtbar**
- [ ] **C4 — Rebase-after-Merge und WorktreeMaintenance**
- [ ] **C5 — periodische Dienste**
> **Einzige offene Gruppe.** C2C5 sind unblockiert (C1 ist gemergt). Woran man den Reststand
> erkennt: die vier Keys `ops.worker.startupRecovery`, `.creatingWorktree`, `.rebasingAfterMerge`
> und `.maintainingWorktrees` liegen ungenutzt in beiden Sprachdateien.
**Vorbedingung:** P0 gemergt. Die Abhängigkeit zur Parallelsession ist mit `cad0582`
aufgelöst.
### Vorgaben
- **Der Kanal:**
```
OperationProgress(string opKey, string phase, int current, int total)
```
`opKey` = TaskId bei task-gebundenen Operationen, sonst ein stabiler String
(`"worktree-cleanup"`, `"startup-recovery"`, `"planning-integration:<taskId>"`).
- **Kein Elapsed auf der Leitung.** Das UI tickt lokal. Die heutige Implementierung lässt den
Worker alle 30 s ticken — damit zeigt sie die ersten 30 Sekunden nur „merging". C1 stellt das
um.
- **`MergeProgressEvent` bleibt als Forwarder** (siehe Nachbesserung 2 unter Gruppe A). Vier
Zeilen. Entfernen erst, wenn A und C beide gemergt sind — nicht in dieser Gruppe.
- **`current`/`total` wird als Text gezeigt, nie als Prozentbalken.** Die meisten Operationen
haben kein sinnvolles Total.
- **C3 zuerst prüfen, nicht bauen:**
`docs/superpowers/specs/2026-08-07-ui-reaktivitaet-und-listen-performance-design.md` führt
`WorktreeManager.cs:103` als DB-Write-ohne-Broadcast. Ob das inzwischen geschlossen ist,
gehört geprüft, bevor es doppelt behoben wird.
- **C5 ist der wackeligste Punkt im Design.** Periodische Dienste (Usage, OnlineSync, Prime,
Queue) dürfen **nur Aktivität und Fehler** melden, keinen Tick-Strom. Wenn der Merge-Helper
beim Umsetzen zum Schluss kommt, dass C5 nur Rauschen erzeugt: **weglassen und begründen**,
statt es durchzuziehen.
- **Test-Fakes wachsen mit.** Ein neues Event auf `IWorkerClient`/`WorkerHub` bricht
handgeschriebene Fakes in **beiden** Testprojekten — `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`
und die UiVm-Tests unter `tests/ClaudeDo.Worker.Tests/`.
- **Broadcast-Sparsamkeit:** vor einem „fehlenden Broadcast" immer den Aufrufer prüfen. Im
08-07-Spec war ein gemeldetes Loch in Wahrheit schon vom Aufrufer abgedeckt, und der Fix
wäre ein Duplikat gewesen.
### Definition of Done
Progress-Callbacks in `ClaudeDo.Worker.Tests` mit einem Fake gezählt — keine echten Timeouts,
keine echte CLI. UI-Seite: die Anzeige beim Worker-Start ersetzt „reconnecting" durch die
laufende Recovery-Phase. Visuelle Prüfung offen.
### Task-Entwürfe (grob)
**C1 · Generischer `OperationProgress`-Kanal, `MergeProgress` darauf umstellen**
Neues Hub-Event `OperationProgress(opKey, phase, current, total)`, ohne Elapsed auf der Leitung.
Der Merge-Pfad sendet darüber. **`MergeProgressEvent` bleibt als vierzeiliger Forwarder** —
Gruppe A hängt daran und darf nicht angefasst werden. Fakes in beiden Testprojekten mitziehen.
*Dateien:* `Worker/Hub/HubBroadcaster.cs`, `Worker/Hub/WorkerHub.cs`, `Ui/Services/WorkerClient.cs`, `IWorkerClient.cs`, `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`, UiVm-Fakes in `Worker.Tests`
*Fertig wenn:* Merge-Phasen laufen über den neuen Kanal, das UI tickt Elapsed lokal, `MergeProgressEvent` funktioniert unverändert weiter, beide Testprojekte grün.
**C2 · Worker-Startup-Recovery sichtbar machen**
Die sechs Recovery-Services beim Worker-Start melden Phase und `i/n` über den Kanal
(`opKey = "startup-recovery"`). Das UI ersetzt „reconnecting" durch die laufende Phase.
*Dateien:* `Worker/Lifecycle/*Recovery.cs`, Anzeige im `IslandsShellViewModel`-Umfeld
*Fertig wenn:* Beim Worker-Start ist sichtbar, welche Recovery läuft. Visuelle Prüfung offen.
**C3 · Worktree-Anlage beim Task-Start sichtbar machen**
**Zuerst prüfen**, ob der im 08-07-Spec gemeldete Broadcast-Gap in `WorktreeManager` inzwischen
geschlossen ist — nicht doppelt beheben. Danach die stille Lücke zwischen `Queued` und erster
Ausgabe an der Task-Zeile sichtbar machen.
*Dateien:* `Worker/Runner/WorktreeManager.cs`, Task-Row-Anzeige
*Fertig wenn:* Das Prüfergebnis steht im Task; die Anlage-Phase ist an der Zeile sichtbar. Visuelle Prüfung offen.
**C4 · Rebase-after-Merge und WorktreeMaintenance in den Kanal**
`RebaseOthersAfterMergeAsync` läuft heute innerhalb des Merge-Calls **nach** dem
Phasen-Broadcast und ist damit vollständig unsichtbar. Dazu der Hintergrunddienst über alle
Worktrees, mit `i/n`.
*Dateien:* `Worker/Lifecycle/TaskMergeService.cs` (Rebase-Abschnitt), `Worker/Worktrees/WorktreeMaintenanceService.cs`
*Fertig wenn:* Beide melden Fortschritt; ein Merge zeigt nach dem Verify-Gate die Rebase-Phase statt Stille.
**C5 · Periodische Dienste — optional, Abbruch erlaubt**
Usage, OnlineSync, Prime, Queue sollen **nur Aktivität und Fehler** melden, keinen Tick-Strom.
Der wackeligste Punkt im Design: kommt der ausführende Agent zum Schluss, dass das nur
Anzeige-Rauschen erzeugt, **weglassen und begründen** statt durchziehen.
*Dateien:* `Worker/Usage/`, `Worker/Online/`, `Worker/Prime/`, `Worker/Queue/`
*Fertig wenn:* Entweder sparsame Meldungen ohne Rauschen — oder eine begründete Absage im Task.
---
## Gruppe D — Bild 4: MCP-Stille
- [x] **D1 — `ProgressReporter` extrahieren**
- [x] **D2 — die `batch_*`-Tools** — es sind **sieben**, nicht acht (die Achterzahl unten war ein
Zählfehler beim Schreiben des Plans)
- [x] **D3 — Worktree- und Diff-Tools** — drei davon; `batch_cleanup_task_worktrees` lief über D2
- [x] **D4 — Rest und Doku-Regel** — fand zwei echte Lücken, die D1D3 offen gelassen hatten:
`continue_merge` reichte sein Progress-Token nicht ins Post-Merge-Verify-Gate, und der
Unit-Merge-Drain im `PlanningMergeOrchestrator` verlor es nach dem ersten Tick. Dazu
`list_worktrees`.
**Vorbedingung:** keine. Kann sofort starten, parallel zu P0.
### Vorgaben
- **Warum das zählt:** ohne Progress bricht der MCP-Client bei Stille nach 300 s ab, während
der Worker weiterarbeitet. Das ist die dokumentierte Ursache dafür, dass ein Merge zwar
durchläuft, den Task aber nie auf `Done` bringt und Abhängige dauerhaft blockiert.
- **D1:** `TaskMergeService.RunReportingProgressAsync` ist die einzige existierende
Progress-Schleife. Als eigenständige Klasse extrahieren, plus ein Overload für
Element-Fortschritt (`i/n`). Nicht kopieren — extrahieren, damit es eine Implementierung
bleibt.
- **`TaskMergeService` ist die einzige Datei, die D mit C teilt.** D fasst dort ausschließlich
die Progress-Schleife an. Wenn beide Gruppen gleichzeitig laufen, ist das die Stelle, die der
Merge-Helper im Auge behalten muss.
- **Keine Locale-Keys** (Nachbesserung 3). Englische Klartext-Strings.
- **Zielumfang:** die 7 `batch_*`-Tools mit `i/n`, dann `cleanup_task_worktree`,
`batch_cleanup_task_worktrees`, `get_task_diff`, `preview_merge_set`. Bereits versorgt und
nicht anzufassen: die 5 Tools in `ExternalMcpService` mit `IProgress`, `TaskWaitMcpTools`,
`merge_task`/`review_task`.
- **D4 schreibt die Regel ins Worker-`CLAUDE.md`:** ein MCP-Tool, das über ~5 s laufen kann,
reportet Progress. Ohne die Regel wiederholt sich das Muster beim nächsten Tool.
### Definition of Done
Für jedes umgestellte Tool ein Test in `ClaudeDo.Worker.Tests`, der mit einem Fake-`IProgress`
zählt, dass Meldungen kommen — bei `batch_*` mindestens eine pro Element. Keine visuelle
Prüfung nötig, diese Gruppe hat keine UI.
### Task-Entwürfe (grob)
**D1 · `ProgressReporter` extrahieren**
`TaskMergeService.RunReportingProgressAsync` als eigenständige Klasse herauslösen, plus einen
Overload für Element-Fortschritt (`i/n`). **Extrahieren, nicht kopieren** — es soll eine
Implementierung bleiben. Dies ist die einzige Datei, die D mit C teilt; nur die Progress-Schleife
anfassen.
*Dateien:* `Worker/Lifecycle/TaskMergeService.cs`, neue Klasse im Worker-Projekt
*Fertig wenn:* Der Merge-Pfad nutzt die extrahierte Klasse, Verhalten unverändert, bestehende Merge-Tests grün.
**D2 · Die sieben `batch_*`-Tools mit Element-Fortschritt**
Jedes `batch_*`-Tool reportet pro verarbeitetem Element. Grund: ohne Meldung bricht der
MCP-Client nach 300 s Stille ab, während der Worker weiterläuft — genau der Mechanismus, der
einen Merge durchlaufen lässt, den Task aber nie auf `Done` bringt.
*Dateien:* `Worker/External/BatchMcpTools.cs`
*Fertig wenn:* Pro Tool ein Test mit Fake-`IProgress`, der mindestens eine Meldung je Element zählt. Englische Klartext-Strings, keine Locale-Keys.
**D3 · Worktree- und Diff-Tools mit Progress**
`cleanup_task_worktree`, `batch_cleanup_task_worktrees`, `get_task_diff`, `preview_merge_set`.
Bereits versorgt und **nicht** anzufassen: die fünf Tools mit `IProgress` in
`ExternalMcpService`, `TaskWaitMcpTools`, `merge_task`, `review_task`.
*Dateien:* die betroffenen `Worker/External/*McpTools.cs`, `ExternalMcpService.cs`
*Fertig wenn:* Jedes der vier Tools meldet Fortschritt, mit Test.
**D4 · Restliche Long-Runner und die Regel im Worker-`CLAUDE.md`**
Verbleibende Tools durchgehen, die über ~5 s laufen können, und die Regel festschreiben: ein
MCP-Tool über ~5 s reportet Progress. Ohne die Regel wiederholt sich das Muster beim nächsten
Tool.
*Dateien:* restliche `Worker/External/*McpTools.cs`, `src/ClaudeDo.Worker/CLAUDE.md`
*Fertig wenn:* Die Regel steht mit Begründung; die durchgegangenen Tools sind im Task aufgelistet — auch die, die bewusst nichts bekommen.
---
## Was nach allen vier Gruppen offen bleibt
- ~~**Visuelle Prüfung** für alle Pakete in A und B~~ — **am 2026-08-17 durch**, ohne Fund
(Worktrees-Übersicht, Settings/Skill-Install, WeeklyReport, großer Diff, Detail-Pane-Approve).
- ~~**Auswertung von P0-2**~~ — am 2026-08-13 ausgewertet, siehe A5.
- **`MergeProgressEvent`-Forwarder entfernen**, sobald A und C beide gemergt sind. Ein
Aufräum-Task, kein Paket.
- **Bewusst nicht gebaut:** Cancel, Footer-Anzeige für laufende Operationen, Prozentbalken,
Änderungen am Installer (der hat bereits eine vollständige Progress-Pipeline).
@@ -0,0 +1,262 @@
# Findings-Store — Design
Stand: 2026-08-10.
Persistenter, projektgebundener Speicher für **Fallen und Invarianten**, den Agents im
Moment des Schmerzes selbst befüllen und in späteren Sessions vor dem Erkunden lesen.
Ziel ist Token-Ersparnis durch verhinderte Fehlläufe.
---
## 1. Ausgangslage
Auslöser war die Frage, ob ein Code-Knowledge-Graph (Graphify-Bauart) das
Wiedererkunden in jeder Session einspart. Die Messung sagt: nein, nicht in dieser Form.
### Die Zahlen (aus `docs/usage-optimization.md`, 2026-08-05)
- Kontext-Resend = **99,3 %** des Rohverbrauchs. Jeder Kontext-Token wird im Schnitt
**~425× nachberechnet**. Das gilt symmetrisch: für gespartes *und* für geladenes Wissen.
- **Explore-note ersetzt Exploration:** Note 4,6k tok in Turn 5 eines 60-Turn-Runs
→ 4,6k × 55 ≈ **250k**. Ersparte Exploration (~8k tok Findings ab Turn 10) ≈ **400k**.
Netto ~150k, Faktor 1,6. Wird die Note geladen, ohne gebraucht zu werden: **250k**.
- **Ein verhinderter Retry:** ~11 Mio Token. **27 % der Tasks brauchten einen Retry.**
Faktor 27 zwischen den beiden Klassen. Deshalb ist der Inhalt dieses Stores **nicht**
„Architektur-Map, damit ich nicht neu erkunden muss" (400k-Klasse), sondern „Agent läuft
in die bekannte Falle und der Run ist Müll" (11-Mio-Klasse).
### Was heute schon da ist
| Ebene | Größe | Ladeverhalten |
|---|---:|---|
| `CLAUDE.md` (root) | 6,4 KB ≈ 1,6k tok | immer |
| `src/*/CLAUDE.md` (6×) | 1,514 KB | beim Arbeiten im Verzeichnis |
| `docs/explore-notes/` (6 Notes) | 1118 KB, 92 KB gesamt | on demand, **ganze Datei** |
| Auto-Memory (`~/.claude/projects/.../memory/`) | 55 Dateien, 397 KB | Index immer (nur interaktiv) |
Die Sektionen der Notes sind bereits 1236 Zeilen (~150450 tok) — also schon
knotengroß. Und `conpty-sessions.md` markiert Fallen bereits konventionell als
`## ⚠️ Gotcha: …` (5 Stück). Die anderen Notes tragen dieselbe Art Wissen unmarkiert im
Fließtext. **Das Wissen ist zu großen Teilen schon geschrieben, nur nicht einsammelbar.**
---
## 2. Verworfene Alternativen
- **Code-Knowledge-Graph (Graphify o. ä.).** Ein tree-sitter-Graph liefert Call-Kanten —
genau die Klasse, die Grep exakt und billig liefert (Grep = 58 % des Kontexts; Read =
6068 %, das Problem ist zu viel *lesen*, nicht Finden). Der wertvolle Inhalt ist
semantisch und nicht ableitbar („Approve = merge the whole unit", „no directory arg may
end in a separator"). Dazu: Build-/Staleness-Pipeline über N parallele Worktrees und
Merge-Konflikte auf einem Graph-Artefakt im Repo. AXAML wird ohnehin nicht geparst.
- **DB-Tabelle + UI-Editor.** Kuration passiert in VSCode, damit entfällt der einzige
Vorteil. Agent-Zugriff bräuchte ein Lese-Tool statt `Read`; Agents könnten veraltete
Einträge nicht selbst korrigieren.
- **Hook-Injektion der Findings in den System-Prompt.** Früh und groß in den Prefix ist
das teuerste Muster überhaupt (Befund 5), auch wenn nichts davon gebraucht wird.
- **Post-Merge-Trigger.** Ursprünglicher Entwurf. Hinfällig, weil der Aufrufer des Tools
den Kontext bereits hat — siehe §4.
- **Retro-Distill aus alten Transkripten.** Zurückgestellt, siehe §8.
---
## 3. Speicher
```
<working-dir>/.claudedo/
INDEX.md # eine Zeile je Finding — die einzige Datei, die immer gelesen wird
traps/<slug>.md # ein Finding je Datei, ~150300 tok
```
`maps/` ist reserviert, aber **nicht Teil von v1**.
Ordner liegt im Repo-Root, das er beschreibt — maximale Erkennbarkeit, reist bei
Bedarf mit. `.claudedo` ist als Name frei; die Worktrees liegen unter dem *Geschwister*-Pfad
`.claudedo-worktrees`, und `TranscriptUsageReader` prüft auf das exakte Segment, also kein
Fehlalarm bei der Scope-Erkennung.
### Finding-Format
```markdown
---
slug: conpty-arg-quoting
scope: src/ClaudeDo.Worker/Planning
source-task: 5d627df8
verified-against: e10634b
---
# Ein Verzeichnis-Argument, das auf `\` endet, frisst alle folgenden Argumente
<26 Sätze: was passiert, warum es nicht danach aussieht, was man stattdessen tut.>
```
### INDEX.md
```markdown
# Findings
- [conpty-arg-quoting](traps/conpty-arg-quoting.md) — Dir-Arg auf `\` escapet sein eigenes Quote
- [shared-worktree-partial-commit](traps/shared-worktree-partial-commit.md) — nie blank committen
```
Reine Routing-Ebene: Titel + einzeiliger Aufhänger + Pfad. Ablauf im Agenten:
Index lesen (~700 tok) → ein bis zwei Findings lesen (~400 tok), statt 4 600 für eine
ganze Note.
### Einchecken oder nicht — Toggle je Liste
Beim Anlegen einer Liste: **`.claudedo` einchecken"**, Default **aus**.
- **Aus** → `/.claudedo/` wird nach `.git/info/exclude` geschrieben (per-Clone,
unsichtbar fürs Repo, **keine getrackte Datei angefasst**). Mechanik existiert bereits in
`SessionSkillSeeder.AppendExcludeLineAsync` (auflösen über
`git rev-parse --git-path info/exclude`) — wiederverwenden, nicht neu bauen.
- **An** → nichts tun, der Nutzer commitet den Ordner selbst.
### Worktree-Regeln (hart)
- **Geschrieben wird ausschließlich im Haupt-Checkout, nie in einem Worktree.** Sonst
entstehen `INDEX.md`-Merge-Konflikte über parallele Tasks — exakt das Problem, das oben
gegen den Graph-Ansatz spricht.
- **Gelesen wird über den absoluten Pfad auf den Haupt-Checkout.** Eine Regel für beide
Toggle-Stellungen; in „aus" existiert der Ordner im Worktree ohnehin nicht.
- Falls der Store getrackt ist und der Distiller committet: pfad-skopiert
(`git commit -- .claudedo`), nie blank — der Haupt-Checkout wird von parallelen
Sessions geteilt.
- Ein Finding je Datei heißt: parallele Schreiber kollidieren nicht. Umkämpft ist nur
`INDEX.md`; das Tool serialisiert dessen Rewrite prozessintern.
---
## 4. Schreibweg — ein MCP-Tool
`save_finding(slug, title, body, scope = "", list = "")` am bestehenden `claudedo`-MCP-Server.
Signatur folgt den zwei test-erzwungenen Konventionen des Servers: jeder optionale
Parameter hat einen C#-Defaultwert, und das Tool gibt kein blankes `Task` und keine
nullable Payload zurück (→ `docs/explore-notes/external-mcp.md`). Beschreibungsstil nach
`External/McpToolDocs.cs`.
### Welcher Store wird beschrieben?
Der MCP-Server ist global, der Store projektgebunden — die Zuordnung muss explizit sein:
1. **Autonomer Run:** aus dem Aufrufkontext (`TaskRunMcpContext`) → Task → Liste →
`working_dir`. Der `list`-Parameter wird ignoriert.
2. **Interaktive Session ohne Task-Kontext:** `list` (Name oder Id) entscheidet. Fehlt er
und es existiert **genau eine** Liste, wird diese genommen; bei mehreren gibt das Tool
einen Fehler zurück, der die verfügbaren Listen nennt. Nie raten.
Geschrieben wird immer in `<list.working_dir>/.claudedo/` — also den Haupt-Checkout,
auch wenn der Aufrufer in einem Worktree läuft.
Bewusst ein **dummes Schreib-Tool**, kein „generiere mir Findings"-Tool: der Aufrufer hat
den Kontext bereits. Ein zweiter Claude-Lauf (RefineRunner-Bauart) würde ihn aus
Transkripten rekonstruieren — teurer und schlechter.
**Genau ein Tool.** Gelesen wird mit `Read` auf die Dateien; das kostet nichts und braucht
kein Tool. Der Server exponiert bereits ~60 Tools, eines mehr ist im Prefix Rauschen —
fünf wären es nicht.
**Verhalten:**
- Gleicher `slug`**überschreiben**, nicht anlegen. Dedupe gehört ins Tool, nicht in die
Prompt-Disziplin.
- Schreibt `traps/<slug>.md` **und** aktualisiert die Zeile in `INDEX.md` atomar.
- `verified-against` wird vom Tool aus dem aktuellen HEAD des Haupt-Checkouts gesetzt,
nicht vom Aufrufer.
- `source-task` wird aus dem Aufrufkontext gesetzt, wenn vorhanden (autonome Runs),
sonst leer (interaktive Sessions).
### Aufrufwege
1. **Im Run.** Ein Task-Agent tritt in eine Falle und hält sie fest. Höchste Qualität —
er hat gerade Turns dafür verbrannt. Billigster Weg, kein zusätzlicher Prozess.
2. **Merge-Helper-Abschlussphase.** Der List-Handler sweept am Ende nach, was ihm über den
Batch hinweg auffiel. Er sieht Diffs, nicht das Straucheln — daher Ergänzung zu (1),
kein Ersatz.
3. **Interaktive Sessions.** Der `claudedo`-MCP ist global registriert, das Tool steht
dort ohne Weiteres zur Verfügung.
4. **VSCode.** Nutzer korrigiert und löscht direkt in den Dateien.
### Aufnahmehürde
Ein Finding ist nur, was **dauerhaft**, **nicht offensichtlich** und **verhaltensändernd**
ist: „X sieht aus wie Y, ist aber Z — mach stattdessen W". Ein gefixter Bug ist **kein**
Finding, das ist Git-Historie.
Diese Hürde steht in der Tool-Beschreibung. Sie ist kein Stilhinweis, sondern die
Betriebsbedingung: `INDEX.md` wird immer gelesen; bei ~15 tok je Zeile sind 50 Findings
≈ 750 tok, 300 Findings ≈ 4 500 tok — ab da kostet der Index so viel wie früher eine ganze
Note und die Ersparnis ist weg. Deshalb zusätzlich eine **Warnschwelle bei 80 Einträgen**,
die das Tool im Ergebnis zurückmeldet.
---
## 5. Leseweg
ClaudeDo hängt einen Satz an den System-Prompt seiner eigenen Runs
(`--append-system-prompt`, wie heute schon für andere Zwecke genutzt):
> Findings-Index für dieses Projekt: `<abs>/.claudedo/INDEX.md`. Lies ihn, bevor du den
> Code erkundest, und öffne nur die Findings, die zu deiner Aufgabe passen.
~30 Token, komplett in ClaudeDo gekapselt, **keine CLAUDE.md wird angefasst** — weder die
des Projekts noch die globale.
Interaktive Sessions bekommen diesen Zeiger nicht (ClaudeDo startet sie nicht). Sie können
schreiben, aber nicht automatisch lesen. Wer das will, setzt selbst eine Zeile in seine
globale CLAUDE.md — außerhalb des Scopes dieses Features.
---
## 6. UI
Ein Button an der Liste: **„Findings öffnen"** → öffnet `<working-dir>/.claudedo/` im
Datei-Explorer bzw. der Standardanwendung. **Kein In-App-Editor** — Kuration passiert in
VSCode.
Dazu der Toggle aus §3 im Dialog „Liste anlegen" und in den Listen-Einstellungen.
---
## 7. Scope-Grenze (bewusst akzeptiert)
Die Kapselung in ClaudeDo deckelt den Lese-Nutzen auf den Agent-Anteil: laut Messung
**18,4 %** des Verbrauchs (interaktiv = 81,6 %). Das ist kein Argument gegen das Feature —
Retries passieren ausschließlich auf der Agent-Seite, und dort sitzt der 11-Mio-Hebel.
Aber „Token-Ersparnis" heißt hier: Ersparnis in den 18 %, nicht auf dem Konto insgesamt.
---
## 8. Nicht in v1
- **`maps/`** — Subsystem-Maps. Ordner reserviert, Inhalt später. Die bestehenden
`docs/explore-notes/` bleiben unangetastet.
- **Retro-Distill aus Transkripten.** Erst interessant, wenn belegt ist, dass Findings
überhaupt wirken. Rohmaterial bleibt verfügbar: vollständiges NDJSON je Run unter
`~/.todo-app/logs/{taskId}_run{N}.ndjson`, `ResultMarkdown` in `task_runs`,
`WorktreeEntity.BaseCommit/HeadCommit/MergeCommit` für den nachträglichen Diff.
- **Ernte der bestehenden `⚠️ Gotcha:`-Sektionen** aus `docs/explore-notes/` in den Store.
Naheliegender erster Füllstand, aber eine eigene, manuelle Aktion.
- **Wirkungsmessung** (welches Finding wurde je gelesen, hat es einen Retry verhindert).
Braucht ein eigenes Konzept; Dateien tragen keinen Zähler.
- **Automatischer Staleness-Check** gegen `verified-against`. Frontmatter trägt den Commit,
ausgewertet wird er in v1 nur vom Menschen.
---
## 9. Testbarkeit
- `save_finding`: Neuanlage, Überschreiben bei gleichem Slug, Index-Update, Slug-Validierung
(kein Pfad-Escape), Warnschwelle bei 80 Einträgen, paralleler Index-Rewrite.
- Store-Auflösung: Task-Kontext schlägt `list`; genau eine Liste ohne `list` → Treffer;
mehrere Listen ohne `list` → Fehler statt Raten; Schreibziel ist der Haupt-Checkout,
auch wenn der Aufrufer in einem Worktree sitzt.
- Die beiden `external-mcp`-Konventionen (Default je optionalem Parameter, kein blankes
`Task`/nullable Payload) sind test-erzwungen — die bestehenden Tests greifen automatisch.
- Toggle: `.git/info/exclude`-Zeile wird genau einmal geschrieben (Idempotenz) — die
bestehenden Tests zu `SessionSkillSeeder` sind die Vorlage.
- Kein Test darf die echte `claude`-CLI starten.
- `IWorkerClient` / `WorkerHub` / ViewModel-Konstruktoren ändern → handgeschriebene Fakes in
**beiden** Testprojekten mitziehen.
@@ -0,0 +1,73 @@
# Dependency chain display — Design
**Date:** 2026-08-11
**Status:** approved, not implemented
## Problem
`DependsOnTaskId` is invisible in the UI. `TaskRowViewModel` has no `DependsOnTaskId` property
and `TaskRowView.axaml` renders nothing for it — a chained task looks exactly like an unrelated
one. The execution order the user declared is not readable anywhere in the list.
## Target
A chain renders as a group: the head full width, its dependents indented behind a vertical rail,
each carrying a small circular step badge on the rail.
```
┌──────────────────────────────────────────┐
│ chain head (full width, no badge) │
└──────────────────────────────────────────┘
╷ ┌────────────────────────────────────┐
(1)│ first dependent │
╷ └────────────────────────────────────┘
╷ ┌────────────────────────────────────┐
(2)│ second │
╷ └────────────────────────────────────┘
╷ ┌────────────────────────────────────┐
(2)│ parallel to the second │
╷ └────────────────────────────────────┘
╷ ┌────────────────────────────────────┐
(3)│ third │
└────────────────────────────────────┘
```
## Model reading
- **Step number = hop distance from the chain head**, not a running counter. `DependsOnTaskId` is
a single FK, so several tasks may share one predecessor → same depth → **same number**. Two
rows showing `2` means "these two are both unblocked by step 1", which is the intended reading.
- **Chain head** = a task with no `DependsOnTaskId` that at least one other task points at. A task
with no dependents and no dependency is not a chain and renders exactly as today.
- Cycles are impossible (`TaskStateService.SetDependsOnAsync` rejects them), so the depth walk
always terminates. Still cap the walk defensively.
## Decisions
**One indent level only — parent wins.** The 24 px indent track already exists for planning
children (`TaskRowView.axaml:22-28`, gated on `ShowAsChild`). A planning child that *also* has a
`DependsOnTaskId` keeps the parent indent and shows its chain membership as a small inline
chip (`after #123`) instead of a second indent level. No nesting, no 48 px rows.
**The group is pulled together; the head carries it.** Chain members are re-ordered to sit
directly under their head regardless of `SortOrder`, so displayed order always equals execution
order. Dragging the head moves the whole group; members are not individually draggable.
**Head not in view → no orphan rails.** Exact precedent exists: `ParentInView` /`ShowAsChild`
(`TaskRowViewModel.cs:75`, computed in `Regroup` at `TasksIslandViewModel.cs:543`). Same
treatment — if the head is filtered out (other list, My Day, completed group), the row renders
flat with the `after #123` chip instead of a dangling rail.
**Badge has no `#`.** If task numbers (`2026-08-11-task-numbers-design.md`) also land, a row
would show a rail badge and a `#412` title prefix. The rail badge is a step position, not an
identity — render it bare (`2`), never `#2`.
## Slices
| # | Slice | Depends on |
|---|---|---|
| 1 | VM: `DependsOnTaskId` + depth/head computation in `Regroup`, group pull-together, drag semantics | — |
| 2 | View: rail, step badge, `after …` chip, locale keys | 1 |
Slice 1 exposes the contract Slice 2 binds to: `ShowAsChainMember`, `ChainStep`,
`ChainAfterLabel`.
@@ -0,0 +1,313 @@
# Feedback für langlaufende Operationen
**Datum:** 2026-08-11
**Status:** Design freigegeben, Implementierung offen
**Auslöser:** Ein Merge über das Detail-Pane blockierte minutenlang ohne jede Anzeige.
## Problem
Langlaufende Operationen geben kein Feedback. Der Nutzer klickt, nichts passiert sichtbar,
die App liest sich als abgestürzt. Der gemeldete Fall war ein Merge mit Verify-Gate, aber das
ist ein Symptom eines Musters: Feedback wird **pro Fall ad-hoc** gebaut
(`PrepStarted/Line/Finished`, `RefineStarted/Finished`, `PlanningMerge*`, `MergeProgress`),
und dabei geht regelmäßig eine Hälfte verloren oder eine Fläche wird vergessen.
Belegend: `HubBroadcaster.MergeProgress` existierte bereits, während `WorkerClient` noch
keinen Listener hatte — Sender ohne Empfänger. Das wurde parallel zu diesem Design in einer
anderen Session nachgezogen (siehe *Abhängigkeit zur Parallelarbeit*), deckt aber nur das
MergeModal ab, nicht den Approve-Pfad im Detail-Pane.
## Vier Fehlerbilder
Die Analyse trennt vier Klassen, die **verschiedene** Lösungen brauchen. Sie zusammen als
„fehlendes Feedback" zu behandeln war der Fehler der bisherigen Einzelfall-Fixes.
### Bild 1 — Stiller Await im UI
Ein `[RelayCommand]` awaitet einen Call, ohne Busy-State, ohne Anzeige. Der Button bleibt
klickbar (Doppelklick-Risiko), es gibt keinen Hinweis, dass etwas läuft.
| Stelle | Befund |
|---|---|
| `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs:1206` `ApproveReviewAsync` | **Der gemeldete Fall.** Kein Busy-Flag, kein Indikator; das Verify-Gate kann den Call Minuten halten |
| `DetailsIslandViewModel.cs:1244` `SubmitForReviewAsync` | kein Busy-Flag (`MissionControlViewModel` hat für denselben Call ein `IsSubmitPending`, das Detail-Pane nicht) |
| `DetailsIslandViewModel.cs` `RejectReviewAsync`, `ParkReviewAsync` | kein Busy-Flag; Fehler werden verschluckt (`catch { return; }`) |
| `src/ClaudeDo.Ui/ViewModels/Islands/MergeSectionViewModel.cs:121` `PreviewMergeAsync` | kein Indikator |
| `WorktreesOverviewModalViewModel` (`IsBusy` vorhanden) | `IsBusy` schaltet in `WorktreesOverviewModalView.axaml:107-108` **nur** Buttons aus — kein Spinner, kein Text. Betrifft Cleanup, Reset, ForceRemove, Refresh, Batch-Merge |
| Settings-Tabs: `SessionSkillsSettingsTabViewModel`, `FilesSettingsTabViewModel`, `OnlineInboxSettingsViewModel` | dito — `IsEnabled="{Binding !IsBusy}"`, keine Anzeige. Skill-Install ist ein `git clone` |
| `WeeklyReportModalViewModel`, `RepoImportModalViewModel` | `IsBusy` nur als CanExecute-Gate |
| Islands ↔ SQLite direkt | Die Islands sprechen direkt mit der DB (`new TaskRepository(ctx)` u.ä., ~15 Stellen allein in `DetailsIslandViewModel`). Meist schnell; `ClearCompleted`, Drag-Reorder über viele Zeilen und das Laden großer Listen nicht zwingend |
Bereits sauber gelöst und **nicht** anzufassen: `MergeModalViewModel` (Spinner + Phase +
Elapsed), `ConflictResolverViewModel` (`IsBusy` + Text), `DiffViewerViewModel.IsLoadingCombined`,
`UsageMonitorModalViewModel` (Spinner).
### Bild 2 — UI-Thread-Freeze
Eine andere Fehlerklasse: hier hilft kein Indikator, weil der Dispatcher blockiert ist und
der Spinner nicht gezeichnet werden könnte. Erst auslagern, dann anzeigen.
| Stelle | Befund |
|---|---|
| `src/ClaudeDo.Ui/ViewModels/Modals/DiffViewerViewModel.cs:182` und `:260` | `UnifiedDiffParser.Parse(raw)` läuft **synchron auf dem UI-Thread** |
| `src/ClaudeDo.Ui/Views/Controls/DiffTextView.axaml.cs:102` | `DiffAlignment.Build(File?.Lines)` ebenfalls |
Im gesamten `ClaudeDo.Ui`-Projekt gibt es **zwei** `Task.Run`-Offloads (`RepoScanner`,
`RestartWorkerService`). CPU-Arbeit auf dem UI-Thread ist damit die Norm, nicht die Ausnahme.
### Bild 3 — Worker-Stille
Operationen ohne UI-Auslöser. Kein Client erfährt, dass sie laufen; die UI zeigt im
Startfall nur „reconnecting".
| Stelle | Warum langsam |
|---|---|
| 6× `src/ClaudeDo.Worker/Lifecycle/*Recovery.cs` beim Worker-Start (`OrphanRecovery`, `StaleTaskRecovery`, `PromptFileRecovery`, `AttachmentOrphanRecovery`, `PlanningLineageRecovery`, `LegacyWorktreeFolderRecovery`) | git + DB über alle Tasks/Worktrees |
| `src/ClaudeDo.Worker/Runner/WorktreeManager.cs` — Worktree-Anlage beim Task-Start | `git worktree add` + Branch; stille Lücke zwischen `Queued` und erster Ausgabe |
| `src/ClaudeDo.Worker/Worktrees/WorktreeMaintenanceService.cs` | Hintergrund-git über alle Worktrees |
| `TaskMergeService.RebaseOthersAfterMergeAsync` | läuft **innerhalb** des Merge-Calls, **nach** dem Phasen-Broadcast — vollständig unsichtbar |
| `PlanningAggregator`, `PlanningMergeOrchestrator` | ein Merge pro Subtask |
| `OnlineSyncService`, `UsageMonitorService`, `PrimeScheduler`, `QueueService` | periodisch, Netz/git |
Querverweis: `docs/superpowers/specs/2026-08-07-ui-reaktivitaet-und-listen-performance-design.md`
führt `WorktreeManager.cs:103` als DB-Write-ohne-Broadcast (Loch 3). C3 muss prüfen, ob das
inzwischen geschlossen ist, statt es doppelt zu beheben.
### Bild 4 — MCP-Stille
Trifft nicht die UI, sondern Agenten-Runs. Nur drei Dateien reporten Progress:
`ExternalMcpService` (5 Tools), `TaskWaitMcpTools`, `TaskMergeService`. Ohne Progress laufen
u.a. die 8 `batch_*`-Tools, `cleanup_task_worktree`, `get_task_diff`, `preview_merge_set`.
Das ist die dokumentierte Ursache des Traps „MCP 300s idle abort leaves merges stuck": der
Client bricht bei Stille ab, während der Worker weiterarbeitet, und der Task bleibt hängen.
### Bewusst ausgeschlossen: der Installer
`ClaudeDo.Installer` hat eine vollständige `IProgress<string>`-Step-Pipeline mit Live-Anzeige
(`Core/InstallerService.cs`, `Steps/*`, `Pages/InstallPage`). Das ist die beste Progress-Fläche
im Repo und braucht nichts.
## Design
### 1. UI-Primitive: `OperationStatus`
Eine `ObservableObject`-Klasse, die ein ViewModel als Property exponiert. Mehrere pro VM sind
erlaubt und erwünscht — `WorktreesOverviewModalViewModel` braucht getrennte für Refresh,
Cleanup und Merge, sonst blockiert ein laufender Refresh die Cleanup-Anzeige.
| Property | Verhalten |
|---|---|
| `IsRunning` | sofort `true` → treibt `CanExecute`, verhindert Doppelklick |
| `ShowIndicator` | erst nach **300 ms** `true` → kein Flackern bei schnellen Calls |
| `Label` | lokalisierter Text, **mid-flight überschreibbar** (`Report(label)`) |
| `Elapsed` | `mm:ss`, **lokal getickt** |
| `IsStalled` | `true`, wenn **60 s ohne Aktualisierung** vergangen sind → Hinweis „läuft weiter, Worker antwortet noch" |
`IsStalled` bemisst sich an der Zeit seit dem letzten `Report`, **nicht** an der Gesamtdauer.
Sonst würde ein regulär mehrminütiges Verify-Gate fälschlich als hängend gemeldet. Eine
Operation ohne jeden `Report` gilt nach 60 s als stalled — das ist der Fall, der heute wie ein
Absturz aussieht.
Benutzung:
```csharp
using var op = Approve.Begin(Loc.T("ops.merge.merging"));
var result = await _worker.ApproveReviewAsync(...);
```
`Dispose` beendet die Operation auch im Exception-Fall.
**Zeitquelle ist injizierbar** (`TimeProvider`, in .NET 8 vorhanden), kein statischer
`DispatcherTimer`. Ein geteilter statischer Timer wäre genau die Sorte Shared State, die die
`Ui.Tests` reihenfolgen-abhängig flaky macht. Timer-Callbacks kommen vom Threadpool und müssen
auf den Dispatcher gepostet werden.
**Fehlerbehandlung bleibt unverändert.** Weiter über `ShowErrorAsync` / `ErrorReported` /
`FlashFooterError`. `OperationStatus` transportiert keine Fehler.
### 2. UI-Control: `OperationIndicator`
`src/ClaudeDo.Ui/Views/Controls/OperationIndicator.axaml` — Spinner (`Ellipse.spinner` aus
`IslandStyles`) + Label + Elapsed, gebunden an eine `OperationStatus`. Ohne das Control wird
die Spinner-StackPanel aus `MergeModalView.axaml:28-30` acht Mal von Hand nachgebaut.
Alle Texte über Locale-Keys im Namespace `ops.*`; en/de-Parität erzwingen die
`Localization.Tests`.
### 3. Worker-Kanal: ein generisches Progress-Event
Der Worker sendet nur, was das UI **nicht erraten kann** — interne Phasen und Zählstände. Beim
Klick weiß das UI selbst, was es angestoßen hat.
```
OperationProgress(string opKey, string phase, int current, int total)
```
- `opKey` = TaskId bei task-gebundenen Operationen, sonst ein stabiler String
(`"worktree-cleanup"`, `"startup-recovery"`, `"planning-integration:<taskId>"`)
- **Kein Elapsed auf der Leitung** — das tickt das UI
- `current`/`total` erlaubt echten Fortschritt („Worktree 3/12"), was das heutige
`MergeProgress` nicht kann
Dieses Event **ersetzt** `MergeProgress`. Begründung, dass sich die Verallgemeinerung lohnt:
es gibt drei Produzenten (Merge-Phasen, Worktree-Cleanup, Planning-Integration), nicht einen.
**Aber `IWorkerClient.MergeProgressEvent` bleibt als dünner Forwarder bestehen.** Sonst
zerstört C1 die Gruppen-Isolation: A1 abonniert dieses Event, und ein Rename würde C zwingen,
`DetailsIslandViewModel` zu ändern — eine Datei, die Gruppe A besitzt. Der Forwarder kostet
vier Zeilen und hält die vier Sessions unabhängig. Er darf später entfernt werden, wenn A und
C beide gemergt sind.
### 4. MCP: `ProgressReporter` als eigene Klasse
`TaskMergeService.RunReportingProgressAsync` (Zeile ~175) ist die einzige existierende
Progress-Schleife. Sie wird als eigenständige Klasse extrahiert, sodass jedes MCP-Tool sie
nutzen kann, statt die Schleife zu kopieren. Zusätzlich ein Overload für Element-Fortschritt
(`i/n`) für die `batch_*`-Tools.
## Festgelegte Grenzen
Bewusst **nicht** Teil dieses Designs:
- **Kein Cancel.** Nur Sichtbarkeit. Ein Abbruch wäre bei einem Merge ohnehin irreführend: das
Verify-Gate läuft, wenn der Merge-Commit längst geschrieben ist — „abbrechen" könnte nur
*aufhören zu warten* bedeuten, nicht zurückrollen. Ohne Cancel entfällt der
`opId`-Handshake; die Korrelation läuft über `taskId` bzw. das offene Modal.
- **Keine Footer-Anzeige für laufende Operationen.** Anzeige am auslösenden Control und an der
Task-Zeile / im Detail-Pane. Der Footer bleibt für Fehler (`FlashFooterError`) und den
Worker-Log.
- **Keine Prozent-Balken.** Die meisten Operationen haben kein sinnvolles Total.
`current/total` wird als Text gezeigt, nicht als Balken.
- **Keine EF-Migration.** Damit entfällt der Trap paralleler Migrationen.
## Messung
„Erst messen, dann instrumentieren" gilt für Bild 1 und 3 — aber an **einer** Stelle, nicht an
zwanzig:
1. Ein Timing-Hook in `WorkerClient` loggt jeden Hub-Invoke mit Dauer.
2. Ein zweiter im DB-Pfad der Islands.
Ein Tag Nutzung liefert eine sortierte Liste echter Ausreißer. A5 und C entscheiden danach,
statt zu raten. Bild 2 und 4 brauchen keine Messung — der Befund ist aus dem Code eindeutig.
Faustregel für den Zweifelsfall: instrumentiert wird, was git aufruft, Netz nutzt, einen
Prozess startet, oder O(n) über unbegrenzt viele DB-Zeilen läuft. Einzelzeilen-Reads nicht.
## Parallelisierung: vier Merge-Helper-Gruppen
Der Zuschnitt folgt dem **Datei-Eigentum**, nicht der Fachlichkeit — nur so können vier
Sessions gleichzeitig laufen, ohne sich zu überschreiben.
```
P0 (Fundament, muss allein zuerst landen)
├── Gruppe A Bild 1: UI-Stille (5 Pakete)
└── Gruppe B Bild 2: UI-Freeze (3 Pakete)
Gruppe D Bild 4: MCP (4 Pakete) ← braucht P0 NICHT, kann sofort starten
Gruppe C Bild 3: Worker (5 Pakete) ← nach P0
```
| Gruppe | Exklusiv besessene Dateien |
|---|---|
| **P0** | `OperationStatus.cs`, `OperationIndicator.axaml`, **beide `locales/*.json`** |
| **A** | Island-VMs, Modal-VMs und deren AXAML |
| **B** | `DiffViewerViewModel.cs`, `DiffTextView.axaml.cs` |
| **C** | `HubBroadcaster`, `WorkerHub`, `WorkerClient`, `IWorkerClient`, `StubWorkerClient`, `Lifecycle/*Recovery`, `WorktreeManager` |
| **D** | `Worker/External/*McpTools`, `ExternalMcpService` |
**Der einzige echte Konfliktkandidat sind die Locale-Dateien.** A und C brauchen Keys — D
nicht: MCP-Progress-Meldungen gehen an Agenten, nicht an den Nutzer, und bleiben englische
Klartext-Strings ohne Locale-Eintrag.
Regel: **P0 legt die Keys für A und C vorab an**, danach fasst keine Gruppe die JSONs mehr an.
Braucht eine Gruppe doch einen nicht vorgesehenen Key, hängt sie ihn als **letzte** Änderung
des Pakets an und behandelt ihn im Merge-Helper als bekannte Konfliktstelle.
Drei erzwungene Serialisierungen:
- `TaskMergeService` — die Parallelsession arbeitet dort; C1 danach.
- `WorkerClient.cs` — P0-2 setzt dort den Timing-Hook, C ändert dieselbe Datei. P0 liegt
ohnehin vor C, damit unkritisch — aber C darf nicht vor P0 starten.
- Innerhalb jeder Gruppe `serializeOnFileOverlap` auf der Liste setzen.
Ergänzung zum Datei-Eigentum: `Services/UpdateCheckService.cs` gehört zu **A** (Paket A3),
`Services/WorkerClient.cs` zu **C**. Beide liegen im selben Ordner, sind aber verschiedene
Dateien.
### Abhängigkeit zur Parallelarbeit
Während dieses Designs hat eine andere Session im gemeinsamen `main`-Checkout das
Merge-Feedback für das **MergeModal** implementiert — inzwischen committet als
`cad0582 fix(merge): conventional merge-commit default and live verify progress`:
Phasen-Broadcast in `TaskMergeService`,
`HubBroadcaster.MergeProgress`, `IWorkerClient.MergeProgressEvent`,
`MergeModalViewModel.ProgressMessage`, Spinner in `MergeModalView.axaml`, plus Tests.
Konsequenzen:
- Der Merge-Fall im MergeModal ist **Vorarbeit**, nicht neu zu bauen.
- Zwei Nachbesserungen bleiben: A1 schließt den Approve-Pfad im Detail-Pane an, C1 stellt
Elapsed auf UI-lokalen Tick um (die Implementierung lässt den Worker alle 30 s ticken —
die ersten 30 Sekunden zeigt sie nur „merging").
- Der Blocker ist damit aufgelöst: C hängt nur noch an P0, nicht mehr an der Parallelsession.
## Pakete
### P0 — Fundament
| # | Inhalt |
|---|---|
| P0-1 | `OperationStatus` + `OperationIndicator` + `ops.*`-Keys **für alle vier Gruppen vorab** + `Ui.Tests` mit Fake-`TimeProvider` |
| P0-2 | Timing-Hook in `WorkerClient` + im DB-Pfad der Islands |
| P0-3 | Regel in `src/ClaudeDo.Ui/CLAUDE.md` und `src/ClaudeDo.Worker/CLAUDE.md`: jeder `IWorkerClient`-Call in einem `[RelayCommand]` läuft durch eine `OperationStatus`; MCP-Tools über ~5 s reporten Progress |
### Gruppe A — Bild 1: UI-Stille
| # | Inhalt |
|---|---|
| A1 | Detail-Pane: Approve & Merge, Submit for review, Reject, Park, Preview-Merge. Abonniert das Merge-Phasen-Event zum Nachschärfen des Labels |
| A2 | WorktreesOverview: Refresh, Cleanup, Reset, ForceRemove, Batch-Merge (inkl. Zeilen-Status) |
| A3 | Settings-Tabs: SessionSkill install/update, Restore-Defaults, OnlineInbox-SignIn, RepoImport-Scan, Update-Check |
| A4 | Reports und Planning-UI: GenerateWeekReport, RunDailyPrepNow, BuildPlanningIntegrationBranch, GetPlanningAggregate, Finalize/QueuePlanningSubtasks |
| A5 | Island-DB-Pfade — **nur die, die P0-2 als Ausreißer zeigt** |
### Gruppe B — Bild 2: UI-Freeze
| # | Inhalt |
|---|---|
| B1 | `UnifiedDiffParser.Parse` nach `Task.Run` + Indikator im DiffViewer |
| B2 | `DiffAlignment.Build`: Grenze definieren (ab N Zeilen auslagern oder inkrementell aufbauen) |
| B3 | Guard-Test: Parse/Build über ein großes Fixture blockiert den Dispatcher nicht |
### Gruppe C — Bild 3: Worker-Stille
| # | Inhalt |
|---|---|
| C1 | `OperationProgress(opKey, phase, current, total)` ersetzt `MergeProgress`; Elapsed auf UI-lokalen Tick |
| C2 | Startup-Recovery sichtbar (6 Services) — Anzeige im Shell statt nur „reconnecting" |
| C3 | Worktree-Anlage beim Task-Start sichtbar an der Task-Zeile. Vorher prüfen, ob der Broadcast-Gap aus dem 08-07-Spec schon geschlossen ist |
| C4 | `RebaseOthersAfterMergeAsync` und `WorktreeMaintenanceService` in den Kanal |
| C5 | Periodische Dienste (Usage, OnlineSync, Prime, Queue): nur Aktivität und Fehler, kein Tick-Spam |
### Gruppe D — Bild 4: MCP
| # | Inhalt |
|---|---|
| D1 | `ProgressReporter` aus `TaskMergeService.RunReportingProgressAsync` extrahieren, inkl. `i/n`-Overload |
| D2 | Die 8 `batch_*`-Tools mit Element-Fortschritt |
| D3 | Worktree- und Diff-Tools: `cleanup_task_worktree`, `batch_cleanup_task_worktrees`, `get_task_diff`, `preview_merge_set` |
| D4 | Restliche Long-Runner + Doku-Regel im Worker-`CLAUDE.md` |
## Test-Strategie
- **`OperationStatus`**: reine Unit-Tests mit Fake-`TimeProvider` — 300-ms-Grace, 60-s-Stall,
Elapsed-Formatierung, `Dispose` im Exception-Fall, `Report` überschreibt Label.
- **VM-Tests** (`Ui.Tests`, headless): Command setzt `IsRunning`, `CanExecute` sperrt während
des Laufs, Zustand ist nach einer Exception zurückgesetzt. Fakes: `StubWorkerClient` muss
bei jedem neuen Event mitwachsen.
- **B3** ist der einzige Test, der Blockierung prüft: großes Diff-Fixture, Messung, dass der
Dispatcher weiter Nachrichten verarbeitet.
- **Worker/MCP** (`Worker.Tests`): Progress-Callbacks werden mit einem Fake-`IProgress`
gezählt; keine echte Claude-CLI, keine echten Timeouts.
- **Visuell**: jedes Paket in A und B hat eine offene visuelle Prüfung. Spinner, Grace-Periode
und Layout kann kein Test bestätigen — das läuft über den Nutzer.
@@ -0,0 +1,116 @@
# Task Numbers (`#123`) — Design
**Date:** 2026-08-11
**Status:** approved, not implemented
## Problem
Tasks are only addressable by GUID. Every report from Claude reads
"task `0ef5ga…` is done", which is unreadable and untraceable for the user. A short,
stable, human-speakable handle is needed.
## Decision
Add a **global, monotonically increasing integer** `TaskEntity.Number`, displayed as `#123`.
- **Global, not per list.** Per-list numbering would make `#123` ambiguous across the ~15
lists and would force a list argument into every lookup. Global costs nothing and is
unambiguous.
- **Alias, not identity.** The GUID stays the primary key and stays in branch names
(`claudedo/{id}`), worktree paths, and the `ClaudeDo-Task:` commit trailer. `Number` is
purely a display + lookup alias.
- **Immutable, never reused.** A number that appeared in a log must never later point at a
different task. Deleting tasks leaves gaps — that is correct and intended.
## Allocation
`MAX(number) + 1` is **wrong**: deleting the newest task frees its number for reuse.
Use a persistent counter instead.
- `app_settings.next_task_number` (INTEGER NOT NULL, singleton row — same table as
`MaxTurnsCeiling` etc.).
- Allocation is one statement, atomic under SQLite's single-writer model:
`UPDATE app_settings SET next_task_number = next_task_number + 1
WHERE id = 1 RETURNING next_task_number - 1`
executed in the **same transaction** as the task insert.
- `tasks.number` gets a **UNIQUE index** (`idx_tasks_number`) so a collision is a DB error,
not silent corruption. On a unique violation, retry the allocation (bounded, 5 attempts).
Only **two** insert sites exist and both must allocate — every other creation path
(UI, `add_task`, `batch_add_tasks`, Online Inbox sync, merge-helper handler task,
planning children) routes through one of them:
- `TaskRepository.AddAsync` (`src/ClaudeDo.Data/Repositories/TaskRepository.cs:20`)
- `TaskRepository.AddChildAsync` (same file, ~line 297)
Do **not** reuse `SortOrder` — it is per list and mutable via drag & drop.
## Migration + backfill
One migration, created **alone** (never in parallel with another migration — sibling
migrations off the same parent silently drop each other's columns on a SQLite table rebuild):
1. `ALTER TABLE tasks ADD COLUMN number INTEGER NOT NULL DEFAULT 0`
2. Backfill in creation order:
`UPDATE tasks SET number = (SELECT COUNT(*) FROM tasks t2
WHERE t2.created_at < tasks.created_at OR (t2.created_at = tasks.created_at AND t2.id <= tasks.id))`
(or the equivalent `ROW_NUMBER()` window function).
3. `ALTER TABLE app_settings ADD COLUMN next_task_number INTEGER NOT NULL DEFAULT 1`
then set it to `(SELECT COALESCE(MAX(number), 0) + 1 FROM tasks)`.
4. Create the unique index **after** the backfill.
⚠️ Tests use `EnsureCreated`, which bypasses migrations — the backfill needs a test that
explicitly runs `Migrate()` against a DB seeded with pre-migration rows.
## Resolution: `#123` → GUID
New `TaskIdResolver` in `src/ClaudeDo.Worker/External/`:
- Input `#123` or bare `123` (a GUID is never all-digits, so this is unambiguous) → look up
by number.
- Anything else → passed through as a GUID.
- Unknown number → a clear MCP error (`no task with number 123`), never a silent null.
Wired at the top of every MCP tool taking a task id (~44 parameters across `External/`),
including the id arrays of the `batch_*` tools.
## Output surface
Two central mappers cover most tools:
- `ExternalMcpService.ToDto` (line ~1629) → `TaskDto`
- `ExternalMcpService.GetTaskRefAsync` (line ~335) → `TaskRefDto`
Add `Number` to both. Then the DTOs that carry a bare task id and bypass those mappers:
`RunTaskNowResult`, `MergeTaskResultDto`, `PossibleDuplicateDto`, `SubsetRelationDto`,
`FileOverlapDto`, the `BatchMcpTools` results, `TaskWaitMcpTools`, `QueueStateMcpTools`,
`RunHistoryMcpTools`.
`McpToolDocs` gets a shared clause instructing the agent to **refer to tasks as `#<number>`
when reporting to the user**. Without this the number is present in the payload but never
spoken — this clause is the actual point of the feature.
## UI surface
- `TaskRowViewModel.Number` → dim `#123` in the row, tokenized (no inline literals).
- Detail pane header.
- `HubBroadcaster.WorkerLog` business events in `TaskRunner` / `TaskMergeService` /
`TaskResetService` say `#123` instead of the GUID.
## Out of scope (possible follow-up)
Branch names and worktree folder names (`claudedo/123-<slug>`, `123-<slug>`). Cheap once
numbers exist, but a separate change — it touches merge, self-heal, and the merge helper.
## Slices
| # | Slice | Depends on |
|---|---|---|
| 1 | Schema, counter, allocator, backfill migration + tests | — |
| 2 | MCP output: `Number` in all task-carrying DTOs | 1 |
| 3 | MCP input: `TaskIdResolver` + `McpToolDocs` reporting clause | 2 |
| 4 | UI: row/detail display + worker-log messages | 1 |
| 5 | Docs: `CLAUDE.md` (Data, Worker), explore-notes | 3, 4 |
2 and 3 both edit `ExternalMcpService.cs` heavily, so 3 waits for 2 rather than running
beside it. 4 touches disjoint files and can run beside 2.
+20 -7
View File
@@ -52,8 +52,14 @@ sealed class Program
// Dispose the container so WorkerClient.DisposeAsync runs —
// cancels the retry loop and closes the SignalR connection cleanly
// instead of abandoning it.
try { services.DisposeAsync().AsTask().GetAwaiter().GetResult(); }
catch { /* best effort on shutdown */ }
try
{
services.DisposeAsync().AsTask().GetAwaiter().GetResult();
}
catch
{
/* best effort on shutdown */
}
}
}
@@ -102,7 +108,9 @@ sealed class Program
sc.AddSingleton<IWorkerClient>(sp => sp.GetRequiredService<WorkerClient>());
// Release check + installer update
sc.AddSingleton<HttpClient>(_ => new HttpClient { Timeout = TimeSpan.FromSeconds(10) });
sc.AddSingleton<HttpClient>(_ =>
new HttpClient(new SocketsHttpHandler { PooledConnectionLifetime = TimeSpan.FromMinutes(5) })
{ Timeout = TimeSpan.FromSeconds(10) });
sc.AddSingleton<IReleaseClient>(sp => new ReleaseClient(sp.GetRequiredService<HttpClient>()));
sc.AddSingleton<InstallerLocator>();
sc.AddSingleton<WorkerLocator>();
@@ -111,7 +119,9 @@ sealed class Program
var releases = sp.GetRequiredService<IReleaseClient>();
var informational = Assembly.GetEntryAssembly()?
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
// Strip MinVer build metadata ("+sha") and any prerelease suffix for the update-compare.
// Strip MinVer build metadata ("+sha") only — keep the prerelease suffix
// (e.g. "-alpha.0.14"), VersionComparer needs it to tell a dev build on
// main apart from a tagged release of the same numeric core.
var version = (informational ?? "0.0.0").Split('+')[0];
return new UpdateCheckService(releases, version);
});
@@ -124,7 +134,8 @@ sealed class Program
sc.AddTransient<DiffViewerViewModel>();
sc.AddTransient<Func<DiffViewerViewModel>>(sp => () => sp.GetRequiredService<DiffViewerViewModel>());
sc.AddTransient<WorktreesOverviewModalViewModel>();
sc.AddTransient<Func<WorktreesOverviewModalViewModel>>(sp => () => sp.GetRequiredService<WorktreesOverviewModalViewModel>());
sc.AddTransient<Func<WorktreesOverviewModalViewModel>>(sp =>
() => sp.GetRequiredService<WorktreesOverviewModalViewModel>());
sc.AddTransient<MergeHelperSelectionModalViewModel>();
sc.AddSingleton<IPrimeScheduleApi, WorkerPrimeScheduleApi>();
sc.AddSingleton<INotesApi, WorkerNotesApi>();
@@ -137,9 +148,11 @@ sealed class Program
sc.AddTransient<RepoImportModalViewModel>();
sc.AddTransient<Func<RepoImportModalViewModel>>(sp => () => sp.GetRequiredService<RepoImportModalViewModel>());
sc.AddTransient<WeeklyReportModalViewModel>();
sc.AddTransient<Func<WeeklyReportModalViewModel>>(sp => () => sp.GetRequiredService<WeeklyReportModalViewModel>());
sc.AddTransient<Func<WeeklyReportModalViewModel>>(sp =>
() => sp.GetRequiredService<WeeklyReportModalViewModel>());
sc.AddTransient<UsageMonitorModalViewModel>();
sc.AddTransient<Func<UsageMonitorModalViewModel>>(sp => () => sp.GetRequiredService<UsageMonitorModalViewModel>());
sc.AddTransient<Func<UsageMonitorModalViewModel>>(sp =>
() => sp.GetRequiredService<UsageMonitorModalViewModel>());
sc.AddSingleton<Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>>(sp =>
taskId => new ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel(
sp.GetRequiredService<IWorkerClient>(), taskId));
+9 -5
View File
@@ -4,10 +4,11 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
## Models
- **TaskEntity** — Id, ListId, Title, Description, Status, PlanningPhase, BlockedByTaskId (FK to predecessor in a chain), ScheduledFor, Result, ReviewFeedback, LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath / MaxTurns (nullable overrides), IsStarred, IsMyDay, IsManual, Notes, ParentTaskId, PlanningSessionId / PlanningSessionToken / PlanningFinalizedAt, CreatedBy, HandlerBaseCommit / HandlerHeadCommit, InteractiveSessionId.
- Status / PlanningPhase / BlockedByTaskId semantics + allowed transitions: `ClaudeDo.Worker/CLAUDE.md` → Status Model.
- **TaskEntity** — Id, ListId, Title, Description, Status, PlanningPhase, BlockedByTaskId (FK to predecessor in a chain), DependsOnTaskId (FK to a user/MCP-declared predecessor, distinct from BlockedByTaskId), ScheduledFor, Result, ReviewFeedback, LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath / MaxTurns (nullable overrides), IsStarred, IsMyDay, IsManual, Notes, ParentTaskId, PlanningSessionId / PlanningSessionToken / PlanningFinalizedAt, CreatedBy, HandlerBaseCommit / HandlerHeadCommit, InteractiveSessionId, Number (global immutable human-readable alias).
- Status / PlanningPhase / BlockedByTaskId / DependsOnTaskId semantics + allowed transitions: `ClaudeDo.Worker/CLAUDE.md` → Status Model.
- `HandlerBaseCommit`/`HandlerHeadCommit` = the review range for a **worktree-less "list handler" host task** ("Let Claude handle it"), which commits straight into the list's working dir instead of a per-task worktree. Everything that reads a task's diff falls back to this pair whenever `Worktree` is null → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md).
- `InteractiveSessionId` = the claude session id an embedded ConPTY interactive task session runs under, persisted by `InteractiveLaunchSpecService` before launch so a closed/aborted session can be resumed → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md).
- `Number` (INTEGER NOT NULL, unique index `idx_tasks_number`) — a global, monotonically increasing display alias for tasks. **Never reused**: deleting a task leaves a gap in numbering, ensuring that a task number that appears in a log or report always points to the same task (if it exists). Allocated by `TaskNumberAllocator.AddWithNumberAsync` on every insert via a persistent counter (`app_settings.next_task_number`). **Why not `MAX(number) + 1`:** deleting the highest-numbered task would free its number for reuse, breaking the immutability guarantee. The allocator uses an `UPDATE…RETURNING` statement to claim numbers atomically, retrying up to 5 times if a uniqueness collision occurs (the counter advances on each attempt, so retries always allocate fresh numbers). ⚠️ Tests use `EnsureCreated`, which bypasses migrations — the backfill needs a migration that runs `Migrate()` explicitly (not tested by default; see `worker-task-pipeline` notes).
- Legacy status values `Manual`/`Planning`/`Planned`/`Draft`/`Waiting` were retired; existing rows backfill via the `RetireLegacyTaskStatus` migration.
- **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt, IsManual (reminder list — tasks created here default to `IsManual`)
- **ListConfigEntity** — ListId (PK, 1:1), Model, SystemPrompt, AgentPath, MaxTurns, SessionSkills, VerifyCommand (all nullable). `VerifyCommand` is an optional post-merge gate; null/blank = no gate → [review-merge](../../docs/explore-notes/review-merge.md).
@@ -33,6 +34,7 @@ Beyond the basics it carries:
| `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`. |
| `NextTaskNumber` | `next_task_number` | 1 | Persistent counter for `TaskEntity.Number` allocation. Incremented atomically in `TaskNumberAllocator.AddWithNumberAsync` on every task insert. **Monotonic only:** the counter itself advances across all tasks (no per-list numbering), and it never resets. A deleted task leaves a gap. |
All four usage percentages are clamped 0..100 by `AppSettingsRepository.UpdateAsync`.
Gate/throttle semantics → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md).
@@ -48,7 +50,7 @@ Gate/throttle semantics → [usage-monitoring](../../docs/explore-notes/usage-mo
All use EF Core LINQ via `ClaudeDoDbContext`. The atomic `Queued → Running` claim lives in the
Worker's `QueuePicker` (`FromSqlRaw`), **not** here.
- **TaskRepository** — CRUD, planning helpers (`CreateChildAsync`, `SetPlanningStartedAsync`, `DiscardPlanningAsync`, `UpdateChildAsync`), `UpdateAgentSettingsAsync`. ⚠️ Status-mutation primitives (`MarkRunningAsync`/`MarkDoneAsync`/`MarkFailedAsync`/`FlipAllRunningToFailedAsync`) are **`internal`** — only the Worker's `TaskStateService` may call them. `CreateChildAsync` produces children with `Status=Idle, PlanningPhase=None`.
- **TaskRepository** — CRUD, planning helpers (`CreateChildAsync`, `SetPlanningStartedAsync`, `DiscardPlanningAsync`, `UpdateChildAsync`), `UpdateAgentSettingsAsync`. ⚠️ Status-mutation primitives (`MarkRunningAsync`/`MarkDoneAsync`/`MarkFailedAsync`/`FlipAllRunningToFailedAsync`) are **`internal`** — only the Worker's `TaskStateService` may call them. `CreateChildAsync` produces children with `Status=Idle, PlanningPhase=None`. **Task-number allocation:** `AddAsync` (line 20) and `CreateChildAsync` (line 276) are the two sole task-creation paths; both call `TaskNumberAllocator.AddWithNumberAsync` to allocate a number atomically during the same transaction as the insert. Every other creation path (UI, MCP `add_task`, `batch_add_tasks`, Online Inbox sync, merge-helper handler tasks, planning children) routes through one of these two.
- **ListRepository** — CRUD, `GetConfigAsync`/`SetConfigAsync` (upsert)/`DeleteConfigAsync` for `list_config`
- **WorktreeRepository** — CRUD, `UpdateHeadAsync`, `SetStateAsync`, `SetMergedAsync` (atomically sets State=Merged **and** stamps MergeCommit in one update — the only writer of MergeCommit)
- **TaskAttachmentRepository** — `AddAsync`, `UpdateAsync`, `GetAsync(taskId, fileName)`, `ListByTaskIdAsync`, `DeleteAsync(taskId, fileName)`, `DeleteAllForTaskAsync`
@@ -96,8 +98,10 @@ Tables (one per line so parallel migrations don't collide on the same line):
- `task_attachments`
Managed by EF Core migrations in `Migrations/`**`ls Migrations/` is the authoritative history**;
don't maintain a changelog here. `tasks` holds `status`, `planning_phase` (default `none`), and
`blocked_by_task_id` (FK to `tasks.id`, `ON DELETE SET NULL`).
don't maintain a changelog here. `tasks` holds `status`, `planning_phase` (default `none`),
`blocked_by_task_id` (FK to `tasks.id`, `ON DELETE SET NULL`), and `depends_on_task_id` (same FK
shape, but a separate column — see Worker/CLAUDE.md → Status Model for why it isn't unified with
`blocked_by_task_id`).
## Conventions
@@ -13,6 +13,9 @@ public class AppSettingsEntityConfiguration : IEntityTypeConfiguration<AppSettin
builder.HasKey(s => s.Id);
builder.Property(s => s.Id).HasColumnName("id").ValueGeneratedNever();
builder.Property(s => s.NextTaskNumber)
.HasColumnName("next_task_number").IsRequired().HasDefaultValue(1);
builder.Property(s => s.DefaultClaudeInstructions)
.HasColumnName("default_claude_instructions").IsRequired().HasDefaultValue(string.Empty);
builder.Property(s => s.DefaultModel)
@@ -18,5 +18,7 @@ public class ListConfigEntityConfiguration : IEntityTypeConfiguration<ListConfig
builder.Property(c => c.MaxTurns).HasColumnName("max_turns");
builder.Property(c => c.SessionSkills).HasColumnName("session_skills");
builder.Property(c => c.VerifyCommand).HasColumnName("verify_command");
builder.Property(c => c.SerializeOnFileOverlap).HasColumnName("serialize_on_file_overlap")
.IsRequired().HasDefaultValue(false);
}
}
@@ -18,6 +18,7 @@ public class ListEntityConfiguration : IEntityTypeConfiguration<ListEntity>
builder.Property(l => l.DefaultCommitType).HasColumnName("default_commit_type").IsRequired().HasDefaultValue("chore");
builder.Property(l => l.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0);
builder.Property(l => l.IsManual).HasColumnName("is_manual").IsRequired().HasDefaultValue(false);
builder.Property(l => l.FindingsTracked).HasColumnName("findings_tracked").IsRequired().HasDefaultValue(false);
builder.HasIndex(l => l.SortOrder).HasDatabaseName("idx_lists_sort");
@@ -66,6 +66,7 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
builder.HasKey(t => t.Id);
builder.Property(t => t.Id).HasColumnName("id");
builder.Property(t => t.Number).HasColumnName("number").IsRequired();
builder.Property(t => t.ListId).HasColumnName("list_id").IsRequired();
builder.Property(t => t.Title).HasColumnName("title").IsRequired();
builder.Property(t => t.Description).HasColumnName("description");
@@ -74,10 +75,14 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
builder.Property(t => t.PlanningPhase).HasColumnName("planning_phase").IsRequired()
.HasConversion(PhaseConverter).HasDefaultValue(PlanningPhase.None);
builder.Property(t => t.BlockedByTaskId).HasColumnName("blocked_by_task_id");
builder.Property(t => t.DependsOnTaskId).HasColumnName("depends_on_task_id");
builder.Property(t => t.ScheduledFor).HasColumnName("scheduled_for");
builder.Property(t => t.Result).HasColumnName("result");
builder.Property(t => t.ReviewFeedback).HasColumnName("review_feedback");
builder.Property(t => t.RoadblockCount).HasColumnName("roadblock_count").HasDefaultValue(0);
builder.Property(t => t.FailureReason).HasColumnName("failure_reason");
builder.Property(t => t.FailureTurnsUsed).HasColumnName("failure_turns_used");
builder.Property(t => t.FailureMaxTurns).HasColumnName("failure_max_turns");
builder.Property(t => t.LogPath).HasColumnName("log_path");
builder.Property(t => t.CreatedAt).HasColumnName("created_at").IsRequired();
builder.Property(t => t.StartedAt).HasColumnName("started_at");
@@ -93,6 +98,7 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
builder.Property(t => t.Notes).HasColumnName("notes");
builder.Property(t => t.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0);
builder.Property(t => t.SessionSkills).HasColumnName("session_skills");
builder.Property(t => t.ScopeGlobs).HasColumnName("scope_globs");
builder.Property(t => t.HandlerBaseCommit).HasColumnName("handler_base_commit");
builder.Property(t => t.HandlerHeadCommit).HasColumnName("handler_head_commit");
builder.Property(t => t.InteractiveSessionId).HasColumnName("interactive_session_id");
@@ -115,6 +121,13 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
.HasForeignKey(t => t.BlockedByTaskId)
.OnDelete(DeleteBehavior.SetNull);
// DependsOn: user/MCP-declared predecessor. SetNull on delete so the dependent becomes
// pickable rather than blocked forever on a task that no longer exists.
builder.HasOne<TaskEntity>()
.WithMany()
.HasForeignKey(t => t.DependsOnTaskId)
.OnDelete(DeleteBehavior.SetNull);
builder.HasOne(t => t.List)
.WithMany(l => l.Tasks)
.HasForeignKey(t => t.ListId)
@@ -124,10 +137,12 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
.WithOne(w => w.Task)
.HasForeignKey<WorktreeEntity>(w => w.TaskId);
builder.HasIndex(t => t.Number).IsUnique().HasDatabaseName("idx_tasks_number");
builder.HasIndex(t => t.ListId).HasDatabaseName("idx_tasks_list_id");
builder.HasIndex(t => t.Status).HasDatabaseName("idx_tasks_status");
builder.HasIndex(t => new { t.ListId, t.SortOrder }).HasDatabaseName("idx_tasks_list_sort");
builder.HasIndex(t => t.ParentTaskId).HasDatabaseName("idx_tasks_parent_task_id");
builder.HasIndex(t => t.BlockedByTaskId).HasDatabaseName("idx_tasks_blocked_by");
builder.HasIndex(t => t.DependsOnTaskId).HasDatabaseName("idx_tasks_depends_on");
}
}
@@ -30,6 +30,9 @@ public class TaskRunEntityConfiguration : IEntityTypeConfiguration<TaskRunEntity
builder.Property(r => r.StartedAt).HasColumnName("started_at");
builder.Property(r => r.FinishedAt).HasColumnName("finished_at");
builder.Property(r => r.Model).HasColumnName("model");
builder.Property(r => r.ResultSubtype).HasColumnName("result_subtype");
builder.Property(r => r.TerminalReason).HasColumnName("terminal_reason");
builder.Property(r => r.Errors).HasColumnName("errors");
builder.HasOne(r => r.Task)
.WithMany(t => t.Runs)
@@ -1,13 +1,12 @@
using System.Linq.Expressions;
using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Data.Filtering.Filters;
public sealed class ReviewFilter : ITaskListFilter
public sealed class ReviewFilter : TaskListFilterBase
{
public string Id => "virtual:review";
public bool Matches(TaskEntity t) =>
t.Status == TaskStatus.WaitingForReview;
public bool ShouldCount(TaskEntity t) => Matches(t);
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
public override string Id => "virtual:review";
protected override Expression<Func<TaskEntity, bool>> MatchExpr => t => t.Status == TaskStatus.WaitingForReview;
public override bool ShouldCount(TaskEntity t) => Matches(t);
}
@@ -1,3 +1,4 @@
using System.Linq.Expressions;
using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
@@ -7,10 +8,11 @@ namespace ClaudeDo.Data.Filtering.Filters;
/// Filter for a smart list keyed off a boolean/nullable task flag
/// (My Day, Important, Planned). Counts only non-done matches.
/// </summary>
public sealed class SmartFlagFilter(string id, Func<TaskEntity, bool> flag) : ITaskListFilter
public sealed class SmartFlagFilter(string id, Expression<Func<TaskEntity, bool>> flag) : TaskListFilterBase
{
public string Id => id;
public bool Matches(TaskEntity t) => flag(t);
public bool ShouldCount(TaskEntity t) => flag(t) && t.Status != TaskStatus.Done;
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
private readonly Func<TaskEntity, bool> _flag = flag.Compile();
public override string Id => id;
protected override Expression<Func<TaskEntity, bool>> MatchExpr => flag;
public override bool ShouldCount(TaskEntity t) => _flag(t) && t.Status != TaskStatus.Done;
}
@@ -1,3 +1,4 @@
using System.Linq.Expressions;
using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
@@ -7,12 +8,12 @@ namespace ClaudeDo.Data.Filtering.Filters;
/// Virtual list filter matching tasks by a single status (Queued, Running).
/// Planning parents appear contextually when they host a matching child.
/// </summary>
public sealed class StatusFilter(string id, TaskStatus status) : ITaskListFilter
public sealed class StatusFilter(string id, TaskStatus status) : TaskListFilterBase
{
public string Id => id;
public bool Matches(TaskEntity t) => t.Status == status;
public bool ShouldCount(TaskEntity t) => t.Status == status;
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) =>
public override string Id => id;
protected override Expression<Func<TaskEntity, bool>> MatchExpr => t => t.Status == status;
public override bool ShouldCount(TaskEntity t) => t.Status == status;
public override bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) =>
PlanningRules.IsPlanningParent(t) &&
PlanningRules.HasMatchingChild(t, all, c => c.Status == status);
}
@@ -0,0 +1,27 @@
using System.Linq.Expressions;
using ClaudeDo.Data.Models;
namespace ClaudeDo.Data.Filtering.Filters;
/// <summary>
/// Base for <see cref="ITaskListFilter"/> implementations: subclasses express their
/// primary-match condition once as an expression tree (<see cref="MatchExpr"/>), which
/// doubles as a SQL-translatable predicate (<see cref="MatchExpression"/>) and, compiled
/// on first use, as the in-memory <see cref="Matches"/> predicate.
/// </summary>
public abstract class TaskListFilterBase : ITaskListFilter
{
private Func<TaskEntity, bool>? _compiled;
public abstract string Id { get; }
protected abstract Expression<Func<TaskEntity, bool>> MatchExpr { get; }
public Expression<Func<TaskEntity, bool>> MatchExpression => MatchExpr;
public bool Matches(TaskEntity t) => (_compiled ??= MatchExpr.Compile())(t);
public abstract bool ShouldCount(TaskEntity t);
public virtual bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
}
@@ -1,3 +1,4 @@
using System.Linq.Expressions;
using ClaudeDo.Data.Models;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
@@ -7,7 +8,7 @@ namespace ClaudeDo.Data.Filtering.Filters;
/// Filter for any user-defined list. Constructed on demand from the list id —
/// one instance per list.
/// </summary>
public sealed class UserListFilter : ITaskListFilter
public sealed class UserListFilter : TaskListFilterBase
{
private readonly string _listId;
@@ -17,8 +18,7 @@ public sealed class UserListFilter : ITaskListFilter
Id = $"user:{listId}";
}
public string Id { get; }
public bool Matches(TaskEntity t) => t.ListId == _listId;
public bool ShouldCount(TaskEntity t) => t.ListId == _listId && t.Status != TaskStatus.Done;
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
public override string Id { get; }
protected override Expression<Func<TaskEntity, bool>> MatchExpr => t => t.ListId == _listId;
public override bool ShouldCount(TaskEntity t) => t.ListId == _listId && t.Status != TaskStatus.Done;
}
@@ -1,3 +1,4 @@
using System.Linq.Expressions;
using ClaudeDo.Data.Models;
namespace ClaudeDo.Data.Filtering;
@@ -15,6 +16,9 @@ public interface ITaskListFilter
/// <summary>True if <paramref name="t"/> is a primary citizen of this list — appears as a row.</summary>
bool Matches(TaskEntity t);
/// <summary>The primary-match predicate as an expression tree, so EF Core can push it into SQL.</summary>
Expression<Func<TaskEntity, bool>> MatchExpression { get; }
/// <summary>True if <paramref name="t"/> should be counted in this list's badge.</summary>
bool ShouldCount(TaskEntity t);
+12 -6
View File
@@ -25,10 +25,13 @@ public sealed record MergeSegment
/// <summary>"Theirs" side (the incoming branch) when <see cref="IsConflict"/> is true.</summary>
public string Theirs { get; init; } = "";
public static MergeSegment Stable(string text) => new() { Text = text };
/// <summary>1-based line number in the file where this segment starts (the marker line for a conflict).</summary>
public int StartLine { get; init; } = 1;
public static MergeSegment Conflict(string ours, string? @base, string theirs) =>
new() { IsConflict = true, Ours = ours, Base = @base, Theirs = theirs };
public static MergeSegment Stable(string text, int startLine = 1) => new() { Text = text, StartLine = startLine };
public static MergeSegment Conflict(string ours, string? @base, string theirs, int startLine = 1) =>
new() { IsConflict = true, Ours = ours, Base = @base, Theirs = theirs, StartLine = startLine };
}
/// <summary>
@@ -49,22 +52,25 @@ public static class ConflictMarkerParser
var segments = new List<MergeSegment>();
var lines = SplitKeepLineEndings(fileText);
var stable = new StringBuilder();
var stableStartLine = 1;
var i = 0;
while (i < lines.Count)
{
if (!IsMarker(lines[i], OursMarker))
{
if (stable.Length == 0) stableStartLine = i + 1;
stable.Append(lines[i++]);
continue;
}
if (stable.Length > 0)
{
segments.Add(MergeSegment.Stable(stable.ToString()));
segments.Add(MergeSegment.Stable(stable.ToString(), stableStartLine));
stable.Clear();
}
var conflictStartLine = i + 1;
i++; // consume "<<<<<<<"
var ours = new StringBuilder();
while (i < lines.Count && !IsMarker(lines[i], BaseMarker) && !IsMarker(lines[i], SepMarker))
@@ -88,11 +94,11 @@ public static class ConflictMarkerParser
if (i < lines.Count && IsMarker(lines[i], TheirsMarker)) i++; // consume ">>>>>>>"
segments.Add(MergeSegment.Conflict(ours.ToString(), @base, theirs.ToString()));
segments.Add(MergeSegment.Conflict(ours.ToString(), @base, theirs.ToString(), conflictStartLine));
}
if (stable.Length > 0)
segments.Add(MergeSegment.Stable(stable.ToString()));
segments.Add(MergeSegment.Stable(stable.ToString(), stableStartLine));
return segments;
}
+151 -18
View File
@@ -3,7 +3,10 @@ using System.Text;
namespace ClaudeDo.Data.Git;
public sealed record MergePreview(bool Supported, bool Clean, IReadOnlyList<string> ConflictFiles);
// TreeOid is only populated when Clean — the tree `merge-tree --write-tree` would produce,
// usable to materialize the merge result into a scratch worktree without touching the real
// working tree, index, or refs (see GitService.CommitTreeAsync / WorktreeAddDetachedAsync).
public sealed record MergePreview(bool Supported, bool Clean, IReadOnlyList<string> ConflictFiles, string? TreeOid = null);
public sealed class GitService
{
@@ -42,6 +45,16 @@ public sealed class GitService
};
}
/// <summary>
/// The merge base of two refs, or null when git can't find one (e.g. an unresolvable ref) —
/// callers treat that as "can't evaluate", not "no common history".
/// </summary>
public async Task<string?> MergeBaseAsync(string repoDir, string refA, string refB, CancellationToken ct = default)
{
var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["merge-base", refA, refB], ct);
return exitCode == 0 ? stdout.Trim() : null;
}
public async Task WorktreeAddAsync(string repoDir, string branchName, string worktreePath, string baseCommit, CancellationToken ct = default)
{
await WorktreeAddGate.WaitAsync(ct);
@@ -70,9 +83,12 @@ public sealed class GitService
}
}
// --untracked-files=all: without it, a brand-new untracked directory collapses into a single
// "?? dir/" entry instead of listing the files inside it — callers matching against specific
// paths (e.g. the untracked-collision guard) need the individual files.
public async Task<string> GetStatusPorcelainAsync(string workingDirectory, CancellationToken ct = default)
{
var (exitCode, stdout, stderr) = await RunGitAsync(workingDirectory, ["status", "--porcelain"], ct);
var (exitCode, stdout, stderr) = await RunGitAsync(workingDirectory, ["status", "--porcelain", "--untracked-files=all"], ct);
if (exitCode != 0)
throw new InvalidOperationException($"git status --porcelain failed (exit {exitCode}): {stderr}");
return stdout;
@@ -123,16 +139,20 @@ public sealed class GitService
throw new InvalidOperationException($"git commit failed (exit {exitCode}): {stderr}");
}
public async Task<string> GetDiffAsync(string worktreePath, CancellationToken ct = default)
public async Task<string> GetDiffAsync(
string worktreePath, IReadOnlyList<string>? paths = null, CancellationToken ct = default)
{
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath,
["diff", "HEAD"], ct);
var args = new List<string> { "diff", "HEAD" };
AppendPathFilter(args, paths);
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, args, ct);
if (exitCode != 0)
throw new InvalidOperationException($"git diff HEAD failed (exit {exitCode}): {stderr}");
// If nothing staged vs HEAD, try the index (untracked is never in diff)
if (string.IsNullOrWhiteSpace(stdout))
{
var (e2, s2, _) = await RunGitAsync(worktreePath, ["diff", "--cached"], ct);
var cachedArgs = new List<string> { "diff", "--cached" };
AppendPathFilter(cachedArgs, paths);
var (e2, s2, _) = await RunGitAsync(worktreePath, cachedArgs, ct);
if (e2 == 0) return s2;
}
return stdout;
@@ -143,14 +163,19 @@ public sealed class GitService
/// (committed-on-branch changes + uncommitted work). Used for viewing a Claude
/// task's total impact relative to where the branch started.
/// </summary>
public async Task<string> GetBranchDiffAsync(string worktreePath, string baseRef, CancellationToken ct = default)
public async Task<string> GetBranchDiffAsync(
string worktreePath, string baseRef, IReadOnlyList<string>? paths = null, CancellationToken ct = default)
{
var (exitCode, stdout, _) = await RunGitAsync(worktreePath,
["diff", baseRef], ct);
var args = new List<string> { "diff", baseRef };
AppendPathFilter(args, paths);
var (exitCode, stdout, _) = await RunGitAsync(worktreePath, args, ct);
if (exitCode == 0 && !string.IsNullOrWhiteSpace(stdout))
return stdout;
// Fallback: whatever the worktree has vs HEAD (uncommitted only).
return await GetDiffAsync(worktreePath, ct);
// Fallback: whatever the worktree has vs HEAD (uncommitted only). The same pathspec has to
// come along — a filter makes an empty ranged diff the common case (the caller asked for
// paths this range never touched), and an unfiltered fallback would answer that with the
// whole worktree diff, the exact opposite of what was requested.
return await GetDiffAsync(worktreePath, paths, ct);
}
/// <summary>
@@ -158,24 +183,38 @@ public sealed class GitService
/// task's changes after its worktree has been merged away (the commits survive on
/// the target branch even though the worktree directory and branch ref are gone).
/// </summary>
public async Task<string> GetCommitRangeDiffAsync(string repoDir, string baseCommit, string headCommit, CancellationToken ct = default)
public async Task<string> GetCommitRangeDiffAsync(
string repoDir, string baseCommit, string headCommit, IReadOnlyList<string>? paths = null, CancellationToken ct = default)
{
var (exitCode, stdout, stderr) = await RunGitAsync(repoDir,
["diff", $"{baseCommit}..{headCommit}"], ct);
var args = new List<string> { "diff", $"{baseCommit}..{headCommit}" };
AppendPathFilter(args, paths);
var (exitCode, stdout, stderr) = await RunGitAsync(repoDir, args, ct);
if (exitCode != 0)
throw new InvalidOperationException($"git diff {baseCommit}..{headCommit} failed (exit {exitCode}): {stderr}");
return stdout;
}
public async Task<string> DiffStatAsync(string worktreePath, string baseCommit, string headCommit, CancellationToken ct = default)
public async Task<string> DiffStatAsync(
string worktreePath, string baseCommit, string headCommit, IReadOnlyList<string>? paths = null, CancellationToken ct = default)
{
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath,
["diff", "--stat", $"{baseCommit}..{headCommit}"], ct);
var args = new List<string> { "diff", "--stat", $"{baseCommit}..{headCommit}" };
AppendPathFilter(args, paths);
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath, args, ct);
if (exitCode != 0)
throw new InvalidOperationException($"git diff --stat failed (exit {exitCode}): {stderr}");
return stdout.Trim();
}
// Appends a `-- <paths>` pathspec filter so git itself narrows the diff instead of the
// caller filtering the result after the fact (works identically for --stat and full diffs).
// No-op when paths is null/empty so existing callers see no behavior change.
private static void AppendPathFilter(List<string> args, IReadOnlyList<string>? paths)
{
if (paths is not { Count: > 0 }) return;
args.Add("--");
args.AddRange(paths);
}
public async Task<string> GetFileDiffAsync(string worktreePath, string? baseCommit, string relativePath, CancellationToken ct = default)
{
string[] args = string.IsNullOrEmpty(baseCommit)
@@ -357,7 +396,8 @@ public sealed class GitService
["merge-tree", "--write-tree", "--name-only", targetBranch, sourceBranch], ct);
if (exitCode == 0)
return new MergePreview(true, true, Array.Empty<string>());
// stdout is just the written tree's oid on a clean merge.
return new MergePreview(true, true, Array.Empty<string>(), stdout.Trim());
if (exitCode == 1)
{
@@ -377,6 +417,62 @@ public sealed class GitService
return new MergePreview(false, false, Array.Empty<string>());
}
/// <summary>Resolves <paramref name="revision"/> (a branch, tag, or SHA) to a full commit SHA.</summary>
public async Task<string> RevParseAsync(string repoDir, string revision, CancellationToken ct = default)
{
var (exitCode, stdout, stderr) = await RunGitAsync(repoDir, ["rev-parse", revision], ct);
if (exitCode != 0)
throw new InvalidOperationException($"git rev-parse '{revision}' failed (exit {exitCode}): {stderr}");
return stdout.Trim();
}
/// <summary>
/// Creates a new commit object wrapping <paramref name="treeOid"/> with <paramref name="parentSha"/>
/// as its single parent, writing only a loose object — no ref is created or moved.
/// </summary>
public async Task<string> CommitTreeAsync(
string repoDir, string treeOid, string parentSha, string message, CancellationToken ct = default)
{
var (exitCode, stdout, stderr) = await RunGitAsync(repoDir,
["commit-tree", treeOid, "-p", parentSha, "-m", message], ct);
if (exitCode != 0)
throw new InvalidOperationException($"git commit-tree failed (exit {exitCode}): {stderr}");
return stdout.Trim();
}
/// <summary>
/// Materializes <paramref name="commitish"/> into a new, branchless worktree at
/// <paramref name="worktreePath"/> (detached HEAD) — used to build/verify a merge-tree
/// result without ever creating a branch or touching the real working tree.
/// </summary>
public async Task WorktreeAddDetachedAsync(
string repoDir, string worktreePath, string commitish, CancellationToken ct = default)
{
await WorktreeAddGate.WaitAsync(ct);
try
{
const int maxAttempts = 3;
for (var attempt = 1; ; attempt++)
{
var (exitCode, _, stderr) = await RunGitAsync(repoDir,
["worktree", "add", "--detach", worktreePath, commitish], ct);
if (exitCode == 0)
return;
var transient = stderr.Contains("commondir", StringComparison.OrdinalIgnoreCase)
|| stderr.Contains("failed to read", StringComparison.OrdinalIgnoreCase);
if (!transient || attempt >= maxAttempts)
throw new InvalidOperationException($"git worktree add --detach failed (exit {exitCode}): {stderr}");
await Task.Delay(150 * attempt, ct);
}
}
finally
{
WorktreeAddGate.Release();
}
}
/// <summary>Count of files that differ on <paramref name="sourceBranch"/> since its merge base with the target.</summary>
public async Task<int> CountChangedFilesAsync(
string repoDir, string targetBranch, string sourceBranch, CancellationToken ct = default)
@@ -389,6 +485,43 @@ public sealed class GitService
.Count(s => s.Length > 0);
}
/// <summary>Files that differ between two exact refs (2-dot, no merge-base resolution) -- used to see
/// what a target branch itself picked up since a task's fork point, as opposed to
/// <see cref="CountChangedFilesAsync"/>'s 3-dot count of a branch's own changes.</summary>
public async Task<IReadOnlyList<string>> GetChangedFileNamesAsync(
string repoDir, string fromRef, string toRef, CancellationToken ct = default)
{
var (exitCode, stdout, _) = await RunGitAsync(repoDir,
["diff", "--name-only", $"{fromRef}..{toRef}"], ct);
if (exitCode != 0) return Array.Empty<string>();
return stdout
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(s => s.Length > 0)
.ToList();
}
/// <summary>
/// Rebases the branch checked out at <paramref name="worktreePath"/> onto <paramref name="ontoRef"/>.
/// On conflict or any other failure the rebase is aborted before returning, so the worktree is left
/// exactly as it was rather than stranded mid-rebase; <c>ConflictFiles</c> is best-effort and only
/// populated when the failure was an actual conflict.
/// </summary>
public async Task<(int ExitCode, string Stderr, IReadOnlyList<string> ConflictFiles)> RebaseAsync(
string worktreePath, string ontoRef, CancellationToken ct = default)
{
var (exitCode, _, stderr) = await RunGitAsync(worktreePath, ["rebase", ontoRef], ct);
if (exitCode == 0)
return (0, stderr, Array.Empty<string>());
List<string> conflictFiles;
try { conflictFiles = await ListConflictedFilesAsync(worktreePath, ct); }
catch { conflictFiles = new(); }
await RunGitAsync(worktreePath, ["rebase", "--abort"], ct);
return (exitCode, stderr, conflictFiles);
}
private static async Task<(int ExitCode, string Stdout, string Stderr)> RunGitAsync(
string workDir, IEnumerable<string> args, CancellationToken ct, string? stdinData = null, bool trimOutput = true)
{
@@ -0,0 +1,889 @@
// <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("20260810080113_AddFindingsTracked")]
partial class AddFindingsTracked
{
/// <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>("FindingsTracked")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("findings_tracked");
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,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddFindingsTracked : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "findings_tracked",
table: "lists",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "findings_tracked",
table: "lists");
}
}
}
@@ -0,0 +1,899 @@
// <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("20260810113643_AddScopeOverlapFields")]
partial class AddScopeOverlapFields
{
/// <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<bool>("SerializeOnFileOverlap")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("serialize_on_file_overlap");
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>("FindingsTracked")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("findings_tracked");
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>("ScopeGlobs")
.HasColumnType("TEXT")
.HasColumnName("scope_globs");
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,39 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddScopeOverlapFields : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "scope_globs",
table: "tasks",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<bool>(
name: "serialize_on_file_overlap",
table: "list_config",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "scope_globs",
table: "tasks");
migrationBuilder.DropColumn(
name: "serialize_on_file_overlap",
table: "list_config");
}
}
}
@@ -0,0 +1,905 @@
// <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("20260810115110_AddTaskDependency")]
partial class AddTaskDependency
{
/// <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>("FindingsTracked")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("findings_tracked");
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>("DependsOnTaskId")
.HasColumnType("TEXT")
.HasColumnName("depends_on_task_id");
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>("ScopeGlobs")
.HasColumnType("TEXT")
.HasColumnName("scope_globs");
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("DependsOnTaskId")
.HasDatabaseName("idx_tasks_depends_on");
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.TaskEntity", null)
.WithMany()
.HasForeignKey("DependsOnTaskId")
.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,49 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddTaskDependency : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "depends_on_task_id",
table: "tasks",
type: "TEXT",
nullable: true);
migrationBuilder.CreateIndex(
name: "idx_tasks_depends_on",
table: "tasks",
column: "depends_on_task_id");
migrationBuilder.AddForeignKey(
name: "FK_tasks_tasks_depends_on_task_id",
table: "tasks",
column: "depends_on_task_id",
principalTable: "tasks",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_tasks_tasks_depends_on_task_id",
table: "tasks");
migrationBuilder.DropIndex(
name: "idx_tasks_depends_on",
table: "tasks");
migrationBuilder.DropColumn(
name: "depends_on_task_id",
table: "tasks");
}
}
}
@@ -0,0 +1,913 @@
// <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("20260810115437_AddFailureReason")]
partial class AddFailureReason
{
/// <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>("FindingsTracked")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("findings_tracked");
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<int?>("FailureMaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("failure_max_turns");
b.Property<string>("FailureReason")
.HasColumnType("TEXT")
.HasColumnName("failure_reason");
b.Property<int?>("FailureTurnsUsed")
.HasColumnType("INTEGER")
.HasColumnName("failure_turns_used");
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<string>("Errors")
.HasColumnType("TEXT")
.HasColumnName("errors");
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<string>("ResultSubtype")
.HasColumnType("TEXT")
.HasColumnName("result_subtype");
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<string>("TerminalReason")
.HasColumnType("TEXT")
.HasColumnName("terminal_reason");
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,78 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddFailureReason : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "failure_max_turns",
table: "tasks",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "failure_reason",
table: "tasks",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "failure_turns_used",
table: "tasks",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "errors",
table: "task_runs",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "result_subtype",
table: "task_runs",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "terminal_reason",
table: "task_runs",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "failure_max_turns",
table: "tasks");
migrationBuilder.DropColumn(
name: "failure_reason",
table: "tasks");
migrationBuilder.DropColumn(
name: "failure_turns_used",
table: "tasks");
migrationBuilder.DropColumn(
name: "errors",
table: "task_runs");
migrationBuilder.DropColumn(
name: "result_subtype",
table: "task_runs");
migrationBuilder.DropColumn(
name: "terminal_reason",
table: "task_runs");
}
}
}
@@ -0,0 +1,950 @@
// <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("20260811081530_AddTaskNumbers")]
partial class AddTaskNumbers
{
/// <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<int>("NextTaskNumber")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1)
.HasColumnName("next_task_number");
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,
NextTaskNumber = 1,
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<bool>("SerializeOnFileOverlap")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("serialize_on_file_overlap");
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>("FindingsTracked")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("findings_tracked");
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>("DependsOnTaskId")
.HasColumnType("TEXT")
.HasColumnName("depends_on_task_id");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<int?>("FailureMaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("failure_max_turns");
b.Property<string>("FailureReason")
.HasColumnType("TEXT")
.HasColumnName("failure_reason");
b.Property<int?>("FailureTurnsUsed")
.HasColumnType("INTEGER")
.HasColumnName("failure_turns_used");
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<int>("Number")
.HasColumnType("INTEGER")
.HasColumnName("number");
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>("ScopeGlobs")
.HasColumnType("TEXT")
.HasColumnName("scope_globs");
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("DependsOnTaskId")
.HasDatabaseName("idx_tasks_depends_on");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("Number")
.IsUnique()
.HasDatabaseName("idx_tasks_number");
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<string>("Errors")
.HasColumnType("TEXT")
.HasColumnName("errors");
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<string>("ResultSubtype")
.HasColumnType("TEXT")
.HasColumnName("result_subtype");
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<string>("TerminalReason")
.HasColumnType("TEXT")
.HasColumnName("terminal_reason");
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.TaskEntity", null)
.WithMany()
.HasForeignKey("DependsOnTaskId")
.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,67 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddTaskNumbers : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "number",
table: "tasks",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "next_task_number",
table: "app_settings",
type: "INTEGER",
nullable: false,
defaultValue: 1);
// Backfill in creation order (tiebreak by id) so pre-existing tasks get a stable,
// gapless 1..N numbering. Must run before the unique index below.
migrationBuilder.Sql("""
UPDATE tasks
SET number = (
SELECT COUNT(*) FROM tasks t2
WHERE t2.created_at < tasks.created_at
OR (t2.created_at = tasks.created_at AND t2.id <= tasks.id)
);
""");
migrationBuilder.Sql("""
UPDATE app_settings
SET next_task_number = (SELECT COALESCE(MAX(number), 0) + 1 FROM tasks)
WHERE id = 1;
""");
migrationBuilder.CreateIndex(
name: "idx_tasks_number",
table: "tasks",
column: "number",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "idx_tasks_number",
table: "tasks");
migrationBuilder.DropColumn(
name: "number",
table: "tasks");
migrationBuilder.DropColumn(
name: "next_task_number",
table: "app_settings");
}
}
}
@@ -76,6 +76,12 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT")
.HasColumnName("model_presets");
b.Property<int>("NextTaskNumber")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1)
.HasColumnName("next_task_number");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
@@ -164,6 +170,7 @@ namespace ClaudeDo.Data.Migrations
DefaultPermissionMode = "auto",
MaxParallelExecutions = 1,
MaxTurnsCeiling = 80,
NextTaskNumber = 1,
StandupWeekday = 3,
UsageGateFiveHourPct = 80,
UsageGateSevenDayPct = 90,
@@ -225,6 +232,12 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<bool>("SerializeOnFileOverlap")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("serialize_on_file_overlap");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
@@ -259,6 +272,12 @@ namespace ClaudeDo.Data.Migrations
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<bool>("FindingsTracked")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("findings_tracked");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
@@ -461,10 +480,26 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("DependsOnTaskId")
.HasColumnType("TEXT")
.HasColumnName("depends_on_task_id");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<int?>("FailureMaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("failure_max_turns");
b.Property<string>("FailureReason")
.HasColumnType("TEXT")
.HasColumnName("failure_reason");
b.Property<int?>("FailureTurnsUsed")
.HasColumnType("INTEGER")
.HasColumnName("failure_turns_used");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
@@ -520,6 +555,10 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<int>("Number")
.HasColumnType("INTEGER")
.HasColumnName("number");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
@@ -561,6 +600,10 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<string>("ScopeGlobs")
.HasColumnType("TEXT")
.HasColumnName("scope_globs");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
@@ -594,9 +637,16 @@ namespace ClaudeDo.Data.Migrations
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("DependsOnTaskId")
.HasDatabaseName("idx_tasks_depends_on");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("Number")
.IsUnique()
.HasDatabaseName("idx_tasks_number");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
@@ -627,6 +677,10 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<string>("Errors")
.HasColumnType("TEXT")
.HasColumnName("errors");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
@@ -658,6 +712,10 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<string>("ResultSubtype")
.HasColumnType("TEXT")
.HasColumnName("result_subtype");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
@@ -679,6 +737,10 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("TerminalReason")
.HasColumnType("TEXT")
.HasColumnName("terminal_reason");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
@@ -819,6 +881,11 @@ namespace ClaudeDo.Data.Migrations
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("DependsOnTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
@@ -6,6 +6,11 @@ public sealed class AppSettingsEntity
public int Id { get; set; } = SingletonId;
// Persistent counter backing TaskNumberAllocator: the next task number to hand out. Lives on
// the singleton row so allocation is a single atomic UPDATE...RETURNING in the task's own
// insert transaction, never a MAX(number)+1 recompute.
public int NextTaskNumber { get; set; } = 1;
public string DefaultClaudeInstructions { get; set; } = string.Empty;
public string DefaultModel { get; set; } = "sonnet";
public int DefaultMaxTurns { get; set; } = 40;
@@ -10,6 +10,11 @@ public sealed class ListConfigEntity
public string? SessionSkills { get; set; }
public string? VerifyCommand { get; set; }
// Off by default: tasks in this list run in parallel exactly as before. When on, the queue
// picker holds back a queued task whose declared ScopeGlobs overlaps a running or
// awaiting-merge sibling in the same list instead of claiming it.
public bool SerializeOnFileOverlap { get; set; }
// Navigation property
public ListEntity List { get; set; } = null!;
}
+3
View File
@@ -11,6 +11,9 @@ public sealed class ListEntity
// A manual list holds reminders rather than Claude work: new tasks created here start out
// manual (see TaskEntity.IsManual).
public bool IsManual { get; set; }
// When true the project's .claudedo/ findings store is committed with the repo; when false it
// is kept out of git via .git/info/exclude (see FindingsStore).
public bool FindingsTracked { get; set; }
// Navigation properties
public ListConfigEntity? Config { get; set; }
@@ -9,6 +9,9 @@ public static class ModelRegistry
public const string DefaultAlias = "sonnet";
public const string PlanningAlias = "opus";
public const string HandlerTriageAlias = "opus";
public const string HandlerWaitAlias = "sonnet";
public const string HandlerMergeAlias = "opus";
public const string ListDefaultSentinel = "(default)";
public const string TaskInheritSentinel = "(inherit)";
+23
View File
@@ -22,16 +22,33 @@ public enum PlanningPhase
public sealed class TaskEntity
{
public required string Id { get; init; }
// Global, monotonically increasing, never-reused display/lookup handle shown as `#123`.
// Allocated by TaskNumberAllocator from AppSettingsEntity.NextTaskNumber -- never MAX(number)+1,
// which would reissue a deleted task's number. The GUID Id stays the real identity everywhere else.
public int Number { get; set; }
public required string ListId { get; init; }
public required string Title { get; set; }
public string? Description { get; set; }
public TaskStatus Status { get; set; } = TaskStatus.Idle;
public PlanningPhase PlanningPhase { get; set; } = PlanningPhase.None;
public string? BlockedByTaskId { get; set; }
// A user/MCP-declared predecessor, distinct from BlockedByTaskId (the planning chain's own
// internal link): the picker also skips a Queued task while this is set and the referenced
// task's Status isn't Done. Unlike the chain, a Failed/Cancelled dependency does NOT cascade
// or auto-resolve -- the dependent just stays blocked and reports why (see TaskStateService.
// SetDependsOnAsync and QueuePicker).
public string? DependsOnTaskId { get; set; }
public DateTime? ScheduledFor { get; set; }
public string? Result { get; set; }
public string? ReviewFeedback { get; set; }
public int RoadblockCount { get; set; }
// Denormalized from the failing run, same pattern as RoadblockCount, so get_task/
// batch_get_tasks can report why a Failed task stopped without a second query.
// Null on a non-Failed task, or on a Failed task predating this field ("unknown").
public string? FailureReason { get; set; }
public int? FailureTurnsUsed { get; set; }
public int? FailureMaxTurns { get; set; }
public string? LogPath { get; set; }
public required DateTime CreatedAt { get; init; }
public DateTime? StartedAt { get; set; }
@@ -51,6 +68,12 @@ public sealed class TaskEntity
public int SortOrder { get; set; }
public string? SessionSkills { get; set; }
// Newline-separated declared file paths/globs this task expects to touch. User-supplied only
// (never inferred). Only consulted by the queue picker when the list's
// ListConfigEntity.SerializeOnFileOverlap is on, and only for this task's own overlap check --
// an unset value means "no basis to hold this task back", not "touches nothing".
public string? ScopeGlobs { get; set; }
// Review range for a worktree-less task hosting an interactive "list handler" run
// (Mission Control's "Let Claude handle it"): the handler commits its own changes
// straight to the list's working dir, so there is no per-task worktree to diff. These
@@ -21,6 +21,11 @@ public sealed class TaskRunEntity
public DateTime? StartedAt { get; set; }
public DateTime? FinishedAt { get; set; }
public string? Model { get; set; }
// Raw diagnostics from the CLI's stream-json result event (StreamAnalyzer), kept verbatim
// for get_run/get_task_log — TaskEntity.FailureReason is the classified, MCP-facing summary.
public string? ResultSubtype { get; set; }
public string? TerminalReason { get; set; }
public string? Errors { get; set; }
// Navigation property
public TaskEntity Task { get; set; } = null!;
+16
View File
@@ -25,6 +25,22 @@ public static class Paths
return Path.GetFullPath(expanded);
}
/// <summary>
/// Strips trailing directory separators off a path bound for a Windows command line. Windows
/// argv rules read <c>\"</c> as an escaped quote, so a quoted token ending in '\' never closes
/// (<c>"C:\repo\"</c> parses as <c>C:\repo"</c>) and either the path itself or every argument
/// after it is corrupted. A bare root ("C:\", "/") is all separator and is returned untouched;
/// so is null/blank.
/// </summary>
public static string? TrimTrailingSeparator(string? path)
{
if (string.IsNullOrWhiteSpace(path))
return path;
var trimmed = path.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
return trimmed.Length == 0 || trimmed.EndsWith(':') ? path : trimmed;
}
/// <summary>~/.todo-app — parent directory for db, logs, config, sandbox, worktrees.</summary>
public static string AppDataRoot() =>
Expand("~/.todo-app");
+63 -38
View File
@@ -5,7 +5,7 @@ using System.Text.RegularExpressions;
namespace ClaudeDo.Data;
public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport, ImprovementChild, Refine, MergeHelperTriage, MergeHelperExecute, MergeHelperInitial, MergeHelperHandoff }
public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport, ImprovementChild, Refine, MergeHelperTriage, MergeHelperWait, MergeHelperMerge, MergeHelperInitial, MergeHelperHandoff }
/// <summary>
/// How a prompt kind's on-disk override (if any) relates to the bundled default.
@@ -40,7 +40,8 @@ public static class PromptFiles
PromptKind.ImprovementChild => "improvement-child.md",
PromptKind.Refine => "refine.md",
PromptKind.MergeHelperTriage => "merge-helper-triage.md",
PromptKind.MergeHelperExecute => "merge-helper-execute.md",
PromptKind.MergeHelperWait => "merge-helper-wait.md",
PromptKind.MergeHelperMerge => "merge-helper-merge.md",
PromptKind.MergeHelperInitial => "merge-helper-initial.md",
PromptKind.MergeHelperHandoff => "merge-helper-handoff.md",
_ => throw new ArgumentOutOfRangeException(nameof(kind))
@@ -216,7 +217,8 @@ public static class PromptFiles
PromptKind.ImprovementChild => ImprovementChildDefault,
PromptKind.Refine => RefineDefault,
PromptKind.MergeHelperTriage => MergeHelperTriageDefault,
PromptKind.MergeHelperExecute => MergeHelperExecuteDefault,
PromptKind.MergeHelperWait => MergeHelperWaitDefault,
PromptKind.MergeHelperMerge => MergeHelperMergeDefault,
PromptKind.MergeHelperInitial => MergeHelperInitialDefault,
PromptKind.MergeHelperHandoff => MergeHelperHandoffDefault,
_ => ""
@@ -442,6 +444,13 @@ public static class PromptFiles
Cancel nothing without an explicit answer.
## Phase 2 Enhance for execution
Before touching any task, check the list's verify command ONCE for this whole run (not per task): call get_list_config. If verifyCommand is already set, leave it alone never overwrite an existing one and move straight to sharpening tasks below.
If no verifyCommand is set:
1. Determine the repo's canonical, WIDE build-and-test command read its CLAUDE.md/README. It must be a full build plus the complete test suite, deliberately NOT something narrowed to this run's tasks: a narrow verify only checks the files a task touched and lets test collisions slip through that a src-only build would never see.
2. Run that command yourself once via Bash to prove it actually works in this repo.
3. Only then ask the user to confirm it, and write it with set_list_config(verifyCommand: ...). This value outlives the run and applies to every future task on this list, so confirm never set it silently.
Each surviving task is about to be run by an autonomous agent with no further input. Sharpen it so that run can succeed. For each task, rewrite title and description to carry:
- concrete acceptance criteria what must be true when it is done,
- the files and areas actually involved, found with Read/Grep/Glob in the repo. Do not guess paths; look them up.
@@ -457,62 +466,77 @@ public static class PromptFiles
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.
Then call handoff_list_handler(taskId, survivingTaskIds, nextPhase: "wait"). That opens a fresh session to carry out the wait/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 = """
private const string MergeHelperWaitDefault = """
You are the ClaudeDo list handler, running as an interactive session with the user watching. This is the Wait phase the shortest and cheapest of the three list-handler roles. Your only job is to queue the tasks you were handed and wait for them to finish running; you do not read diffs, merge anything, or restart anything. The Merge session that runs after you owns all of those decisions.
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. A prior session already sharpened these task descriptions do not re-read or re-edit them.
## Phase 3 Queue and wait
1. Queue every task handed to you: update_task_status(id, "Queued") for each one.
2. Wait with wait_for_task_change instead of sleeping and polling get_task yourself. Pass every handed-off id, set treatWaitingForChildrenAsBusy=true without that flag a task with children is reported done the moment it reaches WaitingForChildren while its children are still working and use timeoutSeconds 900. The call blocks server-side up to 900s and pings MCP progress every 30s, so one long wait survives your client's own idle-silence abort and costs one turn where several short ones would cost several.
3. Repeat step 2 with whatever ids are still Queued, Running or WaitingForChildren, until none remain.
Touch nothing else: do not read a diff, merge, reset, restart, or cancel any task. A task that lands in Failed is simply noted and passed on deciding what to do about it belongs to the Merge session, not you.
MaxParallelExecutions is deliberately not reported here the queue enforces it on its own; that reporting was the old combined phase's job and no longer belongs to this role.
## Handoff
Once every task has left Queued/Running/WaitingForChildren, call handoff_list_handler(taskId, <every task id from this round>, nextPhase: "merge") or nextPhase: "merge_final" if your own phase was "wait_final". Then stop your turn immediately.
""";
private const string MergeHelperMergeDefault = """
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. 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, 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; the wait below covers it.
- WaitingForReview leave it; it goes straight to Phase 4.
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.
Your job: review and merge the tasks a prior Wait session ran, then decide what if anything deserves one rerun. 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. The handed-off id list is only the SET of tasks not their current state: call batch_get_tasks yourself to read where each one actually landed.
## 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.
One task at a time. Default to the order the brief lists them. If the overlap check above (or the file lists you gathered in Phase 2) shows two or more tasks touching the same file, tell the user which tasks collide and merge those in an order you can justify (e.g. the one making the smaller change first) deviate from brief order only with that stated reason.
Merge everything mergeable BEFORE starting any rerun. A rerun's fresh worktree forks from the CURRENT main, so running it after the merges lets it fork from the already-updated base and merge conflict-free; a rerun started before the merges forks from the old base and walks straight into a conflict with the merges still to come.
1. Inspect the change with get_task_diff (stat first, then the full diff if it is non-trivial) and sanity-check it against the task's title and description. If preview_merge_set flagged this task in an overlap, also skim the diff of the other task(s) sharing that file.
2. If the change looks wrong, incomplete, or risky, STOP and ask the user before merging offer reject_rerun (with feedback) or skip.
3. Otherwise merge with review_task(taskId, decision="approve", leaveConflictsInTree=true).
- Clean merge the task is Done; move on.
- Conflict (markers left in the working tree, repoPath returned) resolve it.
Before merging anything, call preview_merge_set with every candidate 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 task 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.
One task at a time. Default to the order the brief lists them. If the overlap check shows two or more tasks touching the same file, tell the user which tasks collide and merge those in an order you can justify (e.g. the one making the smaller change first) deviate from brief order only with that stated reason.
1. Never read a task's diff yourself. Instead start a sonnet subagent (Task tool) per task to inspect get_task_diff and sanity-check it against the task's title and description; only its short verdict (clean / risky + reason) comes back into your own context. If preview_merge_set flagged this task in an overlap, tell the subagent to also skim the diff of the other task(s) sharing that file.
2. Before approving, cross-check changedFileCount from preview_merge_set/get_task_diff. 0 changed files means an empty branch, not a clean success do not approve it; treat it like a risky diff instead.
3. If the subagent's verdict is risky, or the change looks wrong or incomplete, STOP and ask the user before merging offer reject_rerun (with feedback) or skip.
4. Otherwise merge with review_task(taskId, decision="approve", leaveConflictsInTree=true).
- Clean merge, verify gate passes (or no verify command is set) the task is Done; move on.
- Clean merge, verify gate fails the merge stands, but the Done transition is deliberately withheld. Report this task as merged-but-not-Done and move on do NOT try to fix the verify failure yourself; that is separate follow-up work.
- Conflict (markers left in the working tree, repoPath returned) resolve it below.
After each merge, main has moved a clean preview_merge_set result from before this merge is now stale for the remaining tasks. If you are unsure whether an earlier overlap warning still matters, call preview_merge for the next task again before merging it.
Every branch in this run forked from the same base, so conflicts between them are the NORMAL case, not a failure. Resolve them and keep going; do not abandon the run because a merge conflicted.
Resolving a conflict:
Resolving a conflict do this yourself, never via a subagent, since every subagent would share this session's same main checkout:
- Open each conflicted file under repoPath (Read/Edit) and resolve the <<<<<<< ======= >>>>>>> markers, guided by BOTH sides' intent. Then call continue_merge(taskId). If markers remain it tells you fix and call again. Use abort_merge(taskId) to cancel a merge you cannot safely resolve.
- For a task WITH children (a unit merge), pass the PARENT task id to continue_merge / abort_merge.
- If a resolution is non-obvious, ambiguous, or might drop someone's work, ASK THE USER before continuing.
- Prefer the MCP tools whenever they apply. Only if the MCP tools cannot reach an in-progress merge may you finish it by hand: resolve the markers, then `git add -- <the resolved paths>` and `git commit` NEVER `git add -A` or a bare commit, because the checkout is shared with other sessions.
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.
## Failed tasks only after every merge above is done
For each task that landed in Failed, read terminal_reason before deciding anything Failed is a catch-all covering an account/usage limit, a max-turns cutoff, and a genuine crash, and each deserves a different response. Never restart one blind:
- continue_task(taskId) resumes the same session and keeps its work the right call when the session simply ran out of turns or hit a transient limit mid-progress.
- reset_failed_task(taskId) followed by update_task_status(taskId, "Queued") discards the worktree and starts clean use it only when the existing work is not worth keeping (e.g. a genuine crash early on).
- Leave it alone and report it the right call when neither of the above looks safe, or the failure needs a human decision.
Allow at most ONE rerun per task in this session.
## Handoff / end
- If you started any reruns: call handoff_list_handler(taskId, <the rerun task ids>, nextPhase: "wait_final"), then stop your turn immediately do not merge anything yet that you just restarted.
- If your own phase was "merge_final": never start a rerun, no matter how tempting just merge what you can, report the rest, and print the summary below.
- Otherwise (no reruns started, phase was "merge"): print the summary below and stop; there is nothing left to hand off.
## Phase 5 Summary
Print one line per task from the brief:
title final status merge commit (if any) conflicts resolved (if any).
title final status merge commit (if any) conflicts resolved (if any) verify-failed (yes/no).
Then list anything you skipped or left for the user and why, and any follow-ups worth turning into new tasks.
""";
@@ -543,6 +567,7 @@ public static class PromptFiles
Start with phase 3 (run), continuing through review/merge and the summary as your
instructions describe.
{finalNote}
""";
private const string WeeklyReportDefault = """
@@ -16,16 +16,25 @@ public sealed class ListRepository
public async Task AddAsync(ListEntity entity, CancellationToken ct = default)
{
NormalizeWorkingDir(entity);
_context.Lists.Add(entity);
await _context.SaveChangesAsync(ct);
}
public async Task UpdateAsync(ListEntity entity, CancellationToken ct = default)
{
NormalizeWorkingDir(entity);
_context.Lists.Update(entity);
await _context.SaveChangesAsync(ct);
}
// A working dir is user-supplied (typed, folder-picked, imported, or set over MCP) and every
// consumer eventually hands it to the claude CLI or wt.exe as one quoted command-line token —
// where a trailing '\' escapes its own closing quote. Normalizing here covers all writers;
// see Paths.TrimTrailingSeparator.
private static void NormalizeWorkingDir(ListEntity entity) =>
entity.WorkingDir = Paths.TrimTrailingSeparator(entity.WorkingDir);
public async Task DeleteAsync(string listId, CancellationToken ct = default)
{
var taskIds = await _context.Tasks
@@ -79,6 +88,7 @@ public sealed class ListRepository
existing.MaxTurns = config.MaxTurns;
existing.SessionSkills = config.SessionSkills;
existing.VerifyCommand = config.VerifyCommand;
existing.SerializeOnFileOverlap = config.SerializeOnFileOverlap;
}
await _context.SaveChangesAsync(ct);
}
@@ -26,8 +26,7 @@ public sealed class TaskRepository
.MaxAsync(ct);
entity.SortOrder = (maxSort ?? -1) + 1;
_context.Tasks.Add(entity);
await _context.SaveChangesAsync(ct);
await TaskNumberAllocator.AddWithNumberAsync(_context, entity, ct);
}
public async Task UpdateAsync(TaskEntity entity, CancellationToken ct = default)
@@ -50,6 +49,11 @@ public sealed class TaskRepository
return await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId, ct);
}
public async Task<TaskEntity?> GetByNumberAsync(int number, CancellationToken ct = default)
{
return await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Number == number, ct);
}
public async Task<List<TaskEntity>> GetByListIdAsync(string listId, CancellationToken ct = default)
{
return await _context.Tasks
@@ -309,8 +313,7 @@ public sealed class TaskRepository
Model = ModelRegistry.NormalizeAlias(model),
MaxTurns = maxTurns,
};
_context.Tasks.Add(child);
await _context.SaveChangesAsync(ct);
await TaskNumberAllocator.AddWithNumberAsync(_context, child, ct);
return child;
}
@@ -45,6 +45,17 @@ public sealed class WorktreeRepository
.SetProperty(w => w.MergeCommit, mergeCommit), ct);
}
/// <summary>Records a successful auto-rebase: the branch's fork point moves to the rebased-onto
/// commit and its head moves to the new tip <c>git rebase</c> produced.</summary>
public async Task SetRebasedAsync(string taskId, string baseCommit, string headCommit, CancellationToken ct = default)
{
await _context.Worktrees
.Where(w => w.TaskId == taskId)
.ExecuteUpdateAsync(s => s
.SetProperty(w => w.BaseCommit, baseCommit)
.SetProperty(w => w.HeadCommit, headCommit), ct);
}
public async Task DeleteAsync(string taskId, CancellationToken ct = default)
{
await _context.Worktrees.Where(w => w.TaskId == taskId).ExecuteDeleteAsync(ct);
+87
View File
@@ -0,0 +1,87 @@
using ClaudeDo.Data.Models;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Data;
// Allocates TaskEntity.Number and inserts the task. Never MAX(number)+1 -- that would reissue a
// deleted task's number. Allocation is a single atomic UPDATE...RETURNING against the
// app_settings singleton row, which SQLite's single-writer model already serializes.
//
// The allocation and the insert are deliberately two separate statements, each committed on its
// own, rather than one shared transaction: a shared transaction would roll the counter bump back
// together with a failed insert, so a retry would hand out the exact same (still colliding)
// number every time and the bounded retry below would be pointless. Committing the bump
// unconditionally means a retry after a collision always advances to a fresh number, at the cost
// of burning the collided number as an unused gap -- which is fine, gaps are expected.
public static class TaskNumberAllocator
{
private const int MaxAttempts = 5;
public static async Task AddWithNumberAsync(
ClaudeDoDbContext context, TaskEntity entity, CancellationToken ct = default)
{
for (var attempt = 1; ; attempt++)
{
var settings = await context.AppSettings.FromSqlRaw(
"""
UPDATE app_settings SET next_task_number = next_task_number + 1
WHERE id = {0}
RETURNING *
""", AppSettingsEntity.SingletonId).AsNoTracking().ToListAsync(ct);
if (settings.Count == 0)
{
// The singleton row is missing (e.g. a corrupted/hand-edited DB) -- recreate it,
// as its own separate insert/commit, and retry the UPDATE above rather than
// indexing into an empty result. Doesn't count against MaxAttempts: it's not a
// number collision.
await EnsureSingletonRowAsync(context, ct);
continue;
}
entity.Number = settings[0].NextTaskNumber - 1;
context.Tasks.Add(entity);
try
{
await context.SaveChangesAsync(ct);
return;
}
catch (DbUpdateException ex)
{
context.Entry(entity).State = EntityState.Detached;
if (attempt < MaxAttempts && IsNumberCollision(ex))
continue;
throw;
}
}
}
private static async Task EnsureSingletonRowAsync(ClaudeDoDbContext context, CancellationToken ct)
{
var row = new AppSettingsEntity { Id = AppSettingsEntity.SingletonId };
context.AppSettings.Add(row);
try
{
await context.SaveChangesAsync(ct);
}
catch (DbUpdateException)
{
// A concurrent process already inserted the singleton -- fine, the next UPDATE
// attempt above will find it.
}
finally
{
context.Entry(row).State = EntityState.Detached;
}
}
// Distinguishes the expected unique-index collision on idx_tasks_number (worth retrying with
// a fresh number) from any other DbUpdateException -- an FK violation, a NOT NULL violation,
// etc. -- which must surface immediately instead of silently burning up to MaxAttempts numbers
// on a failure a retry can never fix.
private static bool IsNumberCollision(DbUpdateException ex) =>
ex.InnerException is SqliteException { SqliteErrorCode: 19 } sqliteEx
&& sqliteEx.Message.Contains("tasks.number", StringComparison.Ordinal);
}
@@ -178,5 +178,6 @@ public sealed class ClaudeHelpLauncher
private static string Quote(string value) => value.Contains(' ') ? $"\"{value}\"" : value;
private static string QuoteDirectory(string directory) => Quote(directory.TrimEnd('\\', '/'));
private static string QuoteDirectory(string directory) =>
Quote(ClaudeDo.Data.Paths.TrimTrailingSeparator(directory)!);
}
+72 -5
View File
@@ -75,6 +75,8 @@
"addSchedule": "+ Zeitplan hinzufügen",
"removeScheduleTip": "Zeitplan entfernen",
"dailyPrepMaxTasks": "Max. Aufgaben pro Tag",
"promptOverrideLabel": "Prompt-Zusatz",
"promptOverridePlaceholder": "Zusätzliche Anweisungen, die für diesen Zeitplan an den Prime-Prompt angehängt werden (optional)",
"dayMo": "Mo",
"dayTu": "Di",
"dayWe": "Mi",
@@ -161,6 +163,7 @@
"badgeDraft": "ENTWURF",
"badgePlanned": "GEPLANT",
"badgeManual": "MANUELL",
"chainAfterPrefix": "nach",
"manualTip": "Manuelle Erinnerung — Claude nimmt sie nie auf",
"ctxMarkManual": "Als manuell markieren",
"ctxMarkClaudeTask": "Als Claude-Aufgabe markieren",
@@ -197,6 +200,7 @@
"contextWorktrees": "Worktrees…",
"contextOpenExplorer": "Im Explorer öffnen",
"contextOpenTerminal": "Im Terminal öffnen",
"contextOpenFindings": "Findings-Ordner öffnen",
"newList": "Neue Liste",
"addReposTip": "Repos als Listen hinzufügen"
},
@@ -263,7 +267,8 @@
"chipDone": "FERTIG",
"chipFailed": "FEHLGESCHLAGEN",
"reviewContinueTip": "Dieses Feedback senden und die Aufgabe erneut ausführen",
"reviewResetTip": "Alle Änderungen verwerfen und die Aufgabe auf Leerlauf zurücksetzen"
"reviewResetTip": "Alle Änderungen verwerfen und die Aufgabe auf Leerlauf zurücksetzen",
"reviewFeedbackPlaceholder": "Feedback für nächsten Lauf"
},
"missionControl": {
"windowTitle": "Mission Control",
@@ -334,6 +339,8 @@
"defaultCommitType": "Standard-Commit-Typ",
"manualList": "Manuelle Liste (Erinnerungen)",
"manualListHint": "Neue Aufgaben in dieser Liste sind zunächst manuell: kein Einreihen, Ausführen oder Verfeinern, und die Automatik überspringt sie. Eine handgesteuerte Sitzung kannst du weiterhin öffnen.",
"findingsTracked": "Findings-Ordner .claudedo einchecken",
"findingsTrackedHint": "Aus: der Ordner bleibt über .git/info/exclude aus git heraus. An: Findings reisen mit dem Repo.",
"sectionAgent": "AGENT",
"resetAgentSettings": "Agent-Einstellungen zurücksetzen",
"sectionVerify": "VERIFIKATION",
@@ -362,7 +369,9 @@
"splitView": "Nebeneinander",
"wrapLines": "Zeilenumbruch",
"paneBase": "BASIS",
"paneWorktree": "WORKTREE"
"paneWorktree": "WORKTREE",
"maximize": "Maximieren",
"restore": "Wiederherstellen"
},
"worktreesOverview": {
"refresh": "Aktualisieren",
@@ -408,7 +417,9 @@
"selectNone": "Keine auswählen",
"empty": "Keine Aufgaben zu übergeben — alles erledigt oder abgebrochen.",
"columnTask": "AUFGABE",
"columnStatus": "STATUS"
"columnStatus": "STATUS",
"orphanHint": "ELTERNTEIL NICHT IN AUSWAHL",
"orphanHintTip": "Das Elternteil dieser Unteraufgabe wird hier nicht angeboten (bereits erledigt oder abgebrochen), daher steht sie einzeln."
},
"unfinishedPlanning": {
"title": "UNVOLLENDETE PLANUNGSSITZUNG",
@@ -654,12 +665,15 @@
"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" },
"failureReason": { "maxTurns": "Turn-Limit erreicht", "timeout": "Zeitüberschreitung", "error": "Fehler", "cancelled": "Abgebrochen", "unknown": "Grund unbekannt" },
"failureReasonTooltip": { "maxTurns": "Turn-Limit erreicht ({0}/{1} Turns) — der Worktree ist meist brauchbar; Task fortsetzen statt zurücksetzen." },
"planningBadge": { "active": "PLANUNG", "finalized": "GEPLANT" },
"taskRow": { "createdPrefix": "Erstellt {0}", "stepsText": "{0}/{1} Schritte" },
"queue": { "baseDirtyWarning": "Achtung: Das Repo dieser Liste hat uncommittete Änderungen ({0} geändert, {1} untracked) — ein neuer Worktree startet vom letzten Commit und enthält sie nicht." },
"tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "planningOpenFailed": "Planungssitzung konnte nicht geöffnet werden: {0}", "planningResumeFailed": "Planungssitzung konnte nicht fortgesetzt werden: {0}", "approveFailed": "Genehmigen & Mergen fehlgeschlagen: {0}", "cancelReviewFailed": "Prüfung abbrechen fehlgeschlagen: {0}", "sendToQueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "queuePlanBlockedInteractive": "Plan kann nicht in die Warteschlange gestellt werden — {0} hat eine offene interaktive Sitzung und muss zuerst geschlossen werden.", "moveRunningRejected": "Ein laufender Task kann nicht in eine andere Liste verschoben werden.", "moveWorktreeRejected": "Verschieben nicht möglich — dieser Task hat einen aktiven Worktree, der auf sein aktuelles Repo zeigt.", "moveRepoConfirm": "Unterschiedliche Repos — {0} → {1}. Task trotzdem verschieben?", "moveConfirmUnavailable": "Verschieben nicht möglich — der Bestätigungsdialog ist nicht verfügbar.", "quickClaudeNoWorkingDir": "Für diese Liste ist kein Arbeitsverzeichnis konfiguriert.", "quickClaudeDirMissing": "Arbeitsverzeichnis existiert nicht mehr: {0}" },
"diff": { "loadFailed": "Diff konnte nicht geladen werden: {0}", "noChanges": "Keine Änderungen anzuzeigen.", "unavailable": "Diff nicht mehr verfügbar — Commit-Bereich unvollständig." },
"planningDiff": { "hubError": "Kombinierte Vorschau konnte nicht erstellt werden (Hub-Fehler).", "conflict": "Kombinierte Vorschau nicht möglich: Teilaufgabe {0} steht im Konflikt mit einer früheren Teilaufgabe ({1} Dateien).", "buildFailed": "Kombinierte Vorschau konnte nicht erstellt werden: {0}" },
"merge": { "commitMessage": "Merge-Aufgabe: {0}", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "conflict": "Merge-Konflikt — Ziel-Branch wiederhergestellt. Manuell oder über Fortsetzen lösen, dann erneut versuchen.", "blocked": "Blockiert: {0}", "verifyFailed": "Merge ist gelandet, aber das Verify-Kommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf Erledigt gesetzt.", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" },
"merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Wird zusammengeführt…", "progressVerifying": "Verify-Kommando der Liste läuft… ({0})", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "conflict": "Merge-Konflikt — Ziel-Branch wiederhergestellt. Manuell oder über Fortsetzen lösen, dann erneut versuchen.", "blocked": "Blockiert: {0}", "verifyFailed": "Merge ist gelandet, aber das Verify-Kommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf Erledigt gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" },
"conflictResolution": { "vsCodeError": "VS Code konnte nicht gestartet werden: {0}. Die Pfade sind oben aufgeführt — kopiere sie manuell.", "subtaskPrefix": "Konflikte in Teilaufgabe: {0}", "targetPrefix": "Zusammenführen in: {0}" },
"settingsModal": { "workerOffline": "Worker offline — Einstellungen schreibgeschützt.", "saveFailed": "Speichern fehlgeschlagen: {0}" },
"onlineInbox": { "workerOffline": "Worker offline — Konfiguration kann nicht geladen werden.", "saved": "Konfiguration gespeichert.", "saveFailed": "Speichern fehlgeschlagen: {0}", "signedIn": "Erfolgreich angemeldet.", "signedInNoRole": "Angemeldet, aber diesem Konto fehlt die Rolle 'user' in Zitadel — die Online-Synchronisierung wird abgelehnt, bis die Rolle im ClaudeDo-Projekt zugewiesen wird.", "signInFailed": "Anmeldung fehlgeschlagen: {0}", "signedOut": "Abgemeldet.", "signOutFailed": "Abmeldung fehlgeschlagen: {0}" },
@@ -674,10 +688,63 @@
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "{0} Worktree(s) entfernt.", "blocked": "Zwangsentfernung nicht möglich: {0} Aufgabe(n) laufen noch. Brich sie zuerst ab.", "removedFrom": "{0} Worktree(s) von {1} Aufgabe(n) entfernt.", "cleanupFailed": "Aufräumen fehlgeschlagen: {0}", "resetFailed": "Zurücksetzen fehlgeschlagen: {0}" },
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "Liste", "cleanupFailed": "Aufräumen fehlgeschlagen.", "cleanupFailedDetailed": "Aufräumen fehlgeschlagen: {0}", "removed": "{0} Worktree(s) entfernt.", "discardFailed": "Worktree konnte nicht verworfen werden.", "keepFailed": "Worktree konnte nicht behalten werden.", "cannotForceRunning": "Eine laufende Aufgabe kann nicht zwangsweise entfernt werden.", "forceRemoveFailed": "Zwangsentfernung fehlgeschlagen.", "forceRemoveFailedDetailed": "Zwangsentfernung fehlgeschlagen: {0}", "batchProgress": "Merge {0}/{1}…", "batchDone": "{0} gemergt, {1} zu lösen." },
"listSettings": { "untitled": "Unbenannt" },
"detailsIsland": { "verifyFailed": "Merge ist erfolgt, aber das Verifikationskommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf 'Erledigt' gesetzt." },
"detailsIsland": { "verifyFailed": "Merge ist erfolgt, aber das Verifikationskommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf 'Erledigt' gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben." },
"lists": { "localSuffix": "{0} / lokal", "smartMyDay": "Mein Tag", "smartImportant": "Wichtig", "smartPlanned": "Geplant", "virtualQueue": "Warteschlange", "virtualRunning": "Läuft", "virtualReview": "Prüfung", "newList": "Neue Liste" },
"repoImport": { "loadFailed": "Gespeicherte Ordner konnten nicht geladen werden: {0}", "saveFailed": "Ordner konnten nicht gespeichert werden: {0}" }
},
"ops": {
"stalledHint": "Läuft weiter — Worker antwortet noch",
"merge": {
"merging": "Wird zusammengeführt…",
"verifying": "Verify-Kommando der Liste läuft… ({0})"
},
"diff": {
"parsing": "Diff wird geparst…"
},
"review": {
"submitting": "Wird zur Prüfung eingereicht…",
"rejecting": "Wird abgelehnt…",
"parking": "Wird geparkt…",
"previewing": "Vorschau wird geladen…"
},
"worktrees": {
"refreshing": "Wird aktualisiert…",
"cleaningUp": "Abgeschlossene Worktrees werden aufgeräumt…",
"resetting": "Worktree wird zurückgesetzt…",
"forceRemoving": "Worktree wird zwangsweise entfernt…",
"batchMerging": "Merge {0}/{1}…"
},
"skills": {
"installing": "Skill wird geklont…",
"updating": "Skill wird aktualisiert…",
"restoringDefaults": "Standard-Agenten werden wiederhergestellt…"
},
"onlineInbox": {
"signingIn": "Wird angemeldet…"
},
"repoImport": {
"scanning": "Repositories werden gescannt…"
},
"updateCheck": {
"checking": "Wird nach Updates gesucht…"
},
"reports": {
"generatingWeekly": "Bericht wird erstellt…",
"runningDailyPrep": "Tagesvorbereitung läuft…"
},
"planning": {
"buildingIntegrationBranch": "Integrations-Branch wird erstellt…",
"loadingAggregate": "Kombinierte Vorschau wird geladen…",
"finalizing": "Plan wird finalisiert…",
"queuingSubtasks": "Teilaufgaben werden eingereiht…"
},
"worker": {
"startupRecovery": "Wiederherstellung läuft…",
"creatingWorktree": "Worktree wird erstellt…",
"rebasingAfterMerge": "Andere Worktrees werden rebased…",
"maintainingWorktrees": "Worktree-Wartung läuft…"
}
},
"usage": {
"pill": {
"empty": "Nutzung ",
+72 -5
View File
@@ -75,6 +75,8 @@
"addSchedule": "+ Add schedule",
"removeScheduleTip": "Remove schedule",
"dailyPrepMaxTasks": "Max tasks per day",
"promptOverrideLabel": "Prompt addition",
"promptOverridePlaceholder": "Extra instructions appended to the daily prep prompt for this schedule (optional)",
"dayMo": "Mo",
"dayTu": "Tu",
"dayWe": "We",
@@ -161,6 +163,7 @@
"badgeDraft": "DRAFT",
"badgePlanned": "PLANNED",
"badgeManual": "MANUAL",
"chainAfterPrefix": "after",
"manualTip": "Manual reminder — Claude never picks this up",
"ctxMarkManual": "Mark as manual",
"ctxMarkClaudeTask": "Mark as Claude task",
@@ -197,6 +200,7 @@
"contextWorktrees": "Worktrees…",
"contextOpenExplorer": "Open in Explorer",
"contextOpenTerminal": "Open in Terminal",
"contextOpenFindings": "Open findings folder",
"newList": "New list",
"addReposTip": "Add repos as lists"
},
@@ -263,7 +267,8 @@
"chipDone": "DONE",
"chipFailed": "FAILED",
"reviewContinueTip": "Send this feedback and re-run the task",
"reviewResetTip": "Discard all changes and reset the task to Idle"
"reviewResetTip": "Discard all changes and reset the task to Idle",
"reviewFeedbackPlaceholder": "Feedback for next run"
},
"missionControl": {
"windowTitle": "Mission Control",
@@ -334,6 +339,8 @@
"defaultCommitType": "Default commit type",
"manualList": "Manual list (reminders)",
"manualListHint": "New tasks in this list start out manual: no queueing, running or refining, and automation skips them. You can still open a hand-driven session.",
"findingsTracked": "Commit the .claudedo findings folder",
"findingsTrackedHint": "Off: the folder stays out of git via .git/info/exclude. On: findings travel with the repo.",
"sectionAgent": "AGENT",
"resetAgentSettings": "Reset agent settings",
"sectionVerify": "VERIFICATION",
@@ -362,7 +369,9 @@
"splitView": "Side by side",
"wrapLines": "Wrap lines",
"paneBase": "BASE",
"paneWorktree": "WORKTREE"
"paneWorktree": "WORKTREE",
"maximize": "Maximize",
"restore": "Restore"
},
"worktreesOverview": {
"refresh": "Refresh",
@@ -408,7 +417,9 @@
"selectNone": "Select none",
"empty": "No tasks to hand over — everything is done or cancelled.",
"columnTask": "TASK",
"columnStatus": "STATUS"
"columnStatus": "STATUS",
"orphanHint": "PARENT NOT IN SELECTION",
"orphanHintTip": "This subtask's parent isn't offered here (already done or cancelled), so it's listed on its own."
},
"unfinishedPlanning": {
"title": "UNFINISHED PLANNING SESSION",
@@ -654,12 +665,15 @@
"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" },
"failureReason": { "maxTurns": "Turn limit reached", "timeout": "Timed out", "error": "Error", "cancelled": "Cancelled", "unknown": "Unknown reason" },
"failureReasonTooltip": { "maxTurns": "Turn limit reached ({0}/{1} turns) — the worktree is usually fine; continue the task instead of resetting it." },
"planningBadge": { "active": "PLANNING", "finalized": "PLANNED" },
"taskRow": { "createdPrefix": "Created {0}", "stepsText": "{0}/{1} steps" },
"queue": { "baseDirtyWarning": "Heads up: this list's repo has uncommitted changes ({0} modified, {1} untracked) — a new worktree starts from the last commit and won't include them." },
"tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "planningOpenFailed": "Couldn't open planning session: {0}", "planningResumeFailed": "Couldn't resume planning session: {0}", "approveFailed": "Approve & merge failed: {0}", "cancelReviewFailed": "Cancel review failed: {0}", "sendToQueueFailed": "Send to queue failed: {0}", "queuePlanBlockedInteractive": "Can't queue the plan — {0} has an open interactive session and must be closed first.", "moveRunningRejected": "Can't move a running task to another list.", "moveWorktreeRejected": "Can't move — this task has an active worktree pointing at its current repo.", "moveRepoConfirm": "Different repos — {0} → {1}. Move the task anyway?", "moveConfirmUnavailable": "Can't move — the confirmation dialog isn't available.", "quickClaudeNoWorkingDir": "This list has no working directory configured.", "quickClaudeDirMissing": "Working directory no longer exists: {0}" },
"diff": { "loadFailed": "Failed to load diff: {0}", "noChanges": "No changes to show.", "unavailable": "Diff no longer available — commit range incomplete." },
"planningDiff": { "hubError": "Could not build combined preview (hub error).", "conflict": "Cannot build combined preview: subtask {0} conflicts with an earlier subtask ({1} files).", "buildFailed": "Could not build combined preview: {0}" },
"merge": { "commitMessage": "Merge task: {0}", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "conflict": "Merge conflict — target branch restored. Resolve manually or via Continue, then retry.", "blocked": "Blocked: {0}", "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" },
"merge": { "commitMessage": "chore: merge {0}", "progressMerging": "Merging…", "progressVerifying": "Running the list's verify command… ({0})", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "conflict": "Merge conflict — target branch restored. Resolve manually or via Continue, then retry.", "blocked": "Blocked: {0}", "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" },
"conflictResolution": { "vsCodeError": "Could not launch VS Code: {0}. Paths are listed above — copy them manually.", "subtaskPrefix": "Conflicts in subtask: {0}", "targetPrefix": "Merging into: {0}" },
"settingsModal": { "workerOffline": "Worker offline — settings read-only.", "saveFailed": "Save failed: {0}" },
"onlineInbox": { "workerOffline": "Worker offline — cannot load config.", "saved": "Config saved.", "saveFailed": "Save failed: {0}", "signedIn": "Signed in successfully.", "signedInNoRole": "Signed in, but this account is missing the 'user' role in Zitadel — online sync will be rejected until the role is granted in the ClaudeDo project.", "signInFailed": "Sign-in failed: {0}", "signedOut": "Signed out.", "signOutFailed": "Sign-out failed: {0}" },
@@ -674,10 +688,63 @@
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "Removed {0} worktree(s).", "blocked": "Cannot force-remove: {0} task(s) still running. Cancel them first.", "removedFrom": "Removed {0} worktree(s) from {1} task(s).", "cleanupFailed": "Cleanup failed: {0}", "resetFailed": "Reset failed: {0}" },
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "list", "cleanupFailed": "Cleanup failed.", "cleanupFailedDetailed": "Cleanup failed: {0}", "removed": "Removed {0} worktree(s).", "discardFailed": "Failed to discard worktree.", "keepFailed": "Failed to keep worktree.", "cannotForceRunning": "Cannot force-remove a running task.", "forceRemoveFailed": "Force remove failed.", "forceRemoveFailedDetailed": "Force remove failed: {0}", "batchProgress": "Merging {0}/{1}…", "batchDone": "Merged {0}, {1} need resolution." },
"listSettings": { "untitled": "Untitled" },
"detailsIsland": { "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done." },
"detailsIsland": { "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory." },
"lists": { "localSuffix": "{0} / local", "smartMyDay": "My Day", "smartImportant": "Important", "smartPlanned": "Planned", "virtualQueue": "Queue", "virtualRunning": "Running", "virtualReview": "Review", "newList": "New list" },
"repoImport": { "loadFailed": "Couldn't load remembered folders: {0}", "saveFailed": "Couldn't save folders: {0}" }
},
"ops": {
"stalledHint": "Still running — worker hasn't responded in a while",
"merge": {
"merging": "Merging…",
"verifying": "Running the list's verify command… ({0})"
},
"diff": {
"parsing": "Parsing diff…"
},
"review": {
"submitting": "Submitting for review…",
"rejecting": "Rejecting…",
"parking": "Parking…",
"previewing": "Loading preview…"
},
"worktrees": {
"refreshing": "Refreshing…",
"cleaningUp": "Cleaning up finished worktrees…",
"resetting": "Resetting worktree…",
"forceRemoving": "Force-removing worktree…",
"batchMerging": "Merging {0}/{1}…"
},
"skills": {
"installing": "Cloning skill…",
"updating": "Updating skill…",
"restoringDefaults": "Restoring default agents…"
},
"onlineInbox": {
"signingIn": "Signing in…"
},
"repoImport": {
"scanning": "Scanning repositories…"
},
"updateCheck": {
"checking": "Checking for updates…"
},
"reports": {
"generatingWeekly": "Generating report…",
"runningDailyPrep": "Running daily prep…"
},
"planning": {
"buildingIntegrationBranch": "Building integration branch…",
"loadingAggregate": "Loading combined preview…",
"finalizing": "Finalizing plan…",
"queuingSubtasks": "Queuing subtasks…"
},
"worker": {
"startupRecovery": "Recovering…",
"creatingWorktree": "Creating worktree…",
"rebasingAfterMerge": "Rebasing other worktrees…",
"maintainingWorktrees": "Maintaining worktrees…"
}
},
"usage": {
"pill": {
"empty": "Usage ",
+14 -1
View File
@@ -10,7 +10,17 @@ public static class VersionComparer
| !Version.TryParse(CoreVersion(current), out var cv);
if (unparseable) return new VersionCompareResult(false, true);
return new VersionCompareResult(lv > cv, false);
if (lv != cv) return new VersionCompareResult(lv > cv, false);
// Same numeric core: a MinVer dev build sitting on main between two tags
// reports a prerelease of its own guessed *next* version (e.g. after tag
// v1.4.0, main computes "1.4.1-alpha.0.12" — already ahead of the tag by
// core, handled above). But when a real release is tagged at exactly that
// guessed version, the tag itself has no prerelease suffix while a still-
// running dev build does — that dev build must still count as older, or a
// genuine new release would be silently missed.
var isNewer = HasPrerelease(current) && !HasPrerelease(latest);
return new VersionCompareResult(isNewer, false);
}
// Reduce a tag/version to its numeric core: drop a leading "v", MinVer build
@@ -18,4 +28,7 @@ public static class VersionComparer
// which System.Version can parse. So "v1.0.2-alpha+abc" -> "1.0.2".
private static string CoreVersion(string value)
=> (value ?? "").TrimStart('v', 'V').Split('+')[0].Split('-')[0];
private static bool HasPrerelease(string value)
=> (value ?? "").TrimStart('v', 'V').Split('+')[0].Contains('-');
}
+4
View File
@@ -89,6 +89,7 @@ new editor boilerplate there rather than copying it a third time.
- **IPrimeScheduleApi** — prime-schedule CRUD.
- **UpdateCheckService** — polls releases; `LastCheckStatus`/`LatestVersion`/`CheckNowAsync` feed the shell's update banner.
- **InheritanceResolver** — resolves the task → list → global override chain to `(value, source)` for the inherited badges.
- **OperationTiming** — NDJSON sink (`~/.todo-app/logs/operation-timing.ndjson`, one rolled `.1` at 4 MB) behind every hub invoke and bulk DB path; each line carries `pid` because app restarts interleave in one file. Off by default — `Shared` only writes when the environment variable `CLAUDEDO_OP_TIMING=1` is set at process start (read once statically, no per-call overhead). Successful calls under 25 ms are dropped, **failed/cancelled ones always land** — a 1 ms cancelled `BindAsync` is churn signal, not noise. `DetailsIsland.BindAsync:<source>` carries the selection trigger (`TasksIslandViewModel.SelectionSource`, set via `SelectFrom` — never assign `SelectedTask` directly). `Shared` is a settable static singleton **on purpose**`Ui.Tests`/`Worker.Tests` each carry a `TestSetup` module initializer that redirects it to a temp file before any test runs (still writing — the kill switch only gates `Shared`'s default, not the constructor), because both projects construct real ViewModels (`DetailsIslandViewModel`, `TasksIslandViewModel`) that call `Shared.Record` directly; without the redirect a single test run appends 50-100 lines straight into the live app's log (a day of ~1900 `BindAsync` lines, 91-96% cancelled, turned out to be 13 such test runs plus pre-fix-binary artifacts — real user-driven binds that day: 4).
- **RepoScanner**, **InstallArtifactLocator**/**InstallerLocator**/**WorkerLocator**, **ForegroundHelper** (Win32 foreground before launching a terminal), **FocusClearing**.
## Converters
@@ -111,11 +112,14 @@ snaps `CanResize="True"` windows, which is the opt-in), and it insets itself by
- **`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**.
- **`NumericUpDown.Value` is `decimal?` and goes null while the box is empty** — i.e. every time the user clears a value to type a new one. Bound TwoWay to a non-nullable `int`/`decimal`, that null throws `InvalidCastException`. Either bind a `decimal?` property (as `AgentConfigEditorViewModel.MaxTurns` does) or add `Converter={StaticResource KeepLastNumber}`, which drops the null via `BindingOperations.DoNothing`.
- **`ScrollViewer.Padding` is excluded from the Extent (Avalonia 12).** At max scroll the content still sticks out below the viewport by the padding height, so the last line(s) are clipped and unreachable — even after `ScrollToEnd()`. Put the inset on the *content* (`Margin` on the inner `ItemsControl`/`StackPanel`) instead. Verified headlessly: 12,8,12,4 on the viewer → last item 20px below the viewport; same inset as content margin → fully visible. **`ListBox` does *not* have this trap** — its Fluent control-theme template forwards `Padding` onto `PART_ItemsPresenter`'s margin (content side), not the raw `ScrollViewer.Padding`; verified headlessly that `ListBox.Padding="10,4"` and a `Margin="10,4"` on the `ItemsPanelTemplate` panel produce an *identical* Extent and leave the last row fully visible at max scroll. Still fine to put the inset on the panel for consistency with the other scrollables above, just don't assume it's fixing a real bug there.
- **Never bind bare punctuation gestures.** Window key bindings live on `MainWindow` (`Ctrl+K` search, `Ctrl+N` add-task). `OemQuestion` once held search focus and silently swallowed `#` app-wide on a German layout.
- **`FocusClearing`'s Escape handler is scoped to `MainWindow`** (`AddClassHandler<MainWindow>`, not `<TopLevel>`) — it clears focus from a TextBox on Escape, mirroring click-outside. Modals are separate `Window` instances that bind their own Escape → close, so it never runs there. Mission Control's ConPTY tiles are in `MissionControlWindow`, also unaffected, so **Escape always reaches the PTY**.
- **Review gate:** Approve & Merge stays disabled until the diff has been opened once, and re-locks per run → [review-merge](../../docs/explore-notes/review-merge.md).
- Context menus exist on both list and task rows; right-click selects before opening the menu.
- "Run Now" CanExecute re-evaluates when worker connection state changes.
- `Ellipse.spinner` (IslandStyles) is the shared indeterminate spinner (starting ConPTY pane, refining task row).
- **Never set a `UserControl`'s own `DataContext` from a `StyledProperty`.** The property binding at the call site (`Status="{Binding RejectOp}"`) resolves *against that control's DataContext* — overwriting it re-targets the binding at the new value and the compiled getter throws `InvalidCastException`. Scope the DataContext to an inner panel instead (`DataContext="{Binding #Root.Status}"`, see `OperationIndicator.axaml`). Corollary: **the inner panel's DataContext is then null whenever the property is null**, so `IsVisible="{Binding ShowIndicator}"` can't resolve and falls back to the property default `true` — a permanent label-less spinner *plus* the stalled hint. `OperationIndicator` therefore collapses itself in code-behind when `Status` is null; wire every call site with `Status=`, never `DataContext=`.
- **Every `IWorkerClient` call in a `[RelayCommand]` runs through an `OperationStatus` (`Services/OperationStatus.cs`), shown via the `OperationIndicator` control** — never a handcrafted spinner. Without it the spinner `StackPanel` from `MergeModalView.axaml` gets rebuilt at every call site, and `IsBusy` flags lock the button but show nothing. **`Begin`/`Report`/`Dispose` must run on the UI thread** — only the tick is marshalled, so a `Task.Run`-wrapped call leaves the bindings updating off-thread (the startup update check did this). A `[RelayCommand]` satisfies this on its own. Separately, **`End` retires the generation** so tick work that was posted while the dispatcher was blocked can't drain afterwards and set `ShowIndicator` back to true on a finished operation — that was the real frozen-spinner bug, and the enabling condition (~2.4s blocked UI thread during window construction) is still there.
- `SessionTerminalView` is the reusable log terminal (StyledProperties `Entries`, `Label`, `IsRunning`, `IsDone`, `IsFailed`) — used for both the task `Log` and the prep `PrepLog`.
- `DetailsIslandView` is a pane-wide drag-and-drop file target (`DragDrop.AllowDrop`, Avalonia 12 `DataFormat.File`) with a "Drop to attach" overlay; `DescriptionStepsCard` shows the attachments list, an "Add file…" picker, and an explicit `DropStatus` line. Keys use the `details.attachments.*` locale namespace (en + de).
@@ -0,0 +1,18 @@
using System.Globalization;
using Avalonia.Data.Converters;
using ClaudeDo.Ui.ViewModels.Islands;
namespace ClaudeDo.Ui.Converters;
// Drives ListBoxItem.Focusable for TasksIslandView's flat Rows list: a HeaderRow container must
// not be a tab stop, a TaskRowViewModel container must behave like any other row.
public class NotHeaderRowConverter : IValueConverter
{
public static NotHeaderRowConverter Instance { get; } = new();
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
=> value is not HeaderRow;
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
+33 -5
View File
@@ -484,12 +484,21 @@
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
<Setter Property="BorderThickness" Value="1" />
</Style>
<!-- "Grabbed" row: lift + slight scale + lower opacity + shadow while the custom drag runs. -->
<!-- "Grabbed" row: collapses out of layout entirely while the custom drag runs — the
floating ghost snapshot (TaskDragController) is what the user sees moving. -->
<Style Selector="Border.task-row.dragging">
<Setter Property="Opacity" Value="0.55" />
<Setter Property="RenderTransform" Value="scale(1.03)" />
<Setter Property="BoxShadow" Value="0 10 26 0 #66000000" />
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
<Setter Property="IsVisible" Value="False" />
<Setter Property="Transitions" Value="{x:Null}" />
</Style>
<!-- Row currently hovered while dragging (shows the dashed drop-hint gap): suppress the
ordinary hover highlight and transitions so they don't fight the hint. Placed after
Border.task-row:pointerover so its extra class also wins on declaration order. -->
<Style Selector="Border.task-row.drop-target">
<Setter Property="Transitions" Value="{x:Null}" />
</Style>
<Style Selector="Border.task-row.drop-target:pointerover">
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}" />
</Style>
<!-- Checkbox indicator (the 18px circle that replaces the native CheckBox template) -->
@@ -1036,6 +1045,25 @@
<Setter Property="Background" Value="{DynamicResource ManualBadgeBrush}"/>
</Style>
<!-- ============================================================ -->
<!-- CHAIN DEPENDENCY RAIL (step badge on the 24px indent track) -->
<!-- ============================================================ -->
<Style Selector="Border.chain-step-badge">
<Setter Property="Width" Value="16"/>
<Setter Property="Height" Value="16"/>
<Setter Property="CornerRadius" Value="{StaticResource PillCornerRadius}"/>
<Setter Property="Background" Value="{StaticResource Surface2Brush}"/>
<Setter Property="BorderBrush" Value="{StaticResource LineBrush}"/>
<Setter Property="BorderThickness" Value="1"/>
</Style>
<Style Selector="Border.chain-step-badge > TextBlock">
<Setter Property="FontFamily" Value="{StaticResource MonoFont}"/>
<Setter Property="FontSize" Value="{StaticResource FontSizeEyebrow}"/>
<Setter Property="Foreground" Value="{StaticResource TextDimBrush}"/>
<Setter Property="HorizontalAlignment" Value="Center"/>
<Setter Property="VerticalAlignment" Value="Center"/>
</Style>
<!-- ============================================================ -->
<!-- SHARED MODAL STYLES (promoted from per-modal Window.Styles) -->
<!-- ============================================================ -->
@@ -25,14 +25,29 @@ public interface IWorkerClient : INotifyPropertyChanged
/// <summary>A pending question was answered, timed out, or the run ended: (taskId, questionId).</summary>
event Action<string, string>? TaskQuestionResolvedEvent;
/// <summary>A running list-handler session called handoff_list_handler at the end of Phase 2:
/// (handlerTaskId, survivingTaskIds). The UI opens a second ConPTY tile for the same task.</summary>
event Action<string, IReadOnlyList<string>>? HandoffRequestedEvent;
/// <summary>A running list-handler session called handoff_list_handler at the end of a phase:
/// (handlerTaskId, survivingTaskIds, nextPhase). The UI opens a second ConPTY tile for the same
/// task to carry out nextPhase ("wait" | "merge" | "wait_final" | "merge_final").</summary>
event Action<string, IReadOnlyList<string>, string>? HandoffRequestedEvent;
event Action? PrepStartedEvent;
event Action<string>? PrepLineEvent;
event Action<bool>? PrepFinishedEvent;
/// <summary>(opKey, phase, current, total) — generic progress channel for long-running worker
/// operations (merge phases, worktree cleanup, startup recovery, planning integration, ...).
/// opKey is the TaskId for a task-bound operation, otherwise a stable string. current/total
/// is meant to be shown as text, never a progress bar — most operations have no meaningful
/// total.</summary>
event Action<string, string, int, int>? OperationProgressEvent;
/// <summary>(taskId, phase, elapsedSeconds) — phase of an in-flight single-task merge
/// ("merging" | "verifying"). Fires while the MergeTask call itself is still pending, so the
/// waiting UI can show what it's blocked on; the verify phase re-fires every 30 s. A thin
/// forwarder over <see cref="OperationProgressEvent"/> kept for existing consumers (elapsed
/// seconds riding in the generic "current" slot).</summary>
event Action<string, string, int>? MergeProgressEvent;
event Action<string, string>? PlanningMergeStartedEvent;
event Action<string, string>? PlanningSubtaskMergedEvent;
/// <summary>(planningTaskId, subtaskId, conflictedFiles, externallyDriven). externallyDriven
@@ -75,7 +90,10 @@ public interface IWorkerClient : INotifyPropertyChanged
Task<List<string>> InstallSessionSkillAsync(string url);
Task UpdateSessionSkillAsync(string sourceUrl);
Task RemoveSessionSkillAsync(string sourceUrl);
Task SetTaskStatusAsync(string taskId, TaskStatus status);
/// <summary>Returns the base-dirty heads-up when this transition just queued the task against
/// a list whose working dir has uncommitted changes (null otherwise) — a new worktree forks
/// from the last commit, not the working tree, so those changes won't be included.</summary>
Task<BaseDirtyWarningDto?> SetTaskStatusAsync(string taskId, TaskStatus status);
Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch);
Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch);
Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage);
@@ -106,10 +124,11 @@ public interface IWorkerClient : INotifyPropertyChanged
/// never queued) so the ConPTY tile can be task-based instead of ad-hoc. Returns the new task id.</summary>
Task<string> CreateMergeHelperTaskAsync(
IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default);
/// <summary>Launch spec for the fresh ConPTY session a merge-helper run hands off to once Phase 2
/// is done -- reuses the SAME handler task id (no new task created).</summary>
/// <summary>Launch spec for the fresh ConPTY session a merge-helper run hands off to once a phase
/// is done -- reuses the SAME handler task id (no new task created). nextPhase picks the next
/// session's role: "wait" | "merge" | "wait_final" | "merge_final".</summary>
Task<LaunchSpec> GetMergeHelperHandoffLaunchSpecAsync(
string taskId, IReadOnlyList<string> survivingTaskIds, CancellationToken ct = default);
string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase, CancellationToken ct = default);
/// <summary>Starts a planning session and returns the launch spec for an embedded ConPTY
/// planning terminal (replaces StartPlanningSessionAsync's external wt window).</summary>
Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default);
+121
View File
@@ -0,0 +1,121 @@
using System.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClaudeDo.Ui.Services;
/// Feedback primitive for a single long-running operation behind a [RelayCommand]. Multiple
/// independent instances per ViewModel are expected (one per command) — there is no shared or
/// static state, which is exactly what a static DispatcherTimer would introduce and the cause of
/// the order-dependent flakiness in Ui.Tests.
///
/// **Call Begin/Report/Dispose from the UI thread.** Only the timer tick is marshalled; the other
/// three write their observable properties inline on the calling thread, so an off-thread caller
/// leaves the bindings updating off the UI thread. A [RelayCommand] satisfies this by itself (its
/// awaits resume on the UI thread); wrapping the call in Task.Run breaks it.
public sealed partial class OperationStatus : ObservableObject
{
private static readonly TimeSpan GracePeriod = TimeSpan.FromMilliseconds(300);
private static readonly TimeSpan StallThreshold = TimeSpan.FromSeconds(60);
private static readonly TimeSpan TickInterval = TimeSpan.FromSeconds(1);
private readonly TimeProvider _time;
private readonly Action<Action> _postToUiThread;
private ITimer? _tickTimer;
private DateTimeOffset _startedAt;
private DateTimeOffset _lastReportAt;
private int _generation;
[ObservableProperty] private bool _isRunning;
[ObservableProperty] private bool _showIndicator;
[ObservableProperty] private string? _label;
[ObservableProperty] private string _elapsed = "00:00";
[ObservableProperty] private bool _isStalled;
public OperationStatus(TimeProvider? timeProvider = null) : this(timeProvider, null) { }
/// Test-only seam. Production always marshals the threadpool timer callback onto the
/// dispatcher; tests inject a synchronous poster so they can drive a fake clock without
/// bootstrapping a real Avalonia dispatcher.
internal OperationStatus(TimeProvider? timeProvider, Action<Action>? postToUiThread)
{
_time = timeProvider ?? TimeProvider.System;
_postToUiThread = postToUiThread ?? (action => Avalonia.Threading.Dispatcher.UIThread.Post(action));
}
/// Starts the operation; Dispose (even via an exception unwinding a `using` block) ends it.
public IDisposable Begin(string label)
{
_tickTimer?.Dispose();
var generation = ++_generation;
_startedAt = _time.GetUtcNow();
_lastReportAt = _startedAt;
Label = label;
Elapsed = FormatElapsed(TimeSpan.Zero);
ShowIndicator = false;
IsStalled = false;
IsRunning = true;
_tickTimer = _time.CreateTimer(_ => OnTick(generation), null, GracePeriod, TickInterval);
return new EndOnDispose(this, generation);
}
/// Overwrites the label mid-flight and resets the stall clock — call this whenever the
/// operation's phase changes so a normally multi-minute step doesn't report itself as stuck.
public void Report(string label)
{
Label = label;
_lastReportAt = _time.GetUtcNow();
IsStalled = false;
}
private void OnTick(int generation) => _postToUiThread(() =>
{
if (generation != _generation) return;
var now = _time.GetUtcNow();
Elapsed = FormatElapsed(now - _startedAt);
if (!ShowIndicator && now - _startedAt >= GracePeriod) ShowIndicator = true;
IsStalled = now - _lastReportAt >= StallThreshold;
});
private void End(int generation)
{
if (generation != _generation) return;
// Retire the generation *before* touching the flags. The timer callback fires on a
// threadpool thread and only posts its UI work, so a tick that fired while the dispatcher
// was busy can drain *after* End — with the generation still current it would set
// ShowIndicator back to true on an already-finished operation, and since the timer is gone
// by then no later tick ever clears it. That is the frozen spinner.
_generation++;
_tickTimer?.Dispose();
_tickTimer = null;
IsRunning = false;
ShowIndicator = false;
IsStalled = false;
}
private static string FormatElapsed(TimeSpan elapsed) =>
(elapsed < TimeSpan.Zero ? TimeSpan.Zero : elapsed).ToString(@"mm\:ss");
private sealed class EndOnDispose : IDisposable
{
private OperationStatus? _owner;
private readonly int _generation;
public EndOnDispose(OperationStatus owner, int generation)
{
_owner = owner;
_generation = generation;
}
public void Dispose()
{
var owner = _owner;
_owner = null;
owner?.End(_generation);
}
}
}
@@ -0,0 +1,96 @@
using System.Text.Json;
using ClaudeDo.Data;
namespace ClaudeDo.Ui.Services;
/// <summary>
/// Appends one NDJSON line per timed operation (hub invoke, bulk DB read/write) to a log file, so
/// a day of normal usage yields a file that can be sorted by duration to find real outliers.
/// Never throws — a failed write is swallowed, because a measurement must never disturb the app.
/// <see cref="Shared"/> is off by default; set <c>CLAUDEDO_OP_TIMING=1</c> to turn it back on.
/// </summary>
public sealed class OperationTiming
{
/// <summary>Fast successful operations are dropped: the first day of data was 43% 3ms reconcile
/// ticks, which crowd out the outliers the file exists to surface. Failed/cancelled operations
/// are always kept regardless of duration — a 1ms cancelled call is a churn signal, not noise.</summary>
public const int DefaultMinMs = 25;
/// <summary>One rollover file is kept (<c>.1</c>); at ~1000 lines/h a 4 MB cap holds weeks.</summary>
public const long DefaultMaxBytes = 4L * 1024 * 1024;
public static string DefaultPath =>
Path.Combine(Paths.AppDataRoot(), "logs", "operation-timing.ndjson");
/// <summary>Kill switch for <see cref="Shared"/>: off by default, set
/// <c>CLAUDEDO_OP_TIMING=1</c> in the environment to turn the sink back on.</summary>
private static readonly bool DefaultEnabled =
Environment.GetEnvironmentVariable("CLAUDEDO_OP_TIMING") == "1";
// Settable, not just get-only: xUnit constructs real ViewModels (DetailsIslandViewModel,
// TasksIslandViewModel, ...) that call Shared.Record directly, so without a way to redirect
// it every test run appended its churn straight into the live app's NDJSON file at the real
// AppData path — see Ui.Tests' TestSetup module initializer, which points this at a temp file
// for the whole test process before any test runs.
public static OperationTiming Shared { get; set; } = new(DefaultPath, enabled: DefaultEnabled);
private static readonly int ProcessId = Environment.ProcessId;
private readonly string _path;
private readonly int _minMs;
private readonly long _maxBytes;
private readonly bool _enabled;
private readonly object _writeLock = new();
public OperationTiming(string filePath, int minMs = DefaultMinMs, long maxBytes = DefaultMaxBytes, bool enabled = true)
{
_path = filePath;
_minMs = minMs;
_maxBytes = maxBytes;
_enabled = enabled;
}
public void Record(string kind, string operation, TimeSpan elapsed, bool ok)
{
if (!_enabled) return;
try
{
var ms = (long)elapsed.TotalMilliseconds;
if (ok && ms < _minMs) return;
var line = JsonSerializer.Serialize(new
{
ts = DateTime.UtcNow,
// Runs of the app interleave in one file; without the pid, a restart looks like
// concurrent work by a single instance.
pid = ProcessId,
kind,
op = operation,
ms,
ok,
});
lock (_writeLock)
{
var dir = Path.GetDirectoryName(_path);
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
Roll();
File.AppendAllText(_path, line + Environment.NewLine);
}
}
catch
{
// A measurement must never disturb the app.
}
}
private void Roll()
{
if (!File.Exists(_path) || new FileInfo(_path).Length < _maxBytes) return;
var rolled = _path + ".1";
File.Delete(rolled);
File.Move(_path, rolled);
}
}
@@ -1,4 +1,5 @@
using ClaudeDo.Releases;
using ClaudeDo.Ui.Localization;
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClaudeDo.Ui.Services;
@@ -15,10 +16,11 @@ public sealed partial class UpdateCheckService : ObservableObject
{
private readonly IReleaseClient _releases;
public OperationStatus Op { get; } = new();
[ObservableProperty] private bool _isUpdateAvailable;
[ObservableProperty] private string? _latestVersion;
[ObservableProperty] private string _currentVersion;
[ObservableProperty] private bool _isChecking;
[ObservableProperty] private UpdateCheckStatus _lastCheckStatus = UpdateCheckStatus.NeverChecked;
public UpdateCheckService(IReleaseClient releases, string currentVersion)
@@ -29,9 +31,8 @@ public sealed partial class UpdateCheckService : ObservableObject
public async Task CheckNowAsync(CancellationToken ct)
{
IsChecking = true;
try
{
using var op = Op.Begin(Loc.T("ops.updateCheck.checking"));
GiteaRelease? rel;
try
{
@@ -65,9 +66,4 @@ public sealed partial class UpdateCheckService : ObservableObject
LastCheckStatus = UpdateCheckStatus.UpToDate;
}
}
finally
{
IsChecking = false;
}
}
}
+123 -72
View File
@@ -49,7 +49,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public event Action<string>? TaskUpdatedEvent;
public event Action<string, string, string>? TaskQuestionAskedEvent;
public event Action<string, string>? TaskQuestionResolvedEvent;
public event Action<string, IReadOnlyList<string>>? HandoffRequestedEvent;
public event Action<string, IReadOnlyList<string>, string>? HandoffRequestedEvent;
public event Action? ConnectionRestoredEvent;
public event Action<string>? WorktreeUpdatedEvent;
public event Action<string>? ListUpdatedEvent;
@@ -64,6 +64,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public event Action<UsageSnapshotDto>? UsageUpdatedEvent;
public event Action<string, string, int, int>? OperationProgressEvent;
public event Action<string, string, int>? MergeProgressEvent;
public event Action<string, string>? PlanningMergeStartedEvent;
public event Action<string, string>? PlanningSubtaskMergedEvent;
public event Action<string, string, IReadOnlyList<string>, bool>? PlanningMergeConflictEvent;
@@ -151,9 +154,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
Dispatcher.UIThread.Post(() => TaskQuestionResolvedEvent?.Invoke(taskId, questionId));
});
_hub.On<string, IReadOnlyList<string>>("HandoffRequested", (taskId, survivingTaskIds) =>
_hub.On<string, IReadOnlyList<string>, string>("HandoffRequested", (taskId, survivingTaskIds, nextPhase) =>
{
Dispatcher.UIThread.Post(() => HandoffRequestedEvent?.Invoke(taskId, survivingTaskIds));
Dispatcher.UIThread.Post(() => HandoffRequestedEvent?.Invoke(taskId, survivingTaskIds, nextPhase));
});
_hub.On<string>("WorktreeUpdated", taskId =>
@@ -172,6 +175,15 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
WorkerLogReceivedEvent?.Invoke(new WorkerLogEntry(message, level, timestampUtc)));
});
_hub.On<string, string, int, int>("OperationProgress", (opKey, phase, current, total) =>
{
Dispatcher.UIThread.Post(() =>
{
OperationProgressEvent?.Invoke(opKey, phase, current, total);
MergeProgressEvent?.Invoke(opKey, phase, current);
});
});
_hub.On<string, string>("PlanningMergeStarted", (planningTaskId, targetBranch) =>
{
Dispatcher.UIThread.Post(() => PlanningMergeStartedEvent?.Invoke(planningTaskId, targetBranch));
@@ -270,23 +282,58 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
/// <summary>Invoke a hub method, returning default (null) when the worker is offline or errors.</summary>
private async Task<T?> TryInvokeAsync<T>(string method, params object?[] args)
{
try { return await _hub.InvokeCoreAsync<T>(method, args); }
var sw = System.Diagnostics.Stopwatch.StartNew();
var ok = false;
try
{
var result = await _hub.InvokeCoreAsync<T>(method, args);
ok = true;
return result;
}
catch { return default; }
finally { OperationTiming.Shared.Record("hub", method, sw.Elapsed, ok); }
}
/// <summary>Times a void hub invoke without changing its exception/cancellation behavior.</summary>
private async Task InvokeTimedAsync(string method, Func<Task> invoke)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var ok = false;
try
{
await invoke();
ok = true;
}
finally { OperationTiming.Shared.Record("hub", method, sw.Elapsed, ok); }
}
/// <summary>Times a hub invoke that returns a value, without changing its exception/cancellation behavior.</summary>
private async Task<T> InvokeTimedAsync<T>(string method, Func<Task<T>> invoke)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var ok = false;
try
{
var result = await invoke();
ok = true;
return result;
}
finally { OperationTiming.Shared.Record("hub", method, sw.Elapsed, ok); }
}
public async Task RunNowAsync(string taskId)
{
await _hub.InvokeAsync("RunNow", taskId);
await InvokeTimedAsync("RunNow", () => _hub.InvokeAsync("RunNow", taskId));
}
public async Task ContinueTaskAsync(string taskId, string followUpPrompt)
{
await _hub.InvokeAsync("ContinueTask", taskId, followUpPrompt);
await InvokeTimedAsync("ContinueTask", () => _hub.InvokeAsync("ContinueTask", taskId, followUpPrompt));
}
public async Task AnswerTaskQuestionAsync(string taskId, string questionId, string answer)
{
try { await _hub.InvokeAsync<bool>("AnswerTaskQuestion", taskId, questionId, answer); }
try { await InvokeTimedAsync<bool>("AnswerTaskQuestion", () => _hub.InvokeAsync<bool>("AnswerTaskQuestion", taskId, questionId, answer)); }
catch { /* offline or already resolved — the UI clears optimistically */ }
}
@@ -295,43 +342,43 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task ResetTaskAsync(string taskId)
{
await _hub.InvokeAsync("ResetTask", taskId);
await InvokeTimedAsync("ResetTask", () => _hub.InvokeAsync("ResetTask", taskId));
}
public async Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage)
{
return await _hub.InvokeAsync<MergeResultDto>(
"MergeTask", taskId, targetBranch, removeWorktree, commitMessage);
return await InvokeTimedAsync<MergeResultDto>("MergeTask", () => _hub.InvokeAsync<MergeResultDto>(
"MergeTask", taskId, targetBranch, removeWorktree, commitMessage));
}
public Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch)
=> _hub.InvokeAsync<MergeResultDto>("StartConflictMerge", taskId, targetBranch);
=> InvokeTimedAsync<MergeResultDto>("StartConflictMerge", () => _hub.InvokeAsync<MergeResultDto>("StartConflictMerge", taskId, targetBranch));
public Task<MergeConflictDocumentsDto> GetMergeConflictDocumentsAsync(string taskId)
=> _hub.InvokeAsync<MergeConflictDocumentsDto>("GetMergeConflictDocuments", taskId);
=> InvokeTimedAsync<MergeConflictDocumentsDto>("GetMergeConflictDocuments", () => _hub.InvokeAsync<MergeConflictDocumentsDto>("GetMergeConflictDocuments", taskId));
public Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent)
=> _hub.InvokeAsync("WriteConflictResolution", taskId, path, resolvedContent);
=> InvokeTimedAsync("WriteConflictResolution", () => _hub.InvokeAsync("WriteConflictResolution", taskId, path, resolvedContent));
public Task<MergeResultDto> ContinueConflictMergeAsync(string taskId)
=> _hub.InvokeAsync<MergeResultDto>("ContinueConflictMerge", taskId);
=> InvokeTimedAsync<MergeResultDto>("ContinueConflictMerge", () => _hub.InvokeAsync<MergeResultDto>("ContinueConflictMerge", taskId));
public Task AbortConflictMergeAsync(string taskId)
=> _hub.InvokeAsync("AbortConflictMerge", taskId);
=> InvokeTimedAsync("AbortConflictMerge", () => _hub.InvokeAsync("AbortConflictMerge", taskId));
public Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId)
=> TryInvokeAsync<MergeTargetsDto>("GetMergeTargets", taskId);
public async Task CancelTaskAsync(string taskId)
{
await _hub.InvokeAsync("CancelTask", taskId);
await InvokeTimedAsync("CancelTask", () => _hub.InvokeAsync("CancelTask", taskId));
}
public async Task<(bool Ok, string? Error)> DeleteTaskAsync(string taskId)
{
try
{
await _hub.InvokeAsync("DeleteTask", taskId, CancellationToken.None);
await InvokeTimedAsync("DeleteTask", () => _hub.InvokeAsync("DeleteTask", taskId, CancellationToken.None));
return (true, null);
}
catch (HubException ex)
@@ -347,7 +394,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task WakeQueueAsync()
{
await _hub.InvokeAsync("WakeQueue");
await InvokeTimedAsync("WakeQueue", () => _hub.InvokeAsync("WakeQueue"));
}
public async Task<List<AgentInfo>> GetAgentsAsync()
@@ -355,17 +402,17 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task RefreshAgentsAsync()
{
await _hub.InvokeAsync("RefreshAgents");
await InvokeTimedAsync("RefreshAgents", () => _hub.InvokeAsync("RefreshAgents"));
}
public async Task<SeedResultDto?> RestoreDefaultAgentsAsync()
=> await _hub.InvokeAsync<SeedResultDto>("RestoreDefaultAgents");
=> await InvokeTimedAsync<SeedResultDto>("RestoreDefaultAgents", () => _hub.InvokeAsync<SeedResultDto>("RestoreDefaultAgents"));
private async Task SeedActiveTasksAsync()
{
try
{
var active = await _hub.InvokeAsync<List<ActiveTaskDto>>("GetActive");
var active = await InvokeTimedAsync<List<ActiveTaskDto>>("GetActive", () => _hub.InvokeAsync<List<ActiveTaskDto>>("GetActive"));
Dispatcher.UIThread.Post(() =>
{
ActiveTasks.Clear();
@@ -395,18 +442,18 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task UpdateAppSettingsAsync(AppSettingsDto dto)
{
await _hub.InvokeAsync("UpdateAppSettings", dto);
await InvokeTimedAsync("UpdateAppSettings", () => _hub.InvokeAsync("UpdateAppSettings", dto));
}
public async Task<List<PrimeScheduleDto>> GetPrimeSchedulesAsync()
=> await TryInvokeAsync<List<PrimeScheduleDto>>("ListPrimeSchedules") ?? new List<PrimeScheduleDto>();
public async Task<PrimeScheduleDto?> UpsertPrimeScheduleAsync(PrimeScheduleDto dto)
=> await _hub.InvokeAsync<PrimeScheduleDto>("UpsertPrimeSchedule", dto);
=> await InvokeTimedAsync<PrimeScheduleDto>("UpsertPrimeSchedule", () => _hub.InvokeAsync<PrimeScheduleDto>("UpsertPrimeSchedule", dto));
public async Task DeletePrimeScheduleAsync(Guid id)
{
try { await _hub.InvokeAsync("DeletePrimeSchedule", id); }
try { await InvokeTimedAsync("DeletePrimeSchedule", () => _hub.InvokeAsync("DeletePrimeSchedule", id)); }
catch { /* offline */ }
}
@@ -416,27 +463,27 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
=> TryInvokeAsync<string>("GetWeekReport", IsoDay(start), IsoDay(end));
public Task<string> GenerateWeekReportAsync(DateOnly start, DateOnly end)
=> _hub.InvokeAsync<string>("GenerateWeekReport", IsoDay(start), IsoDay(end));
=> InvokeTimedAsync<string>("GenerateWeekReport", () => _hub.InvokeAsync<string>("GenerateWeekReport", IsoDay(start), IsoDay(end)));
public Task<bool> RunDailyPrepNowAsync()
=> _hub.InvokeAsync<bool>("RunDailyPrepNow");
=> InvokeTimedAsync<bool>("RunDailyPrepNow", () => _hub.InvokeAsync<bool>("RunDailyPrepNow"));
public Task RefineTaskAsync(string taskId) => _hub.InvokeAsync("RefineTask", taskId);
public Task RefineTaskAsync(string taskId) => InvokeTimedAsync("RefineTask", () => _hub.InvokeAsync("RefineTask", taskId));
public Task ClearMyDayAsync()
=> _hub.InvokeAsync("ClearMyDay");
=> InvokeTimedAsync("ClearMyDay", () => _hub.InvokeAsync("ClearMyDay"));
public async Task<List<DailyNoteDto>> GetDailyNotesAsync(DateOnly day)
=> await TryInvokeAsync<List<DailyNoteDto>>("GetDailyNotes", IsoDay(day)) ?? new List<DailyNoteDto>();
public async Task<DailyNoteDto?> AddDailyNoteAsync(DateOnly day, string text)
=> await _hub.InvokeAsync<DailyNoteDto>("AddDailyNote", IsoDay(day), text);
=> await InvokeTimedAsync<DailyNoteDto>("AddDailyNote", () => _hub.InvokeAsync<DailyNoteDto>("AddDailyNote", IsoDay(day), text));
public async Task UpdateDailyNoteAsync(string id, string text)
=> await _hub.InvokeAsync("UpdateDailyNote", id, text);
=> await InvokeTimedAsync("UpdateDailyNote", () => _hub.InvokeAsync("UpdateDailyNote", id, text));
public async Task DeleteDailyNoteAsync(string id)
=> await _hub.InvokeAsync("DeleteDailyNote", id);
=> await InvokeTimedAsync("DeleteDailyNote", () => _hub.InvokeAsync("DeleteDailyNote", id));
public async Task<string> GetLastPrepLogAsync()
=> await TryInvokeAsync<string>("GetLastPrepLog") ?? string.Empty;
@@ -449,12 +496,12 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task UpdateListAsync(UpdateListDto dto)
{
await _hub.InvokeAsync("UpdateList", dto);
await InvokeTimedAsync("UpdateList", () => _hub.InvokeAsync("UpdateList", dto));
}
public async Task UpdateListConfigAsync(UpdateListConfigDto dto)
{
await _hub.InvokeAsync("UpdateListConfig", dto);
await InvokeTimedAsync("UpdateListConfig", () => _hub.InvokeAsync("UpdateListConfig", dto));
}
public Task<ListConfigDto?> GetListConfigAsync(string listId)
@@ -462,36 +509,37 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto)
{
await _hub.InvokeAsync("UpdateTaskAgentSettings", dto);
await InvokeTimedAsync("UpdateTaskAgentSettings", () => _hub.InvokeAsync("UpdateTaskAgentSettings", dto));
}
public async Task<List<string>> GetRepoImportFoldersAsync()
=> await _hub.InvokeAsync<List<string>>("GetRepoImportFolders");
=> await InvokeTimedAsync<List<string>>("GetRepoImportFolders", () => _hub.InvokeAsync<List<string>>("GetRepoImportFolders"));
public Task SetRepoImportFoldersAsync(List<string> folders)
=> _hub.InvokeAsync("SetRepoImportFolders", folders);
=> InvokeTimedAsync("SetRepoImportFolders", () => _hub.InvokeAsync("SetRepoImportFolders", folders));
public async Task<List<SessionSkillDto>> GetSessionSkillsAsync()
=> await TryInvokeAsync<List<SessionSkillDto>>("GetSessionSkills") ?? [];
public Task<List<string>> InstallSessionSkillAsync(string url)
=> _hub.InvokeAsync<List<string>>("InstallSessionSkill", url);
=> InvokeTimedAsync<List<string>>("InstallSessionSkill", () => _hub.InvokeAsync<List<string>>("InstallSessionSkill", url));
public Task UpdateSessionSkillAsync(string sourceUrl)
=> _hub.InvokeAsync("UpdateSessionSkill", sourceUrl);
=> InvokeTimedAsync("UpdateSessionSkill", () => _hub.InvokeAsync("UpdateSessionSkill", sourceUrl));
public Task RemoveSessionSkillAsync(string sourceUrl)
=> _hub.InvokeAsync("RemoveSessionSkill", sourceUrl);
=> InvokeTimedAsync("RemoveSessionSkill", () => _hub.InvokeAsync("RemoveSessionSkill", sourceUrl));
public async Task SetTaskStatusAsync(string taskId, ClaudeDo.Data.Models.TaskStatus status)
public async Task<BaseDirtyWarningDto?> SetTaskStatusAsync(string taskId, ClaudeDo.Data.Models.TaskStatus status)
{
await _hub.InvokeAsync("SetTaskStatus", taskId, status.ToString());
var result = await InvokeTimedAsync<SetTaskStatusResultDto>("SetTaskStatus", () => _hub.InvokeAsync<SetTaskStatusResultDto>("SetTaskStatus", taskId, status.ToString()));
return result?.BaseDirty;
}
public async Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch)
{
LastApproveTarget = targetBranch;
return await _hub.InvokeAsync<MergeResultDto>("ApproveReview", taskId, targetBranch);
return await InvokeTimedAsync<MergeResultDto>("ApproveReview", () => _hub.InvokeAsync<MergeResultDto>("ApproveReview", taskId, targetBranch));
}
public Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch)
@@ -499,24 +547,24 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task RejectReviewToQueueAsync(string taskId, string feedback)
{
await _hub.InvokeAsync("RejectReviewToQueue", taskId, feedback);
await InvokeTimedAsync("RejectReviewToQueue", () => _hub.InvokeAsync("RejectReviewToQueue", taskId, feedback));
}
public async Task RejectReviewToIdleAsync(string taskId)
{
await _hub.InvokeAsync("RejectReviewToIdle", taskId);
await InvokeTimedAsync("RejectReviewToIdle", () => _hub.InvokeAsync("RejectReviewToIdle", taskId));
}
public async Task CancelReviewAsync(string taskId)
{
await _hub.InvokeAsync("CancelReview", taskId);
await InvokeTimedAsync("CancelReview", () => _hub.InvokeAsync("CancelReview", taskId));
}
public async Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync(string? listId = null)
=> await _hub.InvokeAsync<WorktreeCleanupDto>("CleanupFinishedWorktrees", listId);
=> await InvokeTimedAsync<WorktreeCleanupDto>("CleanupFinishedWorktrees", () => _hub.InvokeAsync<WorktreeCleanupDto>("CleanupFinishedWorktrees", listId));
public async Task<WorktreeResetDto?> ResetAllWorktreesAsync()
=> await _hub.InvokeAsync<WorktreeResetDto>("ResetAllWorktrees");
=> await InvokeTimedAsync<WorktreeResetDto>("ResetAllWorktrees", () => _hub.InvokeAsync<WorktreeResetDto>("ResetAllWorktrees"));
public async Task<List<WorktreeOverviewDto>> GetWorktreesOverviewAsync(string? listId)
=> await TryInvokeAsync<List<WorktreeOverviewDto>>("GetWorktreesOverview", listId)
@@ -526,7 +574,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
{
try
{
var ok = await _hub.InvokeAsync<bool>("SetWorktreeState", taskId, newState);
var ok = await InvokeTimedAsync<bool>("SetWorktreeState", () => _hub.InvokeAsync<bool>("SetWorktreeState", taskId, newState));
return (ok, null);
}
catch (HubException ex)
@@ -540,63 +588,63 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
}
public async Task<ForceRemoveResultDto?> ForceRemoveWorktreeAsync(string taskId)
=> await _hub.InvokeAsync<ForceRemoveResultDto>("ForceRemoveWorktree", taskId);
=> await InvokeTimedAsync<ForceRemoveResultDto>("ForceRemoveWorktree", () => _hub.InvokeAsync<ForceRemoveResultDto>("ForceRemoveWorktree", taskId));
public async Task<PlanningSessionStartInfo> StartPlanningSessionAsync(string taskId, CancellationToken ct = default)
=> await _hub.InvokeAsync<PlanningSessionStartInfo>("StartPlanningSessionAsync", taskId, ct);
=> await InvokeTimedAsync<PlanningSessionStartInfo>("StartPlanningSessionAsync", () => _hub.InvokeAsync<PlanningSessionStartInfo>("StartPlanningSessionAsync", taskId, ct));
public async Task<PlanningSessionResumeInfo> ResumePlanningSessionAsync(string taskId, CancellationToken ct = default)
=> await _hub.InvokeAsync<PlanningSessionResumeInfo>("ResumePlanningSessionAsync", taskId, ct);
=> await InvokeTimedAsync<PlanningSessionResumeInfo>("ResumePlanningSessionAsync", () => _hub.InvokeAsync<PlanningSessionResumeInfo>("ResumePlanningSessionAsync", taskId, ct));
public async Task SubmitTaskForReviewAsync(string taskId, CancellationToken ct = default)
=> await _hub.InvokeAsync("SubmitTaskForReview", taskId, ct);
=> await InvokeTimedAsync("SubmitTaskForReview", () => _hub.InvokeAsync("SubmitTaskForReview", taskId, ct));
public async Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default)
=> await _hub.InvokeAsync<LaunchSpec>("GetInteractiveLaunchSpec", taskId, ct);
=> await InvokeTimedAsync<LaunchSpec>("GetInteractiveLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetInteractiveLaunchSpec", taskId, ct));
public async Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
=> await _hub.InvokeAsync<LaunchSpec>("GetAdHocLaunchSpec", directory, ct);
=> await InvokeTimedAsync<LaunchSpec>("GetAdHocLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetAdHocLaunchSpec", directory, ct));
public async Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct = default)
=> await _hub.InvokeAsync<LaunchSpec>("GetMergeHelperLaunchSpec", taskIds, listId, ct);
=> await InvokeTimedAsync<LaunchSpec>("GetMergeHelperLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetMergeHelperLaunchSpec", taskIds, listId, ct));
public async Task<string> CreateMergeHelperTaskAsync(
IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default)
=> await _hub.InvokeAsync<string>("CreateMergeHelperTask", taskIds, listId, title, descriptionHeader, ct);
=> await InvokeTimedAsync<string>("CreateMergeHelperTask", () => _hub.InvokeAsync<string>("CreateMergeHelperTask", taskIds, listId, title, descriptionHeader, ct));
public async Task<LaunchSpec> GetMergeHelperHandoffLaunchSpecAsync(
string taskId, IReadOnlyList<string> survivingTaskIds, CancellationToken ct = default)
=> await _hub.InvokeAsync<LaunchSpec>("GetMergeHelperHandoffLaunchSpec", taskId, survivingTaskIds, ct);
string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase, CancellationToken ct = default)
=> await InvokeTimedAsync<LaunchSpec>("GetMergeHelperHandoffLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetMergeHelperHandoffLaunchSpec", taskId, survivingTaskIds, nextPhase, ct));
public async Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default)
=> await _hub.InvokeAsync<LaunchSpec>("GetPlanningStartLaunchSpec", taskId, ct);
=> await InvokeTimedAsync<LaunchSpec>("GetPlanningStartLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetPlanningStartLaunchSpec", taskId, ct));
public async Task<LaunchSpec> GetPlanningResumeLaunchSpecAsync(string taskId, CancellationToken ct = default)
=> await _hub.InvokeAsync<LaunchSpec>("GetPlanningResumeLaunchSpec", taskId, ct);
=> await InvokeTimedAsync<LaunchSpec>("GetPlanningResumeLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetPlanningResumeLaunchSpec", taskId, ct));
public async Task<DiscardPlanningOutcome> DiscardPlanningSessionAsync(string taskId, bool dequeueQueuedChildren = false, CancellationToken ct = default)
=> await _hub.InvokeAsync<DiscardPlanningOutcome>("DiscardPlanningSessionAsync", taskId, dequeueQueuedChildren, ct);
=> await InvokeTimedAsync<DiscardPlanningOutcome>("DiscardPlanningSessionAsync", () => _hub.InvokeAsync<DiscardPlanningOutcome>("DiscardPlanningSessionAsync", taskId, dequeueQueuedChildren, ct));
public async Task<int> FinalizePlanningSessionAsync(string taskId, bool queueAgentTasks = true, CancellationToken ct = default)
=> await _hub.InvokeAsync<int>("FinalizePlanningSessionAsync", taskId, queueAgentTasks, ct);
=> await InvokeTimedAsync<int>("FinalizePlanningSessionAsync", () => _hub.InvokeAsync<int>("FinalizePlanningSessionAsync", taskId, queueAgentTasks, ct));
public async Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default)
=> await _hub.InvokeAsync<int>("GetPendingDraftCountAsync", taskId, ct);
=> await InvokeTimedAsync<int>("GetPendingDraftCountAsync", () => _hub.InvokeAsync<int>("GetPendingDraftCountAsync", taskId, ct));
public async Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregateAsync(string planningTaskId)
=> await TryInvokeAsync<List<SubtaskDiffDto>>("GetPlanningAggregate", planningTaskId) ?? [];
public async Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch)
=> await _hub.InvokeAsync<CombinedDiffResultDto>("BuildPlanningIntegrationBranch", planningTaskId, targetBranch);
=> await InvokeTimedAsync<CombinedDiffResultDto>("BuildPlanningIntegrationBranch", () => _hub.InvokeAsync<CombinedDiffResultDto>("BuildPlanningIntegrationBranch", planningTaskId, targetBranch));
public async Task ContinuePlanningMergeAsync(string planningTaskId)
{
await _hub.InvokeAsync("ContinuePlanningMerge", planningTaskId);
await InvokeTimedAsync("ContinuePlanningMerge", () => _hub.InvokeAsync("ContinuePlanningMerge", planningTaskId));
}
public async Task AbortPlanningMergeAsync(string planningTaskId)
{
await _hub.InvokeAsync("AbortPlanningMerge", planningTaskId);
await InvokeTimedAsync("AbortPlanningMerge", () => _hub.InvokeAsync("AbortPlanningMerge", planningTaskId));
}
public async Task<IReadOnlyList<PlanningMergeConflictStateDto>> GetActiveExternalPlanningMergeConflictsAsync()
@@ -605,20 +653,20 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default)
{
await _hub.InvokeAsync("QueuePlanningSubtasksAsync", parentTaskId, ct);
await InvokeTimedAsync("QueuePlanningSubtasksAsync", () => _hub.InvokeAsync("QueuePlanningSubtasksAsync", parentTaskId, ct));
}
public Task<OnlineInboxStateDto?> GetOnlineInboxStateAsync()
=> TryInvokeAsync<OnlineInboxStateDto>("GetOnlineInboxState");
public async Task SetOnlineInboxConfigAsync(OnlineInboxConfigInputDto input)
=> await _hub.InvokeAsync("SetOnlineInboxConfig", input);
=> await InvokeTimedAsync("SetOnlineInboxConfig", () => _hub.InvokeAsync("SetOnlineInboxConfig", input));
public async Task SetOnlineInboxAuthAsync(string refreshToken)
=> await _hub.InvokeAsync("SetOnlineInboxAuth", refreshToken);
=> await InvokeTimedAsync("SetOnlineInboxAuth", () => _hub.InvokeAsync("SetOnlineInboxAuth", refreshToken));
public async Task ClearOnlineInboxAuthAsync()
=> await _hub.InvokeAsync("ClearOnlineInboxAuth");
=> await InvokeTimedAsync("ClearOnlineInboxAuth", () => _hub.InvokeAsync("ClearOnlineInboxAuth"));
public Task<UsageSnapshotDto?> GetUsageSnapshotAsync()
=> TryInvokeAsync<UsageSnapshotDto>("GetUsageSnapshot");
@@ -690,12 +738,15 @@ public sealed record SessionSkillDto(
public sealed record WorktreeCleanupDto(int Removed);
public sealed record WorktreeResetDto(int Removed, int TasksAffected, bool Blocked, int RunningTasks);
public record MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage);
public record BaseDirtyWarningDto(int ModifiedCount, int UntrackedCount);
public record SetTaskStatusResultDto(BaseDirtyWarningDto? BaseDirty);
public record MergePreviewDto(string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount);
public record MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalBranches);
public record MergeTargetsDto(
string DefaultBranch, IReadOnlyList<string> LocalBranches, string DefaultCommitMessage);
public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDocumentDto> Files);
public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList<MergeSegmentDto> Segments);
public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs);
public sealed record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType, bool IsManual = false);
public sealed record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType, bool IsManual = false, bool FindingsTracked = false);
public sealed record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null, string? VerifyCommand = null);
public sealed record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
public sealed record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null, string? VerifyCommand = null);
@@ -143,7 +143,9 @@ public sealed partial class ConflictResolverViewModel : ObservableObject
var start = await _worker.StartConflictMergeAsync(_taskId, targetBranch);
if (!string.Equals(start.Status, "conflict", StringComparison.Ordinal))
{
if (string.Equals(start.Status, "blocked", StringComparison.Ordinal))
// Any non-conflict outcome here means nothing was left mid-merge for this dialog
// to resolve -- surface why instead of quietly closing with no explanation.
if (start.Status is "blocked" or "untracked_collision")
Error = start.ErrorMessage;
return false;
}
@@ -190,7 +190,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
public string SessionLabel => "claude-session";
public string TaskIdBadge => Task != null ? $"#T{Task.Id[..Math.Min(3, Task.Id.Length)].ToUpperInvariant()}" : "";
public string TaskIdBadge => Task != null ? $"#{Task.Number}" : "";
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(ContinueCommand))]
@@ -299,7 +299,12 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
[NotifyCanExecuteChangedFor(nameof(RejectReviewCommand))]
private string _reviewFeedback = "";
public bool HasReviewFeedback => !string.IsNullOrWhiteSpace(ReviewFeedback);
public bool HasReviewFeedback => !string.IsNullOrWhiteSpace(ReviewFeedback) && !RejectOp.IsRunning;
public OperationStatus ApproveOp { get; } = new();
public OperationStatus SubmitOp { get; } = new();
public OperationStatus RejectOp { get; } = new();
public OperationStatus ParkOp { get; } = new();
public DetailsIslandViewModel(
IDbContextFactory<ClaudeDoDbContext> dbFactory,
@@ -330,6 +335,30 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
};
Prep = new PrepPanelViewModel(worker);
// OperationStatus.IsRunning is a nested ObservableObject property, so
// [NotifyCanExecuteChangedFor] on the command can't see it -- without this, the
// button stays clickable for the whole duration of the call.
ApproveOp.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(OperationStatus.IsRunning)) ApproveReviewCommand.NotifyCanExecuteChanged();
};
SubmitOp.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(OperationStatus.IsRunning)) SubmitForReviewCommand.NotifyCanExecuteChanged();
};
RejectOp.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(OperationStatus.IsRunning))
{
OnPropertyChanged(nameof(HasReviewFeedback));
RejectReviewCommand.NotifyCanExecuteChanged();
}
};
ParkOp.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(OperationStatus.IsRunning)) ParkReviewCommand.NotifyCanExecuteChanged();
};
Notes = new NotesEditorViewModel(_notesApi);
Notes.ErrorReported += msg => { if (ShowErrorAsync is not null) _ = ShowErrorAsync(msg); };
Subtasks.CollectionChanged += (_, _) => NotifyStepsChanged();
@@ -549,7 +578,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
public void ShowNotes()
{
Bind(null);
Bind(null, "notes");
IsPrepMode = false;
IsNotesMode = true;
_ = Notes.LoadDayAsync(DateOnly.FromDateTime(DateTime.Today));
@@ -557,13 +586,15 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
public void ShowPrep()
{
Bind(null);
Bind(null, "prep");
IsNotesMode = false;
IsPrepMode = true;
_ = Prep.LoadLastPrepLogIfEmptyAsync();
}
public void Bind(TaskRowViewModel? row)
/// <param name="source">What triggered the selection (<see cref="TasksIslandViewModel.SelectionSource"/>);
/// logged with the bind timing so rebind churn can be attributed to a trigger.</param>
public void Bind(TaskRowViewModel? row, string source = "?")
{
IsNotesMode = false;
IsPrepMode = false;
@@ -605,11 +636,13 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
// 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);
_ = BindAsync(row, ct, source);
}
private async System.Threading.Tasks.Task BindAsync(TaskRowViewModel row, CancellationToken ct)
private async System.Threading.Tasks.Task BindAsync(TaskRowViewModel row, CancellationToken ct, string source)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var ok = false;
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
@@ -701,12 +734,16 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
}
}
await Merge.RefreshMergePreviewAsync();
ok = true;
}
catch (OperationCanceledException) { }
finally { OperationTiming.Shared.Record("db", $"DetailsIsland.BindAsync:{source}", sw.Elapsed, ok); }
}
private async System.Threading.Tasks.Task LoadChildOutcomesAsync(string parentTaskId, CancellationToken ct)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var ok = false;
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
@@ -717,6 +754,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
.ToListAsync(ct);
ct.ThrowIfCancellationRequested();
ok = true;
if (children.Count == 0) return;
ChildOutcomes.Clear();
@@ -749,10 +787,13 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
}
catch (OperationCanceledException) { }
catch { /* best-effort */ }
finally { OperationTiming.Shared.Record("db", "DetailsIsland.LoadChildOutcomesAsync", sw.Elapsed, ok); }
}
private async System.Threading.Tasks.Task LoadPlanningChildrenAsync(string parentTaskId, CancellationToken ct)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var ok = false;
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
@@ -762,6 +803,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
.Where(t => t.ParentTaskId == parentTaskId)
.ToListAsync(ct);
ct.ThrowIfCancellationRequested();
ok = true;
foreach (var child in children)
{
@@ -791,6 +833,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
}
catch (OperationCanceledException) { }
catch { /* best-effort */ }
finally { OperationTiming.Shared.Record("db", "DetailsIsland.LoadPlanningChildrenAsync", sw.Elapsed, ok); }
}
private async System.Threading.Tasks.Task RefreshPlanningChildAsync(string childTaskId)
@@ -1097,12 +1140,20 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
if (Task == null) return;
try
{
await _worker.SetTaskStatusAsync(Task.Id, ClaudeDo.Data.Models.TaskStatus.Queued);
var baseDirty = await _worker.SetTaskStatusAsync(Task.Id, ClaudeDo.Data.Models.TaskStatus.Queued);
AgentState = "queued";
ReportBaseDirty(baseDirty);
}
catch { /* offline */ }
}
private void ReportBaseDirty(BaseDirtyWarningDto? warning)
{
if (warning is null) return;
ErrorReported?.Invoke(Loc.T(
"vm.queue.baseDirtyWarning", warning.ModifiedCount, warning.UntrackedCount));
}
private bool CanEnqueue() =>
Task != null && _worker.IsConnected && IsIdle
&& (!Task.IsChild || Task.ParentFinalized);
@@ -1168,8 +1219,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
try
{
await _worker.SetTaskStatusAsync(Task.Id, ClaudeDo.Data.Models.TaskStatus.Queued);
var baseDirty = await _worker.SetTaskStatusAsync(Task.Id, ClaudeDo.Data.Models.TaskStatus.Queued);
AgentState = "queued";
ReportBaseDirty(baseDirty);
}
catch { /* offline */ }
}
@@ -1197,41 +1249,68 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
private async System.Threading.Tasks.Task ApproveReviewAsync()
{
if (Task is null || !_worker.IsConnected) return;
var taskId = Task.Id;
using var op = ApproveOp.Begin(Loc.T("ops.merge.merging"));
// Subscribed only for the duration of the call: the worker broadcasts to every
// client, and a VM left on this event would keep reacting to merges for other tasks.
void OnMergeProgress(string progressTaskId, string phase, int elapsedSeconds)
{
if (progressTaskId != taskId || phase != MergePhaseVerifying) return;
ApproveOp.Report(Loc.T("ops.merge.verifying", FormatElapsed(elapsedSeconds)));
}
_worker.MergeProgressEvent += OnMergeProgress;
try
{
var hasChildren = Subtasks.Count > 0 || ChildOutcomes.Count > 0;
var result = await _worker.ApproveReviewAsync(Task.Id, Merge.SelectedMergeTarget ?? "");
var result = await _worker.ApproveReviewAsync(taskId, Merge.SelectedMergeTarget ?? "");
if (!hasChildren && result?.Status == "conflict")
await _merge.ResolveConflictAsync(Task.Id, Merge.SelectedMergeTarget ?? "");
await _merge.ResolveConflictAsync(taskId, Merge.SelectedMergeTarget ?? "");
// The merge itself already landed; the verify command failed, so the task stayed
// out of Done. Surface that instead of silently looking like nothing happened.
else if (!hasChildren && result?.Status == "verify_failed" && ShowErrorAsync != null)
await ShowErrorAsync(result.ErrorMessage ?? Loc.T("vm.detailsIsland.verifyFailed"));
// Nothing landed -- the merge was refused before touching the repo because the
// branch would have overwritten an untracked file in the target working directory.
else if (!hasChildren && result?.Status == "untracked_collision" && ShowErrorAsync != null)
await ShowErrorAsync(result.ErrorMessage ?? Loc.T("vm.detailsIsland.untrackedCollision"));
}
catch (Exception ex)
{
if (ShowErrorAsync != null)
await ShowErrorAsync(ex.Message);
}
finally
{
_worker.MergeProgressEvent -= OnMergeProgress;
}
}
/// Mirrors TaskMergeService.PhaseVerifying — a hub payload token, not a display string.
private const string MergePhaseVerifying = "verifying";
private static string FormatElapsed(int seconds) =>
TimeSpan.FromSeconds(Math.Max(0, seconds)).ToString(@"mm\:ss");
// Force the diff to have been opened before a merge can happen — but only when
// there is actually something to inspect (a childless sandbox run with no worktree
// has no diff, so it approves straight through).
private bool CanApproveReview() =>
Task != null && _worker.IsConnected && IsWaitingForReview
&& (!Merge.HasReviewableDiff || ReviewDiffViewed);
&& (!Merge.HasReviewableDiff || ReviewDiffViewed)
&& !ApproveOp.IsRunning;
// An interactive (ConPTY) session leaves its worktree changed but never flips the task
// status. Offer "Submit for review" for an Idle/Failed task that still has a worktree, so
// its hand-driven work can enter the normal review/merge flow (commits first, server-side).
public bool CanSubmitForReview =>
Task != null && _worker.IsConnected && (IsIdle || IsFailed) && !string.IsNullOrEmpty(WorktreePath);
Task != null && _worker.IsConnected && (IsIdle || IsFailed) && !string.IsNullOrEmpty(WorktreePath)
&& !SubmitOp.IsRunning;
[RelayCommand(CanExecute = nameof(CanSubmitForReview))]
private async System.Threading.Tasks.Task SubmitForReviewAsync()
{
if (Task is null || !_worker.IsConnected) return;
using var op = SubmitOp.Begin(Loc.T("ops.review.submitting"));
try { await _worker.SubmitTaskForReviewAsync(Task.Id); }
catch (Exception ex)
{
@@ -1245,20 +1324,24 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
if (Task is null || !_worker.IsConnected) return;
var feedback = ReviewFeedback;
if (string.IsNullOrWhiteSpace(feedback)) return;
using var op = RejectOp.Begin(Loc.T("ops.review.rejecting"));
try { await _worker.RejectReviewToQueueAsync(Task.Id, feedback); }
catch { /* stale review action; broadcast reconciles */ return; }
ReviewFeedback = "";
}
// Park: set the task aside (back to Idle), keeping its worktree intact.
[RelayCommand]
[RelayCommand(CanExecute = nameof(CanParkReview))]
private async System.Threading.Tasks.Task ParkReviewAsync()
{
if (Task is null || !_worker.IsConnected) return;
using var op = ParkOp.Begin(Loc.T("ops.review.parking"));
try { await _worker.RejectReviewToIdleAsync(Task.Id); }
catch { /* stale review action; broadcast reconciles */ }
}
private bool CanParkReview() => Task != null && _worker.IsConnected && !ParkOp.IsRunning;
[RelayCommand]
private async System.Threading.Tasks.Task ResetReviewAsync()
{
@@ -14,6 +14,8 @@ public sealed partial class ListNavItemViewModel : ViewModelBase
[ObservableProperty] private string _defaultCommitType = CommitTypeRegistry.DefaultType;
// Reminder list: tasks created here default to manual.
[ObservableProperty] private bool _isManual;
// Whether this list's .claudedo/ findings folder is committed with the repo (ListEntity.FindingsTracked).
[ObservableProperty] private bool _findingsTracked;
[ObservableProperty] private bool _dropHintAbove;
[ObservableProperty] private bool _dropHintBelow;
// Set while a dragged task hovers over this row as a move target — distinct from
@@ -50,7 +50,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
if (row is null || Dialogs is null || _services is null) return;
var rawId = row.Id.StartsWith("user:", StringComparison.Ordinal) ? row.Id["user:".Length..] : row.Id;
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
await vm.LoadAsync(rawId, row.Name, row.WorkingDir, row.DefaultCommitType, row.IsManual);
await vm.LoadAsync(rawId, row.Name, row.WorkingDir, row.DefaultCommitType, row.IsManual, row.FindingsTracked);
await Dialogs.ShowListSettingsAsync(vm);
if (vm.Deleted) await LoadAsync();
else await RefreshRowAsync(row.Id);
@@ -121,20 +121,44 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
}
[RelayCommand]
private void OpenInTerminal(ListNavItemViewModel? row)
private void OpenFindings(ListNavItemViewModel? row)
{
var dir = row?.WorkingDir;
if (string.IsNullOrWhiteSpace(dir) || !System.IO.Directory.Exists(dir)) return;
ForegroundHelper.AllowAny();
if (string.IsNullOrWhiteSpace(dir)) return;
var findings = System.IO.Path.Combine(dir, ".claudedo");
if (!System.IO.Directory.Exists(findings)) return;
try
{
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
{
FileName = "wt.exe",
Arguments = $"-d \"{dir}\"",
FileName = findings,
UseShellExecute = true,
});
}
catch { /* best-effort */ }
}
[RelayCommand]
private void OpenInTerminal(ListNavItemViewModel? row)
{
var dir = row?.WorkingDir;
if (string.IsNullOrWhiteSpace(dir) || !System.IO.Directory.Exists(dir)) return;
// Trailing separator + ArgumentList, not string interpolation: "C:\repo\" would parse as
// C:\repo" and wt would refuse it as a starting directory (Paths.TrimTrailingSeparator).
// Rows loaded before ListRepository normalized on write can still carry one.
dir = Paths.TrimTrailingSeparator(dir)!;
ForegroundHelper.AllowAny();
try
{
var psi = new System.Diagnostics.ProcessStartInfo
{
FileName = "wt.exe",
UseShellExecute = true,
};
psi.ArgumentList.Add("-d");
psi.ArgumentList.Add(dir);
System.Diagnostics.Process.Start(psi);
}
catch
{
// Windows Terminal not installed — fall back to a plain console at the directory.
@@ -231,6 +255,10 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
};
foreach (var s in smart) { Items.Add(s); SmartLists.Add(s); }
var sw = System.Diagnostics.Stopwatch.StartNew();
var ok = false;
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var lists = new ListRepository(ctx);
var seedNames = new HashSet<string>(new[] { "My Day", "Important", "Planned" });
@@ -249,11 +277,15 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
WorkingDir = l.WorkingDir,
DefaultCommitType = l.DefaultCommitType,
IsManual = l.IsManual,
FindingsTracked = l.FindingsTracked,
};
Items.Add(item);
UserLists.Add(item);
idx++;
}
ok = true;
}
finally { OperationTiming.Shared.Record("db", "ListsIsland.LoadAsync", sw.Elapsed, ok); }
await RefreshCountsAsync(ct);
SelectedList = Items.FirstOrDefault();
@@ -261,6 +293,8 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
public async Task RefreshCountsAsync(CancellationToken ct = default)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var ok = false;
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
@@ -281,9 +315,11 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
var filter = _filters.Resolve(item.Id);
item.Count = filter is null ? 0 : all.Count(filter.ShouldCount);
}
ok = true;
}
catch (OperationCanceledException) { throw; }
catch { /* best-effort refresh */ }
finally { OperationTiming.Shared.Record("db", "ListsIsland.RefreshCountsAsync", sw.Elapsed, ok); }
}
[RelayCommand]
@@ -323,7 +359,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
if (Dialogs is not null && _services is not null)
{
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
await vm.LoadAsync(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType, entity.IsManual);
await vm.LoadAsync(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType, entity.IsManual, entity.FindingsTracked);
await Dialogs.ShowListSettingsAsync(vm);
if (vm.Deleted) await LoadAsync();
else await RefreshRowAsync(item.Id);
@@ -357,9 +393,16 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
MoveWithinCollection(UserLists, source, target, placeBelow);
var orderedIds = UserLists.Select(i => i.Id["user:".Length..]).ToList();
var sw = System.Diagnostics.Stopwatch.StartNew();
var ok = false;
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
var lists = new ListRepository(ctx);
await lists.ReorderAsync(orderedIds);
ok = true;
}
finally { OperationTiming.Shared.Record("db", "ListsIsland.ReorderAsync", sw.Elapsed, ok); }
}
private static void MoveWithinCollection(
@@ -404,6 +447,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
row.WorkingDir = entity.WorkingDir;
row.DefaultCommitType = entity.DefaultCommitType;
row.IsManual = entity.IsManual;
row.FindingsTracked = entity.FindingsTracked;
}
catch { /* best-effort refresh */ }
}
@@ -1,6 +1,7 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using System.Collections.ObjectModel;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Modals;
using Microsoft.Extensions.DependencyInjection;
@@ -27,6 +28,10 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
[ObservableProperty] private ObservableCollection<string> _mergeTargetBranches = new();
[ObservableProperty] private string? _selectedMergeTarget;
// No CanExecute to gate here — RefreshMergePreviewAsync is triggered from the Bind path,
// not a [RelayCommand], so PreviewOp only drives the indicator, never locks a button.
public OperationStatus PreviewOp { get; } = new();
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowMergePreviewMuted))]
private string _mergePreviewText = "";
@@ -118,6 +123,7 @@ public sealed partial class MergeSectionViewModel : ViewModelBase
}
var capturedTaskId = TaskId;
var capturedTarget = SelectedMergeTarget;
using var op = PreviewOp.Begin(Loc.T("ops.review.previewing"));
var dto = await _worker.PreviewMergeAsync(capturedTaskId, capturedTarget ?? "");
if (TaskId != capturedTaskId || SelectedMergeTarget != capturedTarget) return;
var (text, clean, conflict) = MergePreviewPresenter.Describe(dto);
@@ -3,6 +3,7 @@ using System.Text;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Ui.Helpers;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
namespace ClaudeDo.Ui.ViewModels.Islands;
@@ -19,11 +20,23 @@ public sealed partial class PrepPanelViewModel : ViewModelBase, IDisposable
[ObservableProperty] private bool _isPrepRunning;
// Covers the gap between the click and the worker's PrepStartedEvent broadcast — IsPrepRunning
// stays false until that event arrives, so without this the button click looks like a no-op.
public OperationStatus PrepOperation { get; } = new();
public ObservableCollection<LogLineViewModel> PrepLog { get; } = new();
public bool ShowPrepEmptyState => !IsPrepRunning && PrepLog.Count == 0;
partial void OnIsPrepRunningChanged(bool value) => OnPropertyChanged(nameof(ShowPrepEmptyState));
// The long-running broadcast-driven state (IsPrepRunning) and the click-to-event gap
// (PrepOperation.IsRunning) both need to block a second click.
public bool IsPlanDayEnabled => !IsPrepRunning && !PrepOperation.IsRunning;
partial void OnIsPrepRunningChanged(bool value)
{
OnPropertyChanged(nameof(ShowPrepEmptyState));
OnPropertyChanged(nameof(IsPlanDayEnabled));
}
public PrepPanelViewModel(IWorkerClient worker)
{
@@ -37,6 +50,15 @@ public sealed partial class PrepPanelViewModel : ViewModelBase, IDisposable
_worker.PrepFinishedEvent += _onPrepFinishedHandler;
PrepLog.CollectionChanged += (_, _) => OnPropertyChanged(nameof(ShowPrepEmptyState));
// OperationStatus is a nested ObservableObject — [NotifyCanExecuteChangedFor] can't see
// into it, so IsRunning changes are relayed by hand.
PrepOperation.PropertyChanged += (_, e) =>
{
if (e.PropertyName != nameof(OperationStatus.IsRunning)) return;
PlanDayCommand.NotifyCanExecuteChanged();
OnPropertyChanged(nameof(IsPlanDayEnabled));
};
}
public void Dispose()
@@ -46,9 +68,12 @@ public sealed partial class PrepPanelViewModel : ViewModelBase, IDisposable
_worker.PrepFinishedEvent -= _onPrepFinishedHandler;
}
[RelayCommand]
private bool CanPlanDay() => IsPlanDayEnabled;
[RelayCommand(CanExecute = nameof(CanPlanDay))]
private async System.Threading.Tasks.Task PlanDayAsync()
{
using var op = PrepOperation.Begin(Loc.T("ops.reports.runningDailyPrep"));
try { await _worker.RunDailyPrepNowAsync(); }
catch { }
}
@@ -8,6 +8,7 @@ namespace ClaudeDo.Ui.ViewModels.Islands;
public sealed partial class TaskRowViewModel : ViewModelBase
{
public required string Id { get; init; }
[ObservableProperty] private int _number;
[ObservableProperty] private string _title = "";
[ObservableProperty] private string _listName = "";
[ObservableProperty] private bool _done;
@@ -27,13 +28,25 @@ public sealed partial class TaskRowViewModel : ViewModelBase
[ObservableProperty] private string? _parentTaskId;
[ObservableProperty] private string? _createdBy;
[ObservableProperty] private string? _blockedByTaskId;
[ObservableProperty] private string? _dependsOnTaskId;
[ObservableProperty] private bool _isExpanded = true;
[ObservableProperty] private bool _hasPlanningChildren;
[ObservableProperty] private bool _hasQueuedSubtasks;
[ObservableProperty] private bool _showListChip = true;
[ObservableProperty] private bool _parentFinalized;
[ObservableProperty] private bool _parentInView = true;
// The three below are extrinsic — computed and assigned by TasksIslandViewModel.Regroup
// (it alone has the cross-row graph needed to walk DependsOnTaskId chains), mirroring how
// ParentInView is assigned by the same pass rather than derived locally.
[ObservableProperty] private bool _showAsChainMember;
[ObservableProperty] private int? _chainStep;
[ObservableProperty] private string? _chainAfterLabel;
[ObservableProperty] private int _roadblockCount;
// Only meaningful when Status=Failed; stamped by TaskRunner.MarkFailed. "unknown" for a
// Failed task that predates this field. Null on every other status.
[ObservableProperty] private string? _failureReason;
[ObservableProperty] private int? _failureTurnsUsed;
[ObservableProperty] private int? _failureMaxTurns;
[ObservableProperty] private bool _isRefining;
// Manual = a reminder only the user can do. Every "hand this to Claude" affordance is hidden
// and automation skips it; opening a hand-driven ConPTY session stays allowed.
@@ -45,6 +58,10 @@ public sealed partial class TaskRowViewModel : ViewModelBase
// Set by the custom drag while this row is being dragged — drives the "grabbed" row style.
[ObservableProperty] private bool _isDragging;
// True while a drag is hovering this row (i.e. it would show a drop-hint gap). Used to
// suppress the ordinary hover highlight/transitions so they don't fight the hint.
public bool IsDropTarget => DropHintAbove || DropHintBelow;
public bool CanRefine => Status == TaskStatus.Idle && PlanningPhase == PlanningPhase.None
&& !IsRefining && !IsManual;
@@ -56,6 +73,10 @@ public sealed partial class TaskRowViewModel : ViewModelBase
public int StepsCount { get; init; }
public int StepsCompleted { get; init; }
// Number is 0 for rows created outside TaskNumberAllocator (test seeds, a future import
// path); a bare "#0" would be meaningless, so hide the badge entirely below 1.
public bool ShowNumberBadge => Number > 0;
public bool IsChild => !string.IsNullOrEmpty(ParentTaskId);
public bool IsAgentSuggested => IsChild && !string.IsNullOrEmpty(CreatedBy) && CreatedBy == ParentTaskId;
public bool IsPlanningParent => PlanningPhase != PlanningPhase.None
@@ -124,15 +145,33 @@ public sealed partial class TaskRowViewModel : ViewModelBase
? "1 roadblock reported during the run — see details"
: $"{RoadblockCount} roadblocks reported during the run — see details";
// True for every Failed task, even one that predates this field — FailureReasonLabel then
// falls back to "unknown" instead of leaving the tooltip blank.
public bool HasFailureReason => Status == TaskStatus.Failed;
public string FailureReasonLabel => FailureReason switch
{
"max_turns" => Loc.T("vm.failureReason.maxTurns"),
"timeout" => Loc.T("vm.failureReason.timeout"),
"cancelled" => Loc.T("vm.failureReason.cancelled"),
"error" => Loc.T("vm.failureReason.error"),
_ => Loc.T("vm.failureReason.unknown"),
};
// max_turns gets the actionable detail (turns used/configured) since that's the one case
// where the fix is "raise maxTurns and continue_task", not "reset and re-run".
public string? FailureReasonTooltip => !HasFailureReason ? null
: FailureReason == "max_turns" && FailureTurnsUsed is { } used && FailureMaxTurns is { } max
? Loc.T("vm.failureReasonTooltip.maxTurns", used, max)
: FailureReasonLabel;
// Drives the status chip tooltip: an open interactive session takes priority (it's tappable),
// otherwise a Failed task's reason, otherwise no tooltip.
public string? StatusChipTooltip
=> HasInteractiveSession ? Loc.T("tasks.interactiveChipTip") : FailureReasonTooltip;
public string DiffAdditionsText => $"+{DiffAdditions}";
public string DiffDeletionsText => $"{DiffDeletions}";
public string StepsText => Loc.T("vm.taskRow.stepsText", StepsCompleted, StepsCount);
// Null on a plain status chip so no tooltip shows there — the chip is only actionable
// while a session is open.
public string? InteractiveChipTooltip
=> HasInteractiveSession ? Loc.T("tasks.interactiveChipTip") : null;
public string StatusLabel
=> HasInteractiveSession ? Loc.T("vm.taskStatus.interactive")
: IsParked ? Loc.T("vm.taskStatus.parked") : Status switch
@@ -179,6 +218,9 @@ public sealed partial class TaskRowViewModel : ViewModelBase
OnPropertyChanged(nameof(IsParked));
OnPropertyChanged(nameof(IsQueued));
OnPropertyChanged(nameof(IsWaiting));
OnPropertyChanged(nameof(HasFailureReason));
OnPropertyChanged(nameof(FailureReasonTooltip));
OnPropertyChanged(nameof(StatusChipTooltip));
OnPropertyChanged(nameof(IsDraft));
OnPropertyChanged(nameof(IsPlanned));
OnPropertyChanged(nameof(CanOpenPlanningSession));
@@ -245,10 +287,21 @@ public sealed partial class TaskRowViewModel : ViewModelBase
OnPropertyChanged(nameof(StatusChipClass));
OnPropertyChanged(nameof(StatusLabel));
OnPropertyChanged(nameof(ShowStatusChip));
OnPropertyChanged(nameof(InteractiveChipTooltip));
OnPropertyChanged(nameof(StatusChipTooltip));
OnPropertyChanged(nameof(CanSendToQueue));
}
partial void OnFailureReasonChanged(string? value)
{
OnPropertyChanged(nameof(HasFailureReason));
OnPropertyChanged(nameof(FailureReasonLabel));
OnPropertyChanged(nameof(FailureReasonTooltip));
OnPropertyChanged(nameof(StatusChipTooltip));
}
partial void OnFailureTurnsUsedChanged(int? value) => OnPropertyChanged(nameof(FailureReasonTooltip));
partial void OnFailureMaxTurnsChanged(int? value) => OnPropertyChanged(nameof(FailureReasonTooltip));
partial void OnHasQueuedSubtasksChanged(bool value)
{
OnPropertyChanged(nameof(CanRemoveFromQueue));
@@ -269,6 +322,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
OnPropertyChanged(nameof(CanQueuePlan));
}
partial void OnNumberChanged(int value) => OnPropertyChanged(nameof(ShowNumberBadge));
partial void OnBranchChanged(string? value) => OnPropertyChanged(nameof(HasBranch));
partial void OnWorktreeStateChanged(ClaudeDo.Data.Models.WorktreeState? value)
{
@@ -289,6 +343,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase
partial void OnDiffAdditionsChanged(int value) { OnPropertyChanged(nameof(HasDiff)); OnPropertyChanged(nameof(DiffAdditionsText)); }
partial void OnDiffDeletionsChanged(int value) { OnPropertyChanged(nameof(HasDiff)); OnPropertyChanged(nameof(DiffDeletionsText)); }
partial void OnRoadblockCountChanged(int value) { OnPropertyChanged(nameof(HasRoadblock)); OnPropertyChanged(nameof(RoadblockTooltip)); }
partial void OnDropHintAboveChanged(bool value) => OnPropertyChanged(nameof(IsDropTarget));
partial void OnDropHintBelowChanged(bool value) => OnPropertyChanged(nameof(IsDropTarget));
public void RefreshLocalized()
{
@@ -308,6 +364,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
public void UpdateFromEntity(TaskEntity t)
{
var (add, del) = ParseDiffStat(t.Worktree?.DiffStat);
Number = t.Number;
Title = t.Title;
ListName = t.List?.Name ?? "";
Done = t.Status == TaskStatus.Done;
@@ -325,7 +382,11 @@ public sealed partial class TaskRowViewModel : ViewModelBase
ParentTaskId = t.ParentTaskId;
CreatedBy = t.CreatedBy;
BlockedByTaskId = t.BlockedByTaskId;
DependsOnTaskId = t.DependsOnTaskId;
RoadblockCount = t.RoadblockCount;
FailureReason = t.FailureReason;
FailureTurnsUsed = t.FailureTurnsUsed;
FailureMaxTurns = t.FailureMaxTurns;
}
// Best-effort parse of diff stat strings like "+12 -3" or "12 additions, 3 deletions".
@@ -1,5 +1,6 @@
using System.Collections.ObjectModel;
using System.Globalization;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Data;
@@ -14,6 +15,19 @@ using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Ui.ViewModels.Islands;
/// <summary>A group header entry in <see cref="TasksIslandViewModel.Rows"/> — the flat list's
/// other union member alongside <see cref="TaskRowViewModel"/>. Instances are persistent (one per
/// section, mutated in place by <see cref="TasksIslandViewModel.Regroup"/>) so an unchanged header
/// keeps its identity across a reconcile.</summary>
public sealed partial class HeaderRow : ViewModelBase
{
[ObservableProperty] private string _label = "";
[ObservableProperty] private int _count;
public bool IsOverdue { get; init; }
public IRelayCommand? ActionCommand { get; init; }
public bool HasAction => ActionCommand is not null;
}
public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
{
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
@@ -29,8 +43,23 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
// 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;
// Phase 3 reconcile tick — see the block at the end of this class.
private readonly System.Timers.Timer _reconcileTimer = new(4_000);
public event EventHandler? SelectionChanged;
/// <summary>Why the selection last changed. Recorded with the detail-pane bind, so the timing
/// log attributes rebind churn to a trigger instead of leaving an anonymous count.</summary>
public string SelectionSource { get; private set; } = "init";
/// <summary>Sets <see cref="SelectedTask"/> and tags what triggered it — use this instead of
/// assigning the property, otherwise the bind is logged as "?" .</summary>
public void SelectFrom(TaskRowViewModel? row, string source)
{
SelectionSource = source;
SelectedTask = row;
}
public event EventHandler? FocusAddTaskRequested;
public event EventHandler? TasksChanged;
public event Action? NotesRequested;
@@ -41,7 +70,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
[RelayCommand]
private void OpenNotes()
{
SelectedTask = null;
SelectFrom(null, "notes");
NotesRequested?.Invoke();
}
@@ -57,9 +86,15 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
}
public ObservableCollection<TaskRowViewModel> Items { get; } = new();
public ObservableCollection<TaskRowViewModel> OverdueItems { get; } = new();
public ObservableCollection<TaskRowViewModel> OpenItems { get; } = new();
public ObservableCollection<TaskRowViewModel> CompletedItems { get; } = new();
/// <summary>Flat, virtualization-friendly view of <see cref="Items"/>: group headers
/// (<see cref="HeaderRow"/>) interleaved with visible <see cref="TaskRowViewModel"/> rows, in
/// display order. Reconciled granularly by <see cref="Regroup"/> — never Clear+Add.</summary>
public ObservableCollection<object> Rows { get; } = new();
private readonly HeaderRow _overdueHeaderRow = new() { IsOverdue = true };
private readonly HeaderRow _openHeaderRow = new();
private readonly HeaderRow _completedHeaderRow;
[ObservableProperty] private string _newTaskTitle = "";
[ObservableProperty] private TaskRowViewModel? _selectedTask;
@@ -79,6 +114,12 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
[ObservableProperty] private bool _isLetClaudeVisible;
[ObservableProperty] private bool _isQuickClaudeVisible;
// Shared by QueuePlanningSubtasksAsync and FinalizePlanningSessionAsync — both are triggered
// from a context menu that closes the instant a click lands, so there is no per-row surface
// left standing to anchor an indicator to. The island header is the nearest surface still
// visible after the menu closes.
public OperationStatus PlanningOperation { get; } = new();
public event EventHandler? LetClaudeHandleRequested;
[RelayCommand]
@@ -115,6 +156,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
{
_dbFactory = dbFactory;
_worker = worker;
_completedHeaderRow = new() { ActionCommand = ClearCompletedCommand };
CompletedHeader = Loc.T("vm.tasksIsland.completedHeader");
if (_worker is not null)
{
@@ -127,18 +169,33 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
}
_langChangedHandler = (_, _) => RefreshLocalizedText();
Loc.LanguageChanged += _langChangedHandler;
_reconcileTimer.Elapsed += (_, _) => Dispatcher.UIThread.Post(() => _ = ReconcileTickAsync());
_reconcileTimer.Start();
// OperationStatus is a nested ObservableObject — [NotifyCanExecuteChangedFor] can't see
// into it, so IsRunning changes are relayed by hand.
PlanningOperation.PropertyChanged += (_, e) =>
{
if (e.PropertyName != nameof(OperationStatus.IsRunning)) return;
QueuePlanningSubtasksCommand.NotifyCanExecuteChanged();
FinalizePlanningSessionCommand.NotifyCanExecuteChanged();
};
}
public void Dispose()
{
Loc.LanguageChanged -= _langChangedHandler;
_reconcileTimer.Stop();
_reconcileTimer.Dispose();
}
private void RefreshLocalizedText()
{
CompletedHeader = Loc.T("vm.tasksIsland.completedHeader");
foreach (var row in Items) row.RefreshLocalized();
foreach (var row in CompletedItems) row.RefreshLocalized();
// The section headers are now Rows entries whose Label is a resolved string, not a live
// {loc:Tr} binding in the view — without re-emitting them they keep the old language.
Regroup();
}
private async void OnWorkerListUpdated(string listId)
@@ -309,15 +366,13 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
_currentList.PropertyChanged += OnCurrentListPropertyChanged;
Items.Clear();
OverdueItems.Clear();
OpenItems.Clear();
CompletedItems.Clear();
Rows.Clear();
HasOverdue = false;
HasOpen = false;
HasCompleted = false;
ShowOpenLabel = false;
ShowNotesRow = false;
if (listChanged) SelectedTask = null;
if (listChanged) SelectFrom(null, "list-change");
if (list is null) { IsLetClaudeVisible = false; IsQuickClaudeVisible = false; LoadTask = Task.CompletedTask; return; }
HeaderTitle = list.Name;
@@ -333,28 +388,57 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
private async Task LoadForListAsync(
ListNavItemViewModel list, CancellationToken ct, Dictionary<string, TaskRowViewModel>? reusable)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var ok = false;
try
{
await using var db = await _dbFactory.CreateDbContextAsync(ct);
var all = await db.Tasks
.Include(t => t.List)
.Include(t => t.Worktree)
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
.ToListAsync(ct);
var filter = _filters.Resolve(list.Id);
var baseQuery = db.Tasks.Include(t => t.List).Include(t => t.Worktree);
var filteredList = filter is null
? new List<TaskEntity>()
: await baseQuery.Where(filter.MatchExpression).ToListAsync(ct);
ct.ThrowIfCancellationRequested();
var filter = _filters.Resolve(list.Id);
var filteredList = filter is null
? new List<TaskEntity>()
: all.Where(t => filter.Matches(t) || filter.MatchesAsContext(t, all)).ToList();
var topIds = filteredList.Where(t => t.ParentTaskId == null).Select(t => t.Id).ToHashSet();
var existingIds = filteredList.Select(t => t.Id).ToHashSet();
foreach (var c in all.Where(t => t.ParentTaskId != null && topIds.Contains(t.ParentTaskId!)))
if (filter is not null)
{
if (existingIds.Add(c.Id))
filteredList.Add(c);
// Contextual parent rows (e.g. a planning parent hosting an already-matched queued/running
// child): only fetch candidates that could plausibly qualify — parents of tasks we already
// matched — instead of scanning the whole table.
var candidateParentIds = filteredList
.Where(t => t.ParentTaskId != null)
.Select(t => t.ParentTaskId!)
.Distinct()
.ToList();
if (candidateParentIds.Count > 0)
{
var existingParentIds = filteredList.Select(t => t.Id).ToHashSet();
var parentCandidates = await baseQuery
.Where(t => candidateParentIds.Contains(t.Id) && !existingParentIds.Contains(t.Id))
.ToListAsync(ct);
foreach (var p in parentCandidates)
if (filter.MatchesAsContext(p, filteredList))
filteredList.Add(p);
}
}
ct.ThrowIfCancellationRequested();
// Pull in every child of an already-matched top-level row, regardless of whether the child
// itself matches the filter, so subtasks render nested under their parent.
var topIds = filteredList.Where(t => t.ParentTaskId == null).Select(t => t.Id).ToHashSet();
if (topIds.Count > 0)
{
var existingIds = filteredList.Select(t => t.Id).ToHashSet();
var extraChildren = await baseQuery
.Where(t => t.ParentTaskId != null && topIds.Contains(t.ParentTaskId!) && !existingIds.Contains(t.Id))
.ToListAsync(ct);
filteredList.AddRange(extraChildren);
}
filteredList = filteredList.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt).ToList();
var showListChip = list.Kind == ListKind.Virtual;
foreach (var t in filteredList)
@@ -406,8 +490,10 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
Regroup();
UpdateSubtitle();
ok = true;
}
catch (OperationCanceledException) { }
finally { OperationTiming.Shared.Record("db", "TasksIsland.LoadForListAsync", sw.Elapsed, ok); }
}
internal void Regroup()
@@ -432,7 +518,55 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
r.IsExpanded = saved;
}
// Build hierarchy-aware flat list: top-level rows interleaved with visible children.
var (overdue, open, completed) = ClassifyItems();
HasOverdue = overdue.Count > 0;
HasOpen = open.Count > 0;
HasCompleted = completed.Count > 0;
ShowOpenLabel = HasOpen && HasOverdue;
CompletedHeader = Loc.T("vm.tasksIsland.completedHeaderCount", completed.Count);
// Build the flat Rows target: header + rows per section, omitting a section entirely
// when it has nothing to show (no header, no rows) instead of hiding it via IsVisible.
var target = new List<object>();
if (HasOverdue)
{
_overdueHeaderRow.Label = Loc.T("tasks.overdue");
_overdueHeaderRow.Count = overdue.Count;
target.Add(_overdueHeaderRow);
target.AddRange(overdue);
}
if (HasOpen)
{
if (ShowOpenLabel)
{
_openHeaderRow.Label = Loc.T("tasks.tasks");
_openHeaderRow.Count = open.Count;
target.Add(_openHeaderRow);
}
target.AddRange(open);
}
if (HasCompleted && IsShowingCompleted)
{
_completedHeaderRow.Label = CompletedHeader;
_completedHeaderRow.Count = completed.Count;
target.Add(_completedHeaderRow);
target.AddRange(completed);
}
// Reconcile Rows in place (granular Insert/Move/Remove) rather than Clear+Add, so toggling
// a parent only touches its own child rows — the ListBox keeps every unchanged container
// instead of tearing the whole list down on a Reset.
SyncCollection(Rows, target);
}
// Builds the hierarchy-aware flat ordering (top-level rows interleaved with visible children,
// orphans flagged so they render flat) and partitions it into Overdue/Open/Completed. Pure
// with respect to chain state (ShowAsChainMember/ChainStep/ChainAfterLabel) — callers that
// only need section membership/counts (e.g. ClearCompletedAsync) should use this directly
// rather than ClassifyItems, which additionally mutates every row via ApplyChainGrouping.
private (List<TaskRowViewModel> Overdue, List<TaskRowViewModel> Open, List<TaskRowViewModel> Completed) PartitionItems()
{
// Items is already ordered by SortOrder from the DB query.
// Treat rows whose ParentTaskId is not in the current view as orphans -> top-level.
var visibleIds = Items.Select(r => r.Id).ToHashSet();
@@ -452,7 +586,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
if (!emitted.Add(parent.Id)) continue;
flat.Add(parent);
// Also expand for Done parents so their (Done) children reach the classification
// loop and land in CompletedItems alongside the parent.
// loop and land alongside the parent in Completed.
if ((parent.IsPlanningParent || parent.Done) && parent.IsExpanded)
{
var children = Items.Where(r => r.ParentTaskId == parent.Id);
@@ -479,18 +613,133 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
open.Add(r);
}
// Reconcile the bound collections in place (granular Insert/Move/Remove) rather than
// Clear+Add, so toggling a parent only touches its own child rows — the ItemsControl
// keeps every unchanged container instead of tearing the whole list down on a Reset.
SyncCollection(OverdueItems, overdue);
SyncCollection(OpenItems, open);
SyncCollection(CompletedItems, completed);
return (overdue, open, completed);
}
HasOverdue = OverdueItems.Count > 0;
HasOpen = OpenItems.Count > 0;
HasCompleted = CompletedItems.Count > 0;
ShowOpenLabel = HasOpen && HasOverdue;
CompletedHeader = Loc.T("vm.tasksIsland.completedHeaderCount", CompletedItems.Count);
// ClassifyItems layers the chain-grouping mutation on top of PartitionItems, exactly as
// Regroup renders it — every row in the result has ShowAsChainMember/ChainStep/ChainAfterLabel
// written. Only call this from a path that actually renders the result; a caller that just
// needs section membership (e.g. a count) should call PartitionItems instead.
private (List<TaskRowViewModel> Overdue, List<TaskRowViewModel> Open, List<TaskRowViewModel> Completed) ClassifyItems()
{
var (overdue, open, completed) = PartitionItems();
// Dependency chains are resolved and pulled together per section (not on the
// pre-split `flat` list): a chain head might land in a different section than its
// dependent (e.g. a Done head in Completed, an open dependent in Open) — in that case
// the two are never rendered adjacent, so the dependent must fall back to a flat row
// with a label rather than an orphaned rail. Whether the head is resolvable at all
// still uses the whole-Items graph (rowsById), independent of section.
var rowsById = Items.ToDictionary(r => r.Id);
overdue = ApplyChainGrouping(overdue, rowsById);
open = ApplyChainGrouping(open, rowsById);
completed = ApplyChainGrouping(completed, rowsById);
return (overdue, open, completed);
}
// Cycles are rejected by TaskStateService.SetDependsOnAsync, so this should always
// terminate quickly — capped anyway against a corrupt/legacy row forming a loop.
private const int MaxChainWalkDepth = 64;
private readonly record struct ChainWalkResult(TaskRowViewModel Head, int Step);
// Walks DependsOnTaskId back to its root. Returns null when the chain can't be resolved —
// either the row has no predecessor at all, or a predecessor along the way isn't loaded
// into the current Items (e.g. filtered out of this list/view).
private static ChainWalkResult? WalkChainHead(TaskRowViewModel row, Dictionary<string, TaskRowViewModel> rowsById)
{
var current = row;
var step = 0;
var visited = new HashSet<string> { row.Id };
while (!string.IsNullOrEmpty(current.DependsOnTaskId))
{
if (!rowsById.TryGetValue(current.DependsOnTaskId, out var predecessor)) return null;
if (step >= MaxChainWalkDepth || !visited.Add(predecessor.Id)) return null;
current = predecessor;
step++;
}
return step == 0 ? null : new ChainWalkResult(current, step);
}
// Content only — "after " + localization is Slice 2's concern (the view), which is why this
// holds just the identifying fragment rather than an assembled sentence.
private static string? BuildAfterLabel(TaskRowViewModel row, Dictionary<string, TaskRowViewModel> rowsById)
{
if (string.IsNullOrEmpty(row.DependsOnTaskId)) return null;
if (!rowsById.TryGetValue(row.DependsOnTaskId, out var predecessor)) return null;
return predecessor.Number > 0 ? $"#{predecessor.Number}" : predecessor.Title;
}
// Assigns ShowAsChainMember/ChainStep/ChainAfterLabel for every row in `section`, then
// returns a re-ordered copy with chain dependents pulled directly under their head
// (ascending ChainStep), regardless of their SortOrder-derived position — mirroring how
// planning children already sit right after their parent regardless of Items order.
private static List<TaskRowViewModel> ApplyChainGrouping(
List<TaskRowViewModel> section, Dictionary<string, TaskRowViewModel> rowsById)
{
var sectionIds = section.Select(r => r.Id).ToHashSet();
var headIdOf = new Dictionary<TaskRowViewModel, string>();
var stepOf = new Dictionary<TaskRowViewModel, int>();
foreach (var r in section)
{
if (string.IsNullOrEmpty(r.DependsOnTaskId))
{
r.ShowAsChainMember = false;
r.ChainStep = null;
r.ChainAfterLabel = null;
continue;
}
// A planning child keeps its parent indent — chain membership only ever shows as a
// label for it, never as a second, nested rail (design: "parent wins").
if (r.IsChild)
{
r.ShowAsChainMember = false;
r.ChainStep = null;
r.ChainAfterLabel = BuildAfterLabel(r, rowsById);
continue;
}
var walk = WalkChainHead(r, rowsById);
if (walk is { } w && sectionIds.Contains(w.Head.Id))
{
r.ShowAsChainMember = true;
r.ChainStep = w.Step;
r.ChainAfterLabel = null;
headIdOf[r] = w.Head.Id;
stepOf[r] = w.Step;
}
else
{
r.ShowAsChainMember = false;
r.ChainStep = null;
r.ChainAfterLabel = BuildAfterLabel(r, rowsById);
}
}
if (headIdOf.Count == 0) return section;
var membersByHeadId = section
.Where(headIdOf.ContainsKey)
.GroupBy(r => headIdOf[r])
.ToDictionary(g => g.Key, g => g.OrderBy(r => stepOf[r]).ToList());
var ordered = new List<TaskRowViewModel>(section.Count);
var consumed = new HashSet<string>();
foreach (var r in section)
{
if (consumed.Contains(r.Id)) continue;
if (headIdOf.ContainsKey(r)) continue; // placed via its head below, in step order
ordered.Add(r);
consumed.Add(r.Id);
if (membersByHeadId.TryGetValue(r.Id, out var members))
foreach (var m in members)
if (consumed.Add(m.Id))
ordered.Add(m);
}
return ordered;
}
private void UpdateSubtitle()
@@ -536,7 +785,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
row.ShowListChip = _currentList?.Kind == ListKind.Virtual;
Items.Add(row);
Regroup();
SelectedTask = row;
SelectFrom(row, "new-task");
NewTaskTitle = "";
UpdateSubtitle();
TasksChanged?.Invoke(this, EventArgs.Empty);
@@ -588,17 +837,21 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
// Master Items: single Move event (no Reset) so ItemsControls animate, not rebuild.
MoveWithinCollection(Items, source, target, placeBelow);
// Apply the same move in whichever section the row lives in.
// Apply the same move in Rows, but only when both rows live in the same section.
// Reorder never changes which section (Open/Overdue/Completed) a row belongs to —
// that's determined by Done flag and ScheduledFor date, not drag-drop.
var sourceSection = SectionFor(source);
var targetSection = SectionFor(target);
if (sourceSection is not null && ReferenceEquals(sourceSection, targetSection))
MoveWithinCollection(sourceSection, source, target, placeBelow);
MoveWithinCollection<object>(Rows, source, target, placeBelow);
var listId = _currentList.Id["user:".Length..];
var orderedIds = Items.Select(i => i.Id).ToList();
var sw = System.Diagnostics.Stopwatch.StartNew();
var ok = false;
try
{
await using var db = await _dbFactory.CreateDbContextAsync();
var idSet = orderedIds.ToHashSet();
var entities = await db.Tasks
@@ -610,12 +863,15 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
if (e is not null) e.SortOrder = i;
}
await db.SaveChangesAsync();
ok = true;
}
finally { OperationTiming.Shared.Record("db", "TasksIsland.ReorderAsync", sw.Elapsed, ok); }
}
private static void MoveWithinCollection(
System.Collections.ObjectModel.ObservableCollection<TaskRowViewModel> coll,
TaskRowViewModel source,
TaskRowViewModel target,
private static void MoveWithinCollection<T>(
System.Collections.ObjectModel.ObservableCollection<T> coll,
T source,
T target,
bool placeBelow)
{
var srcIdx = coll.IndexOf(source);
@@ -633,11 +889,11 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
// Reconcile a bound collection toward a target order using granular Remove/Move/Insert,
// so unchanged rows keep their containers (no Reset-driven full re-render).
private static void SyncCollection(
System.Collections.ObjectModel.ObservableCollection<TaskRowViewModel> dst,
List<TaskRowViewModel> target)
private static void SyncCollection<T>(
System.Collections.ObjectModel.ObservableCollection<T> dst,
List<T> target)
{
var keep = new HashSet<TaskRowViewModel>(target);
var keep = new HashSet<T>(target);
for (int i = dst.Count - 1; i >= 0; i--)
if (!keep.Contains(dst[i]))
dst.RemoveAt(i);
@@ -653,12 +909,21 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
}
}
private System.Collections.ObjectModel.ObservableCollection<TaskRowViewModel>? SectionFor(TaskRowViewModel row)
// Marker returned for a row that sits in the flat Open section before any header — Open is the
// only section that can render without a preceding HeaderRow (no Overdue and ShowOpenLabel
// false). Distinct from null, which means "row not found in Rows at all".
private static readonly object UnlabeledSection = new();
// A row's section is read off the nearest preceding HeaderRow in Rows rather than
// re-classified from Done/ScheduledFor, so it always matches what Regroup actually rendered
// (including planning-parent edge cases the classification alone can't reconstruct).
private object? SectionFor(TaskRowViewModel row)
{
if (OverdueItems.Contains(row)) return OverdueItems;
if (OpenItems.Contains(row)) return OpenItems;
if (CompletedItems.Contains(row)) return CompletedItems;
return null;
var idx = Rows.IndexOf(row);
if (idx < 0) return null;
for (int i = idx - 1; i >= 0; i--)
if (Rows[i] is HeaderRow header) return header;
return UnlabeledSection;
}
/// <summary>
@@ -751,11 +1016,12 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
[RelayCommand]
private async Task ClearCompletedAsync()
{
if (CompletedItems.Count == 0) return;
var (_, _, completed) = PartitionItems();
if (completed.Count == 0) return;
// Delete children before parents so the parent-child FK (Restrict) doesn't
// block removing a completed planning parent together with its done children.
var toDelete = CompletedItems.OrderByDescending(r => r.IsChild).ToList();
var toDelete = completed.OrderByDescending(r => r.IsChild).ToList();
if (ConfirmAsync is not null)
{
@@ -763,6 +1029,9 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
if (!ok) return;
}
var sw = System.Diagnostics.Stopwatch.StartNew();
try
{
await using var db = await _dbFactory.CreateDbContextAsync();
var repo = new TaskRepository(db);
foreach (var row in toDelete)
@@ -774,6 +1043,8 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
}
catch { /* still referenced by open child tasks; leave it visible */ }
}
}
finally { OperationTiming.Shared.Record("db", "TasksIsland.ClearCompletedAsync", sw.Elapsed, ok: true); }
Regroup();
UpdateSubtitle();
@@ -860,10 +1131,21 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
public async Task SetStatusOnRowAsync(TaskRowViewModel row, TaskStatus status)
{
if (_worker is null) return;
try { await _worker.SetTaskStatusAsync(row.Id, status); }
try
{
var baseDirty = await _worker.SetTaskStatusAsync(row.Id, status);
ReportBaseDirty(baseDirty);
}
catch { /* offline; broadcast won't fire */ }
}
private void ReportBaseDirty(BaseDirtyWarningDto? warning)
{
if (warning is null) return;
ErrorReported?.Invoke(Loc.T(
"vm.queue.baseDirtyWarning", warning.ModifiedCount, warning.UntrackedCount));
}
[RelayCommand]
private async Task SendToQueueAsync(TaskRowViewModel? row)
{
@@ -890,7 +1172,11 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
// Goes through the worker hub (TaskStateService.EnqueueAsync) rather than a raw EF write
// so the manual/draft-child guards apply here too; the row refreshes from the resulting
// TaskUpdated broadcast.
try { await _worker.SetTaskStatusAsync(row.Id, TaskStatus.Queued); }
try
{
var baseDirty = await _worker.SetTaskStatusAsync(row.Id, TaskStatus.Queued);
ReportBaseDirty(baseDirty);
}
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("vm.tasksIsland.sendToQueueFailed", ex.Message)); }
}
@@ -999,7 +1285,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
row is null ? Task.CompletedTask : SetScheduledForAsync(row, null);
[RelayCommand]
private void Select(TaskRowViewModel row) => SelectedTask = row;
private void Select(TaskRowViewModel row) => SelectFrom(row, "row-click");
public async System.Threading.Tasks.Task<bool> SelectByIdAsync(string taskId)
{
@@ -1009,13 +1295,17 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
}
var row = Items.FirstOrDefault(r => r.Id == taskId);
if (row is null) return false;
SelectedTask = row;
SelectFrom(row, "select-by-id");
return true;
}
[RelayCommand]
private void ToggleShowCompleted() => IsShowingCompleted = !IsShowingCompleted;
// The completed section is only emitted into Rows while the toggle is on — flipping it
// doesn't change grouping/order data, so a full reload isn't needed, just a re-emit.
partial void OnIsShowingCompletedChanged(bool value) => Regroup();
public event EventHandler? OpenListSettingsRequested;
[RelayCommand]
@@ -1075,7 +1365,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
OpenPlanningConPtyRequested?.Invoke(row.Id, true);
break;
case UnfinishedPlanningModalResult.FinalizeNow:
await _worker.FinalizePlanningSessionAsync(row.Id, queueAgentTasks: false);
await FinalizePlanningSessionAsync(row);
break;
case UnfinishedPlanningModalResult.Discard:
await TryDiscardPlanningWithRetryAsync(row.Id);
@@ -1131,18 +1421,22 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
/// </summary>
public Func<string, Task<bool>>? ConfirmAsync { get; set; }
[RelayCommand]
private bool CanRunPlanningOperation() => !PlanningOperation.IsRunning;
[RelayCommand(CanExecute = nameof(CanRunPlanningOperation))]
private async Task QueuePlanningSubtasksAsync(TaskRowViewModel? row)
{
if (row is null || _worker is null) return;
using var op = PlanningOperation.Begin(Loc.T("ops.planning.queuingSubtasks"));
try { await _worker.QueuePlanningSubtasksAsync(row.Id); }
catch { }
}
[RelayCommand]
[RelayCommand(CanExecute = nameof(CanRunPlanningOperation))]
private async Task FinalizePlanningSessionAsync(TaskRowViewModel? row)
{
if (row is null) return;
using var op = PlanningOperation.Begin(Loc.T("ops.planning.finalizing"));
try { await _worker!.FinalizePlanningSessionAsync(row.Id, queueAgentTasks: false); }
catch { }
}
@@ -1183,4 +1477,106 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
foreach (var i in Items) i.IsSelected = ReferenceEquals(i, value);
SelectionChanged?.Invoke(this, EventArgs.Empty);
}
// ── Phase 3: reconcile tick ──────────────────────────────────────────────
// Self-healing safety net for a lost broadcast: every few seconds, diff the flat `Items`
// master collection against SQLite and patch properties in place. Never rebuilds a row and
// never falls back to LoadForList — the delta path already owns that escalation. It DOES
// call Regroup(), but only when a patch actually moved a row's grouping inputs: a healed
// row can change section, order or rail label, and none of that shows until Rows is
// re-emitted.
// Above this many rows, only the first N (in Items order) are reconciled per tick, so the
// query cost stays bounded instead of growing with an ever-larger list.
internal const int ReconcileRowCap = 500;
// Set by the shell from the main window's WindowState; the tick no-ops while minimized.
public bool IsWindowVisible { get; set; } = true;
// Test seam: awaited right after the reconcile query returns and before its results are
// applied, so a test can land a fresher, higher-sequence update in that gap and exercise the
// stale-sequence guard deterministically. Always null outside tests.
internal Func<Task>? ReconcileTickTestBarrier { get; set; }
// Awaitable so tests can drive it deterministically (mirrors RefreshTaskFromWorkerAsync).
internal async Task ReconcileTickAsync(CancellationToken ct = default)
{
var list = _currentList;
if (!IsWindowVisible || list is null) return;
// Mirrors the full-reload guard in RefreshTaskFromWorkerAsync: matching virtual:queued /
// virtual:running depends on a Planning parent's children, not a single entity.
if (list.Kind == ListKind.Virtual &&
(list.Id == "virtual:queued" || list.Id == "virtual:running"))
return;
var ids = Items.Select(r => r.Id).Take(ReconcileRowCap).ToList();
if (ids.Count == 0) return;
// Same monotonic per-task sequence as the Phase 1 delta path, so whichever of the two
// started most recently for a given task id wins, regardless of completion order.
var seqByTaskId = new Dictionary<string, long>(ids.Count);
foreach (var id in ids)
{
var seq = ++_deltaCounter;
seqByTaskId[id] = seq;
_deltaSeq[id] = seq;
}
List<TaskEntity> entities;
var sw = System.Diagnostics.Stopwatch.StartNew();
var dbOk = false;
try
{
var idSet = ids.ToHashSet();
await using var db = await _dbFactory.CreateDbContextAsync(ct);
entities = await db.Tasks
.Include(t => t.List)
.Include(t => t.Worktree)
.Where(t => idSet.Contains(t.Id))
.ToListAsync(ct);
dbOk = true;
}
catch (OperationCanceledException) { return; }
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"TasksIsland: reconcile tick failed ({ex.Message})");
return;
}
finally { OperationTiming.Shared.Record("db", "TasksIsland.ReconcileTickAsync", sw.Elapsed, dbOk); }
if (ReconcileTickTestBarrier is { } barrier) await barrier();
var byId = entities.ToDictionary(e => e.Id);
// Index the rows once instead of scanning Items per id — at the 500-row cap a linear
// scan per id is 250k comparisons every few seconds, for nothing.
var rowById = new Dictionary<string, TaskRowViewModel>(Items.Count);
foreach (var r in Items) rowById[r.Id] = r;
var groupingChanged = false;
foreach (var id in ids)
{
// Superseded by a fresher delta refresh or a later tick that landed while this one
// was reading — its result is stale, discard it.
if (!_deltaSeq.TryGetValue(id, out var current) || current != seqByTaskId[id]) continue;
if (!byId.TryGetValue(id, out var entity)) continue; // deleted; the delta path removes rows, not the tick
if (!rowById.TryGetValue(id, out var row)) continue;
var before = GroupingKey(row);
row.UpdateFromEntity(entity);
if (!before.Equals(GroupingKey(row))) groupingChanged = true;
}
// A patched row can have left its section (Done), moved inside it (a new/removed
// depends-on link re-orders the chain), or changed what its rail label reads. Rows only
// reflects any of that after a Regroup — without this the tick would heal the row's data
// while leaving a completed task sitting in the Open section under a stale count. Gated
// on a real change so an idle tick stays free.
if (groupingChanged) Regroup();
}
// The slice of a row that Regroup reads: which section it lands in, where it sits inside it,
// and what its chain rail/after-chip says. Only fields UpdateFromEntity actually writes —
// IsExpanded and HasPlanningChildren are owned elsewhere and would produce false positives.
private static (bool, DateTime?, string?, string?, PlanningPhase, int, string) GroupingKey(TaskRowViewModel r) =>
(r.Done, r.ScheduledFor, r.ParentTaskId, r.DependsOnTaskId, r.PlanningPhase, r.Number, r.Title);
}
@@ -32,6 +32,13 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
public bool IsOffline => Worker?.IsConnected != true && Worker?.IsReconnecting != true;
// Gate for the footer pill: the help dialog says "WORKER NOT REACHABLE", so it must never open
// on a connected worker. Not `IsOffline` — the retry loop stays in "connecting" forever while
// the worker is down, and that's exactly when the dialog's "Start Worker" is needed.
public bool CanOpenWorkerConnectionHelp => DecideCanOpenConnectionHelp(Worker?.IsConnected == true);
internal static bool DecideCanOpenConnectionHelp(bool isConnected) => !isConnected;
private readonly UpdateCheckService _updateCheck = null!;
private readonly InstallerLocator _installerLocator = null!;
private readonly WorkerLocator _workerLocator = null!;
@@ -296,7 +303,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
_git = git;
Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList);
Lists.SelectionChanged += (_, _) => _ = RefreshStaleWorkerCheckAsync();
Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask);
Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask, Tasks.SelectionSource);
Tasks.NotesRequested += () => Details.ShowNotes();
Tasks.PrepRequested += () => Details.ShowPrep();
Tasks.ErrorReported += FlashFooterError;
@@ -333,7 +340,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
Lists.LetClaudeHandleListCommand.Execute(row);
};
Details.ErrorReported += FlashFooterError;
Details.CloseDetail = () => Tasks.SelectedTask = null;
Details.CloseDetail = () => Tasks.SelectFrom(null, "close-detail");
Details.DeleteFromList = row =>
{
Tasks.LoadForList(Lists.SelectedList);
@@ -346,6 +353,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
{
OnPropertyChanged(nameof(ConnectionText));
OnPropertyChanged(nameof(IsOffline));
OnPropertyChanged(nameof(CanOpenWorkerConnectionHelp));
}
};
Worker.WorkerLogReceivedEvent += OnWorkerLogReceived;
@@ -377,11 +385,20 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
RefreshBannerFromStatus();
}
};
// Fire-and-forget startup check — never block UI.
_ = Task.Run(async () =>
_updateCheck.Op.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(OperationStatus.IsRunning)) CheckForUpdatesCommand.NotifyCanExecuteChanged();
};
// Fire-and-forget startup check — never block UI. Deliberately *not* Task.Run: the check is
// pure async I/O (no sync blocking), and `OperationStatus` writes its observable properties
// on the calling thread. From a threadpool thread the final `ShowIndicator=false` never
// reaches the binding, so the indicator froze on the last tick ("Checking… 00:03").
_ = StartupUpdateCheckAsync();
}
private async Task StartupUpdateCheckAsync()
{
try { await _updateCheck.CheckNowAsync(CancellationToken.None); } catch { }
});
}
public void Dispose()
@@ -513,8 +530,12 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
if (Dialogs is not null) await Dialogs.ShowWorkerConnectionAsync(vm);
}
// The gate lives in the body, not in CanExecute: the footer pill *is* this button, so a
// disabled command renders the "ONLINE" chip greyed out — which reads as a broken connection,
// the opposite of what it says.
[RelayCommand]
private Task OpenWorkerConnectionHelp() => OpenWorkerConnectionHelpAsync();
private Task OpenWorkerConnectionHelp() =>
CanOpenWorkerConnectionHelp ? OpenWorkerConnectionHelpAsync() : Task.CompletedTask;
[RelayCommand]
private async Task OpenRepoImport()
@@ -578,7 +599,9 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
finally { _usageMonitorOpen = false; }
}
[RelayCommand]
private bool CanCheckForUpdates() => !_updateCheck.Op.IsRunning;
[RelayCommand(CanExecute = nameof(CanCheckForUpdates))]
private async Task CheckForUpdatesAsync()
{
await _updateCheck.CheckNowAsync(CancellationToken.None);
@@ -20,7 +20,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
private readonly Action<string, string, string, DateTime> _onTaskFinished;
private readonly Action<string> _onTaskUpdated;
private readonly Action _onConnectionRestored;
private readonly Action<string, IReadOnlyList<string>> _onHandoffRequested;
private readonly Action<string, IReadOnlyList<string>, string> _onHandoffRequested;
// Embedded ConPTY sessions (task-based only) — a manual cockpit detached from the
// review/merge/status machinery.
@@ -85,7 +85,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
_onConnectionRestored = () => { _ = RefreshQueueAsync(); };
_worker.ConnectionRestoredEvent += _onConnectionRestored;
_onHandoffRequested = (taskId, survivingTaskIds) => { _ = OpenMergeHelperHandoffConPtySessionAsync(taskId, survivingTaskIds); };
_onHandoffRequested = (taskId, survivingTaskIds, nextPhase) => { _ = OpenMergeHelperHandoffConPtySessionAsync(taskId, survivingTaskIds, nextPhase); };
_worker.HandoffRequestedEvent += _onHandoffRequested;
_ = RefreshQueueAsync();
@@ -131,7 +131,13 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
{
if (string.IsNullOrEmpty(taskId)) return;
if (ConPtySessions.Any(s => s.TaskId == taskId)) return;
try { await _worker.SetTaskStatusAsync(taskId, ClaudeDo.Data.Models.TaskStatus.Queued); }
try
{
var baseDirty = await _worker.SetTaskStatusAsync(taskId, ClaudeDo.Data.Models.TaskStatus.Queued);
if (baseDirty is not null)
ErrorReported?.Invoke(Loc.T(
"vm.queue.baseDirtyWarning", baseDirty.ModifiedCount, baseDirty.UntrackedCount));
}
catch { /* best-effort enqueue */ }
await RefreshQueueAsync();
}
@@ -147,6 +153,8 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
public async System.Threading.Tasks.Task OpenConPtySessionAsync(string taskId)
{
if (string.IsNullOrEmpty(taskId)) return;
// One pane per TaskId is an invariant -- a merge-helper handoff closes its outgoing pane
// before opening the next phase's (see OpenMergeHelperHandoffConPtySessionAsync).
if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is { } existing)
{
FocusedPane = existing;
@@ -275,13 +283,16 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
}
}
// List-handler handoff: the running session called handoff_list_handler at the end of Phase 2.
// Replaces the Phase 1-2 tile with a fresh one for the SAME handler task id, which carries out
// Phases 3-5. The old tile used to be left open so its last message could still be read, but
// its process is gone by then and the terminal renders empty — it was only ever a dead
// placeholder to close by hand. No new task is created here; see
// List-handler handoff: the running session called handoff_list_handler at the end of a phase.
// Opens a NEW tile for the SAME handler task id to carry out nextPhase. Each tile is a live
// `claude` process with the full mcp__claudedo__* surface, so the outgoing phase's pane is
// closed first -- it was only ever told to end its turn, never to exit, and a multi-phase run
// would otherwise leave one live process per phase behind. This keeps the one-pane-per-TaskId
// invariant intact, so OpenConPtySessionAsync's dedupe and OnPaneSubmitForReview can keep using
// FirstOrDefault. No new task is created here; see
// InteractiveLaunchSpecService.BuildForMergeHelperHandoffAsync.
public async System.Threading.Tasks.Task OpenMergeHelperHandoffConPtySessionAsync(string taskId, IReadOnlyList<string> survivingTaskIds)
public async System.Threading.Tasks.Task OpenMergeHelperHandoffConPtySessionAsync(
string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase)
{
if (string.IsNullOrEmpty(taskId) || survivingTaskIds is not { Count: > 0 }) return;
@@ -304,7 +315,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
catch { /* best-effort title lookup */ }
AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
() => DescribeAsync(() => _worker.GetMergeHelperHandoffLaunchSpecAsync(taskId, survivingTaskIds))));
() => DescribeAsync(() => _worker.GetMergeHelperHandoffLaunchSpecAsync(taskId, survivingTaskIds, nextPhase))));
}
// Wires a freshly built pane and shows it immediately — the pane resolves its own launch spec,
@@ -334,6 +345,8 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
// SubmitTaskForReviewAsync calls, with the loser flashing a spurious footer error.
private async void OnPaneSubmitForReview(string taskId)
{
// See OpenConPtySessionAsync -- one pane per TaskId is an invariant, so FirstOrDefault is
// always the current/active pane for this task.
if (ConPtySessions.FirstOrDefault(s => s.TaskId == taskId) is not { } pane || pane.IsSubmitPending)
return;
@@ -98,6 +98,14 @@ public sealed partial class DiffViewerViewModel : ViewModelBase
public Action? CloseAction { get; set; }
// Parsing a large diff (UnifiedDiffParser.Parse + DiffTree.Build, both pure CPU work) is
// offloaded to a threadpool thread so it never blocks the UI dispatcher.
public OperationStatus ParseOp { get; } = new();
// Guards the fire-and-forget planning parse below against a stale write: a second
// DisplayedDiff change while the first parse is still running must not clobber PlanningFiles.
private int _planningFilesGeneration;
public DiffViewerViewModel(GitService git, IWorkerClient worker, AppSettings settings)
{
_git = git;
@@ -145,6 +153,16 @@ public sealed partial class DiffViewerViewModel : ViewModelBase
public Task LoadAsync(CancellationToken ct = default) =>
Mode == DiffViewerMode.Planning ? LoadPlanningAsync() : LoadFilesAsync(ct);
// Shared by both parse call sites (LoadFilesAsync, ApplyParsedPlanningFilesAsync) so the
// ParseOp begin/offload/end ceremony isn't duplicated. Begin runs synchronously on the UI
// thread (the caller hasn't awaited anything yet), and the `using` guarantees End runs when
// the offloaded parse finishes — even if the caller's own result turns out to be stale.
private async Task<T> ParseOffUiThreadAsync<T>(Func<T> parse, CancellationToken ct = default)
{
using (ParseOp.Begin(Loc.T("ops.diff.parsing")))
return await Task.Run(parse, ct);
}
private async Task LoadFilesAsync(CancellationToken ct)
{
FileTree.Clear();
@@ -162,10 +180,10 @@ public sealed partial class DiffViewerViewModel : ViewModelBase
try
{
raw = FromCommitRange && BaseRef is not null && HeadCommit is not null
? await _git.GetCommitRangeDiffAsync(WorktreePath, BaseRef, HeadCommit, ct)
? await _git.GetCommitRangeDiffAsync(WorktreePath, BaseRef, HeadCommit, ct: ct)
: BaseRef is not null
? await _git.GetBranchDiffAsync(WorktreePath, BaseRef, ct)
: await _git.GetDiffAsync(WorktreePath, ct);
? await _git.GetBranchDiffAsync(WorktreePath, BaseRef, ct: ct)
: await _git.GetDiffAsync(WorktreePath, ct: ct);
}
catch (Exception ex)
{
@@ -179,8 +197,13 @@ public sealed partial class DiffViewerViewModel : ViewModelBase
return;
}
var files = UnifiedDiffParser.Parse(raw).ToList();
foreach (var node in DiffTree.Build(files))
var (files, tree) = await ParseOffUiThreadAsync(() =>
{
var parsed = UnifiedDiffParser.Parse(raw);
return (parsed, DiffTree.Build(parsed));
}, ct);
foreach (var node in tree)
FileTree.Add(node);
SelectedNode = DiffTree.FirstLeaf(FileTree);
@@ -254,10 +277,24 @@ public sealed partial class DiffViewerViewModel : ViewModelBase
partial void OnIsCombinedModeChanged(bool value) => ToggleCombinedCommand.Execute(null);
// DisplayedDiff is set from four places (OnSelectedSubtaskChanged plus three branches of
// ToggleCombinedAsync), and OnSelectedSubtaskChanged is itself a synchronous, non-awaitable
// callback — funneling the offload through this single change handler covers all of them
// without touching every call site. Fire-and-forget is safe because the generation counter
// below discards a parse whose result is no longer the current DisplayedDiff.
partial void OnDisplayedDiffChanged(string value)
{
var generation = ++_planningFilesGeneration;
_ = ApplyParsedPlanningFilesAsync(value, generation);
}
private async Task ApplyParsedPlanningFilesAsync(string diff, int generation)
{
var files = await ParseOffUiThreadAsync(() => UnifiedDiffParser.Parse(diff));
if (generation != _planningFilesGeneration) return;
PlanningFiles.Clear();
foreach (var file in UnifiedDiffParser.Parse(value))
foreach (var file in files)
PlanningFiles.Add(file);
}
@@ -30,6 +30,9 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
[ObservableProperty] private string _defaultCommitType = CommitTypeRegistry.DefaultType;
// A manual list holds reminders: tasks created here start out manual (TaskEntity.IsManual).
[ObservableProperty] private bool _isManual;
// When true the project's .claudedo/ findings store is committed with the repo; when false it
// is kept out of git via .git/info/exclude (see FindingsStore / ListEntity.FindingsTracked).
[ObservableProperty] private bool _findingsTracked;
// Optional post-merge verification command (build/test), run in WorkingDir after a merge
// lands; a non-zero exit keeps the task out of Done instead of silently reporting merged.
[ObservableProperty] private string _verifyCommand = "";
@@ -55,11 +58,13 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
string? workingDir,
string defaultCommitType,
bool isManual = false,
bool findingsTracked = false,
CancellationToken ct = default)
{
ListId = listId;
Name = name;
IsManual = isManual;
FindingsTracked = findingsTracked;
WorkingDir = workingDir ?? "";
DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? CommitTypeRegistry.DefaultType : defaultCommitType;
@@ -76,7 +81,8 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name,
string.IsNullOrWhiteSpace(WorkingDir) ? null : WorkingDir,
DefaultCommitType,
IsManual));
IsManual,
FindingsTracked));
await Agent.SaveAsync(string.IsNullOrWhiteSpace(VerifyCommand) ? null : VerifyCommand);
@@ -41,13 +41,21 @@ public sealed partial class LogVisualizerViewModel : ViewModelBase
_worker = worker;
_copyToClipboard = copyToClipboard;
Rows.CollectionChanged += (_, _) => CopyLastCommand.NotifyCanExecuteChanged();
// Phase 3 reconcile tick: this overlay is long-lived (stays open while the user reads
// through the log), so refresh it on the same cadence as TasksIslandViewModel's tick
// instead of leaving it frozen at the moment it was opened.
_reconcileTimer.Elapsed += (_, _) =>
Avalonia.Threading.Dispatcher.UIThread.Post(() => _ = RefreshAsync());
_reconcileTimer.Start();
}
private readonly System.Timers.Timer _reconcileTimer = new(4_000);
[RelayCommand]
public async Task RefreshAsync()
{
_all = await _worker.GetRecentLogsAsync();
Apply();
var latest = await _worker.GetRecentLogsAsync();
Reconcile(latest);
}
partial void OnWarnErrorOnlyChanged(bool value) => Apply();
@@ -60,12 +68,86 @@ public sealed partial class LogVisualizerViewModel : ViewModelBase
: _all;
foreach (var e in items.Reverse())
Rows.Add(new LogVisualizerRow(e.TimestampUtc.ToLocalTime().ToString("HH:mm:ss"), e.Message, e.Level));
UpdateStatusText();
}
// `_all` (oldest-first) mirrors the worker's LogRingBuffer: append-mostly, with the oldest
// entries falling off the front once they age past the 30-min window. A full Rows.Clear()
// on every 4s tick would reset the user's scroll position/selection while they're mid-read,
// so patch just the delta instead: entries still present keep their row, aged-out entries
// are trimmed off the (oldest) tail of Rows, new entries are inserted at the (newest) head.
private void Reconcile(IReadOnlyList<WorkerLogEntry> latest)
{
var previous = _all;
_all = latest;
if (previous.Count == 0)
{
Apply();
return;
}
var overlap = FindOverlap(previous, latest);
var evictedCount = previous.Count - overlap;
var added = latest.Skip(overlap).ToList();
if (evictedCount == 0 && added.Count == 0)
return; // nothing aged out, nothing new — leave Rows (and scroll/selection) untouched
if (evictedCount > 0)
{
var removeCount = WarnErrorOnly
? previous.Take(evictedCount).Count(e => e.Level is WorkerLogLevel.Warn or WorkerLogLevel.Error)
: evictedCount;
for (var i = 0; i < removeCount; i++)
Rows.RemoveAt(Rows.Count - 1);
}
foreach (var e in added)
{
if (WarnErrorOnly && e.Level is not (WorkerLogLevel.Warn or WorkerLogLevel.Error))
continue;
Rows.Insert(0, new LogVisualizerRow(e.TimestampUtc.ToLocalTime().ToString("HH:mm:ss"), e.Message, e.Level));
}
UpdateStatusText();
}
// The longest run of `previous`'s tail that reappears as `latest`'s head — i.e. how much of
// the old snapshot survived unevicted. 0 when nothing overlaps (e.g. right after opening).
private static int FindOverlap(IReadOnlyList<WorkerLogEntry> previous, IReadOnlyList<WorkerLogEntry> latest)
{
for (var k = Math.Min(previous.Count, latest.Count); k > 0; k--)
{
var matches = true;
for (var i = 0; i < k; i++)
{
if (!previous[previous.Count - k + i].Equals(latest[i])) { matches = false; break; }
}
if (matches) return k;
}
return 0;
}
private void UpdateStatusText() =>
StatusText = Rows.Count == 0
? Loc.T("modals.logVisualizer.empty")
: Loc.T("modals.logVisualizer.count", Rows.Count);
[RelayCommand]
private void Close()
{
StopReconcileTick();
CloseAction?.Invoke();
}
[RelayCommand] private void Close() => CloseAction?.Invoke();
/// <summary>Stops the reconcile tick. Also called from the dialog's native-close fallback
/// (WindowDialogService), which bypasses <see cref="CloseCommand"/> — otherwise an Alt+F4
/// leaves the timer polling the worker on an orphaned VM. Idempotent.</summary>
internal void StopReconcileTick()
{
_reconcileTimer.Stop();
_reconcileTimer.Dispose();
}
private bool CanCopyLast() => Rows.Count > 0;
@@ -14,6 +14,16 @@ public sealed partial class MergeHelperTaskRowViewModel : ViewModelBase
public required string Id { get; init; }
public required string Title { get; init; }
public required string StatusText { get; init; }
public string? ParentTaskId { get; init; }
/// <summary>True when the parent is also a candidate and rendered directly above this row.</summary>
public bool IsChild { get; init; }
/// <summary>
/// True when <see cref="ParentTaskId"/> is set but the parent isn't a candidate itself
/// (e.g. already Done/Cancelled) — the row still renders top-level, flagged with a hint.
/// </summary>
public bool IsOrphanChild { get; init; }
[ObservableProperty] private bool _isSelected;
}
@@ -28,6 +38,23 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private string _listId = "";
// Parent id -> its child rows, built after load; drives the tick-parent-ticks-children cascade.
private Dictionary<string, List<MergeHelperTaskRowViewModel>> _childrenByParentId = new();
// Set while ReconcileTickAsync restores remembered ticks, so replaying a parent's state
// doesn't cascade over its children's own remembered state.
private bool _suppressCascade;
// Only one tick may be in flight. A second one entering while the first awaits LoadAsync
// would snapshot an already-cleared Tasks collection, restore that emptiness, and let
// IsTickedByDefault re-tick every row — silently undoing the user's unticks, i.e. exactly
// what the remember/restore below exists to prevent.
private bool _tickInFlight;
// Test seam: awaited inside ReconcileTickAsync after the reload and before the restore, so a
// test can hold one tick open in exactly the window a second one would corrupt. Null in prod.
internal Func<Task>? ReconcileTickTestBarrier { get; set; }
public ObservableCollection<MergeHelperTaskRowViewModel> Tasks { get; } = new();
[ObservableProperty] private string _scopeLabel = "";
@@ -38,8 +65,18 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
public TaskCompletionSource<IReadOnlyList<string>?> Result { get; } = new();
public Action? CloseAction { get; set; }
private readonly System.Timers.Timer _reconcileTimer = new(4_000);
public MergeHelperSelectionModalViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory)
=> _dbFactory = dbFactory;
{
_dbFactory = dbFactory;
// Phase 3 reconcile tick: this overlay is long-lived (stays open while the user ticks
// through candidates), so refresh it on the same cadence as TasksIslandViewModel's tick
// instead of leaving it frozen at the moment it was opened.
_reconcileTimer.Elapsed += (_, _) =>
Avalonia.Threading.Dispatcher.UIThread.Post(() => _ = ReconcileTickAsync());
_reconcileTimer.Start();
}
public void Configure(string listId, string listName)
{
@@ -47,10 +84,57 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
ScopeLabel = Loc.T("modals.mergeHelper.scopeList", listName);
}
// LoadAsync rebuilds every row from scratch and re-applies IsTickedByDefault, which would wipe
// the user's ticks out from under them on every tick — in BOTH directions. Remembering only
// the ticked ids would silently re-tick a row the user deliberately unticked (Idle/Queued/
// WaitingForReview/Failed all default to ticked), so carry the full state of every row that
// already existed across the reload; only genuinely new rows get the default.
internal async Task ReconcileTickAsync()
{
if (string.IsNullOrEmpty(_listId) || _tickInFlight) return;
_tickInFlight = true;
try
{
// Hold the row INSTANCES, not a value snapshot, and read their state back only after
// the reload: a tick the user lands during the await happens before LoadAsync clears
// the collection, so the live instance still carries it. Snapshotting up front would
// drop that click.
var previousRows = Tasks.ToList();
await LoadAsync();
if (ReconcileTickTestBarrier is { } barrier) await barrier();
var previous = new Dictionary<string, bool>(previousRows.Count);
foreach (var t in previousRows) previous[t.Id] = t.IsSelected;
// Restore without the parent→children cascade: it would overwrite a child's own
// remembered state with its parent's, undoing exactly the per-child choice being
// restored.
_suppressCascade = true;
try
{
foreach (var t in Tasks)
if (previous.TryGetValue(t.Id, out var wasSelected))
t.IsSelected = wasSelected;
}
finally
{
_suppressCascade = false;
}
OnPropertyChanged(nameof(CanConfirm));
}
finally
{
_tickInFlight = false;
}
}
public async Task LoadAsync(CancellationToken ct = default)
{
foreach (var row in Tasks) row.PropertyChanged -= OnRowChanged;
Tasks.Clear();
_childrenByParentId = new();
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var candidates = await ctx.Tasks.AsNoTracking()
@@ -59,21 +143,56 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
// Manual tasks are reminders the user owns — never offer them to the handler.
.Where(t => !t.IsManual)
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
.Select(t => new { t.Id, t.Title, t.Status })
.Select(t => new { t.Id, t.Title, t.Status, t.ParentTaskId })
.ToListAsync(ct);
foreach (var c in candidates)
var candidateIds = candidates.Select(c => c.Id).ToHashSet();
bool ParentIsCandidate(string? parentId) =>
!string.IsNullOrEmpty(parentId) && candidateIds.Contains(parentId);
var childrenByParent = candidates
.Where(c => ParentIsCandidate(c.ParentTaskId))
.GroupBy(c => c.ParentTaskId!)
.ToDictionary(g => g.Key, g => g.ToList());
// Hierarchy-ordered walk, same idea as TasksIslandViewModel.Regroup: top-level rows
// (including orphaned children whose parent isn't a candidate) interleaved with their
// visible children, preserving the SortOrder/CreatedAt order within each level.
var emitted = new HashSet<string>();
var ordered = new List<(string Id, string Title, TaskStatus Status, string? ParentTaskId)>();
void Emit(string id, string title, TaskStatus status, string? parentTaskId)
{
if (!emitted.Add(id)) return;
ordered.Add((id, title, status, parentTaskId));
if (childrenByParent.TryGetValue(id, out var kids))
foreach (var kid in kids)
Emit(kid.Id, kid.Title, kid.Status, kid.ParentTaskId);
}
foreach (var c in candidates.Where(c => !ParentIsCandidate(c.ParentTaskId)))
Emit(c.Id, c.Title, c.Status, c.ParentTaskId);
foreach (var c in ordered)
{
var isChild = ParentIsCandidate(c.ParentTaskId);
var row = new MergeHelperTaskRowViewModel
{
Id = c.Id,
Title = c.Title,
StatusText = c.Status.ToString(),
ParentTaskId = c.ParentTaskId,
IsChild = isChild,
IsOrphanChild = !isChild && !string.IsNullOrEmpty(c.ParentTaskId),
IsSelected = IsTickedByDefault(c.Status),
};
row.PropertyChanged += OnRowChanged;
Tasks.Add(row);
}
_childrenByParentId = Tasks
.Where(r => r.IsChild)
.GroupBy(r => r.ParentTaskId!)
.ToDictionary(g => g.Key, g => g.ToList());
OnPropertyChanged(nameof(HasTasks));
OnPropertyChanged(nameof(CanConfirm));
}
@@ -85,7 +204,18 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
private void OnRowChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(MergeHelperTaskRowViewModel.IsSelected))
if (e.PropertyName != nameof(MergeHelperTaskRowViewModel.IsSelected)) return;
// Ticking/unticking a parent cascades to its children; a child can still be
// deselected on its own afterwards without affecting the parent's checkbox —
// ClaudeDo has no indeterminate/tri-state parent checkbox.
if (!_suppressCascade
&& sender is MergeHelperTaskRowViewModel row
&& _childrenByParentId.TryGetValue(row.Id, out var children))
{
foreach (var child in children) child.IsSelected = row.IsSelected;
}
OnPropertyChanged(nameof(CanConfirm));
}
@@ -104,6 +234,7 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
[RelayCommand]
private void Confirm()
{
StopReconcileTick();
Result.TrySetResult(Tasks.Where(t => t.IsSelected).Select(t => t.Id).ToList());
CloseAction?.Invoke();
}
@@ -111,7 +242,17 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
[RelayCommand]
private void Cancel()
{
StopReconcileTick();
Result.TrySetResult(null);
CloseAction?.Invoke();
}
// Also called from the dialog's native-close fallback (WindowDialogService), which resolves
// Result without going through Confirm/Cancel. Idempotent — Timer.Stop()/Dispose() tolerate
// repeated calls.
internal void StopReconcileTick()
{
_reconcileTimer.Stop();
_reconcileTimer.Dispose();
}
}
@@ -21,6 +21,12 @@ public sealed partial class MergeModalViewModel : ViewModelBase
[ObservableProperty] private string _commitMessage = "";
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(SubmitCommand))] private bool _isBusy;
/// What the pending merge is doing right now, fed by the worker's MergeProgress broadcast.
/// A merge whose list has a verify command can occupy this call for minutes — without this the
/// modal only greys the button out and looks dead.
[ObservableProperty] private string? _progressMessage;
[ObservableProperty] private string? _errorMessage;
[ObservableProperty] private string? _warningMessage;
[ObservableProperty] private string? _successMessage;
@@ -58,6 +64,10 @@ public sealed partial class MergeModalViewModel : ViewModelBase
ErrorMessage = Loc.T("vm.merge.workerOfflineBranches");
return;
}
// The worker owns the default message — only it knows the task's commit type and the
// list name the scope is slugged from. The locale string stays as the offline fallback.
if (!string.IsNullOrWhiteSpace(targets.DefaultCommitMessage))
CommitMessage = targets.DefaultCommitMessage;
foreach (var b in targets.LocalBranches) Branches.Add(b);
SelectedBranch = Branches.Contains(targets.DefaultBranch)
? targets.DefaultBranch
@@ -81,6 +91,10 @@ public sealed partial class MergeModalViewModel : ViewModelBase
ErrorMessage = null;
WarningMessage = null;
SuccessMessage = null;
// Subscribed only for the duration of the call: the worker's broadcast reaches every
// client, and a transient VM left on that event would outlive its window.
ProgressMessage = Loc.T("vm.merge.progressMerging");
_worker.MergeProgressEvent += OnMergeProgress;
try
{
var result = await _worker.MergeTaskAsync(
@@ -116,6 +130,12 @@ public sealed partial class MergeModalViewModel : ViewModelBase
// point of the gate.
ErrorMessage = result.ErrorMessage ?? Loc.T("vm.merge.verifyFailed");
break;
case "untracked_collision":
// Nothing landed -- the merge was refused before touching the repo. Show the
// real message (it names the colliding path and size); the locale string is
// only a fallback.
ErrorMessage = result.ErrorMessage ?? Loc.T("vm.merge.untrackedCollision");
break;
default:
ErrorMessage = Loc.T("vm.merge.unknownStatus", result.Status);
break;
@@ -127,10 +147,28 @@ public sealed partial class MergeModalViewModel : ViewModelBase
}
finally
{
_worker.MergeProgressEvent -= OnMergeProgress;
ProgressMessage = null;
IsBusy = false;
}
}
private void OnMergeProgress(string taskId, string phase, int elapsedSeconds)
{
if (taskId != TaskId) return;
ProgressMessage = phase switch
{
MergePhaseVerifying => Loc.T("vm.merge.progressVerifying", FormatElapsed(elapsedSeconds)),
_ => Loc.T("vm.merge.progressMerging"),
};
}
/// Mirrors TaskMergeService.PhaseVerifying — a hub payload token, not a display string.
private const string MergePhaseVerifying = "verifying";
private static string FormatElapsed(int seconds) =>
TimeSpan.FromSeconds(Math.Max(0, seconds)).ToString(@"mm\:ss");
[RelayCommand]
private void Cancel() => CloseAction?.Invoke();
}
@@ -33,6 +33,8 @@ public sealed partial class RepoImportModalViewModel : ViewModelBase
private readonly HashSet<string> _existingDirs = new(StringComparer.OrdinalIgnoreCase);
private readonly List<string> _folders = new();
public OperationStatus ScanOp { get; } = new();
public ObservableCollection<RepoImportItemViewModel> Repos { get; } = new();
public Action? CloseAction { get; set; }
@@ -96,6 +98,7 @@ public sealed partial class RepoImportModalViewModel : ViewModelBase
private async Task ScanAndAddAsync(IEnumerable<string> folders)
{
using var op = ScanOp.Begin(Loc.T("ops.repoImport.scanning"));
var current = new HashSet<string>(
Repos.Select(r => r.FullPath), StringComparer.OrdinalIgnoreCase);
@@ -11,8 +11,9 @@ public sealed partial class FilesSettingsTabViewModel : ViewModelBase
{
private readonly IWorkerClient _worker;
public OperationStatus RestoreOp { get; } = new();
[ObservableProperty] private string _statusMessage = "";
[ObservableProperty] private bool _isBusy;
[ObservableProperty] private bool _hasCustomizedPrompts;
[ObservableProperty] private string? _viewedKindName;
[ObservableProperty] private string _viewedContent = "";
@@ -30,12 +31,19 @@ public sealed partial class FilesSettingsTabViewModel : ViewModelBase
{
_worker = worker;
RefreshCustomizedPrompts();
RestoreOp.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(OperationStatus.IsRunning)) RestoreDefaultAgentsCommand.NotifyCanExecuteChanged();
};
}
[RelayCommand]
private bool CanRestoreDefaultAgents() => !RestoreOp.IsRunning;
[RelayCommand(CanExecute = nameof(CanRestoreDefaultAgents))]
private async Task RestoreDefaultAgents()
{
IsBusy = true; StatusMessage = "";
StatusMessage = "";
using var op = RestoreOp.Begin(Loc.T("ops.skills.restoringDefaults"));
try
{
var r = await _worker.RestoreDefaultAgentsAsync();
@@ -46,7 +54,6 @@ public sealed partial class FilesSettingsTabViewModel : ViewModelBase
await _worker.RefreshAgentsAsync();
}
catch (Exception ex) { StatusMessage = Loc.T("vm.filesTab.restoreFailed", ex.Message); }
finally { IsBusy = false; }
}
[RelayCommand]
@@ -21,6 +21,7 @@ public sealed partial class PrimeScheduleRowViewModel : ViewModelBase
[ObservableProperty] private bool _sunday;
[ObservableProperty] private TimeSpan _timeOfDay;
[ObservableProperty] private DateTimeOffset? _lastRunAt;
[ObservableProperty] private string? _promptOverride;
public string LastRunLabel => LastRunAt is { } v ? v.LocalDateTime.ToString("g") : "—";
@@ -58,6 +59,7 @@ public sealed partial class PrimeScheduleRowViewModel : ViewModelBase
Sunday = (dto.Days & Sun) != 0;
TimeOfDay = dto.TimeOfDay;
LastRunAt = dto.LastRunAt;
PromptOverride = dto.PromptOverride;
}
public int DaysMask()
@@ -74,5 +76,6 @@ public sealed partial class PrimeScheduleRowViewModel : ViewModelBase
}
public PrimeScheduleDto ToDto() =>
new(Id, DaysMask(), TimeOfDay, Enabled, LastRunAt, null);
new(Id, DaysMask(), TimeOfDay, Enabled, LastRunAt,
string.IsNullOrWhiteSpace(PromptOverride) ? null : PromptOverride);
}
@@ -10,6 +10,9 @@ public sealed partial class SessionSkillsSettingsTabViewModel : ViewModelBase
{
private readonly IWorkerClient _worker;
public OperationStatus InstallOp { get; } = new();
public OperationStatus UpdateOp { get; } = new();
[ObservableProperty] private string _installUrl = "";
[ObservableProperty] private string _statusMessage = "";
[ObservableProperty] private bool _isBusy;
@@ -17,7 +20,18 @@ public sealed partial class SessionSkillsSettingsTabViewModel : ViewModelBase
public ObservableCollection<SessionSkillDto> Skills { get; } = new();
public SessionSkillsSettingsTabViewModel(IWorkerClient worker) => _worker = worker;
public SessionSkillsSettingsTabViewModel(IWorkerClient worker)
{
_worker = worker;
InstallOp.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(OperationStatus.IsRunning)) InstallCommand.NotifyCanExecuteChanged();
};
UpdateOp.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(OperationStatus.IsRunning)) UpdateCommand.NotifyCanExecuteChanged();
};
}
public async Task LoadAsync()
{
@@ -32,11 +46,14 @@ public sealed partial class SessionSkillsSettingsTabViewModel : ViewModelBase
finally { IsBusy = false; }
}
[RelayCommand]
private bool CanInstall() => !InstallOp.IsRunning;
[RelayCommand(CanExecute = nameof(CanInstall))]
private async Task InstallAsync()
{
if (string.IsNullOrWhiteSpace(InstallUrl)) return;
IsBusy = true; StatusMessage = "";
StatusMessage = "";
using var op = InstallOp.Begin(Loc.T("ops.skills.installing"));
try
{
var installed = await _worker.InstallSessionSkillAsync(InstallUrl.Trim());
@@ -45,14 +62,16 @@ public sealed partial class SessionSkillsSettingsTabViewModel : ViewModelBase
await LoadAsync();
}
catch (Exception ex) { StatusMessage = Loc.T("vm.sessionSkillsTab.installFailed", ex.Message); }
finally { IsBusy = false; }
}
[RelayCommand]
private bool CanUpdate(string? sourceUrl) => !UpdateOp.IsRunning;
[RelayCommand(CanExecute = nameof(CanUpdate))]
private async Task UpdateAsync(string? sourceUrl)
{
if (string.IsNullOrWhiteSpace(sourceUrl)) return;
IsBusy = true; StatusMessage = "";
StatusMessage = "";
using var op = UpdateOp.Begin(Loc.T("ops.skills.updating"));
try
{
await _worker.UpdateSessionSkillAsync(sourceUrl);
@@ -60,7 +79,6 @@ public sealed partial class SessionSkillsSettingsTabViewModel : ViewModelBase
await LoadAsync();
}
catch (Exception ex) { StatusMessage = Loc.T("vm.sessionSkillsTab.updateFailed", ex.Message); }
finally { IsBusy = false; }
}
[RelayCommand]
@@ -19,6 +19,8 @@ public sealed partial class WorktreesSettingsTabViewModel : ViewModelBase
[ObservableProperty] private string _statusMessage = "";
[ObservableProperty] private bool _isBusy;
public OperationStatus ResetStatus { get; } = new();
public IReadOnlyList<string> WorktreeStrategies { get; } = new[] { "sibling", "central" };
public WorktreesSettingsTabViewModel(IWorkerClient worker) => _worker = worker;
@@ -56,7 +58,8 @@ public sealed partial class WorktreesSettingsTabViewModel : ViewModelBase
[RelayCommand]
private async Task ConfirmResetAll()
{
ShowResetConfirm = false; IsBusy = true; StatusMessage = "";
ShowResetConfirm = false; StatusMessage = "";
using var op = ResetStatus.Begin(Loc.T("ops.worktrees.resetting"));
try
{
var r = await _worker.ResetAllWorktreesAsync();
@@ -65,6 +68,5 @@ public sealed partial class WorktreesSettingsTabViewModel : ViewModelBase
else StatusMessage = Loc.T("vm.worktreesTab.removedFrom", r.Removed, r.TasksAffected);
}
catch (Exception ex) { StatusMessage = Loc.T("vm.worktreesTab.resetFailed", ex.Message); }
finally { IsBusy = false; }
}
}
@@ -9,24 +9,32 @@ public sealed partial class WeeklyReportModalViewModel : ViewModelBase
{
private readonly IWorkerClient _worker;
public WeeklyReportModalViewModel(IWorkerClient worker) => _worker = worker;
public WeeklyReportModalViewModel(IWorkerClient worker)
{
_worker = worker;
// OperationStatus is a nested ObservableObject — [NotifyCanExecuteChangedFor] can't see
// into it, so IsRunning changes are relayed by hand.
GenerateOperation.PropertyChanged += (_, e) =>
{
if (e.PropertyName != nameof(OperationStatus.IsRunning)) return;
GenerateCommand.NotifyCanExecuteChanged();
OnPropertyChanged(nameof(EmptyStateVisible));
};
}
public OperationStatus GenerateOperation { get; } = new();
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasReport))]
[NotifyPropertyChangedFor(nameof(EmptyStateVisible))]
private string? _reportMarkdown;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(EmptyStateVisible))]
[NotifyCanExecuteChangedFor(nameof(GenerateCommand))]
private bool _isBusy;
[ObservableProperty] private DateTime? _startDate;
[ObservableProperty] private DateTime? _endDate;
[ObservableProperty] private string _statusMessage = "";
public bool HasReport => !string.IsNullOrWhiteSpace(ReportMarkdown);
public bool EmptyStateVisible => !HasReport && !IsBusy;
public bool EmptyStateVisible => !HasReport && !GenerateOperation.IsRunning;
public Action? CloseAction { get; set; }
[RelayCommand] private void Close() => CloseAction?.Invoke();
@@ -68,21 +76,19 @@ public sealed partial class WeeklyReportModalViewModel : ViewModelBase
catch (Exception ex) { StatusMessage = ex.Message; }
}
private bool CanGenerate() => !IsBusy;
private bool CanGenerate() => !GenerateOperation.IsRunning;
[RelayCommand(CanExecute = nameof(CanGenerate))]
private async Task Generate()
{
if (!RangeValid) { StatusMessage = Loc.T("vm.weeklyReport.invalidRange"); return; }
IsBusy = true;
StatusMessage = Loc.T("vm.weeklyReport.generating");
using var op = GenerateOperation.Begin(Loc.T("ops.reports.generatingWeekly"));
StatusMessage = "";
try
{
ReportMarkdown = await _worker.GenerateWeekReportAsync(
DateOnly.FromDateTime(StartDate!.Value), DateOnly.FromDateTime(EndDate!.Value));
StatusMessage = "";
}
catch (Exception ex) { StatusMessage = Loc.T("vm.weeklyReport.error", ex.Message); }
finally { IsBusy = false; }
}
}
@@ -75,14 +75,20 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
[ObservableProperty] private string? _listIdFilter;
[ObservableProperty] private string _title = "Worktrees";
[ObservableProperty] private bool _isGlobal;
[ObservableProperty] private bool _isBusy;
[ObservableProperty] private string? _statusMessage;
[ObservableProperty] private WorktreeOverviewRowViewModel? _selectedRow;
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(MergeAllCommand))] private string? _selectedTarget;
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(MergeAllCommand))] private int _selectedCount;
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(MergeAllCommand))] private bool _isMerging;
[ObservableProperty] private string? _batchProgress;
// Separate per-action status: a running Refresh must not block the Cleanup indicator (and
// vice versa). OperationStatus is a nested ObservableObject, so [NotifyCanExecuteChangedFor]
// can't observe it directly — IsRunning changes are wired to NotifyCanExecuteChanged() below.
public OperationStatus RefreshStatus { get; } = new();
public OperationStatus CleanupStatus { get; } = new();
public OperationStatus ForceRemoveStatus { get; } = new();
public OperationStatus BatchMergeStatus { get; } = new();
public ObservableCollection<WorktreeOverviewRowViewModel> Rows { get; } = new();
public ObservableCollection<WorktreesGroupViewModel> Groups { get; } = new();
public ObservableCollection<string> MergeTargets { get; } = new();
@@ -100,6 +106,64 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
_worker = worker;
_diffVmFactory = diffVmFactory;
_merge = merge;
BatchMergeStatus.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(OperationStatus.IsRunning)) MergeAllCommand.NotifyCanExecuteChanged();
};
ForceRemoveStatus.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(OperationStatus.IsRunning)) ForceRemoveCommand.NotifyCanExecuteChanged();
};
// Phase 3 reconcile tick: this overlay is long-lived (stays open while the user reviews
// worktrees), so refresh it on the same cadence as TasksIslandViewModel's tick instead of
// leaving it frozen at the moment it was opened. Skipped while a load or batch merge is
// already in flight — reloading mid-merge would replace the row instances the merge loop
// is still updating.
_reconcileTimer.Elapsed += (_, _) =>
Avalonia.Threading.Dispatcher.UIThread.Post(() => _ = ReconcileTickAsync());
_reconcileTimer.Start();
}
private readonly System.Timers.Timer _reconcileTimer = new(4_000);
// LoadAsync rebuilds every row instance from scratch and resets SelectedCount/ConflictRows/
// BatchProgress, so a bare reload on a 4s timer would wipe the user's batch-merge ticks, the
// highlighted row and a finished batch's outcome badges out from under them — assembling a
// multi-worktree selection would be impossible. Carry the state that is the user's (or a
// finished batch's) across the reload, keyed by task id; genuinely new rows come up unticked.
internal async Task ReconcileTickAsync()
{
if (RefreshStatus.IsRunning || CleanupStatus.IsRunning || ForceRemoveStatus.IsRunning || BatchMergeStatus.IsRunning) return;
// Hold the row INSTANCES, not a value snapshot, and read them back only after the reload:
// a tick the user lands during the await happens before LoadAsync clears the collection,
// so the live instance still carries it.
var previousRows = AllRows.ToList();
var previousProgress = BatchProgress;
await LoadAsync();
var previous = new Dictionary<string, (bool Checked, BatchMergeOutcome Outcome, bool Selected)>(previousRows.Count);
foreach (var r in previousRows) previous[r.TaskId] = (r.IsChecked, r.MergeOutcome, r.IsSelected);
WorktreeOverviewRowViewModel? reselect = null;
foreach (var row in AllRows)
{
if (!previous.TryGetValue(row.TaskId, out var state)) continue;
row.IsChecked = state.Checked;
row.MergeOutcome = state.Outcome;
if (state.Outcome == BatchMergeOutcome.Conflict) ConflictRows.Add(row);
if (state.Selected) reselect = row;
}
// SelectedRow still points at a detached pre-reload instance either way — re-point it at
// the fresh row, or clear it when that worktree is gone rather than leave it dangling.
if (reselect is not null) SelectRow(reselect);
else SelectedRow = null;
BatchProgress = previousProgress;
}
public void SelectRow(WorktreeOverviewRowViewModel row)
@@ -119,9 +183,6 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
}
public async Task LoadAsync(CancellationToken ct = default)
{
IsBusy = true;
try
{
var dtos = await _worker.GetWorktreesOverviewAsync(ListIdFilter);
var ordered = dtos
@@ -151,23 +212,19 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
}
await LoadMergeTargetsAsync();
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private Task Refresh()
private async Task Refresh()
{
StatusMessage = null;
return LoadAsync();
using var op = RefreshStatus.Begin(Loc.T("ops.worktrees.refreshing"));
await LoadAsync();
}
[RelayCommand]
private async Task CleanupFinished()
{
IsBusy = true;
using var op = CleanupStatus.Begin(Loc.T("ops.worktrees.cleaningUp"));
try
{
var result = await _worker.CleanupFinishedWorktreesAsync(ListIdFilter);
@@ -175,11 +232,23 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
await LoadAsync();
}
catch (Exception ex) { StatusMessage = Loc.T("vm.worktreesOverview.cleanupFailedDetailed", ex.Message); }
finally { IsBusy = false; }
}
[RelayCommand]
private void Close() => CloseAction?.Invoke();
private void Close()
{
StopReconcileTick();
CloseAction?.Invoke();
}
/// <summary>Stops the reconcile tick. Also called from the dialog's native-close fallback
/// (WindowDialogService), which bypasses <see cref="CloseCommand"/> — otherwise an Alt+F4
/// leaves the timer re-running LoadAsync (git + DB) on an orphaned VM. Idempotent.</summary>
internal void StopReconcileTick()
{
_reconcileTimer.Stop();
_reconcileTimer.Dispose();
}
[RelayCommand]
private void ShowDiff(WorktreeOverviewRowViewModel? row)
@@ -235,13 +304,16 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
else StatusMessage = err ?? Loc.T("vm.worktreesOverview.keepFailed");
}
[RelayCommand]
private bool CanForceRemove(WorktreeOverviewRowViewModel? row) => !ForceRemoveStatus.IsRunning;
[RelayCommand(CanExecute = nameof(CanForceRemove))]
private async Task ForceRemove(WorktreeOverviewRowViewModel? row)
{
if (row is null) return;
if (row.IsRunning) { StatusMessage = Loc.T("vm.worktreesOverview.cannotForceRunning"); return; }
if (ConfirmAction is not null && !await ConfirmAction($"Force remove worktree for '{row.TaskTitle}'? This deletes the directory and branch.")) return;
using var op = ForceRemoveStatus.Begin(Loc.T("ops.worktrees.forceRemoving"));
ForceRemoveResultDto? result;
try
{
@@ -335,7 +407,7 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
catch { MergeTargets.Clear(); SelectedTarget = null; }
}
private bool CanMergeAll() => !IsMerging && SelectedCount > 0 && !string.IsNullOrWhiteSpace(SelectedTarget);
private bool CanMergeAll() => !BatchMergeStatus.IsRunning && SelectedCount > 0 && !string.IsNullOrWhiteSpace(SelectedTarget);
[RelayCommand(CanExecute = nameof(CanMergeAll))]
private Task MergeAll() => MergeSelectedAsync(_worker.MergeTaskAsync);
@@ -365,22 +437,20 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
var selected = AllRows.Where(r => r.IsChecked && r.IsActive).ToList();
if (selected.Count == 0) return;
IsMerging = true;
ConflictRows.Clear();
var done = 0;
try
{
using var op = BatchMergeStatus.Begin(Loc.T("ops.worktrees.batchMerging", done, selected.Count));
foreach (var row in selected)
{
ct.ThrowIfCancellationRequested();
row.MergeOutcome = BatchMergeOutcome.Merging;
BatchProgress = Loc.T("vm.worktreesOverview.batchProgress", ++done, selected.Count);
BatchMergeStatus.Report(Loc.T("ops.worktrees.batchMerging", ++done, selected.Count));
MergeResultDto result;
try
{
result = await mergeFn(row.TaskId, target!, false,
Loc.T("vm.merge.commitMessage", row.TaskTitle));
// Blank message: the worker builds the conventional default per task.
result = await mergeFn(row.TaskId, target!, false, "");
}
catch
{
@@ -415,12 +485,10 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
break;
}
}
// Batch-Merge OperationStatus.ShowIndicator (and its live "i/n" label) hides once this
// method returns and `op` disposes — BatchProgress is the post-run summary that survives
// after the indicator disappears, not a duplicate of the live ticker.
BatchProgress = Loc.T("vm.worktreesOverview.batchDone",
selected.Count(r => r.MergeOutcome == BatchMergeOutcome.Merged), ConflictRows.Count);
}
finally
{
IsMerging = false;
}
}
}
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Media;
@@ -60,6 +61,24 @@ public partial class DiffTextView : UserControl
private bool _renderersInstalled;
// Above this many raw diff lines, DiffAlignment.Build moves to a background thread instead
// of running inline in ReloadFile. Build is O(lines) plus one word-diff LCS per 1:1 changed
// pair, and each LCS is up to MaxWordDiffTokens² (400² = 160k int cells, ~640KB) — a diff
// this size can plausibly stack up enough such pairs to blow a 16ms frame, and Planning mode
// instantiates one DiffTextView per changed file in an ItemsControl, so several can hit this
// at once. A line-count threshold was chosen over chunked/incremental alignment because Build
// is already pure and side-effect-free (safe to run on Task.Run as-is), whereas incremental
// construction would need to fragment its LCS/gap-detection state across calls for no benefit
// to the common case. Below the threshold, staying synchronous avoids a placeholder frame
// (there is nothing to show until the first build completes) for the overwhelming majority of
// diffs, which finish well under a frame either way.
private const int AsyncBuildLineThreshold = 500;
// Bumped by every ReloadFile call; a background build only applies its result if this is
// still the generation that started it, so a fast File-to-File switch during a slow build
// can't overwrite the newer file's rows with a stale AlignedDiff.
private int _buildGeneration;
public DiffTextView()
{
InitializeComponent();
@@ -97,9 +116,29 @@ public partial class DiffTextView : UserControl
/// 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.
/// Small diffs build inline; large ones move to a background thread (see
/// <see cref="AsyncBuildLineThreshold"/>) and keep showing the previous file's rows until
/// the new build lands, rather than flashing an empty frame.
private void ReloadFile()
{
_aligned = DiffAlignment.Build(File?.Lines);
var lines = File?.Lines;
var generation = ++_buildGeneration;
if (lines is null || lines.Count <= AsyncBuildLineThreshold)
{
_aligned = DiffAlignment.Build(lines);
RefreshLayout();
return;
}
_ = BuildAsync(lines, generation);
}
private async Task BuildAsync(IReadOnlyList<DiffLineViewModel> lines, int generation)
{
var aligned = await Task.Run(() => DiffAlignment.Build(lines));
if (generation != _buildGeneration) return; // superseded by a newer File in the meantime.
_aligned = aligned;
RefreshLayout();
}
@@ -14,14 +14,19 @@
Background="{DynamicResource DeepBrush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="*,Auto" Margin="14,0">
<Grid ColumnDefinitions="*,Auto,Auto" Margin="14,0">
<TextBlock Text="{TemplateBinding Title}"
FontFamily="{DynamicResource MonoFont}"
FontSize="{DynamicResource FontSizeMono}"
LetterSpacing="1.4"
Foreground="{DynamicResource TextBrush}"
VerticalAlignment="Center"/>
<Button Grid.Column="1" Classes="icon-btn" Content="✕"
<Button Name="PART_MaxButton" Grid.Column="1" Classes="icon-btn"
IsVisible="{TemplateBinding ShowMaximizeButton}"
VerticalAlignment="Center">
<PathIcon Name="PART_MaxIcon" Data="{StaticResource Icon.WinMax}" Width="10" Height="10"/>
</Button>
<Button Grid.Column="2" Classes="icon-btn" Content="✕"
FontSize="{DynamicResource FontSizeBody}"
Command="{TemplateBinding CloseCommand}"
VerticalAlignment="Center"/>
@@ -3,6 +3,8 @@ using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.Media;
namespace ClaudeDo.Ui.Views.Controls;
@@ -18,17 +20,29 @@ public class ModalShell : ContentControl
public static readonly StyledProperty<ICommand?> CloseCommandProperty =
AvaloniaProperty.Register<ModalShell, ICommand?>(nameof(CloseCommand));
// Opt-in: most modals are fixed-purpose dialogs that don't need maximise. Set true only
// on windows (e.g. the diff viewer) that benefit from a maximised full-screen layout.
public static readonly StyledProperty<bool> ShowMaximizeButtonProperty =
AvaloniaProperty.Register<ModalShell, bool>(nameof(ShowMaximizeButton));
public string? Title { get => GetValue(TitleProperty); set => SetValue(TitleProperty, value); }
public object? Footer { get => GetValue(FooterProperty); set => SetValue(FooterProperty, value); }
public ICommand? CloseCommand { get => GetValue(CloseCommandProperty); set => SetValue(CloseCommandProperty, value); }
public bool ShowMaximizeButton { get => GetValue(ShowMaximizeButtonProperty); set => SetValue(ShowMaximizeButtonProperty, value); }
private Window? _window;
private PathIcon? _maxIcon;
private Button? _maxButton;
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
base.OnApplyTemplate(e);
if (e.NameScope.Find<Border>("PART_TitleBar") is { } bar)
bar.PointerPressed += OnTitleBarPressed;
_maxButton = e.NameScope.Find<Button>("PART_MaxButton");
if (_maxButton is not null) _maxButton.Click += OnMaxButtonClick;
_maxIcon = e.NameScope.Find<PathIcon>("PART_MaxIcon");
UpdateMaxIcon();
}
/// Mirrors what MainWindow does for itself: with an extended client area a maximised window
@@ -42,6 +56,7 @@ public class ModalShell : ContentControl
if (_window is null) return;
Margin = _window.OffScreenMargin;
_window.PropertyChanged += OnWindowPropertyChanged;
UpdateMaxIcon();
}
protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e)
@@ -55,6 +70,32 @@ public class ModalShell : ContentControl
{
if (e.Property == Window.OffScreenMarginProperty && _window is not null)
Margin = _window.OffScreenMargin;
if (e.Property == Window.WindowStateProperty)
UpdateMaxIcon();
}
private void UpdateMaxIcon()
{
if (_maxIcon is null || _window is null) return;
var maximized = _window.WindowState == WindowState.Maximized;
var key = maximized ? "Icon.WinRestore" : "Icon.WinMax";
if (this.TryFindResource(key, out var geometry) && geometry is Geometry g)
_maxIcon.Data = g;
if (_maxButton is not null)
{
var tipKey = maximized ? "modals.diff.restore" : "modals.diff.maximize";
ToolTip.SetTip(_maxButton, ClaudeDo.Ui.Localization.Loc.T(tipKey));
}
}
private void OnMaxButtonClick(object? sender, RoutedEventArgs e) => ToggleMaximize();
private void ToggleMaximize()
{
if (_window is null) return;
_window.WindowState = _window.WindowState == WindowState.Maximized
? WindowState.Normal
: WindowState.Maximized;
}
/// <summary>
@@ -74,6 +115,11 @@ public class ModalShell : ContentControl
private void OnTitleBarPressed(object? sender, PointerPressedEventArgs e)
{
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) return;
if (ShowMaximizeButton && e.ClickCount == 2)
{
ToggleMaximize();
return;
}
(TopLevel.GetTopLevel(this) as Window)?.BeginMoveDrag(e);
}
}
@@ -0,0 +1,21 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:svc="using:ClaudeDo.Ui.Services"
xmlns:loc="using:ClaudeDo.Ui.Localization"
x:Class="ClaudeDo.Ui.Views.Controls.OperationIndicator"
x:Name="Root"
x:DataType="svc:OperationStatus">
<!-- DataContext is scoped to the inner panel, never to the UserControl itself: overriding the
control's own DataContext would re-target the `Status="{Binding ...}"` binding at every call
site (it resolves against this control's DataContext) and throw an InvalidCastException. -->
<StackPanel Orientation="Horizontal" Spacing="{StaticResource SpaceSm}" VerticalAlignment="Center"
DataContext="{Binding #Root.Status}"
IsVisible="{Binding ShowIndicator}">
<Ellipse Classes="spinner" VerticalAlignment="Center"/>
<TextBlock Classes="meta" VerticalAlignment="Center" Text="{Binding Label}"/>
<TextBlock Classes="meta" VerticalAlignment="Center" Text="{Binding Elapsed}"/>
<TextBlock Classes="meta" VerticalAlignment="Center" Opacity="0.7"
Text="{loc:Tr ops.stalledHint}"
IsVisible="{Binding IsStalled}"/>
</StackPanel>
</UserControl>
@@ -0,0 +1,35 @@
using Avalonia;
using Avalonia.Controls;
using ClaudeDo.Ui.Services;
namespace ClaudeDo.Ui.Views.Controls;
public partial class OperationIndicator : UserControl
{
public static readonly StyledProperty<OperationStatus?> StatusProperty =
AvaloniaProperty.Register<OperationIndicator, OperationStatus?>(nameof(Status));
public OperationStatus? Status
{
get => GetValue(StatusProperty);
set => SetValue(StatusProperty, value);
}
public OperationIndicator()
{
InitializeComponent();
IsVisible = false;
}
/// A null `Status` leaves the inner panel's DataContext null, so its `IsVisible="{Binding
/// ShowIndicator}"` can't resolve and falls back to the property default — *visible*. That
/// renders a label-less spinner plus the stalled hint forever. Collapse the whole control
/// instead. Deliberately a direct set, not a binding: no call site binds `IsVisible` on the
/// indicator, and a local value would win over one anyway.
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == StatusProperty)
IsVisible = change.GetNewValue<OperationStatus?>() is not null;
}
}
@@ -2,6 +2,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
xmlns:loc="using:ClaudeDo.Ui.Localization"
xmlns:controls="using:ClaudeDo.Ui.Views.Controls"
x:DataType="vm:DetailsIslandViewModel"
x:Class="ClaudeDo.Ui.Views.Islands.Detail.WorkConsole">
@@ -249,7 +250,7 @@
FontFamily="{StaticResource MonoFont}"
FontSize="{StaticResource FontSizeMono}"
Foreground="{DynamicResource AccentBrush}"
VerticalAlignment="Center" Margin="0,0,8,0" />
VerticalAlignment="Top" Margin="0,2,8,0" />
<TextBox Grid.Column="1"
Name="ReviewInput"
Classes="review-prompt"
@@ -258,21 +259,27 @@
AcceptsReturn="True"
TextWrapping="Wrap"
MaxHeight="160"
PlaceholderText="Feedback for the next run…"
VerticalContentAlignment="Center"
PlaceholderText="{loc:Tr session.reviewFeedbackPlaceholder}"
VerticalContentAlignment="Top"
ScrollViewer.VerticalScrollBarVisibility="Auto"
FontFamily="{StaticResource MonoFont}"
FontSize="{StaticResource FontSizeMono}" />
<Button Grid.Column="2" Classes="prompt-action accent" Content="[Resume]"
VerticalAlignment="Center" Margin="12,0,0,0"
<StackPanel Grid.Column="2" Spacing="4" Margin="12,2,0,0">
<Button Classes="prompt-action accent" Content="[Resume]"
HorizontalAlignment="Right"
ToolTip.Tip="{loc:Tr session.reviewContinueTip}"
Command="{Binding RejectReviewCommand}" />
<controls:OperationIndicator Status="{Binding RejectOp}" HorizontalAlignment="Right" />
</StackPanel>
</Grid>
<!-- Inset lives on the content, NOT on the ScrollViewer: Avalonia 12 leaves
ScrollViewer.Padding out of the Extent, so at max scroll the last lines
sit below the viewport and stay unreachable. -->
<ScrollViewer Name="LogScroll"
VerticalScrollBarVisibility="Visible"
AllowAutoHide="False"
Padding="12,8,12,4">
<ItemsControl ItemsSource="{Binding Log}">
AllowAutoHide="False">
<ItemsControl ItemsSource="{Binding Log}" Margin="12,8,12,4">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:LogLineViewModel">
<Grid ColumnDefinitions="60,*" Margin="0,1">
@@ -292,8 +299,8 @@
</DockPanel>
<!-- Git: the review + merge cockpit -->
<ScrollViewer IsVisible="{Binding IsGitTab}" Padding="14,10">
<StackPanel Spacing="14">
<ScrollViewer IsVisible="{Binding IsGitTab}">
<StackPanel Spacing="14" Margin="14,10">
<!-- Merge controls — shown whenever there's a worktree / unit to merge.
Header reads REVIEW while a decision is pending, otherwise MERGE. -->
@@ -328,6 +335,7 @@
<TextBlock Classes="meta" Text="{Binding Merge.MergePreviewText}" TextWrapping="Wrap"
Foreground="{DynamicResource TextMuteBrush}"
IsVisible="{Binding Merge.ShowMergePreviewMuted}" />
<controls:OperationIndicator Status="{Binding Merge.PreviewOp}" />
</StackPanel>
<!-- Inspect: diff / worktree / combined diff -->
@@ -364,11 +372,17 @@
</StackPanel>
<WrapPanel Orientation="Horizontal">
<Button Classes="btn accent" Content="Approve &amp; Merge" Margin="0,0,8,8"
<StackPanel Orientation="Horizontal" Spacing="{StaticResource SpaceSm}" Margin="0,0,8,8">
<Button Classes="btn accent" Content="Approve &amp; Merge"
Command="{Binding ApproveReviewCommand}" />
<Button Classes="btn" Content="Park" Margin="0,0,8,8"
<controls:OperationIndicator Status="{Binding ApproveOp}" />
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="{StaticResource SpaceSm}" Margin="0,0,8,8">
<Button Classes="btn" Content="Park"
ToolTip.Tip="Set aside — back to Idle, keeps the worktree"
Command="{Binding ParkReviewCommand}" />
<controls:OperationIndicator Status="{Binding ParkOp}" />
</StackPanel>
<Button Classes="btn" Content="Cancel" Margin="0,0,8,8"
Command="{Binding CancelReviewCommand}" />
</WrapPanel>
@@ -385,15 +399,18 @@
<TextBlock Classes="meta" TextWrapping="Wrap"
Foreground="{DynamicResource TextMuteBrush}"
Text="Worked on this by hand? Submit the worktree for review to merge it." />
<StackPanel Orientation="Horizontal" Spacing="{StaticResource SpaceSm}">
<Button Classes="btn accent" Content="Submit for review" HorizontalAlignment="Left"
Command="{Binding SubmitForReviewCommand}" />
<controls:OperationIndicator Status="{Binding SubmitOp}" />
</StackPanel>
</StackPanel>
</StackPanel>
</ScrollViewer>
<!-- Session: subtask outcomes (review lives in Output, merge in Git) -->
<ScrollViewer IsVisible="{Binding IsSessionTab}" Padding="14,10">
<StackPanel Spacing="14">
<ScrollViewer IsVisible="{Binding IsSessionTab}">
<StackPanel Spacing="14" Margin="14,10">
<!-- Attention band: a child failed, was cancelled, still needs its own
review, or reported roadblocks. The parent stays waiting until resolved. -->
@@ -3,6 +3,7 @@
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
xmlns:islands="using:ClaudeDo.Ui.Views.Islands"
xmlns:detail="using:ClaudeDo.Ui.Views.Islands.Detail"
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
xmlns:loc="using:ClaudeDo.Ui.Localization"
x:Class="ClaudeDo.Ui.Views.Islands.DetailsIslandView"
x:DataType="vm:DetailsIslandViewModel"
@@ -154,10 +155,13 @@
<Panel IsVisible="{Binding IsPrepMode}">
<DockPanel>
<Border DockPanel.Dock="Top" Padding="12,8">
<StackPanel Orientation="Horizontal" Spacing="{StaticResource SpaceSm}">
<Button Classes="btn primary"
Command="{Binding Prep.PlanDayCommand}"
IsEnabled="{Binding !Prep.IsPrepRunning}"
IsEnabled="{Binding Prep.IsPlanDayEnabled}"
Content="{loc:Tr details.planDay}"/>
<ctl:OperationIndicator Status="{Binding Prep.PrepOperation}"/>
</StackPanel>
</Border>
<Panel>
<islands:SessionTerminalView
@@ -18,6 +18,7 @@ public partial class DetailsIslandView : UserControl
// never dragged stay dynamic (Auto-sized description).
private readonly Dictionary<string, double> _descriptionHeights = new();
private DetailsIslandViewModel? _vm;
private DiffViewerView? _diffViewerWindow;
public DetailsIslandView()
{
@@ -142,12 +143,26 @@ public partial class DetailsIslandView : UserControl
vm.PropertyChanged += OnViewModelPropertyChanged;
ApplyResizeStateForCurrentTask();
vm.Merge.ShowDiffViewer = async (diffVm) =>
// Non-modal: the diff viewer must not block navigating the rest of the app.
// Reuse the one open window (swap its DataContext + bring to front) instead of
// stacking a second one when the user re-opens the diff for another task.
vm.Merge.ShowDiffViewer = (diffVm) =>
{
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner == null) return;
var modal = new DiffViewerView { DataContext = diffVm };
await modal.ShowDialog(owner);
if (owner == null) return System.Threading.Tasks.Task.CompletedTask;
if (_diffViewerWindow is { } existing)
{
existing.DataContext = diffVm;
if (existing.WindowState == WindowState.Minimized)
existing.WindowState = WindowState.Normal;
existing.Activate();
return System.Threading.Tasks.Task.CompletedTask;
}
var window = new DiffViewerView { DataContext = diffVm };
window.Closed += (_, _) => _diffViewerWindow = null;
_diffViewerWindow = window;
window.Show(owner);
return System.Threading.Tasks.Task.CompletedTask;
};
vm.Merge.ShowMergeModal = async (mergeVm) =>
@@ -131,6 +131,10 @@
IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenInExplorerCommand}"
CommandParameter="{Binding}"/>
<MenuItem Header="{loc:Tr lists.contextOpenFindings}"
IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenFindingsCommand}"
CommandParameter="{Binding}"/>
<MenuItem Header="{loc:Tr lists.contextOpenTerminal}"
IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenInTerminalCommand}"
@@ -51,11 +51,12 @@
</Grid>
<!-- ── Log output ── -->
<!-- Inset on the content, not on the ScrollViewer — Avalonia 12 excludes
ScrollViewer.Padding from the Extent, which clips the last log lines. -->
<ScrollViewer Name="LogScroll"
VerticalScrollBarVisibility="Visible"
AllowAutoHide="False"
Padding="10,8,10,12">
<ItemsControl ItemsSource="{Binding #Root.Entries}">
AllowAutoHide="False">
<ItemsControl ItemsSource="{Binding #Root.Entries}" Margin="10,8,10,12">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:LogLineViewModel">
<Grid ColumnDefinitions="60,*" Margin="0,1">
+51 -66
View File
@@ -7,82 +7,41 @@
x:DataType="vm:TaskRowViewModel">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="6"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Above-row indicator: lives in the 6px gap between cards -->
<Border Grid.Row="0" Height="2" VerticalAlignment="Center" Margin="4,0"
Background="{DynamicResource MossBrush}" CornerRadius="1"
IsVisible="{Binding DropHintAbove}"/>
<!-- Above-row indicator: dashed placeholder gap showing where the dragged row will land -->
<Grid Grid.Row="0" Height="52" IsVisible="{Binding DropHintAbove}">
<Rectangle Margin="4,3" Stroke="{DynamicResource MossBrush}" StrokeThickness="1.5"
StrokeDashArray="4,3" Fill="Transparent" RadiusX="8" RadiusY="8"/>
</Grid>
<!-- Indent wrapper: col 0 = 24px child indent track, col 1 = content -->
<!-- Indent wrapper: col 0 = 24px child/chain indent track, col 1 = content -->
<Grid Grid.Row="1" ColumnDefinitions="Auto,*">
<!-- Indent track (only while the parent shares this view; orphaned children render flat) -->
<Border Grid.Column="0" Width="24" IsVisible="{Binding ShowAsChild}" VerticalAlignment="Stretch">
<!-- Indent track (only while the parent/chain-head shares this view; orphaned rows render flat).
Auto + Width: collapses to 0 on rows with no chain/child relation, so those stay flat. -->
<Border Grid.Column="0" Width="24" VerticalAlignment="Stretch">
<Border.IsVisible>
<MultiBinding Converter="{x:Static BoolConverters.Or}">
<Binding Path="ShowAsChild"/>
<Binding Path="ShowAsChainMember"/>
</MultiBinding>
</Border.IsVisible>
<Rectangle Width="1" Fill="{DynamicResource LineBrush}"
HorizontalAlignment="Right" Margin="0,4"/>
</Border>
<!-- Main task card -->
<Border Grid.Column="1" Classes="task-row"
Margin="0"
Margin="0,2"
Classes.selected="{Binding IsSelected}"
Classes.dragging="{Binding IsDragging}"
Classes.done="{Binding Done}">
<Border.ContextMenu>
<ContextMenu>
<MenuItem Header="{loc:Tr tasks.ctxSendToQueue}"
IsVisible="{Binding CanSendToQueue}"
Click="OnSendToQueueClick"/>
<MenuItem Header="{loc:Tr tasks.ctxRemoveFromQueue}"
IsVisible="{Binding CanRemoveFromQueue}"
Click="OnRemoveFromQueueClick"/>
<MenuItem Header="{loc:Tr tasks.ctxCancelExecution}"
IsVisible="{Binding IsRunning}"
Click="OnCancelExecutionClick"/>
<Separator/>
<MenuItem Header="{loc:Tr tasks.ctxMarkAs}">
<MenuItem Header="{loc:Tr tasks.ctxMarkDone}" Tag="Done" Click="OnSetStatusClick"/>
<MenuItem Header="{loc:Tr tasks.ctxMarkCancelled}" Tag="Cancelled" Click="OnSetStatusClick"/>
<Separator/>
<MenuItem Header="{loc:Tr tasks.ctxMarkManual}"
IsVisible="{Binding !IsManual}"
Click="OnToggleManualClick"/>
<MenuItem Header="{loc:Tr tasks.ctxMarkClaudeTask}"
IsVisible="{Binding IsManual}"
Click="OnToggleManualClick"/>
</MenuItem>
<Separator/>
<MenuItem Header="{loc:Tr tasks.ctxOpenConPtySession}"
Click="OnOpenConPtySessionClick"/>
<MenuItem Header="{loc:Tr tasks.ctxOpenPlanningSession}"
Click="OnOpenPlanningSessionClick"
IsVisible="{Binding CanOpenPlanningSession}"/>
<MenuItem Header="{loc:Tr tasks.ctxResumePlanningSession}"
Click="OnResumePlanningSessionClick"
IsVisible="{Binding CanResumeOrDiscardPlanning}"/>
<MenuItem Header="{loc:Tr tasks.ctxFinalizePlanningSession}"
Click="OnFinalizePlanningSessionClick"
IsVisible="{Binding CanFinalizePlanning}"/>
<MenuItem Header="{loc:Tr tasks.ctxDiscardPlanningSession}"
Click="OnDiscardPlanningSessionClick"
IsVisible="{Binding CanResumeOrDiscardPlanning}"/>
<Separator/>
<MenuItem Header="{loc:Tr tasks.ctxScheduleFor}" Click="OnScheduleForClick"/>
<MenuItem Header="{loc:Tr tasks.ctxClearSchedule}"
IsVisible="{Binding HasSchedule}"
Click="OnClearScheduleClick"/>
<MenuItem Header="{loc:Tr tasks.ctxAddToMyDay}"
IsVisible="{Binding CanAddToMyDay}"
Click="OnAddToMyDayClick"/>
<MenuItem Header="{loc:Tr tasks.ctxRemoveFromMyDay}"
IsVisible="{Binding IsMyDay}"
Click="OnRemoveFromMyDayClick"/>
</ContextMenu>
</Border.ContextMenu>
Classes.drop-target="{Binding IsDropTarget}"
Classes.done="{Binding Done}"
ContextRequested="OnRowContextRequested">
<Grid ColumnDefinitions="0,18,32,*,Auto,Auto,32" Margin="6,8,10,8">
<!-- Chevron toggle (only for planning parent tasks) -->
@@ -114,7 +73,11 @@
<!-- Title + chip row + live tail -->
<StackPanel Grid.Column="3" Spacing="6" VerticalAlignment="Center">
<Grid ColumnDefinitions="*,Auto" VerticalAlignment="Center">
<TextBlock Grid.Column="0"
<Grid Grid.Column="0" ColumnDefinitions="Auto,*" VerticalAlignment="Center">
<TextBlock Grid.Column="0" Classes="meta" Text="{Binding Number, StringFormat='#{0}'}"
IsVisible="{Binding ShowNumberBadge}"
VerticalAlignment="Center" Margin="0,0,6,0"/>
<TextBlock Grid.Column="1"
Classes="task-title"
Text="{Binding Title}" FontSize="{StaticResource FontSizeTaskTitle}"
Foreground="{DynamicResource TextBrush}"
@@ -122,6 +85,7 @@
FontStyle="{Binding IsDraft, Converter={StaticResource BoolToItalic}}"
Opacity="{Binding IsDraft, Converter={StaticResource BoolToDraftOpacity}}"
TextDecorations="{Binding Done, Converter={StaticResource StrikeIfTrue}}"/>
</Grid>
<!-- Badges: DRAFT and planning session -->
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4"
@@ -167,7 +131,7 @@
<Border Classes="chip"
IsVisible="{Binding ShowStatusChip}"
Tapped="OnStatusChipTapped"
ToolTip.Tip="{Binding InteractiveChipTooltip}"
ToolTip.Tip="{Binding StatusChipTooltip}"
Classes.interactive="{Binding HasInteractiveSession}"
Classes.parked="{Binding IsParked}"
Classes.running="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Running}"
@@ -216,6 +180,17 @@
</StackPanel>
</Border>
<!-- Chain-after chip: the head-relative fallback when this row isn't rendered as a
rail member (planning child, or the head is filtered out of this view). -->
<Border Classes="chip chip-tag"
IsVisible="{Binding ChainAfterLabel, Converter={StaticResource NotNullToBool}}">
<TextBlock>
<Run Text="{loc:Tr tasks.chainAfterPrefix}"/>
<Run Text=" "/>
<Run Text="{Binding ChainAfterLabel}"/>
</TextBlock>
</Border>
</StackPanel>
</StackPanel>
@@ -249,12 +224,22 @@
</Grid>
</Border>
<!-- Chain step badge: centered on the rail line, half laid over the card's left edge.
Lives in col 1 with a negative margin so it never widens the Auto indent column.
Declared after the card so it paints on top instead of underneath it. -->
<Border Grid.Column="1" Classes="chain-step-badge"
IsVisible="{Binding ShowAsChainMember}"
HorizontalAlignment="Left" VerticalAlignment="Center"
Margin="-8,0,0,0">
<TextBlock Text="{Binding ChainStep}"/>
</Border>
</Grid>
<!-- Below-row indicator: only expands when visible (used for the last row of a section) -->
<Grid Grid.Row="2" Height="6" IsVisible="{Binding DropHintBelow}">
<Border Height="2" VerticalAlignment="Center" Margin="4,0"
Background="{DynamicResource MossBrush}" CornerRadius="1"/>
<Grid Grid.Row="2" Height="52" IsVisible="{Binding DropHintBelow}">
<Rectangle Margin="4,3" Stroke="{DynamicResource MossBrush}" StrokeThickness="1.5"
StrokeDashArray="4,3" Fill="Transparent" RadiusX="8" RadiusY="8"/>
</Grid>
<!-- Hidden schedule anchor (its Flyout is shown from the context menu) -->
@@ -1,9 +1,11 @@
using System.Linq;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Input;
using Avalonia.Interactivity;
using Avalonia.VisualTree;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.ViewModels.Islands;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
@@ -19,6 +21,57 @@ public partial class TaskRowView : UserControl
this.GetVisualAncestors().OfType<ItemsControl>()
.Select(ic => ic.DataContext).OfType<TasksIslandViewModel>().FirstOrDefault();
// The context menu is built here instead of declared in XAML so the ~18 MenuItems only
// exist while a row's menu is actually open, not for every row rendered in the list.
private void OnRowContextRequested(object? sender, ContextRequestedEventArgs e)
{
if (sender is not Border border) return;
if (DataContext is not TaskRowViewModel row) return;
if (FindTasksVm() is not { } vm) return;
// OnTunnelPointerPressed (TasksIslandView) only selects on the left button, so
// right-click needs its own explicit selection before the menu opens.
vm.SelectFrom(row, "context-menu");
var menu = new ContextMenu { DataContext = row };
MenuItem MakeItem(string key, EventHandler<RoutedEventArgs> click, bool isVisible = true, string? tag = null)
{
var item = new MenuItem { Header = Loc.T(key), DataContext = row, IsVisible = isVisible };
if (tag is not null) item.Tag = tag;
item.Click += click;
return item;
}
menu.Items.Add(MakeItem("tasks.ctxSendToQueue", OnSendToQueueClick, row.CanSendToQueue));
menu.Items.Add(MakeItem("tasks.ctxRemoveFromQueue", OnRemoveFromQueueClick, row.CanRemoveFromQueue));
menu.Items.Add(MakeItem("tasks.ctxCancelExecution", OnCancelExecutionClick, row.IsRunning));
menu.Items.Add(new Separator());
var markAs = new MenuItem { Header = Loc.T("tasks.ctxMarkAs"), DataContext = row };
markAs.Items.Add(MakeItem("tasks.ctxMarkDone", OnSetStatusClick, tag: "Done"));
markAs.Items.Add(MakeItem("tasks.ctxMarkCancelled", OnSetStatusClick, tag: "Cancelled"));
markAs.Items.Add(new Separator());
markAs.Items.Add(MakeItem("tasks.ctxMarkManual", OnToggleManualClick, !row.IsManual));
markAs.Items.Add(MakeItem("tasks.ctxMarkClaudeTask", OnToggleManualClick, row.IsManual));
menu.Items.Add(markAs);
menu.Items.Add(new Separator());
menu.Items.Add(MakeItem("tasks.ctxOpenConPtySession", OnOpenConPtySessionClick));
menu.Items.Add(MakeItem("tasks.ctxOpenPlanningSession", OnOpenPlanningSessionClick, row.CanOpenPlanningSession));
menu.Items.Add(MakeItem("tasks.ctxResumePlanningSession", OnResumePlanningSessionClick, row.CanResumeOrDiscardPlanning));
menu.Items.Add(MakeItem("tasks.ctxFinalizePlanningSession", OnFinalizePlanningSessionClick, row.CanFinalizePlanning));
menu.Items.Add(MakeItem("tasks.ctxDiscardPlanningSession", OnDiscardPlanningSessionClick, row.CanResumeOrDiscardPlanning));
menu.Items.Add(new Separator());
menu.Items.Add(MakeItem("tasks.ctxScheduleFor", OnScheduleForClick));
menu.Items.Add(MakeItem("tasks.ctxClearSchedule", OnClearScheduleClick, row.HasSchedule));
menu.Items.Add(MakeItem("tasks.ctxAddToMyDay", OnAddToMyDayClick, row.CanAddToMyDay));
menu.Items.Add(MakeItem("tasks.ctxRemoveFromMyDay", OnRemoveFromMyDayClick, row.IsMyDay));
menu.Open(border);
e.Handled = true;
}
private async void OnSendToQueueClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
@@ -2,7 +2,8 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
xmlns:islands="using:ClaudeDo.Ui.Views.Islands"
xmlns:converters="using:Avalonia.Data.Converters"
xmlns:uiconverters="using:ClaudeDo.Ui.Converters"
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
xmlns:loc="using:ClaudeDo.Ui.Localization"
x:Class="ClaudeDo.Ui.Views.Islands.TasksIslandView"
x:DataType="vm:TasksIslandViewModel">
@@ -20,6 +21,7 @@
Foreground="{DynamicResource TextMuteBrush}"
Text="{Binding Subtitle}"
TextTrimming="CharacterEllipsis"/>
<ctl:OperationIndicator Status="{Binding PlanningOperation}"/>
</StackPanel>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="4"
@@ -89,82 +91,66 @@
Command="{Binding OpenNotesCommand}"
Content="{loc:Tr tasks.notesPinnedRow}"/>
<!-- Task list -->
<ScrollViewer>
<StackPanel Margin="10,4">
<!-- OVERDUE -->
<StackPanel IsVisible="{Binding HasOverdue}">
<TextBlock Classes="eyebrow section-label overdue"
Text="{loc:Tr tasks.overdue}" Margin="14,14,14,6"/>
<ItemsControl ItemsSource="{Binding OverdueItems}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:TaskRowViewModel">
<Button Classes="flat" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Stretch"
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).SelectCommand}"
CommandParameter="{Binding}">
<islands:TaskRowView/>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<!-- TASKS -->
<StackPanel IsVisible="{Binding HasOpen}">
<TextBlock Classes="eyebrow section-label"
Text="{loc:Tr tasks.tasks}" Margin="14,14,14,6"
IsVisible="{Binding ShowOpenLabel}"/>
<ItemsControl ItemsSource="{Binding OpenItems}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:TaskRowViewModel">
<Button Classes="flat" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Stretch"
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).SelectCommand}"
CommandParameter="{Binding}">
<islands:TaskRowView/>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<!-- COMPLETED -->
<StackPanel>
<StackPanel.IsVisible>
<MultiBinding Converter="{x:Static converters:BoolConverters.And}">
<Binding Path="HasCompleted"/>
<Binding Path="IsShowingCompleted"/>
</MultiBinding>
</StackPanel.IsVisible>
<!-- Task list: one flat, virtualized ListBox. Rows is HeaderRow | TaskRowViewModel — group
headers are regular entries, not a separate ItemsControl per section, so a
VirtualizingStackPanel actually bounds the realized container count.
Inset lives on the panel, NOT on the ListBox: Avalonia 12 leaves ScrollViewer.Padding
out of the Extent, so at max scroll the last row would sit below the viewport and stay
unreachable. -->
<ListBox x:Name="RowsListBox"
ItemsSource="{Binding Rows}"
Background="Transparent"
BorderThickness="0">
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel Margin="10,4"/>
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.Styles>
<!-- SelectedItem is never bound — selection is handled entirely by the inner row Button's
SelectCommand, mirroring the pre-virtualization ItemsControl. Avalonia's ListBox has
no "no selection" mode, so neutralize the app-wide selected/pointerover
ContentPresenter overlay (App.axaml) instead of letting it double up with
TaskRowView's own hover/selected style. -->
<Style Selector="ListBoxItem">
<Setter Property="Padding" Value="0"/>
<Setter Property="MinHeight" Value="0"/>
<Setter Property="CornerRadius" Value="0"/>
<Setter Property="HorizontalContentAlignment" Value="Stretch"/>
<Setter Property="Focusable" Value="{Binding Converter={x:Static uiconverters:NotHeaderRowConverter.Instance}}"/>
</Style>
<Style Selector="ListBoxItem:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="Transparent"/>
</Style>
<Style Selector="ListBoxItem:selected /template/ ContentPresenter">
<Setter Property="Background" Value="Transparent"/>
</Style>
</ListBox.Styles>
<ListBox.DataTemplates>
<DataTemplate DataType="vm:HeaderRow">
<Grid ColumnDefinitions="*,Auto" Margin="14,14,14,6">
<TextBlock Grid.Column="0" Classes="eyebrow section-label"
Text="{Binding CompletedHeader}" VerticalAlignment="Center"/>
<Button Grid.Column="1" Classes="icon-btn"
Command="{Binding ClearCompletedCommand}"
Classes.overdue="{Binding IsOverdue}"
Text="{Binding Label}" VerticalAlignment="Center"/>
<Button Grid.Column="1" Classes="icon-btn" IsVisible="{Binding HasAction}"
Command="{Binding ActionCommand}"
ToolTip.Tip="{loc:Tr tasks.clearCompletedTip}"
VerticalAlignment="Center">
<PathIcon Data="{StaticResource Icon.Trash}" Width="13" Height="13"
Foreground="{DynamicResource BloodBrush}"/>
</Button>
</Grid>
<ItemsControl ItemsSource="{Binding CompletedItems}">
<ItemsControl.ItemTemplate>
</DataTemplate>
<DataTemplate DataType="vm:TaskRowViewModel">
<Button Classes="flat" HorizontalAlignment="Stretch"
HorizontalContentAlignment="Stretch"
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).SelectCommand}"
Command="{Binding $parent[ListBox].((vm:TasksIslandViewModel)DataContext).SelectCommand}"
CommandParameter="{Binding}">
<islands:TaskRowView/>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</StackPanel>
</ScrollViewer>
</ListBox.DataTemplates>
</ListBox>
</DockPanel>
</UserControl>
@@ -1,5 +1,4 @@
using System;
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
@@ -62,19 +61,14 @@ public partial class TasksIslandView : UserControl
};
}
// Bring the selected row into view — the task list is a plain ItemsControl with no
// built-in selection scrolling, so a programmatic select (e.g. Mission Control's
// "Open in app") would otherwise highlight a row that stays off-screen.
// Bring the selected row into view — a programmatic select (e.g. Mission Control's "Open in
// app") can target a row that isn't currently realized. RowsListBox.ScrollIntoView is
// virtualization-aware (it realizes the container as needed); a visual-tree search for an
// existing Button, as this used to do, only finds rows already on screen.
private void ScrollSelectedIntoView()
{
if (DataContext is not TasksIslandViewModel vm || vm.SelectedTask is not { } target) return;
Dispatcher.UIThread.Post(() =>
{
var match = this.GetVisualDescendants()
.OfType<Button>()
.FirstOrDefault(b => ReferenceEquals(b.DataContext, target));
match?.BringIntoView();
}, DispatcherPriority.Background);
Dispatcher.UIThread.Post(() => RowsListBox.ScrollIntoView(target), DispatcherPriority.Background);
}
private async System.Threading.Tasks.Task<bool> ShowConfirmAsync(string message)
@@ -137,7 +131,7 @@ public partial class TasksIslandView : UserControl
if (!e.GetCurrentPoint(button).Properties.IsLeftButtonPressed) return;
// Select now so the details pane updates whether the gesture becomes a click or a drag.
if (DataContext is TasksIslandViewModel vm) vm.SelectedTask = row;
if (DataContext is TasksIslandViewModel vm) vm.SelectFrom(row, "pointer-press");
// If the click landed on a nested Button (e.g. the done-toggle checkbox or star),
// don't start a drag — that would capture the pointer and swallow the inner Click.
@@ -363,13 +357,12 @@ public partial class TasksIslandView : UserControl
return null;
}
// The next row in Rows is only "in the same section" while it's a TaskRowViewModel —
// hitting a HeaderRow (or the end of the list) means row was the last one in its section.
private static TaskRowViewModel? FindNextInSameSection(TasksIslandViewModel vm, TaskRowViewModel row)
{
foreach (var section in new[] { vm.OverdueItems, vm.OpenItems, vm.CompletedItems })
{
var idx = section.IndexOf(row);
if (idx >= 0) return idx + 1 < section.Count ? section[idx + 1] : null;
}
return null;
var idx = vm.Rows.IndexOf(row);
if (idx < 0 || idx + 1 >= vm.Rows.Count) return null;
return vm.Rows[idx + 1] as TaskRowViewModel;
}
}
+2 -2
View File
@@ -18,7 +18,7 @@ internal static class JumpToTaskHelper
s.Lists.SelectedList = item;
var existing = s.Tasks.Items.FirstOrDefault(r => r.Id == taskId);
if (existing is not null) { s.Tasks.SelectedTask = existing; return; }
if (existing is not null) { s.Tasks.SelectFrom(existing, "jump-to-task"); return; }
var tcs = new TaskCompletionSource<bool>();
void OnChanged(object? _, NotifyCollectionChangedEventArgs __)
@@ -32,7 +32,7 @@ internal static class JumpToTaskHelper
{
await Task.WhenAny(tcs.Task, Task.Delay(5000));
var row = s.Tasks.Items.FirstOrDefault(r => r.Id == taskId);
if (row is not null) s.Tasks.SelectedTask = row;
if (row is not null) s.Tasks.SelectFrom(row, "jump-to-task");
}
finally
{

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