Compare commits
165
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e314e44dd | ||
|
|
9c4f0d1872 | ||
|
|
23aab26daf | ||
|
|
1341c5f4f3 | ||
|
|
aeb1a5eb82 | ||
|
|
0de3816fd0 | ||
|
|
53e26f0340 | ||
|
|
9099b22eea | ||
|
|
ce1a26d407 | ||
|
|
555933a93c | ||
|
|
43fbfd3fa2 | ||
|
|
ec9ad1b0e1 | ||
|
|
8a1e2c9698 | ||
|
|
c1ec05a437 | ||
|
|
b8caa27027 | ||
|
|
db32e5307f | ||
|
|
c795827162 | ||
|
|
a9af650407 | ||
|
|
c9afc81f0a | ||
|
|
ea4db9d0bd | ||
|
|
589b9e75f3 | ||
|
|
f6994f32d5 | ||
|
|
9fc8ed391e | ||
|
|
b9827eac01 | ||
|
|
69b07fff26 | ||
|
|
030b3ceb6c | ||
|
|
ec4cbcbaf9 | ||
|
|
5fca459579 | ||
|
|
5a74a67660 | ||
|
|
bea85a9ee5 | ||
|
|
bbd6151ba6 | ||
|
|
a0d5db0db0 | ||
|
|
d3abd4b88b | ||
|
|
8098b7f4ec | ||
|
|
3e3f44bfb9 | ||
|
|
5353e5c39c | ||
|
|
ca65849280 | ||
|
|
a614feb96d | ||
|
|
eca16a3506 | ||
|
|
260cb5a89c | ||
|
|
72d46af6e5 | ||
|
|
ba1a745e09 | ||
|
|
cad0582b37 | ||
|
|
fc9df7f9ac | ||
|
|
e55c84cda4 | ||
|
|
d327a1007e | ||
|
|
1861832b99 | ||
|
|
c35fabe0a2 | ||
|
|
ad6af68895 | ||
|
|
fb9fc6e9b5 | ||
|
|
1a0cb97325 | ||
|
|
340f2c9e14 | ||
|
|
4ce5ae7189 | ||
|
|
f6926f08a6 | ||
|
|
b656a241a2 | ||
|
|
1003fdbf46 | ||
|
|
e343baf21a | ||
|
|
39b24c2f74 | ||
|
|
6c4fa7b1a5 | ||
|
|
79b35801ae | ||
|
|
eb66ae7b8b | ||
|
|
3c7871afe2 | ||
|
|
b81d6e0b4f | ||
|
|
9977fe4c3e | ||
|
|
42279e0806 | ||
|
|
18babe3dee | ||
|
|
e8e2fcec3f | ||
|
|
0e12be480b | ||
|
|
38af549a80 | ||
|
|
a1aeb21481 | ||
|
|
2a3133efad | ||
|
|
9660a29da4 | ||
|
|
106c964410 | ||
|
|
8da1a12389 | ||
|
|
5ab5a6c0a8 | ||
|
|
9e46c96b24 | ||
|
|
31a9e87b56 | ||
|
|
d885b4eb41 | ||
|
|
c8244c67f3 | ||
|
|
ee7003d964 | ||
|
|
f2609d186a | ||
|
|
6927805600 | ||
|
|
1657a70962 | ||
|
|
a311152b25 | ||
|
|
1203323102 | ||
|
|
e555bdb8a4 | ||
|
|
d3b91b5214 | ||
|
|
095d216bda | ||
|
|
d60ecb7f1b | ||
|
|
6a731beb53 | ||
|
|
11103d4d3e | ||
|
|
1acd4c1734 | ||
|
|
d3ba279b54 | ||
|
|
7106bf754f | ||
|
|
f31b1b4fb2 | ||
|
|
d451edd367 | ||
|
|
6d8cbacc9b | ||
|
|
bbfe40242c | ||
|
|
2fe40f3d65 | ||
|
|
ec6c230822 | ||
|
|
82881acc70 | ||
|
|
1b7b18fdfc | ||
|
|
1cad264967 | ||
|
|
514d6111fe | ||
|
|
66f7a8ae99 | ||
|
|
fb12d965fc | ||
|
|
8b75a92794 | ||
|
|
333ce89eef | ||
|
|
a9396c1a99 | ||
|
|
f41a3e9083 | ||
|
|
88207ee2a2 | ||
|
|
85c8c36f2c | ||
|
|
6db3f0f537 | ||
|
|
21c9c3b3b9 | ||
|
|
f614323fa9 | ||
|
|
2fd7f829bd | ||
|
|
d95ae55415 | ||
|
|
474c7201c9 | ||
|
|
19b31450b1 | ||
|
|
df021a6cb8 | ||
|
|
3fc67fb422 | ||
|
|
52b42c0a35 | ||
|
|
31a89bd93f | ||
|
|
9ee6defd69 | ||
|
|
5a5c95f1c8 | ||
|
|
a1c90041c6 | ||
|
|
0bfb1f5657 | ||
|
|
47918869e9 | ||
|
|
2662e7bd98 | ||
|
|
db50e710b9 | ||
|
|
d3155e6868 | ||
|
|
84715665c2 | ||
|
|
09add29994 | ||
|
|
a067a7bfff | ||
|
|
ca6eb62b5c | ||
|
|
6c8ec245b3 | ||
|
|
359bb3a439 | ||
|
|
5eea439129 | ||
|
|
e98cf46097 | ||
|
|
7f10d45898 | ||
|
|
5653daca6e | ||
|
|
659bf3e743 | ||
|
|
f74c7dd70b | ||
|
|
07ee535b7d | ||
|
|
c715c96af9 | ||
|
|
2a8991496f | ||
|
|
afe1b68f46 | ||
|
|
effece3a41 | ||
|
|
5d362b6973 | ||
|
|
1b7f7f38ae | ||
|
|
29c79e8b8c | ||
|
|
6a2a19cc9e | ||
|
|
0568f003d9 | ||
|
|
696abd0a7a | ||
|
|
f717742379 | ||
|
|
87397ecfa9 | ||
|
|
519f64c02e | ||
|
|
b312095fb2 | ||
|
|
2d4288abca | ||
|
|
9a09dd6186 | ||
|
|
389892d277 | ||
|
|
4d997a5f99 | ||
|
|
d3c2e2e7c8 | ||
|
|
37c3b476cf | ||
|
|
ae1f6fc78a |
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"worktree": {
|
||||
"baseRef": "head"
|
||||
},
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(git add:*)",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 0–2 only), always fresh — this path never resumes |
|
||||
| List handler, post-handoff | `InteractiveLaunchSpecService.BuildForMergeHelperHandoffAsync` | `--append-system-prompt-file <path>` (`PromptKind.MergeHelperExecute` — phases 3–5 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 4–5 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
|
||||
|
||||
@@ -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/90–110px 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 22–23 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 ≈ 22–25) 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 90–110px): 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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,495 @@
|
||||
# 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
|
||||
|
||||
- [ ] **P0-1 — `OperationStatus` + `OperationIndicator` + Locale-Keys**
|
||||
- [ ] **P0-2 — Timing-Hook für die Messung**
|
||||
- [ ] **P0-3 — Regel in den CLAUDE.md-Dateien**
|
||||
|
||||
**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
|
||||
|
||||
- [ ] **A1 — Detail-Pane** ⚠️ *der gemeldete Fall*
|
||||
- [ ] **A2 — WorktreesOverview**
|
||||
- [ ] **A3 — Settings-Tabs**
|
||||
- [ ] **A4 — Reports und Planning-UI**
|
||||
- [ ] **A5 — Island-DB-Pfade nach Messdaten**
|
||||
|
||||
**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
|
||||
|
||||
- [ ] **B1 — `UnifiedDiffParser.Parse` auslagern**
|
||||
- [ ] **B2 — `DiffAlignment.Build` Grenze**
|
||||
- [ ] **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
|
||||
|
||||
- [ ] **C1 — generischer `OperationProgress`-Kanal**
|
||||
- [ ] **C2 — Startup-Recovery sichtbar**
|
||||
- [ ] **C3 — Worktree-Anlage sichtbar**
|
||||
- [ ] **C4 — Rebase-after-Merge und WorktreeMaintenance**
|
||||
- [ ] **C5 — periodische Dienste**
|
||||
|
||||
**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
|
||||
|
||||
- [ ] **D1 — `ProgressReporter` extrahieren**
|
||||
- [ ] **D2 — die 8 `batch_*`-Tools**
|
||||
- [ ] **D3 — Worktree- und Diff-Tools**
|
||||
- [ ] **D4 — Rest und Doku-Regel**
|
||||
|
||||
**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 8 `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 acht `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 — der Nutzer, nicht ein Agent.
|
||||
- **Auswertung von P0-2** steuert A5 und die Feinheiten in C. Vorher ist A5 absichtlich
|
||||
unbestimmt.
|
||||
- **`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,5–14 KB | beim Arbeiten im Verzeichnis |
|
||||
| `docs/explore-notes/` (6 Notes) | 11–18 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 12–36 Zeilen (~150–450 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 = 5–8 % des Kontexts; Read =
|
||||
60–68 %, 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, ~150–300 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
|
||||
|
||||
<2–6 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.
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+899
@@ -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!;
|
||||
}
|
||||
|
||||
@@ -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)";
|
||||
|
||||
@@ -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!;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)!);
|
||||
}
|
||||
|
||||
@@ -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 –",
|
||||
|
||||
@@ -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 –",
|
||||
|
||||
@@ -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('-');
|
||||
}
|
||||
|
||||
@@ -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. Standardmäßig aus — `Shared` schreibt nur, wenn die Umgebungsvariable `CLAUDEDO_OP_TIMING=1` beim Prozessstart gesetzt ist (einmal statisch gelesen, kein Overhead pro Call). 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();
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,45 +31,39 @@ public sealed partial class UpdateCheckService : ObservableObject
|
||||
|
||||
public async Task CheckNowAsync(CancellationToken ct)
|
||||
{
|
||||
IsChecking = true;
|
||||
using var op = Op.Begin(Loc.T("ops.updateCheck.checking"));
|
||||
|
||||
GiteaRelease? rel;
|
||||
try
|
||||
{
|
||||
GiteaRelease? rel;
|
||||
try
|
||||
{
|
||||
rel = await _releases.GetLatestReleaseAsync(ct);
|
||||
}
|
||||
catch
|
||||
{
|
||||
LastCheckStatus = UpdateCheckStatus.CheckFailed;
|
||||
IsUpdateAvailable = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (rel is null)
|
||||
{
|
||||
LastCheckStatus = UpdateCheckStatus.CheckFailed;
|
||||
IsUpdateAvailable = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var latest = (rel.TagName ?? "").TrimStart('v', 'V');
|
||||
var cmp = VersionComparer.Compare(latest, CurrentVersion);
|
||||
if (cmp.IsNewer)
|
||||
{
|
||||
LatestVersion = latest;
|
||||
IsUpdateAvailable = true;
|
||||
LastCheckStatus = UpdateCheckStatus.UpdateAvailable;
|
||||
}
|
||||
else
|
||||
{
|
||||
IsUpdateAvailable = false;
|
||||
LastCheckStatus = UpdateCheckStatus.UpToDate;
|
||||
}
|
||||
rel = await _releases.GetLatestReleaseAsync(ct);
|
||||
}
|
||||
finally
|
||||
catch
|
||||
{
|
||||
IsChecking = false;
|
||||
LastCheckStatus = UpdateCheckStatus.CheckFailed;
|
||||
IsUpdateAvailable = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (rel is null)
|
||||
{
|
||||
LastCheckStatus = UpdateCheckStatus.CheckFailed;
|
||||
IsUpdateAvailable = false;
|
||||
return;
|
||||
}
|
||||
|
||||
var latest = (rel.TagName ?? "").TrimStart('v', 'V');
|
||||
var cmp = VersionComparer.Compare(latest, CurrentVersion);
|
||||
if (cmp.IsNewer)
|
||||
{
|
||||
LatestVersion = latest;
|
||||
IsUpdateAvailable = true;
|
||||
LastCheckStatus = UpdateCheckStatus.UpdateAvailable;
|
||||
}
|
||||
else
|
||||
{
|
||||
IsUpdateAvailable = false;
|
||||
LastCheckStatus = UpdateCheckStatus.UpToDate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,29 +255,37 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
};
|
||||
foreach (var s in smart) { Items.Add(s); SmartLists.Add(s); }
|
||||
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
var lists = new ListRepository(ctx);
|
||||
var seedNames = new HashSet<string>(new[] { "My Day", "Important", "Planned" });
|
||||
var dotColors = new[] { "Moss", "Peat", "Sage" };
|
||||
int idx = 0;
|
||||
foreach (var l in await lists.GetAllAsync(ct))
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var ok = false;
|
||||
try
|
||||
{
|
||||
if (seedNames.Contains(l.Name)) continue;
|
||||
var item = new ListNavItemViewModel
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
var lists = new ListRepository(ctx);
|
||||
var seedNames = new HashSet<string>(new[] { "My Day", "Important", "Planned" });
|
||||
var dotColors = new[] { "Moss", "Peat", "Sage" };
|
||||
int idx = 0;
|
||||
foreach (var l in await lists.GetAllAsync(ct))
|
||||
{
|
||||
Id = $"user:{l.Id}",
|
||||
Name = l.Name,
|
||||
Kind = ListKind.User,
|
||||
IconKey = "Folder",
|
||||
DotColorKey = dotColors[idx % dotColors.Length],
|
||||
WorkingDir = l.WorkingDir,
|
||||
DefaultCommitType = l.DefaultCommitType,
|
||||
IsManual = l.IsManual,
|
||||
};
|
||||
Items.Add(item);
|
||||
UserLists.Add(item);
|
||||
idx++;
|
||||
if (seedNames.Contains(l.Name)) continue;
|
||||
var item = new ListNavItemViewModel
|
||||
{
|
||||
Id = $"user:{l.Id}",
|
||||
Name = l.Name,
|
||||
Kind = ListKind.User,
|
||||
IconKey = "Folder",
|
||||
DotColorKey = dotColors[idx % dotColors.Length],
|
||||
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();
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
var lists = new ListRepository(ctx);
|
||||
await lists.ReorderAsync(orderedIds);
|
||||
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,29 +388,58 @@ 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,34 +837,41 @@ 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();
|
||||
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
var idSet = orderedIds.ToHashSet();
|
||||
var entities = await db.Tasks
|
||||
.Where(t => t.ListId == listId && idSet.Contains(t.Id))
|
||||
.ToListAsync();
|
||||
for (int i = 0; i < orderedIds.Count; i++)
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var ok = false;
|
||||
try
|
||||
{
|
||||
var e = entities.FirstOrDefault(x => x.Id == orderedIds[i]);
|
||||
if (e is not null) e.SortOrder = i;
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
var idSet = orderedIds.ToHashSet();
|
||||
var entities = await db.Tasks
|
||||
.Where(t => t.ListId == listId && idSet.Contains(t.Id))
|
||||
.ToListAsync();
|
||||
for (int i = 0; i < orderedIds.Count; i++)
|
||||
{
|
||||
var e = entities.FirstOrDefault(x => x.Id == orderedIds[i]);
|
||||
if (e is not null) e.SortOrder = i;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
ok = true;
|
||||
}
|
||||
await db.SaveChangesAsync();
|
||||
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,17 +1029,22 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
var repo = new TaskRepository(db);
|
||||
foreach (var row in toDelete)
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
try
|
||||
await using var db = await _dbFactory.CreateDbContextAsync();
|
||||
var repo = new TaskRepository(db);
|
||||
foreach (var row in toDelete)
|
||||
{
|
||||
await repo.DeleteAsync(row.Id);
|
||||
Items.Remove(row);
|
||||
try
|
||||
{
|
||||
await repo.DeleteAsync(row.Id);
|
||||
Items.Remove(row);
|
||||
}
|
||||
catch { /* still referenced by open child tasks; leave it visible */ }
|
||||
}
|
||||
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) =>
|
||||
{
|
||||
try { await _updateCheck.CheckNowAsync(CancellationToken.None); } catch { }
|
||||
});
|
||||
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,8 +204,19 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
|
||||
|
||||
private void OnRowChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(MergeHelperTaskRowViewModel.IsSelected))
|
||||
OnPropertyChanged(nameof(CanConfirm));
|
||||
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));
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
@@ -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)
|
||||
@@ -120,54 +184,47 @@ 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
|
||||
.OrderBy(d => d.State == WorktreeState.Active ? 0 : 1)
|
||||
.ThenByDescending(d => d.CreatedAt)
|
||||
.Select(Map)
|
||||
.ToList();
|
||||
var dtos = await _worker.GetWorktreesOverviewAsync(ListIdFilter);
|
||||
var ordered = dtos
|
||||
.OrderBy(d => d.State == WorktreeState.Active ? 0 : 1)
|
||||
.ThenByDescending(d => d.CreatedAt)
|
||||
.Select(Map)
|
||||
.ToList();
|
||||
|
||||
Rows.Clear();
|
||||
Groups.Clear();
|
||||
ConflictRows.Clear();
|
||||
SelectedCount = 0;
|
||||
BatchProgress = null;
|
||||
if (IsGlobal)
|
||||
{
|
||||
foreach (var grp in ordered.GroupBy(r => (r.ListId, r.ListName))
|
||||
.OrderBy(g => g.Key.ListName, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var group = new WorktreesGroupViewModel { ListId = grp.Key.ListId, ListName = grp.Key.ListName };
|
||||
foreach (var row in grp) { HookRow(row); group.Rows.Add(row); }
|
||||
Groups.Add(group);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var row in ordered) { HookRow(row); Rows.Add(row); }
|
||||
}
|
||||
await LoadMergeTargetsAsync();
|
||||
}
|
||||
finally
|
||||
Rows.Clear();
|
||||
Groups.Clear();
|
||||
ConflictRows.Clear();
|
||||
SelectedCount = 0;
|
||||
BatchProgress = null;
|
||||
if (IsGlobal)
|
||||
{
|
||||
IsBusy = false;
|
||||
foreach (var grp in ordered.GroupBy(r => (r.ListId, r.ListName))
|
||||
.OrderBy(g => g.Key.ListName, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var group = new WorktreesGroupViewModel { ListId = grp.Key.ListId, ListName = grp.Key.ListName };
|
||||
foreach (var row in grp) { HookRow(row); group.Rows.Add(row); }
|
||||
Groups.Add(group);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var row in ordered) { HookRow(row); Rows.Add(row); }
|
||||
}
|
||||
await LoadMergeTargetsAsync();
|
||||
}
|
||||
|
||||
[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,62 +437,58 @@ 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)
|
||||
{
|
||||
foreach (var row in selected)
|
||||
ct.ThrowIfCancellationRequested();
|
||||
row.MergeOutcome = BatchMergeOutcome.Merging;
|
||||
BatchMergeStatus.Report(Loc.T("ops.worktrees.batchMerging", ++done, selected.Count));
|
||||
|
||||
MergeResultDto result;
|
||||
try
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
row.MergeOutcome = BatchMergeOutcome.Merging;
|
||||
BatchProgress = Loc.T("vm.worktreesOverview.batchProgress", ++done, selected.Count);
|
||||
// Blank message: the worker builds the conventional default per task.
|
||||
result = await mergeFn(row.TaskId, target!, false, "");
|
||||
}
|
||||
catch
|
||||
{
|
||||
row.MergeOutcome = BatchMergeOutcome.Failed;
|
||||
continue;
|
||||
}
|
||||
|
||||
MergeResultDto result;
|
||||
try
|
||||
{
|
||||
result = await mergeFn(row.TaskId, target!, false,
|
||||
Loc.T("vm.merge.commitMessage", row.TaskTitle));
|
||||
}
|
||||
catch
|
||||
{
|
||||
switch (result.Status)
|
||||
{
|
||||
case "merged":
|
||||
row.MergeOutcome = BatchMergeOutcome.Merged;
|
||||
row.State = WorktreeState.Merged;
|
||||
row.IsChecked = false;
|
||||
break;
|
||||
case "conflict":
|
||||
row.MergeOutcome = BatchMergeOutcome.Conflict;
|
||||
ConflictRows.Add(row);
|
||||
break;
|
||||
case "blocked":
|
||||
row.MergeOutcome = BatchMergeOutcome.Blocked;
|
||||
break;
|
||||
case "verify_failed":
|
||||
// The merge landed (so the worktree really is merged), but the list's
|
||||
// verify command failed and the task was kept out of Done. Reporting
|
||||
// this as a plain Failed would claim the merge didn't happen.
|
||||
row.MergeOutcome = BatchMergeOutcome.VerifyFailed;
|
||||
row.State = WorktreeState.Merged;
|
||||
row.IsChecked = false;
|
||||
break;
|
||||
default:
|
||||
row.MergeOutcome = BatchMergeOutcome.Failed;
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (result.Status)
|
||||
{
|
||||
case "merged":
|
||||
row.MergeOutcome = BatchMergeOutcome.Merged;
|
||||
row.State = WorktreeState.Merged;
|
||||
row.IsChecked = false;
|
||||
break;
|
||||
case "conflict":
|
||||
row.MergeOutcome = BatchMergeOutcome.Conflict;
|
||||
ConflictRows.Add(row);
|
||||
break;
|
||||
case "blocked":
|
||||
row.MergeOutcome = BatchMergeOutcome.Blocked;
|
||||
break;
|
||||
case "verify_failed":
|
||||
// The merge landed (so the worktree really is merged), but the list's
|
||||
// verify command failed and the task was kept out of Done. Reporting
|
||||
// this as a plain Failed would claim the merge didn't happen.
|
||||
row.MergeOutcome = BatchMergeOutcome.VerifyFailed;
|
||||
row.State = WorktreeState.Merged;
|
||||
row.IsChecked = false;
|
||||
break;
|
||||
default:
|
||||
row.MergeOutcome = BatchMergeOutcome.Failed;
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
BatchProgress = Loc.T("vm.worktreesOverview.batchDone",
|
||||
selected.Count(r => r.MergeOutcome == BatchMergeOutcome.Merged), ConflictRows.Count);
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsMerging = false;
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
ToolTip.Tip="{loc:Tr session.reviewContinueTip}"
|
||||
Command="{Binding RejectReviewCommand}" />
|
||||
<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 & Merge" Margin="0,0,8,8"
|
||||
Command="{Binding ApproveReviewCommand}" />
|
||||
<Button Classes="btn" Content="Park" Margin="0,0,8,8"
|
||||
ToolTip.Tip="Set aside — back to Idle, keeps the worktree"
|
||||
Command="{Binding ParkReviewCommand}" />
|
||||
<StackPanel Orientation="Horizontal" Spacing="{StaticResource SpaceSm}" Margin="0,0,8,8">
|
||||
<Button Classes="btn accent" Content="Approve & Merge"
|
||||
Command="{Binding ApproveReviewCommand}" />
|
||||
<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." />
|
||||
<Button Classes="btn accent" Content="Submit for review" HorizontalAlignment="Left"
|
||||
Command="{Binding SubmitForReviewCommand}" />
|
||||
<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">
|
||||
<Button Classes="btn primary"
|
||||
Command="{Binding Prep.PlanDayCommand}"
|
||||
IsEnabled="{Binding !Prep.IsPrepRunning}"
|
||||
Content="{loc:Tr details.planDay}"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="{StaticResource SpaceSm}">
|
||||
<Button Classes="btn primary"
|
||||
Command="{Binding Prep.PlanDayCommand}"
|
||||
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">
|
||||
|
||||
@@ -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,14 +73,19 @@
|
||||
<!-- Title + chip row + live tail -->
|
||||
<StackPanel Grid.Column="3" Spacing="6" VerticalAlignment="Center">
|
||||
<Grid ColumnDefinitions="*,Auto" VerticalAlignment="Center">
|
||||
<TextBlock Grid.Column="0"
|
||||
Classes="task-title"
|
||||
Text="{Binding Title}" FontSize="{StaticResource FontSizeTaskTitle}"
|
||||
Foreground="{DynamicResource TextBrush}"
|
||||
TextWrapping="Wrap"
|
||||
FontStyle="{Binding IsDraft, Converter={StaticResource BoolToItalic}}"
|
||||
Opacity="{Binding IsDraft, Converter={StaticResource BoolToDraftOpacity}}"
|
||||
TextDecorations="{Binding Done, Converter={StaticResource StrikeIfTrue}}"/>
|
||||
<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}"
|
||||
TextWrapping="Wrap"
|
||||
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 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>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</DataTemplate>
|
||||
<DataTemplate DataType="vm:TaskRowViewModel">
|
||||
<Button Classes="flat" HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Stretch"
|
||||
Command="{Binding $parent[ListBox].((vm:TasksIslandViewModel)DataContext).SelectCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<islands:TaskRowView/>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user