Compare commits

...
15 Commits
Author SHA1 Message Date
CubeGameLPandClaude Opus 5 f640580463 fix(notes): Shift+Enter in the capture box, not just in the note rows
The previous fix put AcceptsReturn on the rows in the DataTemplate and
missed the box you actually type new notes into, which still had it off
and only a KeyBinding for Enter — so Shift+Enter there did nothing.

Both now go through the same tunnel handler: Enter files the note,
Shift+Enter is left to the TextBox for the line break. The KeyBinding is
gone; with AcceptsReturn on it could never have fired anyway, because
TextBox consumes Enter in its own OnKeyDown before bindings or bubbling
handlers run.

Verified against the running app: a note typed with Shift+Enter is
stored as "fe\r\ndwa".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 21:46:59 +02:00
CubeGameLPandClaude Opus 5 b90c5855f4 fix(notes): let Shift+Enter break the line, and clear the leftovers around the editor
Shift+Enter did nothing because AcceptsReturn was only set from a style
setter and never reached the box. Setting it on the element is half the
fix: TextBox.OnKeyDown consumes Enter regardless of modifiers and runs
before any bubbling handler, so the split chain would have died in its
place. Enter is now taken in the tunnel phase and Shift+Enter is left to
the box.

Alongside, the notes list stops borrowing furniture it has no use for:
no "0 open tasks" subtitle, no show-completed and list-settings buttons,
and no details pane beside it — the editor spans that column instead of
leaving it empty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 21:36:28 +02:00
CubeGameLPandClaude Opus 5 0f04b796f4 feat(notes): notes get their own list, a day log and an Enter chain
The editor was reachable from one button that only appeared in My Day,
which is how it got forgotten. It is now a permanent sidebar entry next
to My Day and Planned, and it fills the tasks island rather than the
details pane, because a note list is content.

Inside, the date picker is gone: today sits at the top with the capture
box, older days follow as dated groups. Enter in a note saves it and
opens the next one, Shift+Enter breaks the line so notes can be several,
Backspace on an empty note deletes it and steps back up. Deleting also
has a visible hover button instead of only "clear the text and hope".
Agent-written notes carry a marker.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 21:19:37 +02:00
CubeGameLPandClaude Opus 5 ae0c7d3771 feat(notes): add_note MCP tool with a per-day agent cap
Agents can write into the user's daily notes, but only within limits:
max 10 agent notes a day, max 500 characters, add only — no edit, no
delete. A new from_agent column separates their lines from the user's
so the cap counts the right ones and the UI can mark them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 21:02:37 +02:00
CubeGameLP cf093e3178 docs: empty-repo hardening notes + verification item
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit d771dbf03575bb60e692ede44110ff17c3043f39)
2026-08-28 20:34:24 +02:00
CubeGameLP e0748ba351 fix(worktree): auto-bootstrap commit-less repos before creating worktrees
Covers autonomous runs, interactive ConPTY sessions (GetInteractiveLaunchSpec),
planning sessions, and improvement/planning children — every path that needs a
base commit now self-heals on a fresh 'git init' repo instead of surfacing
"ambiguous argument 'HEAD'" as a HubException.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit ec16a85c495ceceaa22f922e077a27dcda54f035)
2026-08-28 20:33:45 +02:00
CubeGameLP de44196b9a fix(git): bootstrap commit-less repos and survive missing git identity
A fresh 'git init' repo (unborn HEAD) failed every worktree-based flow with raw
git stderr ('ambiguous argument HEAD'). EnsureHeadCommitAsync now creates an
empty initial commit via plumbing only (mktree/commit-tree/update-ref) so the
user's index and working tree are untouched; broken HEADs get a clear error.
CommitAsync retries once with a ClaudeDo identity when the machine has none,
and RevParseHeadAsync reports 'no commits yet' instead of raw git output.
Also: git stdin is now BOM-less UTF-8 (git rejects BOM-prefixed input).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 21af231f3e7b2cf056efe475916627f0cfeeb5c6)
2026-08-28 20:33:45 +02:00
CubeGameLP c259254921 build: allow SDK roll-forward to a newer major (PCs without a .NET 8 SDK)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 76942bc5c55f3f44e1289ff0d6e690fad57e95b7)
2026-08-28 20:31:50 +02:00
CubeGameLP fea8236e8a docs(git): spec + plan for empty-repo (unborn HEAD) hardening
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 5f4b7a9e26af0635cfaecfbc55bfe928dd3466d2)
2026-08-28 20:31:50 +02:00
ClaudeDo CI 1d6cd49beb docs(changelog): update for v2.12.0 2026-08-28 12:17:54 +00:00
mika kuns c31f79d3d4 feat(ui): wählbares Listen-Icon und aufgeräumter Task-Detail-Header
Changelog / changelog (push) Successful in 2s
Release / release (push) Successful in 41s
Listen bekommen eine Icon-Spalte (Migration AddListIcon) und einen Icon-Picker
in den Listeneinstellungen; 34 kuratierte Geometrien aus der Icon-Bibliothek,
Fallback auf "Folder" bei unbekanntem Key.

Der Metadata-Footer im Task-Detail entfällt; created-at wandert in die
Header-Zeile neben das ID-Badge, der Close-Button (und CloseDetailsCommand)
fällt weg.
2026-08-28 14:17:38 +02:00
mika kuns 22e70ffcb4 feat(ui): Mission Control als Session-Hub
- "+" öffnet ein Menü mit allen Listen (working dir) statt direkt den
  Ordner-Dialog; "Ordner wählen…" bleibt als Fallback erste Zeile, damit
  das Menü auch bei fehlgeschlagener Listen-Abfrage nutzbar bleibt
- Rail-Rows: Kontextmenü "Sitzung öffnen" (gesperrt für Running — der
  Worktree gehört dort dem autonomen Lauf)
- Aktivitäts-Strip statt reiner Queue: WaitingForReview/Failed/Roadblock
  kommen dazu, Attention zuerst, unterschieden nur über Farbe (Aktionen
  bleiben in der Task-Detailansicht)
2026-08-28 14:17:38 +02:00
Mika Kuns 8d597df448 fix(worker): make re-approve after revert_merge do a real merge, not a silent no-op
ApproveAndMergeAsync treated any non-Active worktree (including Kept, the
state RevertMergeAsync leaves behind) the same as "never had a worktree",
so re-approving a reverted task ran only the verify gate and marked the
task Done without merging anything back.

Now a Kept worktree is distinguished: if its branch is gone (the common
case, since approve normally deletes it), approve returns Blocked instead
of a fake "merged". If the branch survives, its tip is already an ancestor
of the target (from the original merge), so a plain `git merge` would
silently no-op ("Already up to date."); cherry-picking the original merge
commit's own diff (-m 1) is what actually restores the content, recorded
as a fresh MergeCommit.
2026-08-28 14:17:38 +02:00
Mika Kuns 1affb39716 fix(worker): survive a worker restart when cancelling a mid-merge unit-merge parent
CancelAsync only checked PlanningMergeOrchestrator's in-memory HasActiveMerge, which is
empty after a restart. IActiveMergeState gains an async fallback that checks on-disk
(GitService.IsMidMergeAsync) for a WaitingForReview parent with children, so the guard
still blocks cancel until the merge is continued or aborted.
2026-08-28 14:17:38 +02:00
ClaudeDo CI bbdb022327 docs(changelog): update for v2.11.0 2026-08-27 14:47:02 +00:00
82 changed files with 4044 additions and 294 deletions
+136
View File
@@ -1,5 +1,141 @@
# Changelog # Changelog
## v2.12.0 — 2026-08-28
### Features
- wählbares Listen-Icon und aufgeräumter Task-Detail-Header (c31f79d3)
- Mission Control als Session-Hub (22e70ffc)
### Fixes
- make re-approve after revert_merge do a real merge, not a silent no-op (8d597df4)
- survive a worker restart when cancelling a mid-merge unit-merge parent (1affb397)
### Documentation
- update for v2.11.0 (bbdb0223)
## v2.11.0 — 2026-08-27
### Features
- Listeneinstellungen mit Kategorie-Sidebar (4e55f916)
- Ticketsystem-Anbindung hinter einen Aus-Schalter legen (8329c925)
- Ticket-Projekt pro Liste und Import ueber das Kontextmenue (e91ade9c)
- Settings-Tab fuer die Ticketsystem-Anbindung (020f93d5)
- Ticket-Settings, Projektliste und Import ueber den Hub (f0a3a186)
- Ticket-Import pro Liste (2e236736)
- Ticket-Status-Rueckmeldung ueber TaskStateService (5aafeb6c)
- BandelTicketClient fuer die Ticketsystem-REST-API (10652780)
- TicketSystemConfig + ticket_api_base_url in worker.config.json (2a3c937f)
- ticket_ref auf tasks, ticket_project_id auf list_config (3171690b)
- claude_bin im Dateien-Tab setzbar, mit Auflösungs-Check (13b81cac)
- Throttle-Stufen im Ausfuehrung-Tab sichtbar machen (31d53b00)
- Permission-Modus pro Liste und pro Task ueberschreibbar (4464dc6f)
- Listen-Einstellungen — Checkbox fuer serialize_on_file_overlap (5cc99bfe)
- Review-Merge-Button — Claude nimmt Waiting-for-Review pro Liste ab (741c7ac5)
- verify once per batch via skipVerify + verify_merges (168ab1cd)
- run both MCP servers stateless (69fcceb2)
- add the Prime action radio group to settings (f3cb34b9)
- add the action kind to the settings viewmodels (57be4961)
- add Prime action-kind strings in en and de (514f142a)
- carry the action kind through the hub and validate custom prompts (4e8e0334)
- branch PrimeRunner on the schedule action kind (cf6a6b62)
- migrate prime_schedules with action_kind defaulting to ping (71d2972a)
- add PrimeActionKind to the schedule entity (022eaf38)
- open the TokenTracker dashboard from the usage monitor (3003cfa5)
- show cost columns and the TokenTracker hint card in the usage monitor (074c0e4d)
- surface TokenTracker status and cost in the usage monitor (bac0d69a)
- add TokenTracker analytics strings in en and de (66f743dd)
- add TokenTracker status, refresh and install to the worker client (3c3bb306)
- serve model and task usage from the TokenTracker export (44337d4b)
- cache the TokenTracker export with single-flight refresh (3f3638c1)
- run the TokenTracker CLI through the shared executable resolver (a9218045)
- pin the TokenTracker CLI invocations (d6b22ea5)
- hold the last good TokenTracker export fail-open (580d4e07)
- aggregate TokenTracker sessions by model and per task (2fca0cc4)
- parse the TokenTracker sessions export defensively (106f9a37)
- add TokenTracker export models and test fixture (16952b64)
- reproduce TokenTracker session_hash for task attribution (1cb574bd)
- Improve App Prompts (79438c00)
- Toggle "Continue on session limit reset" für Usage-Limit-Abbrüche (07dd7570)
- confirm main-window close when Mission Control sessions are open (0892883a)
- "Worktree öffnen" im Kontextmenü der Task-Zeile (ac9b0dab)
- update_task kann isManual setzen + List-Handler stellt seinen Task auf Review (ea2271cc)
- Usage-Monitor zeigt Reset-Uhrzeit hinter der Restzeit (8f983851)
- Erststart-Banner und Textkette Repo→Task→Queue→Review (UX-Struktur C) (111dd0fb)
- Settings-Modal auf Sidebar-Kategorien umbauen (56c3719e)
- Task-Zeile entdichten und Kontextmenü gruppieren (UX-Struktur A) (51dc7bd6)
- surface startup recovery via OperationProgress channel (6ec81f65)
- report worktree-creation phase on OperationProgress (eba0d842)
- surface rebase and worktree-maintenance progress on OperationProgress (42de97e1)
- Empty States für Detail-Pane und Task-Liste (UX-Audit #4) (9159f1b5)
### Fixes
- Base-URL ohne Schema laesst die Ticket-Anbindung scheitern (3322152b)
- Import-Erfolgsmeldung nicht mehr als Fehler einfaerben (2fe2ac83)
- MergeHelperSelectionModal — Task-Nummer anzeigen, Titel wrappen, Status-Spalte verbreitern (24e1d648)
- Branch-Zeile im Detail-Header — Icon stroked rendern, ohne Task ausblenden (18755910)
- Review-Merge-Button-Icon stroked rendern statt gefuellt — Linien waren unsichtbar (d7a3aa80)
- block delete on interactive-session tasks, clear ghost selection (c1ab042d)
- store ScheduledFor as real UTC instead of mislabeled local time (f70aec49)
- make modify/delete and rename/delete merge conflicts visible instead of silently staging them (533a287e)
- close status-force bypasses on Running tasks (299c7b74)
- allow the done toggle from any non-Running status (f80af122)
- restore the Settings modal body and tidy the Prime schedule row (d71c0cea)
- close the typographic quote that made de.json unparseable (9ab17d82)
- failureReason "usage_limit" nachziehen + Worktree-Gate prüft die Platte (29171b10)
- Usage-Limit-Auto-Continue verbrennt nicht mehr alle Kandidaten außer dem ersten (3dfae30f)
- thread handler task id into list-handler kickoff prompts (9c4a63d2)
- UsagePillViewModel loads snapshot on connection restore (7f94bf4a)
- Description-Editbox nutzt volle Kartenhoehe statt 320px-Deckel (a6d165a1)
- route done-toggle and dequeue through guarded TaskStateService transitions (7f337b36)
- Reactivität — Description-Save-Race, UsageMonitor-Leak, Listen-Live-Refresh (52efe3ec)
- notify IsTasksEmptyRepoHintVisible on every list switch (b997af44)
- Aktions- und Bestätigungs-Konsistenz (UX-Audit #3) (2935eef7)
- surface precondition hints for silently-disabled review buttons (UX-Audit #2) (dbb73bae)
- silent no-ops raise ErrorReported instead of swallowing failures (UX-Audit #1) (99bb5be5)
- propagate unit-merge failures instead of reporting success (f2058430)
- Stuck-Running-Fenster in Continue- und Stop-Pfad schließen (389c9126)
### Refactoring
- Datei-Scope-Serialisierung entfernen (84219a9f)
- OnlineTokenStore zu DpapiTokenStore verallgemeinert (41bfc242)
- Icon-Faerbung zentral im ICON-PAINTING-Block (1ef67760)
- drop the remaining single-implementation interfaces (0f007c53)
- merge the one-expression converters into SimpleConverters.cs (c593be2f)
- declare the SignalR wire DTOs once in ClaudeDo.Data (8930b564)
- squash the 40 migrations into one InitialCreate (39d82413)
- collapse single-implementation interfaces (296251e2)
- delete unreachable members and redundant package refs (81994aad)
- drop the transcript aggregation path in favour of TokenTracker (40879705)
- dedupe path-open helper across five view models (b2a1b790)
- derive TaskRowViewModel CanX/DisabledReason pairs from one gate method (baba9216)
- unify list-linked-repo predicate into RepoLinkage (9578c950)
- dedupe FormatRemaining between UsagePill and UsageMonitorModal (e164f1e3)
### Documentation
- Ticketsystem-Anbindung als pausiert vermerken (cb69e2a7)
- Ticketsystem-Anbindung in den CLAUDE.md-Dateien (fb1ceac1)
- IsConfigured-Kosten praezise beschreiben (e9dfea90)
- Implementierungsplan, Base-URL in worker.config.json (5a13ad44)
- Design fuer die Ticketsystem-Anbindung (63184ea3)
- neue Config-Felder + Migration-Fixture-Falle in den CLAUDE.md-Dateien (d474fd13)
- bump review-merge verified-against to 168ab1cd (187f5fc5)
- document the schedule action kinds (a1d74ab4)
- implementation plan for schedule action kinds (2dec590d)
- add the TokenTracker analytics implementation plan (884b990e)
- document TokenTracker as the analytics backend (c0bf7db6)
- spec per-schedule action kind (Ping/FillMyDay/Custom) (a873eaa9)
- spec for TokenTracker as the token-analytics backend (0e74650d)
- C5 periodische Dienste abgesagt — kein Mehrwert über bestehende Broadcasts (29868962)
- design and plan for the four structural UX themes (4cf08f81)
- update for v2.10.0 (925ba655)
## v2.10.0 — 2026-08-17 ## v2.10.0 — 2026-08-17
### Features ### Features
+7
View File
@@ -76,6 +76,13 @@ zugleich der erste Test dieser Annahme: schlägt einer davon mit einem Deseriali
## Offene Verifikation (2026-07-27) ## Offene Verifikation (2026-07-27)
- **Empty-Repo-Hardening (2026-07-30)** — Worker/App neu starten (laufende Instanz hat den
Fix noch nicht), dann auf dem Task „HEy this is a Test" (Test List → `C:\Dev\TestRepo`,
aktuell 0 Commits) „Open ConPTY session" klicken: muss jetzt starten und in `C:\Dev\TestRepo`
genau einen leeren Commit „chore: initialize repository (ClaudeDo)" hinterlassen. Idealerweise
auch einmal auf dem Zweit-PC gegentesten (dort zusätzlich: Task-Run auf einem PC ohne
`git config user.name/email` — Auto-Commit darf nicht mehr scheitern).
- **List handler (2026-07-27)** — visual pass: the Broom button is gone from the lists footer, - **List handler (2026-07-27)** — visual pass: the Broom button is gone from the lists footer,
the context-menu item appears only on lists with a working dir, and the selection dialog has no the context-menu item appears only on lists with a working dir, and the selection dialog has no
LIST column. (Der real-Claude-Smoke-Run der fünf Phasen ist am 2026-07-29 gelaufen — 6/6 sauber LIST column. (Der real-Claude-Smoke-Run der fünf Phasen ist am 2026-07-29 gelaufen — 6/6 sauber
@@ -0,0 +1,58 @@
# Empty-repo hardening — implementation plan
Spec: `docs/superpowers/specs/2026-07-30-empty-repo-hardening-design.md`
## Task 1 — GitService: HasHeadCommitAsync + EnsureHeadCommitAsync + friendly unborn error
**Files:** `src/ClaudeDo.Data/Git/GitService.cs`,
`tests/ClaudeDo.Worker.Tests/Runner/GitServiceBootstrapTests.cs` (new)
1. Tests first (skip when git unavailable, mirroring `WorktreeManagerTests`):
- fresh `git init` repo (no commit): `HasHeadCommitAsync` false; after a commit true.
- `EnsureHeadCommitAsync` on empty repo → true; `RevParseHeadAsync` then succeeds;
`git ls-tree HEAD` empty; second call → false.
- staged file in empty repo survives bootstrap: still staged, not in the commit.
- corrupt HEAD (write a garbage SHA into `.git/HEAD` as a detached head):
`EnsureHeadCommitAsync` throws `InvalidOperationException` mentioning the dir.
- `RevParseHeadAsync` on empty repo: message says "has no commits yet" (no raw
"ambiguous argument").
2. Implement: `HasHeadCommitAsync` (`rev-parse --verify --quiet HEAD`),
`EnsureHeadCommitAsync` (symbolic-ref guard → hash-object/commit-tree/update-ref
plumbing behind a static `SemaphoreSlim` with post-acquire re-check), unborn-HEAD
message in `RevParseHeadAsync`.
3. Build Data + run new tests. Commit `fix(git): bootstrap empty repos with an initial commit`.
## Task 2 — CommitAsync identity fallback
**Files:** same two files.
1. Test: repo with local `user.name`/`user.email` set to empty strings → `CommitAsync`
succeeds (retry path), commit exists.
2. Implement: on identity-signature failure, retry once with
`-c user.name=ClaudeDo -c user.email=claudedo@local`.
3. Commit `fix(git): fall back to a ClaudeDo identity when git has none`.
## Task 3 — call sites
**Files:** `src/ClaudeDo.Worker/Runner/WorktreeManager.cs`,
`src/ClaudeDo.Worker/Planning/PlanningSessionManager.cs`,
`tests/ClaudeDo.Worker.Tests/Runner/WorktreeManagerTests.cs`
1. Test: `CreateAsync` on a commit-less repo succeeds; worktree on disk; DB row Active.
2. Wire `EnsureHeadCommitAsync` into `WorktreeManager.CreateAsync` (log info on
bootstrap) and `PlanningSessionManager.StartAsync`.
3. Full Worker.Tests run. Commit `fix(worktree): auto-bootstrap commit-less repos before creating worktrees`.
## Task 4 — docs
- `src/ClaudeDo.Data/CLAUDE.md`: GitService bullet gains bootstrap/identity-fallback.
- `docs/open.md`: verification item — open a ConPTY session on a fresh `git init` repo
on a second PC.
- Commit `docs: empty-repo hardening notes`.
## Verification
- `dotnet test tests/ClaudeDo.Worker.Tests -c Release` green.
- Manual: task 61609f79… in "Test List" (`C:\Dev\TestRepo`, currently commit-less) —
opening the ConPTY session must now succeed and leave `C:\Dev\TestRepo` with exactly
one empty commit by ClaudeDo.
@@ -0,0 +1,98 @@
# Empty-repo hardening (unborn HEAD) — design
**Date:** 2026-07-30
**Status:** approved (option "Auto-Bootstrap" chosen by Mika)
## Problem
`GetInteractiveLaunchSpec` (embedded ConPTY session) fails with a raw git error when the
task's list points at a git repository that has **no commits yet** (fresh `git init`,
unborn HEAD):
```
HubException: git rev-parse HEAD failed (exit 128): fatal: ambiguous argument 'HEAD': unknown revision ...
```
Root cause chain: `InteractiveLaunchSpecService.BuildForTaskAsync` → `WorktreeManager.CreateAsync`
→ `IsGitRepoAsync` (passes — `rev-parse --git-dir` works on an empty repo) →
`ResolveBaseCommitAsync` → `RevParseHeadAsync` (fatals — HEAD is unborn). The same gap
exists in `PlanningSessionManager.StartAsync` and hits autonomous runs (`TaskRunner` →
`CreateAsync` → task `Failed` with the same cryptic message).
This is a "works on my laptop" class of bug: on a fresh PC the first thing a user tries is
a freshly init-ed test repo, which breaks instantly. A second member of the same class:
a PC without `git config user.name/email` breaks every auto-commit
("Please tell me who you are").
## Decision
**Auto-bootstrap:** when a worktree (task run / ConPTY / planning) needs a base commit and
the repo has zero commits, ClaudeDo creates an empty initial commit automatically and
proceeds. Nothing can be lost — the repo is empty. Genuinely broken states (HEAD not a
symbolic ref and not resolvable) still produce a clear, actionable error instead of raw
git stderr.
## Design
### 1. `GitService.HasHeadCommitAsync(dir)`
`git rev-parse --verify --quiet HEAD` → exit 0 means HEAD resolves to a commit.
### 2. `GitService.EnsureHeadCommitAsync(dir)` → `bool` (true = bootstrapped)
- HEAD resolves → return `false` (no-op).
- `git symbolic-ref -q HEAD` fails → repo is corrupt (detached HEAD onto a missing
commit, mangled `.git/HEAD`) → throw `InvalidOperationException` with a clear message
naming the directory. Auto-fixing a corrupt repo is out of scope by design.
- Otherwise HEAD is an unborn branch → bootstrap **via plumbing only** (never touches the
user's index or working tree — staged/untracked files stay exactly as they were):
1. `git hash-object -w -t tree --stdin` with empty stdin → empty tree object.
2. `git -c user.name=ClaudeDo -c user.email=claudedo@local commit-tree <tree> -m
"chore: initialize repository (ClaudeDo)"` — inline identity so this works on a PC
with no git identity configured.
3. `git update-ref <unborn-branch-ref> <commit>`.
- Serialized behind a process-wide gate with a re-check after acquiring, so two parallel
task starts on the same empty repo bootstrap exactly once.
### 3. Defense-in-depth: friendly `RevParseHeadAsync` error
When `rev-parse HEAD` fails with the unborn-HEAD signature ("ambiguous argument 'HEAD'"),
throw "The repository at {dir} has no commits yet" instead of raw stderr — covers any
future call site that forgets the preflight.
### 4. Call sites
- `WorktreeManager.CreateAsync` — after the `IsGitRepoAsync` guard, call
`EnsureHeadCommitAsync`; log when a bootstrap happened. Covers autonomous runs,
interactive ConPTY sessions, improvement/planning children.
- `PlanningSessionManager.StartAsync` — same, before its own `RevParseHeadAsync`.
### 5. Git identity fallback for auto-commits
`GitService.CommitAsync`: if the commit fails with the missing/empty-identity signature,
retry once with inline `-c user.name="ClaudeDo" -c user.email="claudedo@local"`.
Happy path costs nothing (no preflight call); a fresh PC without git identity no longer
fails every task auto-commit.
## Out of scope
- UI validation when configuring a list's WorkingDir (nice-to-have; the worktree paths now
either self-heal or produce clear errors).
- Repairing corrupt repos.
## Test plan (real git, Worker.Tests)
- `HasHeadCommitAsync`: false on fresh `git init`, true after a commit.
- `EnsureHeadCommitAsync` on an empty repo: returns true, `rev-parse HEAD` then works,
tree of the bootstrap commit is empty, second call returns false.
- Staged-file preservation: file staged in empty repo → after bootstrap it is still
staged and NOT part of the bootstrap commit.
- Corrupt HEAD (point `.git/HEAD` at a bogus ref? no — detach onto a garbage SHA by
writing `.git/HEAD` directly): clear error, no bootstrap.
- `CommitAsync` identity fallback: local `user.name`/`user.email` set to empty strings
(forces the identity failure deterministically regardless of global config) → commit
succeeds via fallback, author is ClaudeDo.
- `WorktreeManager.CreateAsync` on an empty repo: succeeds; worktree exists; base commit
= bootstrap commit.
- `PlanningSessionManager.StartAsync` on an empty repo: succeeds (if existing test infra
makes this cheap; otherwise covered by the shared code path).
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"sdk": { "sdk": {
"version": "8.0.418", "version": "8.0.418",
"rollForward": "latestFeature" "rollForward": "latestMajor"
} }
} }
+2 -2
View File
@@ -165,13 +165,13 @@ sealed class Program
sc.AddSingleton<TasksIslandViewModel>(sp => sc.AddSingleton<TasksIslandViewModel>(sp =>
new TasksIslandViewModel( new TasksIslandViewModel(
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(), sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
sp.GetRequiredService<IWorkerClient>())); sp.GetRequiredService<IWorkerClient>(),
sp.GetRequiredService<INotesApi>()));
sc.AddSingleton<DetailsIslandViewModel>(sp => sc.AddSingleton<DetailsIslandViewModel>(sp =>
new DetailsIslandViewModel( new DetailsIslandViewModel(
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(), sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
sp.GetRequiredService<IWorkerClient>(), sp.GetRequiredService<IWorkerClient>(),
sp, sp,
sp.GetRequiredService<INotesApi>(),
sp.GetRequiredService<MergeCoordinator>())); sp.GetRequiredService<MergeCoordinator>()));
sc.AddSingleton<UsagePillViewModel>(sp => sc.AddSingleton<UsagePillViewModel>(sp =>
new UsagePillViewModel(sp.GetRequiredService<IWorkerClient>())); new UsagePillViewModel(sp.GetRequiredService<IWorkerClient>()));
+5
View File
@@ -80,10 +80,15 @@ dir(s) via an optional `AttachmentStore` ctor param (defaults to the production
- Merge: ff-only, no-ff, abort, mid-merge detection (`MERGE_HEAD`), conflicted files - Merge: ff-only, no-ff, abort, mid-merge detection (`MERGE_HEAD`), conflicted files
- Revert: `RevertMergeCommitAsync` (`git revert --no-edit -m 1 <sha>`), `RevertAbortAsync`, `IsMidRevertAsync` (`REVERT_HEAD`, mirrors `IsMidMergeAsync`) - Revert: `RevertMergeCommitAsync` (`git revert --no-edit -m 1 <sha>`), `RevertAbortAsync`, `IsMidRevertAsync` (`REVERT_HEAD`, mirrors `IsMidMergeAsync`)
- `PreviewMergeAsync` (non-destructive check via `git merge-tree --write-tree`), `CountChangedFilesAsync`, rev-parse, is-git-repo - `PreviewMergeAsync` (non-destructive check via `git merge-tree --write-tree`), `CountChangedFilesAsync`, rev-parse, is-git-repo
- Empty repos: `HasHeadCommitAsync` (HEAD resolves to a real commit) and `EnsureHeadCommitAsync` — a fresh `git init` repo (unborn HEAD) gets an empty bootstrap commit via plumbing only (mktree/commit-tree/update-ref), so the user's index and working tree stay untouched; identity is passed inline, so it also works with no git config, and a broken HEAD throws instead of guessing
- Identity fallback: `CommitAsync` retries once as the ClaudeDo identity when the machine has none configured
⚠️ **Revert never resets or rewrites** — it always produces a new commit, because the working ⚠️ **Revert never resets or rewrites** — it always produces a new commit, because the working
directory it operates on is shared with other concurrent sessions. directory it operates on is shared with other concurrent sessions.
⚠️ **Git stdin is BOM-less UTF-8** — `Encoding.UTF8` writes a BOM preamble, which git rejects
as input (`mktree` fails with "input format error", and commit messages would carry it).
## Schema ## Schema
Tables (one per line so parallel migrations don't collide on the same line): Tables (one per line so parallel migrations don't collide on the same line):
@@ -14,6 +14,7 @@ public class DailyNoteEntityConfiguration : IEntityTypeConfiguration<DailyNoteEn
builder.Property(n => n.Date).HasColumnName("note_date").IsRequired(); builder.Property(n => n.Date).HasColumnName("note_date").IsRequired();
builder.Property(n => n.Text).HasColumnName("text").IsRequired(); builder.Property(n => n.Text).HasColumnName("text").IsRequired();
builder.Property(n => n.SortOrder).HasColumnName("sort_order").IsRequired(); builder.Property(n => n.SortOrder).HasColumnName("sort_order").IsRequired();
builder.Property(n => n.FromAgent).HasColumnName("from_agent").IsRequired();
builder.Property(n => n.CreatedAt).HasColumnName("created_at").IsRequired(); builder.Property(n => n.CreatedAt).HasColumnName("created_at").IsRequired();
builder.HasIndex(n => n.Date); builder.HasIndex(n => n.Date);
} }
@@ -19,6 +19,7 @@ public class ListEntityConfiguration : IEntityTypeConfiguration<ListEntity>
builder.Property(l => l.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0); 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.IsManual).HasColumnName("is_manual").IsRequired().HasDefaultValue(false);
builder.Property(l => l.FindingsTracked).HasColumnName("findings_tracked").IsRequired().HasDefaultValue(false); builder.Property(l => l.FindingsTracked).HasColumnName("findings_tracked").IsRequired().HasDefaultValue(false);
builder.Property(l => l.Icon).HasColumnName("icon");
builder.HasIndex(l => l.SortOrder).HasDatabaseName("idx_lists_sort"); builder.HasIndex(l => l.SortOrder).HasDatabaseName("idx_lists_sort");
+119 -1
View File
@@ -25,7 +25,16 @@ public sealed class GitService
{ {
var (exitCode, stdout, stderr) = await RunGitAsync(dir, ["rev-parse", "HEAD"], ct); var (exitCode, stdout, stderr) = await RunGitAsync(dir, ["rev-parse", "HEAD"], ct);
if (exitCode != 0) if (exitCode != 0)
{
// Unborn HEAD (fresh `git init`, no commits) — the raw git stderr ("ambiguous
// argument 'HEAD'") is useless to a user; call sites should have run
// EnsureHeadCommitAsync, this message is the safety net for any that didn't.
if (stderr.Contains("ambiguous argument 'HEAD'", StringComparison.Ordinal) ||
stderr.Contains("unknown revision", StringComparison.Ordinal))
throw new InvalidOperationException(
$"The repository at {dir} has no commits yet (unborn or broken HEAD) — ClaudeDo needs at least one commit to work in it.");
throw new InvalidOperationException($"git rev-parse HEAD failed (exit {exitCode}): {stderr}"); throw new InvalidOperationException($"git rev-parse HEAD failed (exit {exitCode}): {stderr}");
}
return stdout.Trim(); return stdout.Trim();
} }
@@ -55,6 +64,67 @@ public sealed class GitService
return exitCode == 0 ? stdout.Trim() : null; return exitCode == 0 ? stdout.Trim() : null;
} }
/// <summary>HEAD resolves to a commit (false for a fresh `git init` repo or a broken HEAD).</summary>
public async Task<bool> HasHeadCommitAsync(string dir, CancellationToken ct = default)
{
// ^{commit} forces the object to actually exist and be a commit — a bare
// `--verify HEAD` happily accepts a detached HEAD pointing at a missing object.
var (exitCode, _, _) = await RunGitAsync(dir, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], ct);
return exitCode == 0;
}
private const string FallbackUserName = "ClaudeDo";
private const string FallbackUserEmail = "claudedo@local";
// Two parallel task starts on the same empty repo must create exactly one initial commit.
private static readonly SemaphoreSlim BootstrapGate = new(1, 1);
/// <summary>
/// Guarantees HEAD resolves to a commit so worktrees can be created. A repo with an
/// unborn HEAD (fresh `git init`) gets an empty initial commit built from plumbing only
/// (hash-object/commit-tree/update-ref) — the user's index and working tree are never
/// touched, so staged and untracked files stay exactly as they were. Identity is passed
/// inline, so this also works on machines with no git identity configured. A repo whose
/// HEAD is broken (not a symbolic ref and not resolvable) throws instead of guessing.
/// </summary>
/// <returns>true if a bootstrap commit was created; false if HEAD already resolved.</returns>
public async Task<bool> EnsureHeadCommitAsync(string dir, CancellationToken ct = default)
{
if (await HasHeadCommitAsync(dir, ct)) return false;
var (symExit, symRef, _) = await RunGitAsync(dir, ["symbolic-ref", "--quiet", "HEAD"], ct);
if (symExit != 0)
throw new InvalidOperationException(
$"The repository at {dir} has a broken HEAD (detached onto a missing commit, or a corrupt .git/HEAD). Repair the repository manually.");
await BootstrapGate.WaitAsync(ct);
try
{
if (await HasHeadCommitAsync(dir, ct)) return false;
var (treeExit, treeSha, treeErr) = await RunGitAsync(dir, ["mktree"], ct, stdinData: "");
if (treeExit != 0)
throw new InvalidOperationException($"git mktree failed (exit {treeExit}): {treeErr}");
var (commitExit, commitSha, commitErr) = await RunGitAsync(dir,
["-c", $"user.name={FallbackUserName}", "-c", $"user.email={FallbackUserEmail}",
"commit-tree", treeSha.Trim(), "-m", "chore: initialize repository (ClaudeDo)"], ct);
if (commitExit != 0)
throw new InvalidOperationException($"git commit-tree failed (exit {commitExit}): {commitErr}");
var (refExit, _, refErr) = await RunGitAsync(dir,
["update-ref", symRef.Trim(), commitSha.Trim()], ct);
if (refExit != 0)
throw new InvalidOperationException($"git update-ref failed (exit {refExit}): {refErr}");
return true;
}
finally
{
BootstrapGate.Release();
}
}
public async Task WorktreeAddAsync(string repoDir, string branchName, string worktreePath, string baseCommit, CancellationToken ct = default) public async Task WorktreeAddAsync(string repoDir, string branchName, string worktreePath, string baseCommit, CancellationToken ct = default)
{ {
await WorktreeAddGate.WaitAsync(ct); await WorktreeAddGate.WaitAsync(ct);
@@ -135,10 +205,24 @@ public sealed class GitService
{ {
// Use -F - (read message from stdin) to handle multi-line messages safely. // Use -F - (read message from stdin) to handle multi-line messages safely.
var (exitCode, _, stderr) = await RunGitAsync(worktreePath, ["commit", "-F", "-"], ct, stdinData: message); var (exitCode, _, stderr) = await RunGitAsync(worktreePath, ["commit", "-F", "-"], ct, stdinData: message);
if (exitCode != 0 && IsMissingIdentityError(stderr))
{
// A machine without git identity configured must not fail every auto-commit —
// retry once as ClaudeDo. The happy path pays no preflight cost.
(exitCode, _, stderr) = await RunGitAsync(worktreePath,
["-c", $"user.name={FallbackUserName}", "-c", $"user.email={FallbackUserEmail}",
"commit", "-F", "-"], ct, stdinData: message);
}
if (exitCode != 0) if (exitCode != 0)
throw new InvalidOperationException($"git commit failed (exit {exitCode}): {stderr}"); throw new InvalidOperationException($"git commit failed (exit {exitCode}): {stderr}");
} }
private static bool IsMissingIdentityError(string stderr) =>
stderr.Contains("tell me who you are", StringComparison.OrdinalIgnoreCase) ||
stderr.Contains("empty ident", StringComparison.OrdinalIgnoreCase) ||
stderr.Contains("no email was given", StringComparison.OrdinalIgnoreCase) ||
stderr.Contains("unable to auto-detect email address", StringComparison.OrdinalIgnoreCase);
public async Task<string> GetDiffAsync( public async Task<string> GetDiffAsync(
string worktreePath, IReadOnlyList<string>? paths = null, CancellationToken ct = default) string worktreePath, IReadOnlyList<string>? paths = null, CancellationToken ct = default)
{ {
@@ -356,6 +440,38 @@ public sealed class GitService
throw new InvalidOperationException($"git revert --abort failed (exit {exitCode}): {stderr}"); throw new InvalidOperationException($"git revert --abort failed (exit {exitCode}): {stderr}");
} }
/// <summary>
/// Re-applies a merge commit's own diff (against its first parent, `-m 1`) as a new commit on
/// top of HEAD. Needed to redo a merge that was previously reverted: once a branch tip is an
/// ancestor of HEAD, `git merge` on that branch again is a silent "Already up to date." no-op
/// (ancestry, not content, is what merge checks) -- cherry-picking the original merge commit's
/// diff is what actually restores the content.
/// </summary>
public async Task<(int ExitCode, string Stderr)> CherryPickMergeCommitAsync(
string repoDir, string mergeCommitSha, CancellationToken ct = default)
{
var (exitCode, _, stderr) = await RunGitAsync(repoDir,
["-c", "merge.conflictStyle=diff3", "cherry-pick", "-m", "1", "--no-edit", mergeCommitSha], ct);
return (exitCode, stderr);
}
public async Task<bool> IsMidCherryPickAsync(string repoDir, CancellationToken ct = default)
{
var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["rev-parse", "--git-dir"], ct);
if (exitCode != 0) return false;
var gitDir = stdout.Trim();
if (!Path.IsPathRooted(gitDir))
gitDir = Path.Combine(repoDir, gitDir);
return File.Exists(Path.Combine(gitDir, "CHERRY_PICK_HEAD"));
}
public async Task CherryPickAbortAsync(string repoDir, CancellationToken ct = default)
{
var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["cherry-pick", "--abort"], ct);
if (exitCode != 0)
throw new InvalidOperationException($"git cherry-pick --abort failed (exit {exitCode}): {stderr}");
}
public async Task<List<string>> ListConflictedFilesAsync(string repoDir, CancellationToken ct = default) public async Task<List<string>> ListConflictedFilesAsync(string repoDir, CancellationToken ct = default)
{ {
var (exitCode, stdout, stderr) = await RunGitAsync(repoDir, var (exitCode, stdout, stderr) = await RunGitAsync(repoDir,
@@ -552,7 +668,9 @@ public sealed class GitService
CreateNoWindow = true, CreateNoWindow = true,
StandardOutputEncoding = Encoding.UTF8, StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8, StandardErrorEncoding = Encoding.UTF8,
StandardInputEncoding = stdinData is not null ? Encoding.UTF8 : null, // BOM-less: Encoding.UTF8 writes a BOM preamble, which git rejects as input
// (mktree "input format error") and would prepend to stdin commit messages.
StandardInputEncoding = stdinData is not null ? new UTF8Encoding(false) : null,
}; };
psi.ArgumentList.Add("-C"); psi.ArgumentList.Add("-C");
psi.ArgumentList.Add(workDir); psi.ArgumentList.Add(workDir);
@@ -0,0 +1,978 @@
// <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("20260828084750_AddListIcon")]
partial class AddListIcon
{
/// <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<bool>("AutoContinueOnUsageLimit")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("auto_continue_on_usage_limit");
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,
AutoContinueOnUsageLimit = false,
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<string>("PermissionMode")
.HasColumnType("TEXT")
.HasColumnName("permission_mode");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<int?>("TicketProjectId")
.HasColumnType("INTEGER")
.HasColumnName("ticket_project_id");
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<string>("Icon")
.HasColumnType("TEXT")
.HasColumnName("icon");
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<string>("Kind")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("ping")
.HasColumnName("action_kind");
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<string>("PermissionMode")
.HasColumnType("TEXT")
.HasColumnName("permission_mode");
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>("TicketRef")
.HasColumnType("TEXT")
.HasColumnName("ticket_ref");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.Property<DateTime?>("UsageLimitAutoContinuedAt")
.HasColumnType("TEXT")
.HasColumnName("usage_limit_auto_continued_at");
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,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddListIcon : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "icon",
table: "lists",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "icon",
table: "lists");
}
}
}
@@ -0,0 +1,982 @@
// <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("20260828185839_AddDailyNoteFromAgent")]
partial class AddDailyNoteFromAgent
{
/// <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<bool>("AutoContinueOnUsageLimit")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("auto_continue_on_usage_limit");
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,
AutoContinueOnUsageLimit = false,
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<bool>("FromAgent")
.HasColumnType("INTEGER")
.HasColumnName("from_agent");
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>("PermissionMode")
.HasColumnType("TEXT")
.HasColumnName("permission_mode");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<int?>("TicketProjectId")
.HasColumnType("INTEGER")
.HasColumnName("ticket_project_id");
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<string>("Icon")
.HasColumnType("TEXT")
.HasColumnName("icon");
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<string>("Kind")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("ping")
.HasColumnName("action_kind");
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<string>("PermissionMode")
.HasColumnType("TEXT")
.HasColumnName("permission_mode");
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>("TicketRef")
.HasColumnType("TEXT")
.HasColumnName("ticket_ref");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.Property<DateTime?>("UsageLimitAutoContinuedAt")
.HasColumnType("TEXT")
.HasColumnName("usage_limit_auto_continued_at");
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,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddDailyNoteFromAgent : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "from_agent",
table: "daily_notes",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "from_agent",
table: "daily_notes");
}
}
}
@@ -205,6 +205,10 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("note_date"); .HasColumnName("note_date");
b.Property<bool>("FromAgent")
.HasColumnType("INTEGER")
.HasColumnName("from_agent");
b.Property<int>("SortOrder") b.Property<int>("SortOrder")
.HasColumnType("INTEGER") .HasColumnType("INTEGER")
.HasColumnName("sort_order"); .HasColumnName("sort_order");
@@ -287,6 +291,10 @@ namespace ClaudeDo.Data.Migrations
.HasDefaultValue(false) .HasDefaultValue(false)
.HasColumnName("findings_tracked"); .HasColumnName("findings_tracked");
b.Property<string>("Icon")
.HasColumnType("TEXT")
.HasColumnName("icon");
b.Property<bool>("IsManual") b.Property<bool>("IsManual")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("INTEGER") .HasColumnType("INTEGER")
@@ -6,5 +6,9 @@ public sealed class DailyNoteEntity
public DateOnly Date { get; set; } public DateOnly Date { get; set; }
public string Text { get; set; } = string.Empty; public string Text { get; set; } = string.Empty;
public int SortOrder { get; set; } public int SortOrder { get; set; }
/// <summary>True when an agent wrote this note through the MCP tool rather than the user.
/// Drives the UI marker and the per-day cap in <c>NotesMcpTools</c>.</summary>
public bool FromAgent { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
} }
+2
View File
@@ -14,6 +14,8 @@ public sealed class ListEntity
// When true the project's .claudedo/ findings store is committed with the repo; when false it // 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). // is kept out of git via .git/info/exclude (see FindingsStore).
public bool FindingsTracked { get; set; } public bool FindingsTracked { get; set; }
// Icon key into the UI's icon library (e.g. "Star"); null = the default folder icon.
public string? Icon { get; set; }
// Navigation properties // Navigation properties
public ListConfigEntity? Config { get; set; } public ListConfigEntity? Config { get; set; }
@@ -22,7 +22,13 @@ public sealed class DailyNoteRepository
.OrderBy(n => n.Date).ThenBy(n => n.SortOrder) .OrderBy(n => n.Date).ThenBy(n => n.SortOrder)
.ToListAsync(ct); .ToListAsync(ct);
public async Task<DailyNoteEntity> AddAsync(DateOnly day, string text, CancellationToken ct = default) /// <summary>Notes an agent wrote on <paramref name="day"/> — the number the MCP cap counts against.</summary>
public async Task<int> CountAgentNotesAsync(DateOnly day, CancellationToken ct = default) =>
await _context.DailyNotes.AsNoTracking()
.CountAsync(n => n.Date == day && n.FromAgent, ct);
public async Task<DailyNoteEntity> AddAsync(
DateOnly day, string text, bool fromAgent = false, CancellationToken ct = default)
{ {
var nextOrder = await _context.DailyNotes var nextOrder = await _context.DailyNotes
.Where(n => n.Date == day) .Where(n => n.Date == day)
@@ -34,6 +40,7 @@ public sealed class DailyNoteRepository
Date = day, Date = day,
Text = text, Text = text,
SortOrder = nextOrder + 1, SortOrder = nextOrder + 1,
FromAgent = fromAgent,
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
}; };
_context.DailyNotes.Add(note); _context.DailyNotes.Add(note);
+1 -1
View File
@@ -92,7 +92,7 @@ public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList<Merg
public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs); public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs);
public record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType, bool IsManual = false, bool FindingsTracked = false); public record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType, bool IsManual = false, bool FindingsTracked = false, string? Icon = null);
// TicketProjectId is tri-state on purpose: a caller that doesn't own the field (anything but the // TicketProjectId is tri-state on purpose: a caller that doesn't own the field (anything but the
// list-settings modal) must not be able to clear it by omission — SetConfigAsync copies the entity // list-settings modal) must not be able to clear it by omission — SetConfigAsync copies the entity
+15 -6
View File
@@ -177,7 +177,6 @@
"listSettingsTip": "Listeneinstellungen", "listSettingsTip": "Listeneinstellungen",
"addPlaceholder": "Aufgabe hinzufügen…", "addPlaceholder": "Aufgabe hinzufügen…",
"enterKey": "ENTER", "enterKey": "ENTER",
"notesPinnedRow": "Notizen (Tagesnotizen)",
"clearDayTip": "Tag leeren", "clearDayTip": "Tag leeren",
"planMyDayTip": "Meinen Tag planen", "planMyDayTip": "Meinen Tag planen",
"overdue": "ÜBERFÄLLIG", "overdue": "ÜBERFÄLLIG",
@@ -261,7 +260,6 @@
"killSessionTip": "Laufende Sitzung beenden", "killSessionTip": "Laufende Sitzung beenden",
"sendToQueue": "In Warteschlange einreihen", "sendToQueue": "In Warteschlange einreihen",
"sendToQueueTip": "Diese Aufgabe in die Warteschlange einreihen, damit Claude sie beim nächsten Lauf übernimmt", "sendToQueueTip": "Diese Aufgabe in die Warteschlange einreihen, damit Claude sie beim nächsten Lauf übernimmt",
"closeTip": "Schließen",
"copyTaskIdTip": "Aufgaben-ID kopieren", "copyTaskIdTip": "Aufgaben-ID kopieren",
"starTip": "Favorit", "starTip": "Favorit",
"agentSettingsTip": "Agent-Einstellungen", "agentSettingsTip": "Agent-Einstellungen",
@@ -300,8 +298,11 @@
}, },
"notes": { "notes": {
"today": "Heute", "today": "Heute",
"add": "Hinzufügen", "yesterday": "Gestern",
"newNotePlaceholder": "Neue Notiz…" "newNotePlaceholder": "Neue Notiz…",
"emptyHint": "Noch nichts notiert. Tippen, Enter, fertig.",
"deleteTip": "Notiz löschen",
"fromAgentTip": "Von Claude notiert"
}, },
"session": { "session": {
"chipLive": "LIVE", "chipLive": "LIVE",
@@ -320,9 +321,14 @@
"newSession": "Neue Sitzung", "newSession": "Neue Sitzung",
"empty": "Keine laufenden Aufgaben", "empty": "Keine laufenden Aufgaben",
"settings": "Einstellungen", "settings": "Einstellungen",
"queue": "Warteschlange", "activity": "Aktivität",
"blocked": "Blockiert", "blocked": "Blockiert",
"running": "Läuft", "running": "Läuft",
"review": "Im Review",
"failed": "Fehlgeschlagen",
"roadblock": "Blockade gemeldet",
"openSession": "Sitzung öffnen",
"browseForFolder": "Ordner wählen…",
"focusMode": "Fokus", "focusMode": "Fokus",
"overviewMode": "Übersicht", "overviewMode": "Übersicht",
"closeSession": "Sitzung beenden", "closeSession": "Sitzung beenden",
@@ -391,6 +397,9 @@
"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.", "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", "findingsTracked": "Findings-Ordner .claudedo einchecken",
"findingsTrackedHint": "Aus: der Ordner bleibt über .git/info/exclude aus git heraus. An: Findings reisen mit dem Repo.", "findingsTrackedHint": "Aus: der Ordner bleibt über .git/info/exclude aus git heraus. An: Findings reisen mit dem Repo.",
"sectionIcon": "ICON",
"icon": "Listen-Icon",
"iconHint": "Wird in der Seitenleiste neben dem Listennamen angezeigt.",
"sectionAgent": "AGENT", "sectionAgent": "AGENT",
"resetAgentSettings": "Agent-Einstellungen zurücksetzen", "resetAgentSettings": "Agent-Einstellungen zurücksetzen",
"sectionVerify": "VERIFIKATION", "sectionVerify": "VERIFIKATION",
@@ -756,7 +765,7 @@
"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.", "stopOffline": "Worker offline — Task kann nicht gestoppt werden.", "stopFailed": "Stoppen fehlgeschlagen: {0}", "enqueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "dequeueFailed": "Aus der Warteschlange entfernen fehlgeschlagen: {0}", "resetAndRetryFailed": "Zurücksetzen & erneut versuchen fehlgeschlagen: {0}", "toggleDoneFailed": "Erledigt-Status konnte nicht aktualisiert werden: {0}" }, "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.", "stopOffline": "Worker offline — Task kann nicht gestoppt werden.", "stopFailed": "Stoppen fehlgeschlagen: {0}", "enqueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "dequeueFailed": "Aus der Warteschlange entfernen fehlgeschlagen: {0}", "resetAndRetryFailed": "Zurücksetzen & erneut versuchen fehlgeschlagen: {0}", "toggleDoneFailed": "Erledigt-Status konnte nicht aktualisiert werden: {0}" },
"mergeSection": { "openWorktreeFailed": "Worktree konnte nicht geöffnet werden: {0}" }, "mergeSection": { "openWorktreeFailed": "Worktree konnte nicht geöffnet werden: {0}" },
"about": { "openPathFailed": "Pfad konnte nicht geöffnet werden: {0}" }, "about": { "openPathFailed": "Pfad konnte nicht geöffnet werden: {0}" },
"lists": { "localSuffix": "{0} / lokal", "smartMyDay": "Mein Tag", "smartImportant": "Wichtig", "smartPlanned": "Geplant", "virtualQueue": "Warteschlange", "virtualRunning": "Läuft", "virtualReview": "Prüfung", "newList": "Neue Liste", "findingsNotFound": "Für diese Liste gibt es noch keine Findings.", "findingsOpenFailed": "Findings-Ordner konnte nicht geöffnet werden: {0}", "openInExplorerFailed": "Pfad konnte nicht geöffnet werden: {0}" }, "lists": { "localSuffix": "{0} / lokal", "smartMyDay": "Mein Tag", "smartImportant": "Wichtig", "smartPlanned": "Geplant", "smartNotes": "Notizen", "virtualQueue": "Warteschlange", "virtualRunning": "Läuft", "virtualReview": "Prüfung", "newList": "Neue Liste", "findingsNotFound": "Für diese Liste gibt es noch keine Findings.", "findingsOpenFailed": "Findings-Ordner konnte nicht geöffnet werden: {0}", "openInExplorerFailed": "Pfad konnte nicht geöffnet werden: {0}" },
"repoImport": { "loadFailed": "Gespeicherte Ordner konnten nicht geladen werden: {0}", "saveFailed": "Ordner konnten nicht gespeichert werden: {0}" } "repoImport": { "loadFailed": "Gespeicherte Ordner konnten nicht geladen werden: {0}", "saveFailed": "Ordner konnten nicht gespeichert werden: {0}" }
}, },
"ops": { "ops": {
+15 -6
View File
@@ -177,7 +177,6 @@
"listSettingsTip": "List settings", "listSettingsTip": "List settings",
"addPlaceholder": "Add a task…", "addPlaceholder": "Add a task…",
"enterKey": "ENTER", "enterKey": "ENTER",
"notesPinnedRow": "Notes (daily notes)",
"clearDayTip": "Clear day", "clearDayTip": "Clear day",
"planMyDayTip": "Plan My Day", "planMyDayTip": "Plan My Day",
"overdue": "OVERDUE", "overdue": "OVERDUE",
@@ -261,7 +260,6 @@
"killSessionTip": "Kill the running session", "killSessionTip": "Kill the running session",
"sendToQueue": "Send to queue", "sendToQueue": "Send to queue",
"sendToQueueTip": "Queue this task so Claude picks it up on the next run", "sendToQueueTip": "Queue this task so Claude picks it up on the next run",
"closeTip": "Close",
"copyTaskIdTip": "Copy task ID", "copyTaskIdTip": "Copy task ID",
"starTip": "Star", "starTip": "Star",
"agentSettingsTip": "Agent settings", "agentSettingsTip": "Agent settings",
@@ -300,8 +298,11 @@
}, },
"notes": { "notes": {
"today": "Today", "today": "Today",
"add": "Add", "yesterday": "Yesterday",
"newNotePlaceholder": "New note…" "newNotePlaceholder": "New note…",
"emptyHint": "Nothing noted yet. Type, hit Enter, done.",
"deleteTip": "Delete note",
"fromAgentTip": "Written by Claude"
}, },
"session": { "session": {
"chipLive": "LIVE", "chipLive": "LIVE",
@@ -320,9 +321,14 @@
"newSession": "New session", "newSession": "New session",
"empty": "No running tasks", "empty": "No running tasks",
"settings": "Settings", "settings": "Settings",
"queue": "Queue", "activity": "Activity",
"blocked": "Blocked", "blocked": "Blocked",
"running": "Running", "running": "Running",
"review": "In review",
"failed": "Failed",
"roadblock": "Roadblock reported",
"openSession": "Open session",
"browseForFolder": "Choose folder…",
"focusMode": "Focus", "focusMode": "Focus",
"overviewMode": "Overview", "overviewMode": "Overview",
"closeSession": "Kill session", "closeSession": "Kill session",
@@ -391,6 +397,9 @@
"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.", "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", "findingsTracked": "Commit the .claudedo findings folder",
"findingsTrackedHint": "Off: the folder stays out of git via .git/info/exclude. On: findings travel with the repo.", "findingsTrackedHint": "Off: the folder stays out of git via .git/info/exclude. On: findings travel with the repo.",
"sectionIcon": "ICON",
"icon": "List icon",
"iconHint": "Shown next to the list name in the sidebar.",
"sectionAgent": "AGENT", "sectionAgent": "AGENT",
"resetAgentSettings": "Reset agent settings", "resetAgentSettings": "Reset agent settings",
"sectionVerify": "VERIFICATION", "sectionVerify": "VERIFICATION",
@@ -756,7 +765,7 @@
"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.", "stopOffline": "Worker offline — can't stop the task.", "stopFailed": "Stop failed: {0}", "enqueueFailed": "Queue failed: {0}", "dequeueFailed": "Remove from queue failed: {0}", "resetAndRetryFailed": "Reset & retry failed: {0}", "toggleDoneFailed": "Couldn't update done state: {0}" }, "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.", "stopOffline": "Worker offline — can't stop the task.", "stopFailed": "Stop failed: {0}", "enqueueFailed": "Queue failed: {0}", "dequeueFailed": "Remove from queue failed: {0}", "resetAndRetryFailed": "Reset & retry failed: {0}", "toggleDoneFailed": "Couldn't update done state: {0}" },
"mergeSection": { "openWorktreeFailed": "Couldn't open worktree: {0}" }, "mergeSection": { "openWorktreeFailed": "Couldn't open worktree: {0}" },
"about": { "openPathFailed": "Couldn't open path: {0}" }, "about": { "openPathFailed": "Couldn't open path: {0}" },
"lists": { "localSuffix": "{0} / local", "smartMyDay": "My Day", "smartImportant": "Important", "smartPlanned": "Planned", "virtualQueue": "Queue", "virtualRunning": "Running", "virtualReview": "Review", "newList": "New list", "findingsNotFound": "No findings yet for this list.", "findingsOpenFailed": "Couldn't open findings folder: {0}", "openInExplorerFailed": "Couldn't open path: {0}" }, "lists": { "localSuffix": "{0} / local", "smartMyDay": "My Day", "smartImportant": "Important", "smartPlanned": "Planned", "smartNotes": "Notes", "virtualQueue": "Queue", "virtualRunning": "Running", "virtualReview": "Review", "newList": "New list", "findingsNotFound": "No findings yet for this list.", "findingsOpenFailed": "Couldn't open findings folder: {0}", "openInExplorerFailed": "Couldn't open path: {0}" },
"repoImport": { "loadFailed": "Couldn't load remembered folders: {0}", "saveFailed": "Couldn't save folders: {0}" } "repoImport": { "loadFailed": "Couldn't load remembered folders: {0}", "saveFailed": "Couldn't save folders: {0}" }
}, },
"ops": { "ops": {
+88 -10
View File
@@ -33,8 +33,9 @@
<!-- All d-strings sourced from icons.jsx --> <!-- All d-strings sourced from icons.jsx -->
<!-- ============================================================ --> <!-- ============================================================ -->
<!-- Icon.Sun --> <!-- Icon.Sun — filled disc + 8 rays. The old stroke-only version rendered as a bare dot in a
<StreamGeometry x:Key="Icon.Sun">M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z M12 3v2M12 19v2M3 12h2M19 12h2M5.5 5.5l1.4 1.4M17.1 17.1l1.4 1.4M5.5 18.5l1.4-1.4M17.1 6.9l1.4-1.4</StreamGeometry> PathIcon (verified by rendering it); rays are kept clear of the disc so EvenOdd is safe. -->
<StreamGeometry x:Key="Icon.Sun">M7.5 12 C7.5 9.51 9.51 7.5 12 7.5 C14.49 7.5 16.5 9.51 16.5 12 C16.5 14.49 14.49 16.5 12 16.5 C9.51 16.5 7.5 14.49 7.5 12 Z M11 2 H13 V5.5 H11 Z M11 18.5 H13 V22 H11 Z M2 11 H5.5 V13 H2 Z M18.5 11 H22 V13 H18.5 Z M4.22 5.64 L5.64 4.22 L8.11 6.69 L6.69 8.11 Z M18.36 4.22 L19.78 5.64 L17.31 8.11 L15.89 6.69 Z M5.64 19.78 L4.22 18.36 L6.69 15.89 L8.11 17.31 Z M19.78 18.36 L18.36 19.78 L15.89 17.31 L17.31 15.89 Z</StreamGeometry>
<!-- Icon.Activity (pulse waveform) — filled outline of the polyline; PathIcon fills, a stroke-only pulse renders as blobs --> <!-- Icon.Activity (pulse waveform) — filled outline of the polyline; PathIcon fills, a stroke-only pulse renders as blobs -->
<StreamGeometry x:Key="Icon.Activity">M3,11 L6.28,11 L9,2.84 L12.86,14.43 L14.46,8.04 L17.41,11 L21,11 L21,13 L16.59,13 L15.54,11.96 L13.14,21.57 L9,9.16 L7.72,13 L3,13 Z</StreamGeometry> <StreamGeometry x:Key="Icon.Activity">M3,11 L6.28,11 L9,2.84 L12.86,14.43 L14.46,8.04 L17.41,11 L21,11 L21,13 L16.59,13 L15.54,11.96 L13.14,21.57 L9,9.16 L7.72,13 L3,13 Z</StreamGeometry>
@@ -42,20 +43,23 @@
<!-- Icon.Star --> <!-- Icon.Star -->
<StreamGeometry x:Key="Icon.Star">M12 3.5l2.6 5.3 5.8.8-4.2 4.1 1 5.8-5.2-2.7-5.2 2.7 1-5.8-4.2-4.1 5.8-.8z</StreamGeometry> <StreamGeometry x:Key="Icon.Star">M12 3.5l2.6 5.3 5.8.8-4.2 4.1 1 5.8-5.2-2.7-5.2 2.7 1-5.8-4.2-4.1 5.8-.8z</StreamGeometry>
<!-- Icon.Calendar --> <!-- Icon.Calendar — solid header band, hollow body, two binder posts and a date block.
<StreamGeometry x:Key="Icon.Calendar">M3.5 5a2 2 0 0 1 2-2h13a2 2 0 0 1 2 2v15a2 2 0 0 1-2 2h-13a2 2 0 0 1-2-2z M3.5 10h17M8 3v4M16 3v4</StreamGeometry> The old stroke version filled into a plain black square. -->
<StreamGeometry x:Key="Icon.Calendar">F0 M3 5 H21 V21 H3 Z M5 11 H19 V19 H5 Z M7 2 H9 V5 H7 Z M15 2 H17 V5 H15 Z M7.5 13 H10 V15.5 H7.5 Z</StreamGeometry>
<!-- Icon.Eye --> <!-- Icon.Eye -->
<StreamGeometry x:Key="Icon.Eye">M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6z</StreamGeometry> <StreamGeometry x:Key="Icon.Eye">M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6z</StreamGeometry>
<!-- Icon.Inbox --> <!-- Icon.Inbox — tray frame with an arrow dropping into the upper compartment.
<StreamGeometry x:Key="Icon.Inbox">M3 13h5l1 2h6l1-2h5M3 13l3-8h12l3 8v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z</StreamGeometry> The old stroke version filled into a solid slab. -->
<StreamGeometry x:Key="Icon.Inbox">F0 M3 5 H21 V19 H3 Z M5 7 H19 V12 H5 Z M5 14 H19 V17 H5 Z M11 7.6 H13 V9.4 H15 L12 12 L9 9.4 H11 Z</StreamGeometry>
<!-- Icon.Folder --> <!-- Icon.Folder -->
<StreamGeometry x:Key="Icon.Folder">M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z</StreamGeometry> <StreamGeometry x:Key="Icon.Folder">M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z</StreamGeometry>
<!-- Icon.Search --> <!-- Icon.Search — hollow ring + handle. The old stroke version filled into a solid disc with
<StreamGeometry x:Key="Icon.Search">M11 4a7 7 0 1 0 0 14A7 7 0 0 0 11 4z M20 20l-3.5-3.5</StreamGeometry> no handle at all (this one is also the magnifier in the lists search box). -->
<StreamGeometry x:Key="Icon.Search">F0 M3 10.5 C3 6.36 6.36 3 10.5 3 C14.64 3 18 6.36 18 10.5 C18 14.64 14.64 18 10.5 18 C6.36 18 3 14.64 3 10.5 Z M5.2 10.5 C5.2 7.57 7.57 5.2 10.5 5.2 C13.43 5.2 15.8 7.57 15.8 10.5 C15.8 13.43 13.43 15.8 10.5 15.8 C7.57 15.8 5.2 13.43 5.2 10.5 Z M14.88 16.58 L16.58 14.88 L22.05 20.35 L20.35 22.05 Z</StreamGeometry>
<!-- Icon.Plus — filled cross (PathIcon fills, so a stroke-only plus renders invisible) --> <!-- Icon.Plus — filled cross (PathIcon fills, so a stroke-only plus renders invisible) -->
<StreamGeometry x:Key="Icon.Plus">M10.6 5 H13.4 V10.6 H19 V13.4 H13.4 V19 H10.6 V13.4 H5 V10.6 H10.6 Z</StreamGeometry> <StreamGeometry x:Key="Icon.Plus">M10.6 5 H13.4 V10.6 H19 V13.4 H13.4 V19 H10.6 V13.4 H5 V10.6 H10.6 Z</StreamGeometry>
@@ -87,8 +91,8 @@
<!-- Icon.Stop — filled square (stop / interrupt) --> <!-- Icon.Stop — filled square (stop / interrupt) -->
<StreamGeometry x:Key="Icon.Stop">M4 4 H20 V20 H4 Z</StreamGeometry> <StreamGeometry x:Key="Icon.Stop">M4 4 H20 V20 H4 Z</StreamGeometry>
<!-- Icon.Check --> <!-- Icon.Check — filled tick. The old stroke version filled into a solid wedge. -->
<StreamGeometry x:Key="Icon.Check">M4 12l5 5 11-11</StreamGeometry> <StreamGeometry x:Key="Icon.Check">M9.6 18.2 L3.4 12 L5.5 9.9 L9.6 14 L18.5 5.1 L20.6 7.2 Z</StreamGeometry>
<!-- Icon.ArrowOut — filled arrow for "open external" button --> <!-- Icon.ArrowOut — filled arrow for "open external" button -->
<StreamGeometry x:Key="Icon.ArrowOut">M13 4 H20 V11 H18 V7.4 L11.4 14 L10 12.6 L16.6 6 H13 Z M4 6 H10 V8 H6 V18 H16 V14 H18 V20 H4 Z</StreamGeometry> <StreamGeometry x:Key="Icon.ArrowOut">M13 4 H20 V11 H18 V7.4 L11.4 14 L10 12.6 L16.6 6 H13 Z M4 6 H10 V8 H6 V18 H16 V14 H18 V20 H4 Z</StreamGeometry>
@@ -115,6 +119,75 @@
<!-- Icon.Skull — filled silhouette: rounded cranium + eye holes (EvenOdd) + jaw --> <!-- Icon.Skull — filled silhouette: rounded cranium + eye holes (EvenOdd) + jaw -->
<StreamGeometry x:Key="Icon.Skull">F0 M12 2 C7 2 4 5.5 4 10 C4 13.5 6 16 8 17.5 L8 19 C8 20 8.9 21 10 21 L10 18.5 L14 18.5 L14 21 C15.1 21 16 20 16 19 L16 17.5 C18 16 20 13.5 20 10 C20 5.5 17 2 12 2 Z M8.5 8 L8.5 12 L11 12 L11 8 Z M13 8 L13 12 L15.5 12 L15.5 8 Z</StreamGeometry> <StreamGeometry x:Key="Icon.Skull">F0 M12 2 C7 2 4 5.5 4 10 C4 13.5 6 16 8 17.5 L8 19 C8 20 8.9 21 10 21 L10 18.5 L14 18.5 L14 21 C15.1 21 16 20 16 19 L16 17.5 C18 16 20 13.5 20 10 C20 5.5 17 2 12 2 Z M8.5 8 L8.5 12 L11 12 L11 8 Z M13 8 L13 12 L15.5 12 L15.5 8 Z</StreamGeometry>
<!-- ============================================================ -->
<!-- List icons — pickable in list settings → Icon tab. -->
<!-- All filled outlines (PathIcon fills; a stroke-only path is -->
<!-- invisible). Holes rely on the EvenOdd default; the two that -->
<!-- want overlapping subpaths to merge declare F1 explicitly. -->
<!-- ============================================================ -->
<!-- Icon.Box — carton: solid lid band, open body, seam -->
<StreamGeometry x:Key="Icon.Box">F0 M3 5 H21 V20 H3 Z M5 11 H19 V18 H5 Z M11 11 H13 V14 H11 Z</StreamGeometry>
<!-- Icon.Truck — box body + cab + two wheels (F1: wheels overlap the body) -->
<StreamGeometry x:Key="Icon.Truck">F1 M2 6 H13.5 V16 H2 Z M13.5 9.5 H17.4 L21 13.1 V16 H13.5 Z M7 15.5 a2.5 2.5 0 1 0 0 5 a2.5 2.5 0 1 0 0-5 Z M17 15.5 a2.5 2.5 0 1 0 0 5 a2.5 2.5 0 1 0 0-5 Z</StreamGeometry>
<!-- Icon.MapPin — location pin with punched-out centre -->
<StreamGeometry x:Key="Icon.MapPin">F0 M12 2 C8.1 2 5 5.1 5 9 C5 14.2 12 22 12 22 C12 22 19 14.2 19 9 C19 5.1 15.9 2 12 2 Z M12 6.5 a2.5 2.5 0 1 0 0 5 a2.5 2.5 0 1 0 0-5 Z</StreamGeometry>
<!-- Icon.Chart — three rising bars -->
<StreamGeometry x:Key="Icon.Chart">M4 13 H8 V20 H4 Z M10 8 H14 V20 H10 Z M16 4 H20 V20 H16 Z</StreamGeometry>
<!-- Icon.Server — two rack units with punched-out status LEDs -->
<StreamGeometry x:Key="Icon.Server">F0 M3 4 H21 V10 H3 Z M3 14 H21 V20 H3 Z M5.5 6 H7.5 V8 H5.5 Z M5.5 16 H7.5 V18 H5.5 Z</StreamGeometry>
<!-- Icon.Chip — CPU die with hollow core and eight pins -->
<StreamGeometry x:Key="Icon.Chip">F0 M7 7 H17 V17 H7 Z M9.5 9.5 H14.5 V14.5 H9.5 Z M9 3 H10.5 V6.5 H9 Z M13.5 3 H15 V6.5 H13.5 Z M9 17.5 H10.5 V21 H9 Z M13.5 17.5 H15 V21 H13.5 Z M3 9 H6.5 V10.5 H3 Z M3 13.5 H6.5 V15 H3 Z M17.5 9 H21 V10.5 H17.5 Z M17.5 13.5 H21 V15 H17.5 Z</StreamGeometry>
<!-- Icon.Bolt — lightning bolt -->
<StreamGeometry x:Key="Icon.Bolt">M13 2 L4 14 H10 L9 22 L20 9 H13.6 Z</StreamGeometry>
<!-- Icon.Rocket — hull with porthole hole + exhaust -->
<StreamGeometry x:Key="Icon.Rocket">F0 M12 2 C15.5 5 17.5 9.5 17.5 14 V17 L14.5 19 H9.5 L6.5 17 V14 C6.5 9.5 8.5 5 12 2 Z M12 7.5 a2.2 2.2 0 1 0 0 4.4 a2.2 2.2 0 1 0 0-4.4 Z M9.5 19.5 H14.5 L13 22.5 H11 Z</StreamGeometry>
<!-- Icon.Bulb — bulb over two screw-base bands -->
<StreamGeometry x:Key="Icon.Bulb">F0 M12 2 C8.1 2 5 5.1 5 9 C5 11.6 6.4 13.9 8.5 15.1 V17 H15.5 V15.1 C17.6 13.9 19 11.6 19 9 C19 5.1 15.9 2 12 2 Z M8.5 18.2 H15.5 V19.8 H8.5 Z M9.8 20.9 H14.2 V22.3 H9.8 Z</StreamGeometry>
<!-- Icon.Flask — Erlenmeyer flask, single closed outline -->
<StreamGeometry x:Key="Icon.Flask">M9 2 H15 V4 H14 V9.3 L19.4 19.4 A1.5 1.5 0 0 1 18.1 21.6 H5.9 A1.5 1.5 0 0 1 4.6 19.4 L10 9.3 V4 H9 Z</StreamGeometry>
<!-- Icon.Shield — shield with a punched-out check -->
<StreamGeometry x:Key="Icon.Shield">F0 M12 2 L4 5 V11 C4 16 7.4 20.3 12 21.5 C16.6 20.3 20 16 20 11 V5 Z M11 16.2 L7.2 12.4 L8.9 10.7 L11 12.8 L15.4 8.4 L17.1 10.1 Z</StreamGeometry>
<!-- Icon.Camera — body with viewfinder bump and punched-out lens -->
<StreamGeometry x:Key="Icon.Camera">F0 M9 3 H15 L16.5 5.5 H20 A2 2 0 0 1 22 7.5 V19 A2 2 0 0 1 20 21 H4 A2 2 0 0 1 2 19 V7.5 A2 2 0 0 1 4 5.5 H7.5 Z M12 9 a4.5 4.5 0 1 0 0 9 a4.5 4.5 0 1 0 0-9 Z</StreamGeometry>
<!-- Icon.Image — frame with mountains and sun inside the cut-out -->
<StreamGeometry x:Key="Icon.Image">F0 M3 4 H21 V20 H3 Z M5 6 H19 V18 H5 Z M6.5 16.5 L10.5 11 L13.5 14.5 L15.5 12.5 L17.5 16.5 Z M8 8 a1.6 1.6 0 1 0 0 3.2 a1.6 1.6 0 1 0 0-3.2 Z</StreamGeometry>
<!-- Icon.Barcode — six bars of varying width -->
<StreamGeometry x:Key="Icon.Barcode">M3 4 H5 V20 H3 Z M6.5 4 H7.5 V20 H6.5 Z M9 4 H11.5 V20 H9 Z M13 4 H14 V20 H13 Z M15.5 4 H17.5 V20 H15.5 Z M19 4 H21 V20 H19 Z</StreamGeometry>
<!-- Icon.Mail — envelope: frame cut out, flap drawn back inside it -->
<StreamGeometry x:Key="Icon.Mail">F0 M2 5 H22 V19 H2 Z M4 7 H20 V17 H4 Z M4 7 L12 13.5 L20 7 L20 8.7 L12 15.2 L4 8.7 Z</StreamGeometry>
<!-- Icon.Terminal — console frame with prompt chevron and caret -->
<StreamGeometry x:Key="Icon.Terminal">F0 M2 4 H22 V20 H2 Z M4 6 H20 V18 H4 Z M6 8.4 L7.4 7 L11.4 11 L7.4 15 L6 13.6 L8.6 11 Z M12.5 13.4 H17 V15.2 H12.5 Z</StreamGeometry>
<!-- Icon.Code — angle brackets around a slash -->
<StreamGeometry x:Key="Icon.Code">M8.6 5.4 L10 6.8 L5.8 11 L10 15.2 L8.6 16.6 L3 11 Z M15.4 5.4 L21 11 L15.4 16.6 L14 15.2 L18.2 11 L14 6.8 Z M13.6 3.4 L15.4 3.9 L10.4 20.6 L8.6 20.1 Z</StreamGeometry>
<!-- Icon.Database — cylinder with the top ellipse punched out. Béziers, not arcs: an arc
spanning exactly its own diameter degenerates to a straight line here (measured). -->
<StreamGeometry x:Key="Icon.Database">F0 M5.3 5.2 C5.3 3.43 8.3 2 12 2 C15.7 2 18.7 3.43 18.7 5.2 V18.8 C18.7 20.57 15.7 22 12 22 C8.3 22 5.3 20.57 5.3 18.8 Z M5.3 5.2 C5.3 3.43 8.3 2 12 2 C15.7 2 18.7 3.43 18.7 5.2 C18.7 6.97 15.7 8.4 12 8.4 C8.3 8.4 5.3 6.97 5.3 5.2 Z</StreamGeometry>
<!-- Icon.Wand — wand shaft plus three sparkles (kept clear of the shaft) -->
<StreamGeometry x:Key="Icon.Wand">M3.3 19.3 L13.3 9.3 L14.7 10.7 L4.7 20.7 Z M18 2.5 L19.1 5.9 L22.5 7 L19.1 8.1 L18 11.5 L16.9 8.1 L13.5 7 L16.9 5.9 Z M6 3 L6.7 4.8 L8.5 5.5 L6.7 6.2 L6 8 L5.3 6.2 L3.5 5.5 L5.3 4.8 Z M19.5 15 L20.2 16.8 L22 17.5 L20.2 18.2 L19.5 20 L18.8 18.2 L17 17.5 L18.8 16.8 Z</StreamGeometry>
<!-- Icon.Clock — ring with hands; the hands only touch at x=12.8, never overlap (EvenOdd).
Béziers, not arcs: see Icon.Database. -->
<StreamGeometry x:Key="Icon.Clock">F0 M2 12 C2 6.48 6.48 2 12 2 C17.52 2 22 6.48 22 12 C22 17.52 17.52 22 12 22 C6.48 22 2 17.52 2 12 Z M4 12 C4 7.58 7.58 4 12 4 C16.42 4 20 7.58 20 12 C20 16.42 16.42 20 12 20 C7.58 20 4 16.42 4 12 Z M11.2 6.5 H12.8 V12.8 H11.2 Z M12.8 11.2 H16.5 V12.8 H12.8 Z</StreamGeometry>
<!-- Badge brushes --> <!-- Badge brushes -->
<SolidColorBrush x:Key="DraftBadgeBrush" Color="{StaticResource TextMuteColor}"/> <SolidColorBrush x:Key="DraftBadgeBrush" Color="{StaticResource TextMuteColor}"/>
<SolidColorBrush x:Key="PlanningBadgeBrush" Color="{StaticResource PeatColor}"/> <SolidColorBrush x:Key="PlanningBadgeBrush" Color="{StaticResource PeatColor}"/>
@@ -789,6 +862,11 @@
<Setter Property="Background" Value="{StaticResource AccentSoftBrush}" /> <Setter Property="Background" Value="{StaticResource AccentSoftBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" /> <Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
</Style> </Style>
<!-- Resting icon colour. Must live here, not inline on the PathIcon: a local value outranks
every style, so an inline Foreground makes the .active accent below unreachable. -->
<Style Selector="Border.list-item PathIcon.list-icon">
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
</Style>
<!-- Active item text / icon colors --> <!-- Active item text / icon colors -->
<Style Selector="Border.list-item.active TextBlock.list-label"> <Style Selector="Border.list-item.active TextBlock.list-label">
<Setter Property="Foreground" Value="{StaticResource TextBrush}" /> <Setter Property="Foreground" Value="{StaticResource TextBrush}" />
@@ -5,6 +5,8 @@ namespace ClaudeDo.Ui.Services.Interfaces;
public interface INotesApi public interface INotesApi
{ {
Task<List<DailyNoteDto>> ListAsync(DateOnly day); Task<List<DailyNoteDto>> ListAsync(DateOnly day);
/// <summary>Both ends inclusive — today plus its history in one call.</summary>
Task<List<DailyNoteDto>> ListBetweenAsync(DateOnly start, DateOnly end);
Task<DailyNoteDto?> AddAsync(DateOnly day, string text); Task<DailyNoteDto?> AddAsync(DateOnly day, string text);
Task UpdateAsync(string id, string text); Task UpdateAsync(string id, string text);
Task DeleteAsync(string id); Task DeleteAsync(string id);
@@ -30,6 +30,9 @@ public interface IWorkerClient : INotifyPropertyChanged
/// task to carry out nextPhase ("wait" | "merge" | "wait_final" | "merge_final").</summary> /// task to carry out nextPhase ("wait" | "merge" | "wait_final" | "merge_final").</summary>
event Action<string, IReadOnlyList<string>, string>? HandoffRequestedEvent; event Action<string, IReadOnlyList<string>, string>? HandoffRequestedEvent;
/// <summary>An agent added a note through the MCP tool — an open notes editor reloads.</summary>
event Action? NotesUpdatedEvent;
event Action? PrepStartedEvent; event Action? PrepStartedEvent;
event Action<string>? PrepLineEvent; event Action<string>? PrepLineEvent;
event Action<bool>? PrepFinishedEvent; event Action<bool>? PrepFinishedEvent;
@@ -172,6 +175,8 @@ public interface IWorkerClient : INotifyPropertyChanged
Task<AppSettingsDto?> GetAppSettingsAsync(); Task<AppSettingsDto?> GetAppSettingsAsync();
Task UpdateAppSettingsAsync(AppSettingsDto dto); Task UpdateAppSettingsAsync(AppSettingsDto dto);
Task<List<DailyNoteDto>> GetDailyNotesAsync(DateOnly day); Task<List<DailyNoteDto>> GetDailyNotesAsync(DateOnly day);
/// <summary>Both ends inclusive.</summary>
Task<List<DailyNoteDto>> GetDailyNotesBetweenAsync(DateOnly start, DateOnly end);
Task<DailyNoteDto?> AddDailyNoteAsync(DateOnly day, string text); Task<DailyNoteDto?> AddDailyNoteAsync(DateOnly day, string text);
Task UpdateDailyNoteAsync(string id, string text); Task UpdateDailyNoteAsync(string id, string text);
Task DeleteDailyNoteAsync(string id); Task DeleteDailyNoteAsync(string id);
+1 -1
View File
@@ -1,3 +1,3 @@
namespace ClaudeDo.Ui.Services; namespace ClaudeDo.Ui.Services;
public sealed record DailyNoteDto(string Id, string Date, string Text, int SortOrder); public sealed record DailyNoteDto(string Id, string Date, string Text, int SortOrder, bool FromAgent);
+5
View File
@@ -54,6 +54,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public event Action<string>? ListUpdatedEvent; public event Action<string>? ListUpdatedEvent;
public event Action<WorkerLogEntry>? WorkerLogReceivedEvent; public event Action<WorkerLogEntry>? WorkerLogReceivedEvent;
public event Action? NotesUpdatedEvent;
public event Action? PrepStartedEvent; public event Action? PrepStartedEvent;
public event Action<string>? PrepLineEvent; public event Action<string>? PrepLineEvent;
public event Action<bool>? PrepFinishedEvent; public event Action<bool>? PrepFinishedEvent;
@@ -213,6 +214,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
Dispatcher.UIThread.Post(() => PrimeFired?.Invoke(new PrimeFiredEvent(id, ok, msg, when))); Dispatcher.UIThread.Post(() => PrimeFired?.Invoke(new PrimeFiredEvent(id, ok, msg, when)));
}); });
_hub.On("NotesUpdated", () => Dispatcher.UIThread.Post(() => NotesUpdatedEvent?.Invoke()));
_hub.On("PrepStarted", () => Dispatcher.UIThread.Post(() => PrepStartedEvent?.Invoke())); _hub.On("PrepStarted", () => Dispatcher.UIThread.Post(() => PrepStartedEvent?.Invoke()));
_hub.On<string>("PrepLine", line => Dispatcher.UIThread.Post(() => PrepLineEvent?.Invoke(line))); _hub.On<string>("PrepLine", line => Dispatcher.UIThread.Post(() => PrepLineEvent?.Invoke(line)));
_hub.On<bool>("PrepFinished", ok => Dispatcher.UIThread.Post(() => PrepFinishedEvent?.Invoke(ok))); _hub.On<bool>("PrepFinished", ok => Dispatcher.UIThread.Post(() => PrepFinishedEvent?.Invoke(ok)));
@@ -475,6 +477,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task<List<DailyNoteDto>> GetDailyNotesAsync(DateOnly day) public async Task<List<DailyNoteDto>> GetDailyNotesAsync(DateOnly day)
=> await TryInvokeAsync<List<DailyNoteDto>>("GetDailyNotes", IsoDay(day)) ?? new List<DailyNoteDto>(); => await TryInvokeAsync<List<DailyNoteDto>>("GetDailyNotes", IsoDay(day)) ?? new List<DailyNoteDto>();
public async Task<List<DailyNoteDto>> GetDailyNotesBetweenAsync(DateOnly start, DateOnly end)
=> await TryInvokeAsync<List<DailyNoteDto>>("GetDailyNotesBetween", IsoDay(start), IsoDay(end)) ?? new List<DailyNoteDto>();
public async Task<DailyNoteDto?> AddDailyNoteAsync(DateOnly day, string text) public async Task<DailyNoteDto?> AddDailyNoteAsync(DateOnly day, string text)
=> await InvokeTimedAsync<DailyNoteDto>("AddDailyNote", () => _hub.InvokeAsync<DailyNoteDto>("AddDailyNote", IsoDay(day), text)); => await InvokeTimedAsync<DailyNoteDto>("AddDailyNote", () => _hub.InvokeAsync<DailyNoteDto>("AddDailyNote", IsoDay(day), text));
@@ -7,6 +7,7 @@ public sealed class WorkerNotesApi : INotesApi
private readonly IWorkerClient _client; private readonly IWorkerClient _client;
public WorkerNotesApi(IWorkerClient client) => _client = client; public WorkerNotesApi(IWorkerClient client) => _client = client;
public Task<List<DailyNoteDto>> ListAsync(DateOnly day) => _client.GetDailyNotesAsync(day); public Task<List<DailyNoteDto>> ListAsync(DateOnly day) => _client.GetDailyNotesAsync(day);
public Task<List<DailyNoteDto>> ListBetweenAsync(DateOnly start, DateOnly end) => _client.GetDailyNotesBetweenAsync(start, end);
public Task<DailyNoteDto?> AddAsync(DateOnly day, string text) => _client.AddDailyNoteAsync(day, text); public Task<DailyNoteDto?> AddAsync(DateOnly day, string text) => _client.AddDailyNoteAsync(day, text);
public Task UpdateAsync(string id, string text) => _client.UpdateDailyNoteAsync(id, text); public Task UpdateAsync(string id, string text) => _client.UpdateDailyNoteAsync(id, text);
public Task DeleteAsync(string id) => _client.DeleteDailyNoteAsync(id); public Task DeleteAsync(string id) => _client.DeleteDailyNoteAsync(id);
@@ -20,7 +20,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory; private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly IWorkerClient _worker; private readonly IWorkerClient _worker;
private readonly IServiceProvider _services; private readonly IServiceProvider _services;
private readonly INotesApi _notesApi;
private readonly MergeCoordinator _merge; private readonly MergeCoordinator _merge;
// ── Section view models ─────────────────────────────────────────────────── // ── Section view models ───────────────────────────────────────────────────
@@ -39,29 +38,20 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
private readonly Action<string, string?> _workerPlanningMergeAbortedHandler; private readonly Action<string, string?> _workerPlanningMergeAbortedHandler;
private readonly Action<string> _workerPlanningCompletedHandler; private readonly Action<string> _workerPlanningCompletedHandler;
[ObservableProperty] private bool _isNotesMode;
[ObservableProperty] private bool _isPrepMode; [ObservableProperty] private bool _isPrepMode;
public bool IsTaskDetailVisible => !IsNotesMode && !IsPrepMode; public bool IsTaskDetailVisible => !IsPrepMode;
/// <summary>Centered placeholder shown when no task is selected — hidden in Notes/Prep mode, /// <summary>Centered placeholder shown when no task is selected — hidden in Prep mode,
/// where <see cref="IsTaskDetailVisible"/> is already false.</summary> /// where <see cref="IsTaskDetailVisible"/> is already false.</summary>
public bool IsEmptyStateVisible => Task is null && IsTaskDetailVisible; public bool IsEmptyStateVisible => Task is null && IsTaskDetailVisible;
partial void OnIsNotesModeChanged(bool value)
{
OnPropertyChanged(nameof(IsTaskDetailVisible));
OnPropertyChanged(nameof(IsEmptyStateVisible));
}
partial void OnIsPrepModeChanged(bool value) partial void OnIsPrepModeChanged(bool value)
{ {
OnPropertyChanged(nameof(IsTaskDetailVisible)); OnPropertyChanged(nameof(IsTaskDetailVisible));
OnPropertyChanged(nameof(IsEmptyStateVisible)); OnPropertyChanged(nameof(IsEmptyStateVisible));
} }
public NotesEditorViewModel Notes { get; private set; } = null!;
// Current task row (set by IslandsShellViewModel via Bind) // Current task row (set by IslandsShellViewModel via Bind)
[ObservableProperty] [ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(EnqueueCommand))] [NotifyCanExecuteChangedFor(nameof(EnqueueCommand))]
@@ -295,7 +285,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
private bool _suppressTitleSave; private bool _suppressTitleSave;
private CancellationTokenSource? _titleSaveCts; private CancellationTokenSource? _titleSaveCts;
// Set by shell so CloseDetailCommand can clear SelectedTask // Set by shell so deleting a task can clear SelectedTask
public Action? CloseDetail { get; set; } public Action? CloseDetail { get; set; }
// Set by shell so DeleteTaskCommand can remove from list // Set by shell so DeleteTaskCommand can remove from list
@@ -324,13 +314,11 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
IDbContextFactory<ClaudeDoDbContext> dbFactory, IDbContextFactory<ClaudeDoDbContext> dbFactory,
IWorkerClient worker, IWorkerClient worker,
IServiceProvider services, IServiceProvider services,
INotesApi notesApi,
MergeCoordinator merge) MergeCoordinator merge)
{ {
_dbFactory = dbFactory; _dbFactory = dbFactory;
_worker = worker; _worker = worker;
_services = services; _services = services;
_notesApi = notesApi;
_merge = merge; _merge = merge;
Monitor = new TaskMonitorViewModel(dbFactory, worker); Monitor = new TaskMonitorViewModel(dbFactory, worker);
@@ -374,8 +362,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
if (e.PropertyName == nameof(OperationStatus.IsRunning)) ParkReviewCommand.NotifyCanExecuteChanged(); 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(); Subtasks.CollectionChanged += (_, _) => NotifyStepsChanged();
Subtasks.CollectionChanged += (_, _) => Merge.SyncChildOutcomes(HasChildOutcomes, Subtasks.Count); Subtasks.CollectionChanged += (_, _) => Merge.SyncChildOutcomes(HasChildOutcomes, Subtasks.Count);
Attachments.CollectionChanged += (_, _) => OnPropertyChanged(nameof(FilesBadge)); Attachments.CollectionChanged += (_, _) => OnPropertyChanged(nameof(FilesBadge));
@@ -592,18 +578,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
catch { } catch { }
} }
public void ShowNotes()
{
Bind(null, "notes");
IsPrepMode = false;
IsNotesMode = true;
_ = Notes.LoadDayAsync(DateOnly.FromDateTime(DateTime.Today));
}
public void ShowPrep() public void ShowPrep()
{ {
Bind(null, "prep"); Bind(null, "prep");
IsNotesMode = false;
IsPrepMode = true; IsPrepMode = true;
_ = Prep.LoadLastPrepLogIfEmptyAsync(); _ = Prep.LoadLastPrepLogIfEmptyAsync();
} }
@@ -612,7 +589,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
/// logged with the bind timing so rebind churn can be attributed to a trigger.</param> /// logged with the bind timing so rebind churn can be attributed to a trigger.</param>
public void Bind(TaskRowViewModel? row, string source = "?") public void Bind(TaskRowViewModel? row, string source = "?")
{ {
IsNotesMode = false;
IsPrepMode = false; IsPrepMode = false;
_loadCts?.Cancel(); _loadCts?.Cancel();
_loadCts?.Dispose(); _loadCts?.Dispose();
@@ -1008,9 +984,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
} }
} }
[RelayCommand]
private void CloseDetails() => CloseDetail?.Invoke();
[RelayCommand] [RelayCommand]
private async System.Threading.Tasks.Task ToggleStarAsync() private async System.Threading.Tasks.Task ToggleStarAsync()
{ {
@@ -24,6 +24,6 @@ public sealed partial class ListNavItemViewModel : ViewModelBase
// Set while a dragged task hovers over this row as a move target — distinct from // Set while a dragged task hovers over this row as a move target — distinct from
// DropHintAbove/Below, which mark an insertion point for list reordering. // DropHintAbove/Below, which mark an insertion point for list reordering.
[ObservableProperty] private bool _isTaskDropTarget; [ObservableProperty] private bool _isTaskDropTarget;
public string? IconKey { get; init; } // Settable (not init) so a list-settings icon change refreshes the row in place.
public string? DotColorKey { get; init; } [ObservableProperty] private string? _iconKey;
} }
@@ -22,6 +22,10 @@ public sealed record MergeHelperRequest(string ListId, IReadOnlyList<string> Tas
public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
{ {
/// <summary>The day-log list. A smart list with no tasks in it: selecting it makes the tasks
/// island show the notes editor instead of rows.</summary>
public const string NotesListId = "smart:notes";
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory; private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly IServiceProvider? _services; private readonly IServiceProvider? _services;
private readonly IWorkerClient? _worker; private readonly IWorkerClient? _worker;
@@ -54,7 +58,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
if (row is null || Dialogs is null || _services is null) return; 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 rawId = row.Id.StartsWith("user:", StringComparison.Ordinal) ? row.Id["user:".Length..] : row.Id;
var vm = _services.GetRequiredService<ListSettingsModalViewModel>(); var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
await vm.LoadAsync(rawId, row.Name, row.WorkingDir, row.DefaultCommitType, row.IsManual, row.FindingsTracked); await vm.LoadAsync(rawId, row.Name, row.WorkingDir, row.DefaultCommitType, row.IsManual, row.FindingsTracked, row.IconKey);
await Dialogs.ShowListSettingsAsync(vm); await Dialogs.ShowListSettingsAsync(vm);
if (vm.Deleted) await LoadAsync(); if (vm.Deleted) await LoadAsync();
else await RefreshRowAsync(row.Id); else await RefreshRowAsync(row.Id);
@@ -255,6 +259,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
"smart:my-day" => "vm.lists.smartMyDay", "smart:my-day" => "vm.lists.smartMyDay",
"smart:important" => "vm.lists.smartImportant", "smart:important" => "vm.lists.smartImportant",
"smart:planned" => "vm.lists.smartPlanned", "smart:planned" => "vm.lists.smartPlanned",
NotesListId => "vm.lists.smartNotes",
"virtual:queued" => "vm.lists.virtualQueue", "virtual:queued" => "vm.lists.virtualQueue",
"virtual:running" => "vm.lists.virtualRunning", "virtual:running" => "vm.lists.virtualRunning",
"virtual:review" => "vm.lists.virtualReview", "virtual:review" => "vm.lists.virtualReview",
@@ -279,6 +284,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
new ListNavItemViewModel { Id = "smart:my-day", Name = Loc.T("vm.lists.smartMyDay"), Kind = ListKind.Smart, IconKey = "Sun" }, new ListNavItemViewModel { Id = "smart:my-day", Name = Loc.T("vm.lists.smartMyDay"), Kind = ListKind.Smart, IconKey = "Sun" },
new ListNavItemViewModel { Id = "smart:important", Name = Loc.T("vm.lists.smartImportant"), Kind = ListKind.Smart, IconKey = "Star" }, new ListNavItemViewModel { Id = "smart:important", Name = Loc.T("vm.lists.smartImportant"), Kind = ListKind.Smart, IconKey = "Star" },
new ListNavItemViewModel { Id = "smart:planned", Name = Loc.T("vm.lists.smartPlanned"), Kind = ListKind.Smart, IconKey = "Calendar" }, new ListNavItemViewModel { Id = "smart:planned", Name = Loc.T("vm.lists.smartPlanned"), Kind = ListKind.Smart, IconKey = "Calendar" },
new ListNavItemViewModel { Id = NotesListId, Name = Loc.T("vm.lists.smartNotes"), Kind = ListKind.Smart, IconKey = "Text" },
new ListNavItemViewModel { Id = "virtual:queued", Name = Loc.T("vm.lists.virtualQueue"), Kind = ListKind.Virtual, IconKey = "Inbox" }, new ListNavItemViewModel { Id = "virtual:queued", Name = Loc.T("vm.lists.virtualQueue"), Kind = ListKind.Virtual, IconKey = "Inbox" },
new ListNavItemViewModel { Id = "virtual:running", Name = Loc.T("vm.lists.virtualRunning"), Kind = ListKind.Virtual, IconKey = "Activity" }, new ListNavItemViewModel { Id = "virtual:running", Name = Loc.T("vm.lists.virtualRunning"), Kind = ListKind.Virtual, IconKey = "Activity" },
new ListNavItemViewModel { Id = "virtual:review", Name = Loc.T("vm.lists.virtualReview"), Kind = ListKind.Virtual, IconKey = "Eye" }, new ListNavItemViewModel { Id = "virtual:review", Name = Loc.T("vm.lists.virtualReview"), Kind = ListKind.Virtual, IconKey = "Eye" },
@@ -293,8 +299,6 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
await using var ctx = await _dbFactory.CreateDbContextAsync(ct); await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var lists = new ListRepository(ctx); var lists = new ListRepository(ctx);
var seedNames = new HashSet<string>(new[] { "My Day", "Important", "Planned" }); 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)) foreach (var l in await lists.GetAllAsync(ct))
{ {
if (seedNames.Contains(l.Name)) continue; if (seedNames.Contains(l.Name)) continue;
@@ -304,8 +308,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
Id = $"user:{l.Id}", Id = $"user:{l.Id}",
Name = l.Name, Name = l.Name,
Kind = ListKind.User, Kind = ListKind.User,
IconKey = "Folder", IconKey = l.Icon ?? "Folder",
DotColorKey = dotColors[idx % dotColors.Length],
WorkingDir = l.WorkingDir, WorkingDir = l.WorkingDir,
DefaultCommitType = l.DefaultCommitType, DefaultCommitType = l.DefaultCommitType,
IsManual = l.IsManual, IsManual = l.IsManual,
@@ -314,7 +317,6 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
}; };
Items.Add(item); Items.Add(item);
UserLists.Add(item); UserLists.Add(item);
idx++;
} }
ok = true; ok = true;
} }
@@ -395,7 +397,6 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
Name = entity.Name, Name = entity.Name,
Kind = ListKind.User, Kind = ListKind.User,
IconKey = "Folder", IconKey = "Folder",
DotColorKey = "Moss",
WorkingDir = entity.WorkingDir, WorkingDir = entity.WorkingDir,
DefaultCommitType = entity.DefaultCommitType, DefaultCommitType = entity.DefaultCommitType,
}; };
@@ -407,7 +408,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
if (Dialogs is not null && _services is not null) if (Dialogs is not null && _services is not null)
{ {
var vm = _services.GetRequiredService<ListSettingsModalViewModel>(); var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
await vm.LoadAsync(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType, entity.IsManual, entity.FindingsTracked); await vm.LoadAsync(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType, entity.IsManual, entity.FindingsTracked, entity.Icon);
await Dialogs.ShowListSettingsAsync(vm); await Dialogs.ShowListSettingsAsync(vm);
if (vm.Deleted) await LoadAsync(); if (vm.Deleted) await LoadAsync();
else await RefreshRowAsync(item.Id); else await RefreshRowAsync(item.Id);
@@ -507,6 +508,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
row.DefaultCommitType = entity.DefaultCommitType; row.DefaultCommitType = entity.DefaultCommitType;
row.IsManual = entity.IsManual; row.IsManual = entity.IsManual;
row.FindingsTracked = entity.FindingsTracked; row.FindingsTracked = entity.FindingsTracked;
row.IconKey = entity.Icon ?? "Folder";
row.HasTicketProject = hasTicketProject; row.HasTicketProject = hasTicketProject;
RecomputeHasNoLinkedRepo(); RecomputeHasNoLinkedRepo();
} }
@@ -547,14 +549,12 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
/// </summary> /// </summary>
private async Task AddRowAsync(ListRepository lists, ListEntity entity, bool hasTicketProject = false) private async Task AddRowAsync(ListRepository lists, ListEntity entity, bool hasTicketProject = false)
{ {
var dotColors = new[] { "Moss", "Peat", "Sage" };
var item = new ListNavItemViewModel var item = new ListNavItemViewModel
{ {
Id = $"user:{entity.Id}", Id = $"user:{entity.Id}",
Name = entity.Name, Name = entity.Name,
Kind = ListKind.User, Kind = ListKind.User,
IconKey = "Folder", IconKey = entity.Icon ?? "Folder",
DotColorKey = dotColors[UserLists.Count % dotColors.Length],
WorkingDir = entity.WorkingDir, WorkingDir = entity.WorkingDir,
DefaultCommitType = entity.DefaultCommitType, DefaultCommitType = entity.DefaultCommitType,
IsManual = entity.IsManual, IsManual = entity.IsManual,
@@ -1,25 +1,57 @@
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.Services.Interfaces; using ClaudeDo.Ui.Services.Interfaces;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
namespace ClaudeDo.Ui.ViewModels.Islands; namespace ClaudeDo.Ui.ViewModels.Islands;
/// <summary>Date separator between the note groups. Rows carry these inline (like the task list's
/// HeaderRow) so one ItemsControl renders today and the history without a nested control per day.</summary>
public sealed class NoteDayHeaderViewModel
{
public DateOnly Day { get; }
public string Label { get; }
public bool IsToday { get; }
public NoteDayHeaderViewModel(DateOnly day, DateOnly today)
{
Day = day;
IsToday = day == today;
Label = day == today ? Loc.T("notes.today")
: day == today.AddDays(-1) ? Loc.T("notes.yesterday")
: day.ToString("dddd, dd.MM.");
}
}
public sealed partial class NoteBulletViewModel : ViewModelBase public sealed partial class NoteBulletViewModel : ViewModelBase
{ {
public string Id { get; } /// <summary>Empty until the note is persisted — a row the user started but hasn't typed into
/// yet exists only in the list, so an abandoned Enter leaves nothing behind in the database.</summary>
public string Id { get; set; }
public DateOnly Day { get; }
public bool FromAgent { get; }
public bool IsNew => Id.Length == 0;
[ObservableProperty] private string _text; [ObservableProperty] private string _text;
public NoteBulletViewModel(string id, string text) public NoteBulletViewModel(string id, DateOnly day, string text, bool fromAgent = false)
{ {
Id = id; Id = id;
Day = day;
_text = text; _text = text;
FromAgent = fromAgent;
} }
} }
public sealed partial class NotesEditorViewModel : ViewModelBase public sealed partial class NotesEditorViewModel : ViewModelBase
{ {
/// <summary>How far the history scrolls back. Older notes stay in the database and in the week
/// report; they are simply not worth the round trip in a day log.</summary>
public const int HistoryDays = 14;
private readonly INotesApi _api; private readonly INotesApi _api;
public NotesEditorViewModel(INotesApi api) => _api = api; public NotesEditorViewModel(INotesApi api) => _api = api;
@@ -27,32 +59,58 @@ public sealed partial class NotesEditorViewModel : ViewModelBase
// Raised when a worker call fails so the host VM can surface it (e.g. via ShowErrorAsync). // Raised when a worker call fails so the host VM can surface it (e.g. via ShowErrorAsync).
public event Action<string>? ErrorReported; public event Action<string>? ErrorReported;
public ObservableCollection<NoteBulletViewModel> Bullets { get; } = new(); /// <summary>Raised after a row is inserted so the view can move the caret into it.</summary>
public event Action<NoteBulletViewModel>? FocusRequested;
/// <summary>NoteDayHeaderViewModel | NoteBulletViewModel, newest day first.</summary>
public ObservableCollection<object> Rows { get; } = new();
[ObservableProperty] private DateOnly _currentDay = DateOnly.FromDateTime(DateTime.Today);
[ObservableProperty] private string _newBulletText = ""; [ObservableProperty] private string _newBulletText = "";
[ObservableProperty] private bool _isEmpty = true;
public DateTime CurrentDate public DateOnly Today { get; private set; } = DateOnly.FromDateTime(DateTime.Today);
/// <summary>Loads today plus <see cref="HistoryDays"/> days of history.</summary>
public async Task LoadAsync()
{ {
get => CurrentDay.ToDateTime(TimeOnly.MinValue); Today = DateOnly.FromDateTime(DateTime.Today);
set { var d = DateOnly.FromDateTime(value); if (d != CurrentDay) _ = LoadDayAsync(d); } List<DailyNoteDto> notes;
try
{
notes = await _api.ListBetweenAsync(Today.AddDays(-HistoryDays), Today);
}
catch (Exception ex)
{
ErrorReported?.Invoke(ex.Message);
return;
}
Rows.Clear();
// Today always gets a header, even with nothing under it — it is where typing lands.
Rows.Add(new NoteDayHeaderViewModel(Today, Today));
foreach (var n in notes.Where(n => Day(n) == Today).OrderBy(n => n.SortOrder))
Rows.Add(Bullet(n));
foreach (var group in notes.Where(n => Day(n) != Today)
.GroupBy(Day).OrderByDescending(g => g.Key))
{
Rows.Add(new NoteDayHeaderViewModel(group.Key, Today));
foreach (var n in group.OrderBy(n => n.SortOrder))
Rows.Add(Bullet(n));
}
RefreshEmpty();
} }
public string CurrentDayLabel => CurrentDay.ToString("dddd, dd.MM.yyyy"); private static DateOnly Day(DailyNoteDto n) => DateOnly.Parse(n.Date);
public async Task LoadDayAsync(DateOnly day) private static NoteBulletViewModel Bullet(DailyNoteDto n) =>
{ new(n.Id, Day(n), n.Text, n.FromAgent);
CurrentDay = day;
OnPropertyChanged(nameof(CurrentDate));
OnPropertyChanged(nameof(CurrentDayLabel));
Bullets.Clear();
foreach (var dto in await _api.ListAsync(day))
Bullets.Add(MakeBullet(dto.Id, dto.Text));
}
private NoteBulletViewModel MakeBullet(string id, string text) => private void RefreshEmpty() => IsEmpty = !Rows.OfType<NoteBulletViewModel>().Any();
new(id, text);
/// <summary>The box above the list: Enter files the note under today and keeps the box focused,
/// so a burst of notes is one uninterrupted stream of typing.</summary>
[RelayCommand] [RelayCommand]
private async Task AddBullet() private async Task AddBullet()
{ {
@@ -60,9 +118,10 @@ public sealed partial class NotesEditorViewModel : ViewModelBase
if (text.Length == 0) return; if (text.Length == 0) return;
try try
{ {
var dto = await _api.AddAsync(CurrentDay, text); var dto = await _api.AddAsync(Today, text);
if (dto is not null) Bullets.Add(MakeBullet(dto.Id, dto.Text)); if (dto is not null) Rows.Insert(1, Bullet(dto));
NewBulletText = ""; NewBulletText = "";
RefreshEmpty();
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -70,21 +129,68 @@ public sealed partial class NotesEditorViewModel : ViewModelBase
} }
} }
[RelayCommand] private Task PrevDay() => LoadDayAsync(CurrentDay.AddDays(-1)); /// <summary>Enter inside a note: save it, then open an empty row underneath and focus it.</summary>
[RelayCommand] private Task NextDay() => LoadDayAsync(CurrentDay.AddDays(1)); [RelayCommand]
[RelayCommand] private Task Today() => LoadDayAsync(DateOnly.FromDateTime(DateTime.Today)); private async Task SplitBullet(NoteBulletViewModel? b)
{
if (b is null) return;
await CommitBullet(b);
if (!Rows.Contains(b)) return; // committing an emptied note removed it
var fresh = new NoteBulletViewModel("", b.Day, "");
Rows.Insert(Rows.IndexOf(b) + 1, fresh);
FocusRequested?.Invoke(fresh);
}
/// <summary>Backspace in an already-empty note: drop it and put the caret in the one above.</summary>
[RelayCommand]
private async Task DeleteBullet(NoteBulletViewModel? b)
{
if (b is null) return;
var index = Rows.IndexOf(b);
await RemoveAsync(b);
for (var i = index - 1; i >= 0; i--)
if (Rows[i] is NoteBulletViewModel previous)
{
FocusRequested?.Invoke(previous);
return;
}
}
[RelayCommand] [RelayCommand]
private async Task CommitBullet(NoteBulletViewModel? b) private async Task CommitBullet(NoteBulletViewModel? b)
{ {
if (b is null) return; if (b is null) return;
var text = b.Text?.Trim() ?? ""; var text = b.Text?.Trim() ?? "";
if (text.Length == 0) try
{ {
await _api.DeleteAsync(b.Id); if (text.Length == 0)
Bullets.Remove(b); {
return; await RemoveAsync(b);
return;
}
if (b.IsNew)
{
var dto = await _api.AddAsync(b.Day, text);
if (dto is not null) b.Id = dto.Id;
}
else
{
await _api.UpdateAsync(b.Id, text);
}
} }
await _api.UpdateAsync(b.Id, text); catch (Exception ex)
{
ErrorReported?.Invoke(ex.Message);
}
}
private async Task RemoveAsync(NoteBulletViewModel b)
{
if (!b.IsNew) await _api.DeleteAsync(b.Id);
Rows.Remove(b);
RefreshEmpty();
} }
} }
@@ -9,6 +9,7 @@ using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories; using ClaudeDo.Data.Repositories;
using ClaudeDo.Ui.Localization; using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services; using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.Services.Interfaces;
using ClaudeDo.Ui.ViewModels.Modals; using ClaudeDo.Ui.ViewModels.Modals;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus; using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
@@ -62,18 +63,10 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
public event EventHandler? FocusAddTaskRequested; public event EventHandler? FocusAddTaskRequested;
public event EventHandler? TasksChanged; public event EventHandler? TasksChanged;
public event Action? NotesRequested;
public event Action? PrepRequested; public event Action? PrepRequested;
public event Action<string>? ErrorReported; public event Action<string>? ErrorReported;
public void RequestFocusAddTask() => FocusAddTaskRequested?.Invoke(this, EventArgs.Empty); public void RequestFocusAddTask() => FocusAddTaskRequested?.Invoke(this, EventArgs.Empty);
[RelayCommand]
private void OpenNotes()
{
SelectFrom(null, "notes");
NotesRequested?.Invoke();
}
[RelayCommand] [RelayCommand]
private void ShowPrepLog() => PrepRequested?.Invoke(); private void ShowPrepLog() => PrepRequested?.Invoke();
@@ -118,8 +111,17 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
private bool _hasCompleted; private bool _hasCompleted;
[ObservableProperty] private bool _showOpenLabel; [ObservableProperty] private bool _showOpenLabel;
[ObservableProperty] private string _completedHeader = ""; [ObservableProperty] private string _completedHeader = "";
[ObservableProperty] private bool _showNotesRow;
[ObservableProperty] private bool _isMyDayList; [ObservableProperty] private bool _isMyDayList;
/// <summary>The notes list is content, not a task list — the island swaps its rows for the
/// notes editor instead of showing an empty task list.</summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsTasksEmptyHintVisible))]
[NotifyPropertyChangedFor(nameof(IsTasksEmptyRepoHintVisible))]
private bool _isNotesList;
/// <summary>Null only in tests that construct the island without a notes API.</summary>
public NotesEditorViewModel? Notes { get; }
[ObservableProperty] [ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsTasksEmptyRepoHintVisible))] [NotifyPropertyChangedFor(nameof(IsTasksEmptyRepoHintVisible))]
private bool _isLetClaudeVisible; private bool _isLetClaudeVisible;
@@ -128,7 +130,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
/// <summary>No visible tasks below the add-task row — every item lands in one of /// <summary>No visible tasks below the add-task row — every item lands in one of
/// Overdue/Open/Completed, so all-false here always means the list has zero items.</summary> /// Overdue/Open/Completed, so all-false here always means the list has zero items.</summary>
public bool IsTasksEmptyHintVisible => !HasOverdue && !HasOpen && !HasCompleted; public bool IsTasksEmptyHintVisible => !HasOverdue && !HasOpen && !HasCompleted && !IsNotesList;
/// <summary>Extra empty-state line for a User list with no linked working dir, where the /// <summary>Extra empty-state line for a User list with no linked working dir, where the
/// execution features (queue, Let Claude handle it, Quick session) are unavailable. Derived /// execution features (queue, Let Claude handle it, Quick session) are unavailable. Derived
@@ -182,10 +184,20 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
private readonly EventHandler _langChangedHandler; private readonly EventHandler _langChangedHandler;
public TasksIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient? worker = null) public TasksIslandViewModel(
IDbContextFactory<ClaudeDoDbContext> dbFactory,
IWorkerClient? worker = null,
INotesApi? notesApi = null)
{ {
_dbFactory = dbFactory; _dbFactory = dbFactory;
_worker = worker; _worker = worker;
if (notesApi is not null)
{
Notes = new NotesEditorViewModel(notesApi);
Notes.ErrorReported += msg => ErrorReported?.Invoke(msg);
if (worker is not null)
worker.NotesUpdatedEvent += () => { if (IsNotesList) _ = Notes.LoadAsync(); };
}
_completedHeaderRow = new() { ActionCommand = ClearCompletedCommand }; _completedHeaderRow = new() { ActionCommand = ClearCompletedCommand };
CompletedHeader = Loc.T("vm.tasksIsland.completedHeader"); CompletedHeader = Loc.T("vm.tasksIsland.completedHeader");
if (_worker is not null) if (_worker is not null)
@@ -417,14 +429,14 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
HasOpen = false; HasOpen = false;
HasCompleted = false; HasCompleted = false;
ShowOpenLabel = false; ShowOpenLabel = false;
ShowNotesRow = false;
if (listChanged) SelectFrom(null, "list-change"); if (listChanged) SelectFrom(null, "list-change");
if (list is null) { IsLetClaudeVisible = false; IsQuickClaudeVisible = false; IsMergeReviewsVisible = false; LoadTask = Task.CompletedTask; return; } if (list is null) { IsLetClaudeVisible = false; IsQuickClaudeVisible = false; IsMergeReviewsVisible = false; LoadTask = Task.CompletedTask; return; }
HeaderTitle = list.Name; HeaderTitle = list.Name;
HeaderEyebrow = DateTime.Now.ToString("dddd · MMM dd", CultureInfo.InvariantCulture).ToUpperInvariant(); HeaderEyebrow = DateTime.Now.ToString("dddd · MMM dd", CultureInfo.InvariantCulture).ToUpperInvariant();
ShowNotesRow = list.Id == "smart:my-day";
IsMyDayList = list.Id == "smart:my-day"; IsMyDayList = list.Id == "smart:my-day";
IsNotesList = list.Id == ListsIslandViewModel.NotesListId;
if (IsNotesList && Notes is not null) _ = Notes.LoadAsync();
IsLetClaudeVisible = list.Kind == ListKind.User && !string.IsNullOrWhiteSpace(list.WorkingDir); IsLetClaudeVisible = list.Kind == ListKind.User && !string.IsNullOrWhiteSpace(list.WorkingDir);
IsQuickClaudeVisible = IsLetClaudeVisible; IsQuickClaudeVisible = IsLetClaudeVisible;
IsMergeReviewsVisible = list.Id == "virtual:review"; IsMergeReviewsVisible = list.Id == "virtual:review";
@@ -796,7 +808,8 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
var running = Items.Count(i => i.Status == TaskStatus.Running); var running = Items.Count(i => i.Status == TaskStatus.Running);
var review = Items.Count(i => i.Status == TaskStatus.Done && i.Branch != null); var review = Items.Count(i => i.Status == TaskStatus.Done && i.Branch != null);
Subtitle = open == 1 ? "1 open task" : $"{open} open tasks"; // The notes list holds no tasks — an "0 open tasks" line under its header is just noise.
Subtitle = IsNotesList ? "" : open == 1 ? "1 open task" : $"{open} open tasks";
if (running > 0 || review > 0) if (running > 0 || review > 0)
{ {
@@ -145,7 +145,14 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
[ObservableProperty] [ObservableProperty]
private bool _isWorkerLogVisible; private bool _isWorkerLogVisible;
public bool ShowDetails => WindowWidth >= 1100; /// <summary>The notes list has nothing to show a detail pane for, so the pane goes away and
/// the editor takes the space instead of leaving an empty column beside it.</summary>
public bool ShowDetails => WindowWidth >= 1100 && Tasks?.IsNotesList != true;
/// <summary>Widens the tasks island over the splitter and the details column when that column
/// is hidden — a fixed-width ColumnDefinition keeps its space even with an invisible child.</summary>
public int TasksColumnSpan => ShowDetails ? 1 : 3;
public bool ShowLists => WindowWidth >= 780; public bool ShowLists => WindowWidth >= 780;
private readonly System.Timers.Timer _clearTimer = new(30_000) { AutoReset = false }; private readonly System.Timers.Timer _clearTimer = new(30_000) { AutoReset = false };
@@ -168,10 +175,16 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
partial void OnWindowWidthChanged(double value) partial void OnWindowWidthChanged(double value)
{ {
OnPropertyChanged(nameof(ShowDetails)); NotifyDetailsVisibility();
OnPropertyChanged(nameof(ShowLists)); OnPropertyChanged(nameof(ShowLists));
} }
private void NotifyDetailsVisibility()
{
OnPropertyChanged(nameof(ShowDetails));
OnPropertyChanged(nameof(TasksColumnSpan));
}
public void OnWorkerLogReceived(WorkerLogEntry entry) public void OnWorkerLogReceived(WorkerLogEntry entry)
{ {
var hhmm = entry.TimestampUtc.ToLocalTime().ToString("HH:mm"); var hhmm = entry.TimestampUtc.ToLocalTime().ToString("HH:mm");
@@ -340,8 +353,11 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList); Lists.SelectionChanged += (_, _) => Tasks.LoadForList(Lists.SelectedList);
Lists.SelectionChanged += (_, _) => _ = RefreshStaleWorkerCheckAsync(); Lists.SelectionChanged += (_, _) => _ = RefreshStaleWorkerCheckAsync();
Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask, Tasks.SelectionSource); Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask, Tasks.SelectionSource);
Tasks.NotesRequested += () => Details.ShowNotes();
Tasks.PrepRequested += () => Details.ShowPrep(); Tasks.PrepRequested += () => Details.ShowPrep();
Tasks.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(TasksIslandViewModel.IsNotesList)) NotifyDetailsVisibility();
};
Tasks.ErrorReported += FlashFooterError; Tasks.ErrorReported += FlashFooterError;
Lists.ErrorReported += FlashFooterError; Lists.ErrorReported += FlashFooterError;
Lists.SuccessReported += FlashFooterSuccess; Lists.SuccessReported += FlashFooterSuccess;
@@ -58,10 +58,20 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
public bool HasPanes => Panes.Count > 0; public bool HasPanes => Panes.Count > 0;
// Read-only view of the worker queue (tasks waiting to run), shown as a side strip. // Read-only activity strip. Beyond the queue itself (Queued/Running) it also carries the rows
// that want the user's attention (Failed, WaitingForReview, roadblocked) — colour-coded only;
// clicking a row opens the task in the main app, where the actual actions live.
public ObservableCollection<QueuedTaskViewModel> Queued { get; } = new(); public ObservableCollection<QueuedTaskViewModel> Queued { get; } = new();
public bool HasQueued => Queued.Count > 0; public bool HasQueued => Queued.Count > 0;
// "New session" targets: one entry per list with a working dir, plus a browse fallback, so a
// fresh session is two clicks instead of navigating a folder picker.
public ObservableCollection<SessionTargetViewModel> SessionTargets { get; } = new();
// View-layer seam: the folder picker needs a TopLevel, so the view owns it (see
// MissionControlView.OnNewSessionClicked).
public Action? BrowseForSessionRequested { get; set; }
public UsagePillViewModel UsagePill { get; } public UsagePillViewModel UsagePill { get; }
public MissionControlViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient worker, UsagePillViewModel usagePill) public MissionControlViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient worker, UsagePillViewModel usagePill)
@@ -89,6 +99,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
_worker.HandoffRequestedEvent += _onHandoffRequested; _worker.HandoffRequestedEvent += _onHandoffRequested;
_ = RefreshQueueAsync(); _ = RefreshQueueAsync();
_ = RefreshSessionTargetsAsync();
} }
internal async System.Threading.Tasks.Task RefreshQueueAsync() internal async System.Threading.Tasks.Task RefreshQueueAsync()
@@ -98,23 +109,33 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
await using var ctx = await _dbFactory.CreateDbContextAsync(); await using var ctx = await _dbFactory.CreateDbContextAsync();
var rows = await ctx.Tasks.AsNoTracking() var rows = await ctx.Tasks.AsNoTracking()
.Where(t => t.Status == ClaudeDo.Data.Models.TaskStatus.Queued .Where(t => t.Status == ClaudeDo.Data.Models.TaskStatus.Queued
|| t.Status == ClaudeDo.Data.Models.TaskStatus.Running) || t.Status == ClaudeDo.Data.Models.TaskStatus.Running
.OrderBy(t => t.Status == ClaudeDo.Data.Models.TaskStatus.Running ? 0 : 1) || t.Status == ClaudeDo.Data.Models.TaskStatus.WaitingForReview
|| t.Status == ClaudeDo.Data.Models.TaskStatus.Failed)
// Attention first (failed, then review), then the queue itself.
.OrderBy(t => t.Status == ClaudeDo.Data.Models.TaskStatus.Failed ? 0
: t.Status == ClaudeDo.Data.Models.TaskStatus.WaitingForReview ? 1
: t.Status == ClaudeDo.Data.Models.TaskStatus.Running ? 2 : 3)
.ThenBy(t => t.SortOrder).ThenBy(t => t.CreatedAt) .ThenBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
.Select(t => new { t.Id, t.Title, t.BlockedByTaskId, t.Status }) .Select(t => new { t.Id, t.Title, t.BlockedByTaskId, t.Status, t.RoadblockCount })
.ToListAsync(); .ToListAsync();
Queued.Clear(); Queued.Clear();
foreach (var r in rows) foreach (var r in rows)
{ {
var id = r.Id; var id = r.Id;
var running = r.Status == ClaudeDo.Data.Models.TaskStatus.Running;
var (tint, colorKey, labelKey) = Decorate(r.Status, r.RoadblockCount, r.BlockedByTaskId != null);
Queued.Add(new QueuedTaskViewModel Queued.Add(new QueuedTaskViewModel
{ {
Id = id, Id = id,
Title = r.Title ?? string.Empty, Title = r.Title ?? string.Empty,
IsBlocked = r.BlockedByTaskId != null, IsRunning = running,
IsRunning = r.Status == ClaudeDo.Data.Models.TaskStatus.Running, TintKey = tint,
StatusColorKey = colorKey,
StatusLabel = labelKey is null ? null : Loc.T(labelKey),
OpenInAppCommand = new RelayCommand(() => OpenInApp?.Invoke(id)), OpenInAppCommand = new RelayCommand(() => OpenInApp?.Invoke(id)),
OpenSessionCommand = new RelayCommand(() => _ = OpenConPtySessionAsync(id)),
}); });
} }
OnPropertyChanged(nameof(HasQueued)); OnPropertyChanged(nameof(HasQueued));
@@ -122,6 +143,54 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.queueRefreshFailed", ex.Message)); } catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.queueRefreshFailed", ex.Message)); }
} }
// Row tint + status label for the activity strip. Keys are resolved to brushes by
// DotBrushConverter ("<key>Brush"), so they must match tokens in Tokens.axaml.
private static (string? Tint, string? ColorKey, string? LabelKey) Decorate(
ClaudeDo.Data.Models.TaskStatus status, int roadblockCount, bool blocked)
=> status switch
{
ClaudeDo.Data.Models.TaskStatus.Failed => ("ErrorTint", "StatusError", "missionControl.failed"),
ClaudeDo.Data.Models.TaskStatus.WaitingForReview when roadblockCount > 0
=> ("RoadblockTint", "Amber", "missionControl.roadblock"),
ClaudeDo.Data.Models.TaskStatus.WaitingForReview => ("DoneTint", "StatusReview", "missionControl.review"),
ClaudeDo.Data.Models.TaskStatus.Running => ("RunningTint", "StatusRunning", "missionControl.running"),
_ when blocked => (null, "Amber", "missionControl.blocked"),
_ => (null, null, null),
};
// Rebuilt on every flyout open — lists change rarely, but a stale target would launch a
// session in a directory that is no longer configured.
internal async System.Threading.Tasks.Task RefreshSessionTargetsAsync()
{
SessionTargets.Clear();
// Browse goes in first so the menu stays usable even if the list query below fails.
SessionTargets.Add(new SessionTargetViewModel
{
Label = Loc.T("missionControl.browseForFolder"),
StartCommand = new RelayCommand(() => BrowseForSessionRequested?.Invoke()),
});
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
var lists = await ctx.Lists.AsNoTracking()
.Where(l => l.WorkingDir != null && l.WorkingDir != "")
.OrderBy(l => l.SortOrder).ThenBy(l => l.Name)
.Select(l => new { l.Name, l.WorkingDir })
.ToListAsync();
foreach (var l in lists)
{
var dir = l.WorkingDir!;
SessionTargets.Add(new SessionTargetViewModel
{
Label = l.Name,
StartCommand = new RelayCommand(() => _ = OpenAdHocConPtySessionAsync(dir)),
});
}
}
catch (Exception ex) { ErrorReported?.Invoke(Loc.T("missionControl.queueRefreshFailed", ex.Message)); }
}
// Drop-to-queue: a task dragged from the main app onto Mission Control gets queued. Goes // Drop-to-queue: a task dragged from the main app onto Mission Control gets queued. Goes
// through the worker hub (TaskStateService.EnqueueAsync) rather than a raw EF write so the // through the worker hub (TaskStateService.EnqueueAsync) rather than a raw EF write so the
// manual/draft-child guards apply here too. The interactive-session check has to live here // manual/draft-child guards apply here too. The interactive-session check has to live here
@@ -445,12 +514,28 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
} }
} }
/// <summary>Read-only display row for a queued or running task in the Mission Control side strip.</summary> /// <summary>Display row in the Mission Control activity strip (queued, running, or wanting
/// attention). Read-only apart from opening the task in the app / in a session.</summary>
public sealed class QueuedTaskViewModel public sealed class QueuedTaskViewModel
{ {
public required string Id { get; init; } public required string Id { get; init; }
public required string Title { get; init; } public required string Title { get; init; }
public bool IsBlocked { get; init; }
public bool IsRunning { get; init; } public bool IsRunning { get; init; }
/// <summary>Brush key of the row tint (e.g. "RunningTint"); null = untinted.</summary>
public string? TintKey { get; init; }
public string? TintBorderKey => TintKey is null ? null : TintKey + "Border";
public string? StatusLabel { get; init; }
public string? StatusColorKey { get; init; }
/// <summary>A running task's worktree belongs to the autonomous run — never open a second,
/// interactive claude process into it.</summary>
public bool CanOpenSession => !IsRunning;
public IRelayCommand? OpenInAppCommand { get; init; } public IRelayCommand? OpenInAppCommand { get; init; }
public IRelayCommand? OpenSessionCommand { get; init; }
}
/// <summary>One entry in the "new session" menu: a configured list's working dir, or browse.</summary>
public sealed class SessionTargetViewModel
{
public required string Label { get; init; }
public required IRelayCommand StartCommand { get; init; }
} }
@@ -42,12 +42,34 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
// lands; a non-zero exit keeps the task out of Done instead of silently reporting merged. // lands; a non-zero exit keeps the task out of Done instead of silently reporting merged.
[ObservableProperty] private string _verifyCommand = ""; [ObservableProperty] private string _verifyCommand = "";
// Dropdown hidden entirely when no ticket-system base URL is configured (Settings → Files tab). // Dropdown hidden entirely when no ticket-system base URL is configured (Settings → Files tab).
// Icon key into the UI icon library (IconKeyConverter -> "Icon.<key>" StreamGeometry).
[ObservableProperty] private string _icon = DefaultIcon;
[ObservableProperty] private bool _ticketsAvailable; [ObservableProperty] private bool _ticketsAvailable;
[ObservableProperty] private TicketProjectOption? _selectedTicketProject; [ObservableProperty] private TicketProjectOption? _selectedTicketProject;
public ObservableCollection<TicketProjectOption> TicketProjects { get; } = new(); public ObservableCollection<TicketProjectOption> TicketProjects { get; } = new();
public ObservableCollection<string> CommitTypeOptions { get; } = new(CommitTypeRegistry.Types); public ObservableCollection<string> CommitTypeOptions { get; } = new(CommitTypeRegistry.Types);
public const string DefaultIcon = "Folder";
// Curated subset of the Icon.* geometry library in IslandStyles.axaml — window chrome and
// pure stroke icons (PathIcon fills, so those render invisible) are deliberately left out.
// Grouped by theme so the picker grid reads in rows; every entry needs a matching
// "Icon.<key>" StreamGeometry or it renders as an empty slot.
public static readonly string[] IconOptions =
[
// general
"Folder", "Star", "Sun", "Calendar", "Clock", "Inbox", "Mail", "Text", "Search", "Grid",
// dev
"Code", "Terminal", "Database", "Server", "Chip", "Bolt", "Wand", "Flask", "Shield",
// status / flow
"Activity", "Eye", "Check", "Warning", "Chart", "Rocket", "Bulb", "Broom", "AgentSuggested", "Settings",
// things
"Box", "Truck", "MapPin", "Camera", "Image", "Barcode", "Skull",
];
public ObservableCollection<string> Icons { get; } = new(IconOptions);
// The shared agent-config editor (Model / MaxTurns / SystemPrompt / AgentFile), // The shared agent-config editor (Model / MaxTurns / SystemPrompt / AgentFile),
// scoped to this list (list → global inheritance). // scoped to this list (list → global inheritance).
public AgentConfigEditorViewModel Agent { get; } public AgentConfigEditorViewModel Agent { get; }
@@ -68,12 +90,16 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
string defaultCommitType, string defaultCommitType,
bool isManual = false, bool isManual = false,
bool findingsTracked = false, bool findingsTracked = false,
string? icon = null,
CancellationToken ct = default) CancellationToken ct = default)
{ {
ListId = listId; ListId = listId;
Name = name; Name = name;
IsManual = isManual; IsManual = isManual;
FindingsTracked = findingsTracked; FindingsTracked = findingsTracked;
// An unknown/legacy key would leave the picker with no selection and silently reset the
// icon on the next save — fall back to the default instead.
Icon = IconOptions.Contains(icon) ? icon! : DefaultIcon;
WorkingDir = workingDir ?? ""; WorkingDir = workingDir ?? "";
DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? CommitTypeRegistry.DefaultType : defaultCommitType; DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? CommitTypeRegistry.DefaultType : defaultCommitType;
@@ -111,7 +137,8 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
string.IsNullOrWhiteSpace(WorkingDir) ? null : WorkingDir, string.IsNullOrWhiteSpace(WorkingDir) ? null : WorkingDir,
DefaultCommitType, DefaultCommitType,
IsManual, IsManual,
FindingsTracked)); FindingsTracked,
Icon));
// Tri-state: dropdown never shown (no base URL) -> null, leave the stored link alone. // Tri-state: dropdown never shown (no base URL) -> null, leave the stored link alone.
// Dropdown shown -> always an explicit value, 0 for "— none —" to actively clear it. // Dropdown shown -> always an explicit value, 0 for "— none —" to actively clear it.
@@ -10,12 +10,15 @@
<!-- Column 0: id badge + editable title --> <!-- Column 0: id badge + editable title -->
<StackPanel Grid.Column="0" Spacing="0"> <StackPanel Grid.Column="0" Spacing="0">
<TextBlock Classes="meta" <StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
Text="{Binding TaskIdBadge}" <TextBlock Classes="meta"
Margin="0,0,0,4" Text="{Binding TaskIdBadge}"
Cursor="Hand" Cursor="Hand"
ToolTip.Tip="{loc:Tr details.copyTaskIdTip}" ToolTip.Tip="{loc:Tr details.copyTaskIdTip}"
Tapped="OnTaskIdTapped"/> Tapped="OnTaskIdTapped"/>
<!-- created-at moved up from the removed metadata footer -->
<TextBlock Classes="meta" Text="{Binding Task.CreatedAtFormatted}"/>
</StackPanel>
<TextBox Text="{Binding EditableTitle, Mode=TwoWay}" <TextBox Text="{Binding EditableTitle, Mode=TwoWay}"
Background="Transparent" Background="Transparent"
BorderThickness="0" BorderThickness="0"
@@ -11,27 +11,6 @@
<Panel> <Panel>
<DockPanel> <DockPanel>
<!-- ── Metadata footer (sticky bottom) — created-at + close — task detail only ── -->
<Border DockPanel.Dock="Bottom"
IsVisible="{Binding IsTaskDetailVisible}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,1,0,0"
Padding="14,8">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0"
Classes="meta"
Text="{Binding Task.CreatedAtFormatted}"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
<Button Grid.Column="1" Classes="icon-btn"
Command="{Binding CloseDetailsCommand}"
ToolTip.Tip="{loc:Tr details.closeTip}"
VerticalAlignment="Center">
<PathIcon Data="{StaticResource Icon.X}" Width="14" Height="14"/>
</Button>
</Grid>
</Border>
<!-- ── Header (sticky top): id · title · trash/skull · gear — task detail only ── --> <!-- ── Header (sticky top): id · title · trash/skull · gear — task detail only ── -->
<Border DockPanel.Dock="Top" Classes="island-header" <Border DockPanel.Dock="Top" Classes="island-header"
IsVisible="{Binding IsTaskDetailVisible}"> IsVisible="{Binding IsTaskDetailVisible}">
@@ -146,17 +125,12 @@
DragCompleted="OnSplitterDragCompleted"/> DragCompleted="OnSplitterDragCompleted"/>
</Grid> </Grid>
<!-- Empty state: no task selected, outside Notes/Prep mode --> <!-- Empty state: no task selected, outside Prep mode -->
<TextBlock IsVisible="{Binding IsEmptyStateVisible}" <TextBlock IsVisible="{Binding IsEmptyStateVisible}"
Text="{loc:Tr details.emptyState}" Text="{loc:Tr details.emptyState}"
Foreground="{DynamicResource TextMuteBrush}" Foreground="{DynamicResource TextMuteBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center"/> HorizontalAlignment="Center" VerticalAlignment="Center"/>
<!-- Notes mode -->
<Panel IsVisible="{Binding IsNotesMode}">
<islands:NotesEditorView DataContext="{Binding Notes}"/>
</Panel>
<!-- Daily-prep mode --> <!-- Daily-prep mode -->
<Panel IsVisible="{Binding IsPrepMode}"> <Panel IsVisible="{Binding IsPrepMode}">
<DockPanel> <DockPanel>
@@ -103,7 +103,6 @@
<PathIcon Grid.Column="0" Classes="list-icon" <PathIcon Grid.Column="0" Classes="list-icon"
Width="14" Height="14" Width="14" Height="14"
Data="{Binding IconKey, Converter={StaticResource IconKey}}" Data="{Binding IconKey, Converter={StaticResource IconKey}}"
Foreground="{DynamicResource TextMuteBrush}"
HorizontalAlignment="Center" VerticalAlignment="Center"/> HorizontalAlignment="Center" VerticalAlignment="Center"/>
<!-- Name --> <!-- Name -->
<TextBlock Grid.Column="1" Classes="list-label" <TextBlock Grid.Column="1" Classes="list-label"
@@ -177,11 +176,11 @@
HorizontalAlignment="Left" VerticalAlignment="Center" HorizontalAlignment="Left" VerticalAlignment="Center"
Margin="-8,0,0,0"/> Margin="-8,0,0,0"/>
</Border> </Border>
<!-- Color dot (6px circle, color from DotColorKey) --> <!-- Icon (list-settings → Icon tab; "Folder" when unset) -->
<Ellipse Grid.Column="0" <PathIcon Grid.Column="0" Classes="list-icon"
Width="6" Height="6" Width="14" Height="14"
Fill="{Binding DotColorKey, Converter={StaticResource DotBrush}}" Data="{Binding IconKey, Converter={StaticResource IconKey}}"
HorizontalAlignment="Center" VerticalAlignment="Center"/> HorizontalAlignment="Center" VerticalAlignment="Center"/>
<!-- Name --> <!-- Name -->
<TextBlock Grid.Column="1" Classes="list-label" <TextBlock Grid.Column="1" Classes="list-label"
Text="{Binding Name}" Text="{Binding Name}"
@@ -1,38 +1,113 @@
<UserControl xmlns="https://github.com/avaloniaui" <UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands" xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
xmlns:loc="using:ClaudeDo.Ui.Localization" xmlns:loc="using:ClaudeDo.Ui.Localization"
x:Class="ClaudeDo.Ui.Views.Islands.NotesEditorView" x:Class="ClaudeDo.Ui.Views.Islands.NotesEditorView"
x:DataType="vm:NotesEditorViewModel"> x:DataType="vm:NotesEditorViewModel">
<DockPanel Margin="16">
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8">
<Button Classes="btn" Content="&#x2039;" Command="{Binding PrevDayCommand}"/>
<ctl:ThemedDatePicker SelectedDate="{Binding CurrentDate, Mode=TwoWay}"/>
<Button Classes="btn" Content="&#x203A;" Command="{Binding NextDayCommand}"/>
<Button Classes="btn" Content="{loc:Tr notes.today}" Command="{Binding TodayCommand}"/>
<TextBlock Classes="meta" VerticalAlignment="Center" Text="{Binding CurrentDayLabel}"/>
</StackPanel>
<DockPanel DockPanel.Dock="Top" Margin="0,12,0,8"> <UserControl.Styles>
<Button DockPanel.Dock="Right" Classes="btn" Content="{loc:Tr notes.add}" Margin="8,0,0,0" <!-- A note reads as text, not as a form field: the box only shows itself under the pointer
Command="{Binding AddBulletCommand}"/> and while it has focus. -->
<TextBox PlaceholderText="{loc:Tr notes.newNotePlaceholder}" Text="{Binding NewBulletText}"> <Style Selector="TextBox.note">
<TextBox.KeyBindings> <Setter Property="Background" Value="Transparent"/>
<KeyBinding Gesture="Enter" Command="{Binding AddBulletCommand}"/> <Setter Property="BorderThickness" Value="0"/>
</TextBox.KeyBindings> <Setter Property="Padding" Value="4,3"/>
</TextBox> <Setter Property="MinHeight" Value="0"/>
</DockPanel> </Style>
<Style Selector="TextBox.note /template/ Border#PART_BorderElement">
<Setter Property="Background" Value="Transparent"/>
<Setter Property="BorderThickness" Value="0"/>
</Style>
<ScrollViewer> <Style Selector="Border.note-row">
<ItemsControl ItemsSource="{Binding Bullets}"> <Setter Property="CornerRadius" Value="6"/>
<ItemsControl.ItemTemplate> <Setter Property="Padding" Value="10,1,6,1"/>
<DataTemplate x:DataType="vm:NoteBulletViewModel"> <Setter Property="Margin" Value="4,1"/>
<TextBox Text="{Binding Text}" Margin="0,2" </Style>
LostFocus="OnBulletLostFocus"/> <Style Selector="Border.note-row:pointerover">
</DataTemplate> <Setter Property="Background" Value="{DynamicResource PeatSoftBrush}"/>
</ItemsControl.ItemTemplate> </Style>
</ItemsControl> <!-- Row actions stay out of the way until the row is hovered. -->
</ScrollViewer> <Style Selector="Border.note-row Button.note-del">
<Setter Property="Opacity" Value="0"/>
</Style>
<Style Selector="Border.note-row:pointerover Button.note-del">
<Setter Property="Opacity" Value="1"/>
</Style>
</UserControl.Styles>
<DockPanel LastChildFill="True">
<!-- Today's capture box: Enter files the note and leaves the caret here for the next one. -->
<Border DockPanel.Dock="Top" Classes="add-task" Margin="16,14,16,10">
<Grid ColumnDefinitions="Auto,*,Auto">
<Border Grid.Column="0" Classes="add-task-plus" VerticalAlignment="Center">
<PathIcon Width="12" Height="12" Data="{StaticResource Icon.Plus}"
Foreground="{DynamicResource TextFaintBrush}"/>
</Border>
<!-- Enter/Shift+Enter are handled in the code-behind's tunnel handler, same as the note
rows: a KeyBinding would never fire, because AcceptsReturn makes TextBox consume
Enter first. -->
<TextBox Grid.Column="1" x:Name="NewNoteBox" Classes="add-task-input"
PlaceholderText="{loc:Tr notes.newNotePlaceholder}"
Text="{Binding NewBulletText, Mode=TwoWay}"
AcceptsReturn="True" TextWrapping="Wrap"
VerticalAlignment="Center" Margin="12,0,0,0"/>
<Border Grid.Column="2" Classes="kbd kbd-enter" VerticalAlignment="Center"
IsVisible="{Binding #NewNoteBox.IsFocused}">
<TextBlock Text="{loc:Tr tasks.enterKey}"/>
</Border>
</Grid>
</Border>
<Panel>
<ScrollViewer>
<ItemsControl x:Name="RowsHost" ItemsSource="{Binding Rows}" Margin="10,0,10,12">
<ItemsControl.DataTemplates>
<DataTemplate DataType="vm:NoteDayHeaderViewModel">
<TextBlock Classes="eyebrow section-label" Margin="14,12,14,4" Text="{Binding Label}"/>
</DataTemplate>
<DataTemplate DataType="vm:NoteBulletViewModel">
<Border Classes="note-row">
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
<Ellipse Grid.Column="0" Width="4" Height="4" Margin="0,0,10,0"
VerticalAlignment="Center"
Fill="{DynamicResource TextFaintBrush}"/>
<!-- AcceptsReturn/TextWrapping are set here, not in the style above: TextBox
reads AcceptsReturn in its own OnKeyDown, which runs before any handler a
style or the XAML could attach. Enter is intercepted in the tunnel phase
(see code-behind) so the split still wins over the newline. -->
<TextBox Grid.Column="1" Classes="note" Text="{Binding Text, Mode=TwoWay}"
AcceptsReturn="True" TextWrapping="Wrap"
LostFocus="OnBulletLostFocus"/>
<PathIcon Grid.Column="2" Width="12" Height="12" Margin="6,0"
VerticalAlignment="Center"
IsVisible="{Binding FromAgent}"
ToolTip.Tip="{loc:Tr notes.fromAgentTip}"
Data="{StaticResource Icon.AgentSuggested}"
Foreground="{DynamicResource TextFaintBrush}"/>
<Button Grid.Column="3" Classes="icon-btn note-del"
VerticalAlignment="Center"
ToolTip.Tip="{loc:Tr notes.deleteTip}"
Click="OnDeleteBulletClick">
<PathIcon Classes="danger" Data="{StaticResource Icon.Trash}" Width="12" Height="12"/>
</Button>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.DataTemplates>
</ItemsControl>
</ScrollViewer>
<TextBlock IsVisible="{Binding IsEmpty}"
Text="{loc:Tr notes.emptyHint}"
Foreground="{DynamicResource TextMuteBrush}"
MaxWidth="260" TextAlignment="Center" TextWrapping="Wrap"
HorizontalAlignment="Center" VerticalAlignment="Center"
IsHitTestVisible="False"/>
</Panel>
</DockPanel> </DockPanel>
</UserControl> </UserControl>
@@ -1,12 +1,74 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Interactivity; using Avalonia.Interactivity;
using Avalonia.Threading;
using Avalonia.VisualTree;
using ClaudeDo.Ui.ViewModels.Islands; using ClaudeDo.Ui.ViewModels.Islands;
namespace ClaudeDo.Ui.Views.Islands; namespace ClaudeDo.Ui.Views.Islands;
public partial class NotesEditorView : UserControl public partial class NotesEditorView : UserControl
{ {
public NotesEditorView() => InitializeComponent(); private NotesEditorViewModel? _vm;
public NotesEditorView()
{
InitializeComponent();
DataContextChanged += OnDataContextChanged;
// Tunnel, not bubble: with AcceptsReturn on, TextBox.OnKeyDown consumes Enter (any
// modifier) before a bubbling handler ever sees it. Taking Enter in the tunnel phase
// keeps the split chain, and letting Shift+Enter through leaves the newline to the box.
AddHandler(KeyDownEvent, OnBulletKeyDown, RoutingStrategies.Tunnel);
}
private void OnDataContextChanged(object? sender, EventArgs e)
{
if (_vm is not null) _vm.FocusRequested -= FocusBullet;
_vm = DataContext as NotesEditorViewModel;
if (_vm is not null) _vm.FocusRequested += FocusBullet;
}
/// <summary>Rows the VM just inserted have no container yet — wait for the layout pass that
/// creates it before reaching for the TextBox inside.</summary>
private void FocusBullet(NoteBulletViewModel bullet) =>
Dispatcher.UIThread.Post(
() => RowsHost.ContainerFromItem(bullet)?.FindDescendantOfType<TextBox>()?.Focus(),
DispatcherPriority.Loaded);
private void OnBulletKeyDown(object? sender, KeyEventArgs e)
{
// Source, not sender: the tunnel handler is attached to the whole view, so it sees the
// capture box at the top as well as the note rows. Both need the same Enter/Shift+Enter
// split, and neither can get it from a KeyBinding — with AcceptsReturn on, TextBox eats
// Enter in its own OnKeyDown before any bubbling handler or binding runs.
if (e.Source is not TextBox box || DataContext is not NotesEditorViewModel vm) return;
var shift = e.KeyModifiers.HasFlag(KeyModifiers.Shift);
if (ReferenceEquals(box, NewNoteBox))
{
if (e.Key == Key.Enter && !shift)
{
e.Handled = true;
vm.AddBulletCommand.Execute(null);
}
return;
}
if (box.DataContext is not NoteBulletViewModel bullet) return;
// Shift+Enter falls through to the TextBox and breaks the line — a note can be several.
if (e.Key == Key.Enter && !shift)
{
e.Handled = true;
vm.SplitBulletCommand.Execute(bullet);
}
else if (e.Key == Key.Back && string.IsNullOrEmpty(box.Text))
{
e.Handled = true;
vm.DeleteBulletCommand.Execute(bullet);
}
}
private void OnBulletLostFocus(object? sender, RoutedEventArgs e) private void OnBulletLostFocus(object? sender, RoutedEventArgs e)
{ {
@@ -15,4 +77,11 @@ public partial class NotesEditorView : UserControl
&& vm.CommitBulletCommand.CanExecute(bullet)) && vm.CommitBulletCommand.CanExecute(bullet))
vm.CommitBulletCommand.Execute(bullet); vm.CommitBulletCommand.Execute(bullet);
} }
private void OnDeleteBulletClick(object? sender, RoutedEventArgs e)
{
if (sender is Button { DataContext: NoteBulletViewModel bullet }
&& DataContext is NotesEditorViewModel vm)
vm.DeleteBulletCommand.Execute(bullet);
}
} }
@@ -31,6 +31,7 @@
<TextBlock Text="{Binding StatusPill}"/> <TextBlock Text="{Binding StatusPill}"/>
</Border> </Border>
<Button Classes="icon-btn" Classes.active="{Binding IsShowingCompleted}" <Button Classes="icon-btn" Classes.active="{Binding IsShowingCompleted}"
IsVisible="{Binding !IsNotesList}"
Command="{Binding ToggleShowCompletedCommand}" Command="{Binding ToggleShowCompletedCommand}"
ToolTip.Tip="{loc:Tr tasks.showCompletedTip}"> ToolTip.Tip="{loc:Tr tasks.showCompletedTip}">
<PathIcon Width="15" Height="15" Data="{StaticResource Icon.Eye}"/> <PathIcon Width="15" Height="15" Data="{StaticResource Icon.Eye}"/>
@@ -60,7 +61,8 @@
<Path Classes="icon-stroke" Data="{StaticResource Icon.PlanDay}"/> <Path Classes="icon-stroke" Data="{StaticResource Icon.PlanDay}"/>
</Viewbox> </Viewbox>
</Button> </Button>
<Button Classes="icon-btn" Command="{Binding OpenListSettingsCommand}" ToolTip.Tip="{loc:Tr tasks.listSettingsTip}"> <Button Classes="icon-btn" IsVisible="{Binding !IsNotesList}"
Command="{Binding OpenListSettingsCommand}" ToolTip.Tip="{loc:Tr tasks.listSettingsTip}">
<PathIcon Width="15" Height="15" Data="{StaticResource Icon.Settings}"/> <PathIcon Width="15" Height="15" Data="{StaticResource Icon.Settings}"/>
</Button> </Button>
</StackPanel> </StackPanel>
@@ -68,7 +70,8 @@
</Border> </Border>
<!-- Add-task row --> <!-- Add-task row -->
<Border DockPanel.Dock="Top" Classes="add-task" Margin="16,14,16,8"> <Border DockPanel.Dock="Top" Classes="add-task" Margin="16,14,16,8"
IsVisible="{Binding !IsNotesList}">
<Grid ColumnDefinitions="Auto,*,Auto"> <Grid ColumnDefinitions="Auto,*,Auto">
<Border Grid.Column="0" Classes="add-task-plus" VerticalAlignment="Center"> <Border Grid.Column="0" Classes="add-task-plus" VerticalAlignment="Center">
<PathIcon Width="12" Height="12" Data="{StaticResource Icon.Plus}" <PathIcon Width="12" Height="12" Data="{StaticResource Icon.Plus}"
@@ -90,14 +93,6 @@
</Grid> </Grid>
</Border> </Border>
<!-- Notes pinned row (My Day only) -->
<Button DockPanel.Dock="Top"
Classes="btn" HorizontalAlignment="Stretch" HorizontalContentAlignment="Left"
Margin="16,0,16,8"
IsVisible="{Binding ShowNotesRow}"
Command="{Binding OpenNotesCommand}"
Content="{loc:Tr tasks.notesPinnedRow}"/>
<!-- Task list: one flat, virtualized ListBox. Rows is HeaderRow | TaskRowViewModel — group <!-- Task list: one flat, virtualized ListBox. Rows is HeaderRow | TaskRowViewModel — group
headers are regular entries, not a separate ItemsControl per section, so a headers are regular entries, not a separate ItemsControl per section, so a
VirtualizingStackPanel actually bounds the realized container count. VirtualizingStackPanel actually bounds the realized container count.
@@ -106,6 +101,7 @@
unreachable. --> unreachable. -->
<Panel> <Panel>
<ListBox x:Name="RowsListBox" <ListBox x:Name="RowsListBox"
IsVisible="{Binding !IsNotesList}"
ItemsSource="{Binding Rows}" ItemsSource="{Binding Rows}"
Background="Transparent" Background="Transparent"
BorderThickness="0"> BorderThickness="0">
@@ -172,6 +168,11 @@
Foreground="{DynamicResource TextMuteBrush}" Foreground="{DynamicResource TextMuteBrush}"
TextAlignment="Center" TextWrapping="Wrap"/> TextAlignment="Center" TextWrapping="Wrap"/>
</StackPanel> </StackPanel>
<!-- The notes list is not a task list: same island slot, different content. -->
<Panel IsVisible="{Binding IsNotesList}">
<islands:NotesEditorView DataContext="{Binding Notes}"/>
</Panel>
</Panel> </Panel>
</DockPanel> </DockPanel>
+1 -1
View File
@@ -144,7 +144,7 @@
ResizeDirection="Columns" ResizeDirection="Columns"
ResizeBehavior="PreviousAndNext"/> ResizeBehavior="PreviousAndNext"/>
<Border Grid.Column="2" Classes="island" Margin="3"> <Border Grid.Column="2" Grid.ColumnSpan="{Binding TasksColumnSpan}" Classes="island" Margin="3">
<islands:TasksIslandView DataContext="{Binding Tasks}"/> <islands:TasksIslandView DataContext="{Binding Tasks}"/>
</Border> </Border>
@@ -4,6 +4,7 @@
xmlns:vmm="using:ClaudeDo.Ui.ViewModels.MissionControl" xmlns:vmm="using:ClaudeDo.Ui.ViewModels.MissionControl"
xmlns:mc="using:ClaudeDo.Ui.Views.MissionControl" xmlns:mc="using:ClaudeDo.Ui.Views.MissionControl"
xmlns:controls="using:ClaudeDo.Ui.Views.Controls" xmlns:controls="using:ClaudeDo.Ui.Views.Controls"
xmlns:conv="using:ClaudeDo.Ui.Converters"
xmlns:loc="using:ClaudeDo.Ui.Localization" xmlns:loc="using:ClaudeDo.Ui.Localization"
x:DataType="vm:MissionControlViewModel" x:DataType="vm:MissionControlViewModel"
x:Class="ClaudeDo.Ui.Views.MissionControl.MissionControlView"> x:Class="ClaudeDo.Ui.Views.MissionControl.MissionControlView">
@@ -33,9 +34,19 @@
Margin="0,0,4,0" Margin="0,0,4,0"
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
<Button Classes="icon-btn" <Button Classes="icon-btn"
Click="OnNewSessionClicked"
ToolTip.Tip="{loc:Tr missionControl.newSession}"> ToolTip.Tip="{loc:Tr missionControl.newSession}">
<PathIcon Data="{StaticResource Icon.Plus}" Width="15" Height="15"/> <PathIcon Data="{StaticResource Icon.Plus}" Width="15" Height="15"/>
<Button.Flyout>
<MenuFlyout ItemsSource="{Binding SessionTargets}" Opening="OnSessionFlyoutOpening">
<MenuFlyout.ItemContainerTheme>
<ControlTheme TargetType="MenuItem" BasedOn="{StaticResource {x:Type MenuItem}}"
x:DataType="vm:SessionTargetViewModel">
<Setter Property="Header" Value="{Binding Label}"/>
<Setter Property="Command" Value="{Binding StartCommand}"/>
</ControlTheme>
</MenuFlyout.ItemContainerTheme>
</MenuFlyout>
</Button.Flyout>
</Button> </Button>
<Button Classes="icon-btn" <Button Classes="icon-btn"
Command="{Binding ToggleLayoutCommand}" Command="{Binding ToggleLayoutCommand}"
@@ -100,7 +111,7 @@
BorderThickness="1,0,0,0"> BorderThickness="1,0,0,0">
<DockPanel LastChildFill="True" Margin="10,10"> <DockPanel LastChildFill="True" Margin="10,10">
<TextBlock DockPanel.Dock="Top" Classes="eyebrow" <TextBlock DockPanel.Dock="Top" Classes="eyebrow"
Text="{loc:Tr missionControl.queue}" Text="{loc:Tr missionControl.activity}"
Foreground="{DynamicResource TextMuteBrush}" Foreground="{DynamicResource TextMuteBrush}"
LetterSpacing="1.4" Margin="0,0,0,8" /> LetterSpacing="1.4" Margin="0,0,0,8" />
<ScrollViewer> <ScrollViewer>
@@ -112,27 +123,31 @@
Background="Transparent" BorderThickness="0" Background="Transparent" BorderThickness="0"
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch" HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch"
Cursor="Hand"> Cursor="Hand">
<Button.ContextMenu>
<ContextMenu>
<MenuItem Header="{loc:Tr missionControl.openSession}"
Command="{Binding OpenSessionCommand}"
IsEnabled="{Binding CanOpenSession}" />
</ContextMenu>
</Button.ContextMenu>
<Panel> <Panel>
<Border Background="{DynamicResource SurfaceBrush}" <Border Background="{DynamicResource SurfaceBrush}"
BorderBrush="{DynamicResource LineBrush}" BorderBrush="{DynamicResource LineBrush}"
BorderThickness="1" CornerRadius="6" /> BorderThickness="1" CornerRadius="6" />
<Border Background="{DynamicResource RunningTintBrush}" <!-- Single state tint; brush keys come from the VM (Tokens.axaml). -->
BorderBrush="{DynamicResource RunningTintBorderBrush}" <Border Background="{Binding TintKey, Converter={x:Static conv:DotBrushConverter.Instance}}"
BorderBrush="{Binding TintBorderKey, Converter={x:Static conv:DotBrushConverter.Instance}}"
BorderThickness="1" CornerRadius="6" BorderThickness="1" CornerRadius="6"
IsVisible="{Binding IsRunning}" /> IsVisible="{Binding TintKey, Converter={x:Static ObjectConverters.IsNotNull}}" />
<StackPanel Margin="8,6" Spacing="2"> <StackPanel Margin="8,6" Spacing="2">
<TextBlock Text="{Binding Title}" <TextBlock Text="{Binding Title}"
TextTrimming="CharacterEllipsis" TextTrimming="CharacterEllipsis"
ToolTip.Tip="{Binding Title}" ToolTip.Tip="{Binding Title}"
Foreground="{DynamicResource TextDimBrush}" /> Foreground="{DynamicResource TextDimBrush}" />
<TextBlock Classes="meta" <TextBlock Classes="meta"
Text="{loc:Tr missionControl.blocked}" Text="{Binding StatusLabel}"
IsVisible="{Binding IsBlocked}" IsVisible="{Binding StatusLabel, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Foreground="{DynamicResource AmberBrush}" /> Foreground="{Binding StatusColorKey, Converter={x:Static conv:DotBrushConverter.Instance}}" />
<TextBlock Classes="meta"
Text="{loc:Tr missionControl.running}"
IsVisible="{Binding IsRunning}"
Foreground="{DynamicResource StatusRunningBrush}" />
</StackPanel> </StackPanel>
</Panel> </Panel>
</Button> </Button>
@@ -3,7 +3,6 @@ using System.Collections.Specialized;
using System.ComponentModel; using System.ComponentModel;
using System.Linq; using System.Linq;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Layout; using Avalonia.Layout;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Platform.Storage; using Avalonia.Platform.Storage;
@@ -30,8 +29,10 @@ public partial class MissionControlView : UserControl
QueueSplitter.DragCompleted += (_, _) => _queueWidth = BodyGrid.ColumnDefinitions[2].Width; QueueSplitter.DragCompleted += (_, _) => _queueWidth = BodyGrid.ColumnDefinitions[2].Width;
} }
private void OnSessionFlyoutOpening(object? sender, EventArgs e) => _ = _vm?.RefreshSessionTargetsAsync();
// Ad-hoc ConPTY session: the view owns the folder picker, the VM only takes the chosen path. // Ad-hoc ConPTY session: the view owns the folder picker, the VM only takes the chosen path.
private async void OnNewSessionClicked(object? sender, RoutedEventArgs e) private async void BrowseForSessionAsync()
{ {
if (DataContext is not MissionControlViewModel vm) return; if (DataContext is not MissionControlViewModel vm) return;
var topLevel = TopLevel.GetTopLevel(this); var topLevel = TopLevel.GetTopLevel(this);
@@ -53,6 +54,7 @@ public partial class MissionControlView : UserControl
{ {
_vm.Panes.CollectionChanged -= OnPanesChanged; _vm.Panes.CollectionChanged -= OnPanesChanged;
_vm.PropertyChanged -= OnViewModelPropertyChanged; _vm.PropertyChanged -= OnViewModelPropertyChanged;
_vm.BrowseForSessionRequested = null;
} }
_vm = DataContext as MissionControlViewModel; _vm = DataContext as MissionControlViewModel;
@@ -61,6 +63,7 @@ public partial class MissionControlView : UserControl
{ {
_vm.Panes.CollectionChanged += OnPanesChanged; _vm.Panes.CollectionChanged += OnPanesChanged;
_vm.PropertyChanged += OnViewModelPropertyChanged; _vm.PropertyChanged += OnViewModelPropertyChanged;
_vm.BrowseForSessionRequested = BrowseForSessionAsync;
} }
RebuildOverviewGrid(); RebuildOverviewGrid();
@@ -39,6 +39,7 @@
Background="Transparent" Margin="12,16,8,8" Background="Transparent" Margin="12,16,8,8"
VerticalAlignment="Top"> VerticalAlignment="Top">
<ListBoxItem Content="{loc:Tr modals.listSettings.sectionGeneral}"/> <ListBoxItem Content="{loc:Tr modals.listSettings.sectionGeneral}"/>
<ListBoxItem Content="{loc:Tr modals.listSettings.sectionIcon}"/>
<ListBoxItem Content="{loc:Tr modals.listSettings.sectionAgent}"/> <ListBoxItem Content="{loc:Tr modals.listSettings.sectionAgent}"/>
<ListBoxItem Content="{loc:Tr modals.listSettings.sectionVerify}"/> <ListBoxItem Content="{loc:Tr modals.listSettings.sectionVerify}"/>
</ListBox> </ListBox>
@@ -113,6 +114,35 @@
</ScrollViewer> </ScrollViewer>
</TabItem> </TabItem>
<!-- ICON -->
<TabItem Header="{loc:Tr modals.listSettings.sectionIcon}">
<ScrollViewer>
<StackPanel Spacing="8" Margin="0,8,0,0">
<TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.icon}"/>
<ListBox ItemsSource="{Binding Icons}"
SelectedItem="{Binding Icon, Mode=TwoWay}"
Background="Transparent" BorderThickness="0"
HorizontalAlignment="Left">
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel/>
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.ItemTemplate>
<DataTemplate x:DataType="x:String">
<!-- No Foreground here on purpose: the icon inherits the container's, so the
selected/hover states of ListBoxItem reach it (see Ui/CLAUDE.md). -->
<PathIcon Width="20" Height="20" Margin="6"
Data="{Binding Converter={StaticResource IconKey}}"/>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<TextBlock Text="{loc:Tr modals.listSettings.iconHint}"
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
</StackPanel>
</ScrollViewer>
</TabItem>
<!-- AGENT --> <!-- AGENT -->
<TabItem Header="{loc:Tr modals.listSettings.sectionAgent}"> <TabItem Header="{loc:Tr modals.listSettings.sectionAgent}">
<ScrollViewer> <ScrollViewer>
+59
View File
@@ -0,0 +1,59 @@
using System.ComponentModel;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub;
using ModelContextProtocol.Server;
namespace ClaudeDo.Worker.External;
public sealed record AddNoteResult(bool Saved, string Id, int AgentNotesToday, int RemainingToday);
[McpServerToolType]
public sealed class NotesMcpTools
{
/// <summary>Agent-written notes allowed per day. The user's own notes are never counted or
/// capped — this only stops an agent from burying the day in its own bullets.</summary>
public const int DailyAgentCap = 10;
/// <summary>A note is a line, not a report. Longer output belongs in a task or a finding.</summary>
public const int MaxLength = 500;
private readonly DailyNoteRepository _notes;
private readonly HubBroadcaster _broadcaster;
public NotesMcpTools(DailyNoteRepository notes, HubBroadcaster broadcaster)
{
_notes = notes;
_broadcaster = broadcaster;
}
[McpServerTool, Description(
"Add one short line to the user's notes for today — the day log they read in ClaudeDo, " +
"not a log for you. Write only what the user would want to reread later and would " +
"otherwise lose: a decision they made, something you found that changes their plans, " +
"something they asked you to remember for the day. Task status, your progress and " +
"anything already visible in the task list are noise here. When unsure, don't. " +
"Capped at 10 agent notes per day; you cannot edit or delete notes, so a bad one stays " +
"until the user removes it.")]
public async Task<AddNoteResult> AddNote(
[Description("The note itself: one plain sentence, no markdown, no prefix, max 500 characters.")] string text,
CancellationToken cancellationToken = default)
{
var trimmed = (text ?? "").Trim();
if (trimmed.Length == 0)
throw new InvalidOperationException("text is required.");
if (trimmed.Length > MaxLength)
throw new InvalidOperationException(
$"Note is {trimmed.Length} characters, the limit is {MaxLength}. Shorten it to one line.");
var today = DateOnly.FromDateTime(DateTime.Today);
var used = await _notes.CountAgentNotesAsync(today, cancellationToken);
if (used >= DailyAgentCap)
throw new InvalidOperationException(
$"Daily cap reached: {DailyAgentCap} agent notes already written today. " +
"Nothing was saved — tell the user instead of retrying.");
var note = await _notes.AddAsync(today, trimmed, fromAgent: true, cancellationToken);
await _broadcaster.NotesUpdated();
return new AddNoteResult(true, note.Id, used + 1, DailyAgentCap - used - 1);
}
}
@@ -37,6 +37,10 @@ public sealed class HubBroadcaster : IPrimeBroadcaster, IRefineBroadcaster
public Task HandoffRequested(string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase) => public Task HandoffRequested(string taskId, IReadOnlyList<string> survivingTaskIds, string nextPhase) =>
_hub.Clients.All.SendAsync("HandoffRequested", taskId, survivingTaskIds, nextPhase); _hub.Clients.All.SendAsync("HandoffRequested", taskId, survivingTaskIds, nextPhase);
/// <summary>An agent wrote a note through MCP — the open notes editor reloads.</summary>
public Task NotesUpdated() =>
_hub.Clients.All.SendAsync("NotesUpdated");
public Task ListUpdated(string listId) => public Task ListUpdated(string listId) =>
_hub.Clients.All.SendAsync("ListUpdated", listId); _hub.Clients.All.SendAsync("ListUpdated", listId);
+14 -2
View File
@@ -543,6 +543,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
entity.DefaultCommitType = string.IsNullOrWhiteSpace(dto.DefaultCommitType) ? CommitTypeRegistry.DefaultType : dto.DefaultCommitType; entity.DefaultCommitType = string.IsNullOrWhiteSpace(dto.DefaultCommitType) ? CommitTypeRegistry.DefaultType : dto.DefaultCommitType;
entity.IsManual = dto.IsManual; entity.IsManual = dto.IsManual;
entity.FindingsTracked = dto.FindingsTracked; entity.FindingsTracked = dto.FindingsTracked;
entity.Icon = string.IsNullOrWhiteSpace(dto.Icon) ? null : dto.Icon;
await repo.UpdateAsync(entity); await repo.UpdateAsync(entity);
await _broadcaster.ListUpdated(dto.Id); await _broadcaster.ListUpdated(dto.Id);
@@ -969,14 +970,25 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
{ {
using var ctx = _dbFactory.CreateDbContext(); using var ctx = _dbFactory.CreateDbContext();
var notes = await new DailyNoteRepository(ctx).ListByDayAsync(Day(dayIso)); var notes = await new DailyNoteRepository(ctx).ListByDayAsync(Day(dayIso));
return notes.Select(n => new DailyNoteDto(n.Id, n.Date.ToString("yyyy-MM-dd"), n.Text, n.SortOrder)).ToList(); return notes.Select(Dto).ToList();
} }
/// <summary>Both ends inclusive — the editor loads today plus its recent history in one call.</summary>
public async Task<List<DailyNoteDto>> GetDailyNotesBetween(string startIso, string endIso)
{
using var ctx = _dbFactory.CreateDbContext();
var notes = await new DailyNoteRepository(ctx).ListBetweenAsync(Day(startIso), Day(endIso));
return notes.Select(Dto).ToList();
}
private static DailyNoteDto Dto(Data.Models.DailyNoteEntity n) =>
new(n.Id, n.Date.ToString("yyyy-MM-dd"), n.Text, n.SortOrder, n.FromAgent);
public async Task<DailyNoteDto> AddDailyNote(string dayIso, string text) public async Task<DailyNoteDto> AddDailyNote(string dayIso, string text)
{ {
using var ctx = _dbFactory.CreateDbContext(); using var ctx = _dbFactory.CreateDbContext();
var n = await new DailyNoteRepository(ctx).AddAsync(Day(dayIso), text); var n = await new DailyNoteRepository(ctx).AddAsync(Day(dayIso), text);
return new DailyNoteDto(n.Id, n.Date.ToString("yyyy-MM-dd"), n.Text, n.SortOrder); return Dto(n);
} }
public async Task UpdateDailyNote(string id, string text) public async Task UpdateDailyNote(string id, string text)
@@ -986,6 +986,48 @@ public sealed class TaskMergeService
if (task.Status != TaskStatus.WaitingForReview) if (task.Status != TaskStatus.WaitingForReview)
return Blocked("task is not waiting for review"); return Blocked("task is not waiting for review");
if (wt is not null && wt.State == WorktreeState.Kept)
{
// Kept means either RevertMergeAsync undid a previous merge, or the user manually
// parked an active worktree -- either way its branch may still exist. Re-running the
// real merge (not the "nothing to merge" no-op below) is the only way approve can be
// trusted after a revert; if the branch is truly gone (the common case once the
// original approve already deleted it via removeWorktree:true) there is nothing left
// to merge and that must come back as Blocked, never a silent fake "merged".
if (string.IsNullOrWhiteSpace(list.WorkingDir) || !await _git.IsGitRepoAsync(list.WorkingDir, ct))
return Blocked("list has no working directory");
var branches = await _git.ListLocalBranchesAsync(list.WorkingDir, ct);
if (!branches.Contains(wt.BranchName, StringComparer.Ordinal))
return Blocked(
$"worktree was reverted and its branch '{wt.BranchName}' no longer exists — cannot re-approve; discard the task or start a fresh worktree instead.");
var reviveTarget = string.IsNullOrWhiteSpace(targetBranch)
? await _git.GetCurrentBranchAsync(list.WorkingDir, ct)
: targetBranch;
// A branch that was already merged once (reverted or not) is already an ancestor of
// the target tip -- `git merge` on it again is a silent "Already up to date." no-op,
// since merge only checks ancestry, never content. Detect that case up front and
// reapply the original merge commit's own diff instead of falling into the normal
// merge path below, which would report success without actually restoring anything.
var branchTip = await _git.RevParseAsync(list.WorkingDir, wt.BranchName, ct);
var targetTip = await _git.RevParseAsync(list.WorkingDir, reviveTarget, ct);
if (await _git.IsAncestorAsync(list.WorkingDir, branchTip, targetTip, ct) == true)
{
if (string.IsNullOrWhiteSpace(wt.MergeCommit))
return Blocked("worktree branch has no new commits to merge and no merge commit is recorded to reapply");
return await ReapplyRevertedMergeAsync(
task, list, wt, reviveTarget, verifyCommand, ct, progress, skipVerify);
}
using (var ctx = _dbFactory.CreateDbContext())
await new WorktreeRepository(ctx).SetStateAsync(taskId, WorktreeState.Active, ct);
await _broadcaster.WorktreeUpdated(taskId);
wt.State = WorktreeState.Active;
}
if (wt is null || wt.State != WorktreeState.Active) if (wt is null || wt.State != WorktreeState.Active)
{ {
// There is nothing left to merge -- a sandbox run, or a list-handler task that // There is nothing left to merge -- a sandbox run, or a list-handler task that
@@ -1031,6 +1073,107 @@ public sealed class TaskMergeService
return await MergeAsync(taskId, target, removeWorktree: true, commitMessage: "", leaveConflictsInTree, ct, progress, skipVerify); return await MergeAsync(taskId, target, removeWorktree: true, commitMessage: "", leaveConflictsInTree, ct, progress, skipVerify);
} }
/// <summary>
/// Redoes a merge that <see cref="RevertMergeAsync"/> undid (or that predates it, if the branch
/// was already merged before its worktree reached <see cref="WorktreeState.Kept"/>), by
/// cherry-picking the original merge commit's own diff onto <paramref name="targetBranch"/> --
/// see <see cref="GitService.CherryPickMergeCommitAsync"/> for why a plain re-merge can't be
/// used once the branch tip is already an ancestor of the target. Mirrors <see cref="MergeAsync"/>'s
/// shape (gate, dirty/mid-op checks, cleanup, verify gate, approve) but produces a cherry-pick
/// commit instead of a merge commit, and <see cref="WorktreeRepository.SetMergedAsync"/> still
/// records it as this task's <see cref="WorktreeEntity.MergeCommit"/>.
/// </summary>
private async Task<MergeResult> ReapplyRevertedMergeAsync(
TaskEntity task, ListEntity list, WorktreeEntity wt, string targetBranch, string? verifyCommand,
CancellationToken ct, IProgress<ProgressNotificationValue>? progress, bool skipVerify)
{
var taskId = task.Id;
var workingDir = list.WorkingDir!;
await _broadcaster.OperationProgress(taskId, PhaseMerging, 0, 0);
var gate = GetMergeGate(workingDir);
await gate.WaitAsync(ct);
try
{
if (await _git.IsMidMergeAsync(workingDir, ct))
return Blocked("target working directory is mid-merge");
if (await _git.IsMidCherryPickAsync(workingDir, ct))
return Blocked("target working directory is mid-cherry-pick");
if (await _git.HasChangesAsync(workingDir, includeUntracked: false, ct))
return Blocked("target working tree has uncommitted changes");
var currentBranch = await _git.GetCurrentBranchAsync(workingDir, ct);
if (!string.Equals(currentBranch, targetBranch, StringComparison.Ordinal))
{
try { await _git.CheckoutBranchAsync(workingDir, targetBranch, ct); }
catch (Exception ex) { return Blocked($"failed to switch target branch: {ex.Message}"); }
}
var oldTargetTip = await _git.RevParseHeadAsync(workingDir, ct);
var (exitCode, stderr) = await _git.CherryPickMergeCommitAsync(workingDir, wt.MergeCommit!, ct);
if (exitCode != 0)
{
try { await _git.CherryPickAbortAsync(workingDir, ct); }
catch (Exception ex)
{
_logger.LogError(ex, "git cherry-pick --abort failed after conflict — repo is mid-cherry-pick");
return Blocked($"reapply conflict and abort failed: {ex.Message} — repo is mid-cherry-pick, resolve manually");
}
return Blocked($"reapplying the reverted merge conflicted and was aborted: {stderr}");
}
var mergeSha = await _git.RevParseHeadAsync(workingDir, ct);
string? cleanupWarning = null;
try
{
await _git.WorktreeRemoveAsync(workingDir, wt.Path, force: false, ct);
try { await _git.BranchDeleteAsync(workingDir, wt.BranchName, force: false, ct); }
catch (Exception ex)
{
_logger.LogWarning(ex, "branch delete failed for {Branch}", wt.BranchName);
cleanupWarning = $"worktree removed, branch delete failed: {ex.Message}";
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "worktree remove failed for {Path}", wt.Path);
cleanupWarning = $"worktree remove failed: {ex.Message}";
}
await MarkWorktreeMergedAsync(taskId, mergeSha, ct);
await RebaseOthersAfterMergeAsync(task, list, targetBranch, oldTargetTip, mergeSha, ct);
if (!string.IsNullOrWhiteSpace(verifyCommand) && skipVerify)
{
await _broadcaster.WorkerLog(
$"Re-merged #{task.Number} \"{task.Title}\" into {targetBranch} after a revert — verify deferred to batch",
WorkerLogLevel.Info, DateTime.UtcNow);
return new MergeResult(StatusMergedVerifyPending, Array.Empty<string>(), cleanupWarning);
}
var verifyFailure = await RunVerifyGateAsync(
verifyCommand, workingDir, ct, progress,
elapsed => _ = _broadcaster.OperationProgress(taskId, PhaseVerifying, (int)elapsed.TotalSeconds, 0));
if (verifyFailure is not null)
{
_logger.LogWarning("Verify command failed after reapplying reverted merge for task {TaskId}: {Reason}", taskId, verifyFailure.ErrorMessage);
await _broadcaster.WorkerLog($"Verify failed for #{task.Number} \"{task.Title}\" after re-merge into {targetBranch}", WorkerLogLevel.Warn, DateTime.UtcNow);
return verifyFailure;
}
await ApproveIfWaitingForReviewAsync(task, ct);
_logger.LogInformation(
"Reapplied reverted merge for task {TaskId} branch {Branch} into {Target}",
taskId, wt.BranchName, targetBranch);
await _broadcaster.WorkerLog($"Re-merged #{task.Number} \"{task.Title}\" into {targetBranch} after a revert", WorkerLogLevel.Success, DateTime.UtcNow);
return new MergeResult(StatusMerged, Array.Empty<string>(), cleanupWarning);
}
finally { gate.Release(); }
}
/// <summary> /// <summary>
/// The batch counterpart to the per-merge verify gate: runs the list's verify command ONCE /// The batch counterpart to the per-merge verify gate: runs the list's verify command ONCE
/// (under the same per-repo gate) and, on success, promotes every listed task to Done via /// (under the same per-repo gate) and, on success, promotes every listed task to Done via
@@ -7,4 +7,9 @@ namespace ClaudeDo.Worker.Planning;
public interface IActiveMergeState public interface IActiveMergeState
{ {
bool HasActiveMerge(string taskId); bool HasActiveMerge(string taskId);
// In-memory state above is empty after a worker restart. This falls back to an on-disk
// check (repo mid-merge) for the one case that matters: a unit-merge parent paused on a
// conflict when the process died mid-drain.
Task<bool> HasActiveMergeAsync(string taskId, CancellationToken ct);
} }
@@ -164,6 +164,31 @@ public sealed class PlanningMergeOrchestrator : IActiveMergeState
public bool HasActiveMerge(string parentTaskId) => public bool HasActiveMerge(string parentTaskId) =>
_states.TryGetValue(parentTaskId, out var s) && (s.CurrentSubtaskId is not null || s.IsFinalizing); _states.TryGetValue(parentTaskId, out var s) && (s.CurrentSubtaskId is not null || s.IsFinalizing);
/// <summary>Stateless fallback for <see cref="HasActiveMerge"/>: after a worker restart
/// <see cref="_states"/> is empty, so a unit-merge parent paused on a conflict would look
/// cancellable when its repo is still mid-merge. Only a WaitingForReview task with children
/// (i.e. a would-be merge parent) triggers the on-disk check — an ordinary task in the same
/// list must stay cancellable even while the list's repo is mid-merge.</summary>
public async Task<bool> HasActiveMergeAsync(string taskId, CancellationToken ct)
{
if (HasActiveMerge(taskId)) return true;
string? workingDir;
bool hasChildren;
await using (var ctx = _dbFactory.CreateDbContext())
{
var task = await ctx.Tasks
.Include(t => t.List)
.SingleOrDefaultAsync(t => t.Id == taskId, ct);
if (task is null || task.Status != TaskStatus.WaitingForReview) return false;
workingDir = task.List.WorkingDir;
hasChildren = await ctx.Tasks.AnyAsync(c => c.ParentTaskId == taskId, ct);
}
if (!hasChildren || string.IsNullOrWhiteSpace(workingDir)) return false;
return await _git.IsMidMergeAsync(workingDir, ct);
}
/// <summary>Externally-driven unit merges currently paused on a conflict, so the UI can /// <summary>Externally-driven unit merges currently paused on a conflict, so the UI can
/// recover this on reconnect instead of relying solely on the one-shot broadcast. Checked /// recover this on reconnect instead of relying solely on the one-shot broadcast. Checked
/// against <see cref="GitService.IsMidMergeAsync"/> rather than trusting the in-memory flag /// against <see cref="GitService.IsMidMergeAsync"/> rather than trusting the in-memory flag
@@ -98,6 +98,9 @@ public sealed class PlanningSessionManager
if (!await _git.IsGitRepoAsync(listWorkingDir, ct)) if (!await _git.IsGitRepoAsync(listWorkingDir, ct))
throw new InvalidOperationException($"Working directory is not a git repository: {listWorkingDir}"); throw new InvalidOperationException($"Working directory is not a git repository: {listWorkingDir}");
// A fresh `git init` repo has no HEAD commit to base the planning worktree on.
await _git.EnsureHeadCommitAsync(listWorkingDir, ct);
var appSettings = await settings.GetAsync(ct); var appSettings = await settings.GetAsync(ct);
var worktreePath = WorktreePathFor(taskId, appSettings.WorktreeStrategy, appSettings.CentralWorktreeRoot, listWorkingDir); var worktreePath = WorktreePathFor(taskId, appSettings.WorktreeStrategy, appSettings.CentralWorktreeRoot, listWorkingDir);
var branchName = BranchNameFor(taskId); var branchName = BranchNameFor(taskId);
+4 -1
View File
@@ -355,7 +355,9 @@ if (cfg.ExternalMcpPort > 0)
externalBuilder.Services.AddScoped<TaskWaitMcpTools>(); externalBuilder.Services.AddScoped<TaskWaitMcpTools>();
externalBuilder.Services.AddScoped<QueueStateMcpTools>(); externalBuilder.Services.AddScoped<QueueStateMcpTools>();
externalBuilder.Services.AddScoped<AttachmentMcpTools>(); externalBuilder.Services.AddScoped<AttachmentMcpTools>();
externalBuilder.Services.AddScoped<DailyNoteRepository>();
externalBuilder.Services.AddScoped<FindingsMcpTools>(); externalBuilder.Services.AddScoped<FindingsMcpTools>();
externalBuilder.Services.AddScoped<NotesMcpTools>();
externalBuilder.Services.AddMcpServer() externalBuilder.Services.AddMcpServer()
.WithHttpTransport(o => o.Stateless = true) .WithHttpTransport(o => o.Stateless = true)
.WithRequestFilters(f => f.AddCallToolFilter(ExternalMcpExceptionFilter.Wrap)) .WithRequestFilters(f => f.AddCallToolFilter(ExternalMcpExceptionFilter.Wrap))
@@ -371,7 +373,8 @@ if (cfg.ExternalMcpPort > 0)
.WithTools<TaskWaitMcpTools>() .WithTools<TaskWaitMcpTools>()
.WithTools<QueueStateMcpTools>() .WithTools<QueueStateMcpTools>()
.WithTools<AttachmentMcpTools>() .WithTools<AttachmentMcpTools>()
.WithTools<FindingsMcpTools>(); .WithTools<FindingsMcpTools>()
.WithTools<NotesMcpTools>();
externalBuilder.WebHost.UseUrls($"http://127.0.0.1:{cfg.ExternalMcpPort}"); externalBuilder.WebHost.UseUrls($"http://127.0.0.1:{cfg.ExternalMcpPort}");
externalApp = externalBuilder.Build(); externalApp = externalBuilder.Build();
+1 -1
View File
@@ -1,3 +1,3 @@
namespace ClaudeDo.Worker.Report; namespace ClaudeDo.Worker.Report;
public sealed record DailyNoteDto(string Id, string Date, string Text, int SortOrder); public sealed record DailyNoteDto(string Id, string Date, string Text, int SortOrder, bool FromAgent);
@@ -46,6 +46,10 @@ public sealed class WorktreeManager
if (!await _git.IsGitRepoAsync(workingDir, ct)) if (!await _git.IsGitRepoAsync(workingDir, ct))
throw new InvalidOperationException($"working_dir is not a git repository: {workingDir}"); throw new InvalidOperationException($"working_dir is not a git repository: {workingDir}");
// A fresh `git init` repo has no HEAD commit to base a worktree on — bootstrap one.
if (await _git.EnsureHeadCommitAsync(workingDir, ct))
_logger.LogInformation("Repository at {Dir} had no commits; created an empty bootstrap commit", workingDir);
var baseCommit = await ResolveBaseCommitAsync(task, workingDir, ct); var baseCommit = await ResolveBaseCommitAsync(task, workingDir, ct);
// Use the full task id (dashes stripped) in the branch name so // Use the full task id (dashes stripped) in the branch name so
// two GUIDs sharing an 8-char prefix cannot collide on the same branch. // two GUIDs sharing an 8-char prefix cannot collide on the same branch.
@@ -286,7 +286,7 @@ public sealed class TaskStateService : ITaskStateService
// from under it would flip the parent to Cancelled while the orchestrator keeps merging — // from under it would flip the parent to Cancelled while the orchestrator keeps merging —
// FinalizeParentDoneAsync then finds the parent no longer WaitingForReview and gives up, // FinalizeParentDoneAsync then finds the parent no longer WaitingForReview and gives up,
// leaving the merged children's diffs stranded with no way to roll them back. // leaving the merged children's diffs stranded with no way to roll them back.
if (_mergeState().HasActiveMerge(taskId)) if (await _mergeState().HasActiveMergeAsync(taskId, ct))
return new TransitionResult(false, "A merge is in progress for this task; wait for it to finish before cancelling."); return new TransitionResult(false, "A merge is in progress for this task; wait for it to finish before cancelling.");
List<string> cancelledChildIds; List<string> cancelledChildIds;
@@ -26,6 +26,7 @@ public abstract class StubWorkerClient : IWorkerClient
public event Action<string, string, string>? TaskQuestionAskedEvent; public event Action<string, string, string>? TaskQuestionAskedEvent;
public event Action<string, string>? TaskQuestionResolvedEvent; public event Action<string, string>? TaskQuestionResolvedEvent;
public event Action<string, IReadOnlyList<string>, string>? HandoffRequestedEvent; public event Action<string, IReadOnlyList<string>, string>? HandoffRequestedEvent;
public event Action? NotesUpdatedEvent;
public event Action? PrepStartedEvent; public event Action? PrepStartedEvent;
public event Action<string>? PrepLineEvent; public event Action<string>? PrepLineEvent;
public event Action<bool>? PrepFinishedEvent; public event Action<bool>? PrepFinishedEvent;
@@ -163,6 +164,7 @@ public abstract class StubWorkerClient : IWorkerClient
public virtual Task<(bool Ok, string? Error)> SetWorktreeStateAsync(string taskId, WorktreeState newState) => Task.FromResult((true, (string?)null)); public virtual Task<(bool Ok, string? Error)> SetWorktreeStateAsync(string taskId, WorktreeState newState) => Task.FromResult((true, (string?)null));
public virtual Task<ForceRemoveResultDto?> ForceRemoveWorktreeAsync(string taskId) => Task.FromResult<ForceRemoveResultDto?>(null); public virtual Task<ForceRemoveResultDto?> ForceRemoveWorktreeAsync(string taskId) => Task.FromResult<ForceRemoveResultDto?>(null);
public virtual Task<List<DailyNoteDto>> GetDailyNotesAsync(DateOnly day) => Task.FromResult(new List<DailyNoteDto>()); public virtual Task<List<DailyNoteDto>> GetDailyNotesAsync(DateOnly day) => Task.FromResult(new List<DailyNoteDto>());
public virtual Task<List<DailyNoteDto>> GetDailyNotesBetweenAsync(DateOnly start, DateOnly end) => Task.FromResult(new List<DailyNoteDto>());
public virtual Task<DailyNoteDto?> AddDailyNoteAsync(DateOnly day, string text) => Task.FromResult<DailyNoteDto?>(null); public virtual Task<DailyNoteDto?> AddDailyNoteAsync(DateOnly day, string text) => Task.FromResult<DailyNoteDto?>(null);
public virtual Task UpdateDailyNoteAsync(string id, string text) => Task.CompletedTask; public virtual Task UpdateDailyNoteAsync(string id, string text) => Task.CompletedTask;
public virtual Task DeleteDailyNoteAsync(string id) => Task.CompletedTask; public virtual Task DeleteDailyNoteAsync(string id) => Task.CompletedTask;
+1
View File
@@ -23,6 +23,7 @@ public sealed class NullServiceProvider : IServiceProvider
public sealed class StubNotesApi : INotesApi public sealed class StubNotesApi : INotesApi
{ {
public Task<List<DailyNoteDto>> ListAsync(DateOnly day) => Task.FromResult(new List<DailyNoteDto>()); public Task<List<DailyNoteDto>> ListAsync(DateOnly day) => Task.FromResult(new List<DailyNoteDto>());
public Task<List<DailyNoteDto>> ListBetweenAsync(DateOnly start, DateOnly end) => Task.FromResult(new List<DailyNoteDto>());
public Task<DailyNoteDto?> AddAsync(DateOnly day, string text) => Task.FromResult<DailyNoteDto?>(null); public Task<DailyNoteDto?> AddAsync(DateOnly day, string text) => Task.FromResult<DailyNoteDto?>(null);
public Task UpdateAsync(string id, string text) => Task.CompletedTask; public Task UpdateAsync(string id, string text) => Task.CompletedTask;
public Task DeleteAsync(string id) => Task.CompletedTask; public Task DeleteAsync(string id) => Task.CompletedTask;
@@ -48,7 +48,7 @@ public class DetailsIslandConflictSeamTests : IDisposable
private DetailsIslandViewModel BuildVm(StubWorkerClient worker, MergeCoordinator merge) private DetailsIslandViewModel BuildVm(StubWorkerClient worker, MergeCoordinator merge)
{ {
var factory = new TestDbFactory(NewContext); var factory = new TestDbFactory(NewContext);
return new DetailsIslandViewModel(factory, worker, new NullServiceProvider(), new StubNotesApi(), merge); return new DetailsIslandViewModel(factory, worker, new NullServiceProvider(), merge);
} }
[Fact] [Fact]
@@ -56,7 +56,7 @@ public class DetailsIslandDeleteTaskTests : IDisposable
private DetailsIslandViewModel BuildVm(RecordingWorkerClient worker) private DetailsIslandViewModel BuildVm(RecordingWorkerClient worker)
{ {
var factory = new TestDbFactory(NewContext); var factory = new TestDbFactory(NewContext);
return new DetailsIslandViewModel(factory, worker, new NullServiceProvider(), new StubNotesApi(), new ClaudeDo.Ui.Services.MergeCoordinator()); return new DetailsIslandViewModel(factory, worker, new NullServiceProvider(), new ClaudeDo.Ui.Services.MergeCoordinator());
} }
[Fact] [Fact]
@@ -59,7 +59,7 @@ public class DetailsIslandDescriptionSaveRaceTests : IDisposable
} }
private DetailsIslandViewModel BuildVm() => private DetailsIslandViewModel BuildVm() =>
new(new TestDbFactory(NewContext), new FakeWorker(), new NullServiceProvider(), new StubNotesApi(), new(new TestDbFactory(NewContext), new FakeWorker(), new NullServiceProvider(),
new ClaudeDo.Ui.Services.MergeCoordinator()); new ClaudeDo.Ui.Services.MergeCoordinator());
private async Task<TaskEntity> ReadTaskAsync(string id) private async Task<TaskEntity> ReadTaskAsync(string id)
@@ -38,7 +38,7 @@ public class DetailsIslandEmptyStateTests : IDisposable
private DetailsIslandViewModel NewDetailsVm(StubWorkerClient stub) private DetailsIslandViewModel NewDetailsVm(StubWorkerClient stub)
{ {
var factory = new TestDbFactory(NewContext); var factory = new TestDbFactory(NewContext);
return new DetailsIslandViewModel(factory, stub, new NullServiceProvider(), new StubNotesApi(), new ClaudeDo.Ui.Services.MergeCoordinator()); return new DetailsIslandViewModel(factory, stub, new NullServiceProvider(), new ClaudeDo.Ui.Services.MergeCoordinator());
} }
[Fact] [Fact]
@@ -60,17 +60,6 @@ public class DetailsIslandEmptyStateTests : IDisposable
Assert.False(vm.IsEmptyStateVisible); Assert.False(vm.IsEmptyStateVisible);
} }
[Fact]
public void IsEmptyStateVisible_false_in_notes_mode_even_without_a_task()
{
var vm = NewDetailsVm(new DefaultStub());
vm.ShowNotes();
Assert.True(vm.IsNotesMode);
Assert.Null(vm.Task);
Assert.False(vm.IsEmptyStateVisible);
}
[Fact] [Fact]
public void IsEmptyStateVisible_false_in_prep_mode_even_without_a_task() public void IsEmptyStateVisible_false_in_prep_mode_even_without_a_task()
@@ -86,7 +86,7 @@ public class DetailsIslandErrorFeedbackTests : IDisposable
{ {
var factory = new TestDbFactory(NewContext); var factory = new TestDbFactory(NewContext);
return new DetailsIslandViewModel( return new DetailsIslandViewModel(
factory, worker, new NullServiceProvider(), new StubNotesApi(), new ClaudeDo.Ui.Services.MergeCoordinator()); factory, worker, new NullServiceProvider(), new ClaudeDo.Ui.Services.MergeCoordinator());
} }
[Fact] [Fact]
@@ -48,7 +48,7 @@ public class DetailsIslandHandlerRangeTests : IDisposable
{ {
var factory = new TestDbFactory(NewContext); var factory = new TestDbFactory(NewContext);
return new DetailsIslandViewModel( return new DetailsIslandViewModel(
factory, new FakeWorkerClient(), new NullServiceProvider(), new StubNotesApi(), new MergeCoordinator()); factory, new FakeWorkerClient(), new NullServiceProvider(), new MergeCoordinator());
} }
[Fact] [Fact]
@@ -47,7 +47,7 @@ public class DetailsIslandPlanningTests : IDisposable
private DetailsIslandViewModel BuildVm(StubWorkerClient worker) private DetailsIslandViewModel BuildVm(StubWorkerClient worker)
{ {
var factory = new TestDbFactory(NewContext); var factory = new TestDbFactory(NewContext);
return new DetailsIslandViewModel(factory, worker, new NullServiceProvider(), new StubNotesApi(), new ClaudeDo.Ui.Services.MergeCoordinator()); return new DetailsIslandViewModel(factory, worker, new NullServiceProvider(), new ClaudeDo.Ui.Services.MergeCoordinator());
} }
// Connected worker whose review calls fail the way the hub does when the task // Connected worker whose review calls fail the way the hub does when the task
@@ -37,7 +37,7 @@ public class DetailsIslandPrepModeTests : IDisposable
private DetailsIslandViewModel NewDetailsVm(StubWorkerClient stub) private DetailsIslandViewModel NewDetailsVm(StubWorkerClient stub)
{ {
var factory = new TestDbFactory(NewContext); var factory = new TestDbFactory(NewContext);
return new DetailsIslandViewModel(factory, stub, new NullServiceProvider(), new StubNotesApi(), new ClaudeDo.Ui.Services.MergeCoordinator()); return new DetailsIslandViewModel(factory, stub, new NullServiceProvider(), new ClaudeDo.Ui.Services.MergeCoordinator());
} }
@@ -53,13 +53,12 @@ public class DetailsIslandPrepModeTests : IDisposable
} }
[Fact] [Fact]
public void ShowPrep_sets_prep_mode_and_clears_notes_mode() public void ShowPrep_sets_prep_mode_and_hides_the_task_detail()
{ {
var vm = NewDetailsVm(new DefaultStub()); var vm = NewDetailsVm(new DefaultStub());
vm.ShowPrep(); vm.ShowPrep();
Assert.True(vm.IsPrepMode); Assert.True(vm.IsPrepMode);
Assert.False(vm.IsNotesMode);
Assert.False(vm.IsTaskDetailVisible); Assert.False(vm.IsTaskDetailVisible);
} }
@@ -48,7 +48,7 @@ public class DetailsIslandResetAndRetryTests : IDisposable
{ {
var factory = new TestDbFactory(NewContext); var factory = new TestDbFactory(NewContext);
return new DetailsIslandViewModel( return new DetailsIslandViewModel(
factory, worker, new NullServiceProvider(), new StubNotesApi(), new ClaudeDo.Ui.Services.MergeCoordinator()); factory, worker, new NullServiceProvider(), new ClaudeDo.Ui.Services.MergeCoordinator());
} }
[Fact] [Fact]
@@ -50,7 +50,7 @@ public class DetailsIslandReviewActionsTests : IDisposable
private DetailsIslandViewModel BuildVm(StubWorkerClient worker) private DetailsIslandViewModel BuildVm(StubWorkerClient worker)
{ {
var factory = new TestDbFactory(NewContext); var factory = new TestDbFactory(NewContext);
return new DetailsIslandViewModel(factory, worker, new NullServiceProvider(), new StubNotesApi(), new ClaudeDo.Ui.Services.MergeCoordinator()); return new DetailsIslandViewModel(factory, worker, new NullServiceProvider(), new ClaudeDo.Ui.Services.MergeCoordinator());
} }
[Fact] [Fact]
@@ -63,7 +63,7 @@ public class DetailsIslandRoadblockReplyTests : IDisposable
private DetailsIslandViewModel BuildVm(StubWorkerClient worker) private DetailsIslandViewModel BuildVm(StubWorkerClient worker)
{ {
var factory = new TestDbFactory(NewContext); var factory = new TestDbFactory(NewContext);
return new DetailsIslandViewModel(factory, worker, new NullServiceProvider(), new StubNotesApi(), new ClaudeDo.Ui.Services.MergeCoordinator()); return new DetailsIslandViewModel(factory, worker, new NullServiceProvider(), new ClaudeDo.Ui.Services.MergeCoordinator());
} }
private static DetailsIslandViewModel PrepareDoneTaskWithRoadblock(DetailsIslandViewModel vm, string taskId = "task-rb-1") private static DetailsIslandViewModel PrepareDoneTaskWithRoadblock(DetailsIslandViewModel vm, string taskId = "task-rb-1")
@@ -38,7 +38,7 @@ public class DetailsIslandTabsTests : IDisposable
private DetailsIslandViewModel NewVm() private DetailsIslandViewModel NewVm()
{ {
var factory = new TestDbFactory(NewContext); var factory = new TestDbFactory(NewContext);
return new DetailsIslandViewModel(factory, new DefaultStub(), new NullServiceProvider(), new StubNotesApi(), new ClaudeDo.Ui.Services.MergeCoordinator()); return new DetailsIslandViewModel(factory, new DefaultStub(), new NullServiceProvider(), new ClaudeDo.Ui.Services.MergeCoordinator());
} }
[Fact] [Fact]
@@ -40,7 +40,7 @@ public class DetailsIslandTaskIdBadgeTests : IDisposable
private DetailsIslandViewModel NewVm() private DetailsIslandViewModel NewVm()
{ {
var factory = new TestDbFactory(NewContext); var factory = new TestDbFactory(NewContext);
return new DetailsIslandViewModel(factory, new DefaultStub(), new NullServiceProvider(), new StubNotesApi(), new ClaudeDo.Ui.Services.MergeCoordinator()); return new DetailsIslandViewModel(factory, new DefaultStub(), new NullServiceProvider(), new ClaudeDo.Ui.Services.MergeCoordinator());
} }
[Fact] [Fact]
@@ -57,7 +57,7 @@ public class DetailsIslandTaskUpdatedTests : IDisposable
} }
private DetailsIslandViewModel BuildVm(StubWorkerClient worker) => private DetailsIslandViewModel BuildVm(StubWorkerClient worker) =>
new(new TestDbFactory(NewContext), worker, new NullServiceProvider(), new StubNotesApi(), new(new TestDbFactory(NewContext), worker, new NullServiceProvider(),
new ClaudeDo.Ui.Services.MergeCoordinator()); new ClaudeDo.Ui.Services.MergeCoordinator());
[Fact] [Fact]
@@ -202,6 +202,66 @@ public class MissionControlViewModelTests : IDisposable
Assert.Equal(new[] { "first", "second" }, vm.Queued.Select(q => q.Title).ToArray()); Assert.Equal(new[] { "first", "second" }, vm.Queued.Select(q => q.Title).ToArray());
} }
// ── activity strip: attention rows come first and carry a tint ─────────────
[Fact]
public async Task RefreshQueueAsync_IncludesAttentionRows_AttentionFirst_WithTint()
{
await using (var db = NewContext())
{
db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow });
db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "q1", ListId = "L1", Title = "queued", Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow, SortOrder = 0 });
db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "run1", ListId = "L1", Title = "running", Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow, SortOrder = 1 });
db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "rev1", ListId = "L1", Title = "review", Status = TaskStatus.WaitingForReview, CreatedAt = DateTime.UtcNow, SortOrder = 2 });
db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "rb1", ListId = "L1", Title = "roadblocked", Status = TaskStatus.WaitingForReview, RoadblockCount = 2, CreatedAt = DateTime.UtcNow, SortOrder = 3 });
db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "f1", ListId = "L1", Title = "failed", Status = TaskStatus.Failed, CreatedAt = DateTime.UtcNow, SortOrder = 4 });
db.Tasks.Add(new TaskEntity { Number = TestTaskNumbers.Next(), Id = "done1", ListId = "L1", Title = "done", Status = TaskStatus.Done, CreatedAt = DateTime.UtcNow, SortOrder = 5 });
await db.SaveChangesAsync();
}
using var vm = BuildVm(new FakeWorker());
await vm.RefreshQueueAsync();
// Done is not activity; the other five are, ordered failed → review → running → queued.
Assert.Equal(new[] { "f1", "rev1", "rb1", "run1", "q1" }, vm.Queued.Select(q => q.Id).ToArray());
Assert.Equal("ErrorTint", vm.Queued[0].TintKey);
Assert.Equal("DoneTint", vm.Queued[1].TintKey);
Assert.Equal("RoadblockTint", vm.Queued[2].TintKey); // roadblocks outrank plain review
Assert.Equal("RunningTint", vm.Queued[3].TintKey);
Assert.Null(vm.Queued[4].TintKey);
Assert.Equal("RunningTintBorder", vm.Queued[3].TintBorderKey);
// A running task's worktree belongs to the autonomous run — no interactive session on it.
Assert.False(vm.Queued[3].CanOpenSession);
Assert.True(vm.Queued[4].CanOpenSession);
}
[Fact]
public async Task RefreshSessionTargetsAsync_ListsWithWorkingDir_PlusBrowse()
{
await using (var db = NewContext())
{
db.Lists.Add(new ListEntity { Id = "L1", Name = "Alpha", WorkingDir = @"C:\repos\alpha", CreatedAt = DateTime.UtcNow, SortOrder = 1 });
db.Lists.Add(new ListEntity { Id = "L2", Name = "Beta", WorkingDir = @"C:\repos\beta", CreatedAt = DateTime.UtcNow, SortOrder = 0 });
db.Lists.Add(new ListEntity { Id = "L3", Name = "NoDir", CreatedAt = DateTime.UtcNow, SortOrder = 2 });
await db.SaveChangesAsync();
}
using var vm = BuildVm(new FakeWorker());
var browsed = false;
vm.BrowseForSessionRequested = () => browsed = true;
await vm.RefreshSessionTargetsAsync();
// Browse first (survives a failing list query), then the lists in sort order.
Assert.Equal(3, vm.SessionTargets.Count);
Assert.Equal(new[] { "Beta", "Alpha" }, vm.SessionTargets.Skip(1).Select(t => t.Label).ToArray());
vm.SessionTargets[0].StartCommand.Execute(null);
Assert.True(browsed);
}
private async Task SeedQueueAsync() private async Task SeedQueueAsync()
{ {
await using var db = NewContext(); await using var db = NewContext();
@@ -6,42 +6,110 @@ namespace ClaudeDo.Ui.Tests.ViewModels;
public class NotesEditorViewModelTests public class NotesEditorViewModelTests
{ {
private static readonly DateOnly Today = DateOnly.FromDateTime(DateTime.Today);
private sealed class FakeNotes : INotesApi private sealed class FakeNotes : INotesApi
{ {
public readonly List<DailyNoteDto> Store = new(); public readonly List<DailyNoteDto> Store = new();
private int _seq; private int _seq;
public Task<List<DailyNoteDto>> ListAsync(DateOnly day) => public Task<List<DailyNoteDto>> ListAsync(DateOnly day) =>
Task.FromResult(Store.Where(n => n.Date == day.ToString("yyyy-MM-dd")).ToList()); Task.FromResult(Store.Where(n => n.Date == Iso(day)).ToList());
public Task<List<DailyNoteDto>> ListBetweenAsync(DateOnly start, DateOnly end) =>
Task.FromResult(Store
.Where(n => DateOnly.Parse(n.Date) >= start && DateOnly.Parse(n.Date) <= end)
.ToList());
public Task<DailyNoteDto?> AddAsync(DateOnly day, string text) public Task<DailyNoteDto?> AddAsync(DateOnly day, string text)
{ {
var dto = new DailyNoteDto($"id{_seq++}", day.ToString("yyyy-MM-dd"), text, Store.Count); var dto = new DailyNoteDto($"id{_seq++}", Iso(day), text, Store.Count, false);
Store.Add(dto); Store.Add(dto);
return Task.FromResult<DailyNoteDto?>(dto); return Task.FromResult<DailyNoteDto?>(dto);
} }
public Task UpdateAsync(string id, string text) public Task UpdateAsync(string id, string text)
{ {
var i = Store.FindIndex(n => n.Id == id); var i = Store.FindIndex(n => n.Id == id);
if (i >= 0) Store[i] = Store[i] with { Text = text }; if (i >= 0) Store[i] = Store[i] with { Text = text };
return Task.CompletedTask; return Task.CompletedTask;
} }
public Task DeleteAsync(string id) { Store.RemoveAll(n => n.Id == id); return Task.CompletedTask; } public Task DeleteAsync(string id) { Store.RemoveAll(n => n.Id == id); return Task.CompletedTask; }
public void Seed(DateOnly day, string text, bool fromAgent = false) =>
Store.Add(new DailyNoteDto($"id{_seq++}", Iso(day), text, Store.Count, fromAgent));
private static string Iso(DateOnly d) => d.ToString("yyyy-MM-dd");
} }
private sealed class ThrowingNotes : INotesApi private sealed class ThrowingNotes : INotesApi
{ {
public string ExceptionMessage { get; init; } = "worker offline"; public string ExceptionMessage { get; init; } = "worker offline";
public Task<List<DailyNoteDto>> ListAsync(DateOnly day) => Task.FromResult(new List<DailyNoteDto>()); public Task<List<DailyNoteDto>> ListAsync(DateOnly day) => Task.FromResult(new List<DailyNoteDto>());
public Task<List<DailyNoteDto>> ListBetweenAsync(DateOnly start, DateOnly end) => Task.FromResult(new List<DailyNoteDto>());
public Task<DailyNoteDto?> AddAsync(DateOnly day, string text) => throw new Exception(ExceptionMessage); public Task<DailyNoteDto?> AddAsync(DateOnly day, string text) => throw new Exception(ExceptionMessage);
public Task UpdateAsync(string id, string text) => Task.CompletedTask; public Task UpdateAsync(string id, string text) => Task.CompletedTask;
public Task DeleteAsync(string id) => Task.CompletedTask; public Task DeleteAsync(string id) => Task.CompletedTask;
} }
private static async Task<(NotesEditorViewModel vm, FakeNotes api)> LoadedAsync(Action<FakeNotes>? seed = null)
{
var api = new FakeNotes();
seed?.Invoke(api);
var vm = new NotesEditorViewModel(api);
await vm.LoadAsync();
return (vm, api);
}
private static List<NoteBulletViewModel> Bullets(NotesEditorViewModel vm) =>
vm.Rows.OfType<NoteBulletViewModel>().ToList();
[Fact]
public async Task LoadAsync_groups_notes_by_day_with_today_first()
{
var (vm, _) = await LoadedAsync(api =>
{
api.Seed(Today.AddDays(-2), "vorgestern");
api.Seed(Today, "heute");
});
var headers = vm.Rows.OfType<NoteDayHeaderViewModel>().ToList();
Assert.Equal(Today, headers[0].Day);
Assert.True(headers[0].IsToday);
Assert.Equal(Today.AddDays(-2), headers[1].Day);
Assert.Equal("heute", ((NoteBulletViewModel)vm.Rows[1]).Text);
}
[Fact]
public async Task LoadAsync_keeps_todays_header_when_there_is_nothing_yet()
{
var (vm, _) = await LoadedAsync();
Assert.Single(vm.Rows);
Assert.True(vm.IsEmpty);
}
[Fact]
public async Task AddBullet_files_the_note_under_today_and_clears_the_box()
{
var (vm, api) = await LoadedAsync(api => api.Seed(Today.AddDays(-1), "gestern"));
vm.NewBulletText = "Standup vorbereitet";
await vm.AddBulletCommand.ExecuteAsync(null);
Assert.Equal("Standup vorbereitet", ((NoteBulletViewModel)vm.Rows[1]).Text);
Assert.Equal("", vm.NewBulletText);
Assert.False(vm.IsEmpty);
Assert.Equal(2, api.Store.Count);
}
[Fact] [Fact]
public async Task AddBullet_WhenApiThrows_RaisesErrorReported_AndKeepsDraftText() public async Task AddBullet_WhenApiThrows_RaisesErrorReported_AndKeepsDraftText()
{ {
var api = new ThrowingNotes(); var api = new ThrowingNotes();
var vm = new NotesEditorViewModel(api); var vm = new NotesEditorViewModel(api);
await vm.LoadDayAsync(new DateOnly(2026, 6, 1)); await vm.LoadAsync();
string? reportedError = null; string? reportedError = null;
vm.ErrorReported += msg => reportedError = msg; vm.ErrorReported += msg => reportedError = msg;
@@ -50,73 +118,123 @@ public class NotesEditorViewModelTests
await vm.AddBulletCommand.ExecuteAsync(null); await vm.AddBulletCommand.ExecuteAsync(null);
Assert.Equal(api.ExceptionMessage, reportedError); Assert.Equal(api.ExceptionMessage, reportedError);
Assert.Empty(vm.Bullets); Assert.Empty(Bullets(vm));
Assert.Equal("Standup vorbereitet", vm.NewBulletText); Assert.Equal("Standup vorbereitet", vm.NewBulletText);
} }
[Fact] [Fact]
public async Task AddBullet_PersistsAndAppears_ForCurrentDay() public async Task SplitBullet_saves_the_edit_then_opens_an_empty_row_below_and_focuses_it()
{ {
var api = new FakeNotes(); var (vm, api) = await LoadedAsync(api => api.Seed(Today, "erste"));
var vm = new NotesEditorViewModel(api); NoteBulletViewModel? focused = null;
await vm.LoadDayAsync(new DateOnly(2026, 6, 1)); vm.FocusRequested += b => focused = b;
vm.NewBulletText = "Standup vorbereitet"; var first = Bullets(vm)[0];
await vm.AddBulletCommand.ExecuteAsync(null); first.Text = "erste, bearbeitet";
await vm.SplitBulletCommand.ExecuteAsync(first);
Assert.Single(vm.Bullets); var rows = Bullets(vm);
Assert.Equal("Standup vorbereitet", vm.Bullets[0].Text); Assert.Equal(2, rows.Count);
Assert.Equal("", vm.NewBulletText); Assert.Same(rows[1], focused);
Assert.True(rows[1].IsNew);
Assert.Equal("erste, bearbeitet", api.Store[0].Text);
// The empty row is UI-only until it gets text, so Enter on a blank line leaves nothing behind.
Assert.Single(api.Store); Assert.Single(api.Store);
} }
[Fact] [Fact]
public async Task PrevAndNextDay_NavigateAndReload() public async Task SplitBullet_on_an_emptied_note_deletes_it_and_adds_no_row()
{ {
var api = new FakeNotes(); var (vm, api) = await LoadedAsync(api => api.Seed(Today, "weg damit"));
await api.AddAsync(new DateOnly(2026, 5, 31), "gestern");
var vm = new NotesEditorViewModel(api);
await vm.LoadDayAsync(new DateOnly(2026, 6, 1));
Assert.Empty(vm.Bullets);
await vm.PrevDayCommand.ExecuteAsync(null); var only = Bullets(vm)[0];
Assert.Equal(new DateOnly(2026, 5, 31), vm.CurrentDay); only.Text = " ";
Assert.Single(vm.Bullets); await vm.SplitBulletCommand.ExecuteAsync(only);
await vm.NextDayCommand.ExecuteAsync(null); Assert.Empty(Bullets(vm));
Assert.Equal(new DateOnly(2026, 6, 1), vm.CurrentDay); Assert.Empty(api.Store);
Assert.Empty(vm.Bullets); Assert.True(vm.IsEmpty);
}
[Fact]
public async Task CommitBullet_persists_a_new_row_and_gives_it_an_id()
{
var (vm, api) = await LoadedAsync(api => api.Seed(Today, "erste"));
await vm.SplitBulletCommand.ExecuteAsync(Bullets(vm)[0]);
var fresh = Bullets(vm)[1];
fresh.Text = "zweite";
await vm.CommitBulletCommand.ExecuteAsync(fresh);
Assert.False(fresh.IsNew);
Assert.Equal(2, api.Store.Count);
Assert.Equal("zweite", api.Store[1].Text);
}
[Fact]
public async Task DeleteBullet_removes_it_and_focuses_the_note_above()
{
var (vm, api) = await LoadedAsync(api => { api.Seed(Today, "eins"); api.Seed(Today, "zwei"); });
NoteBulletViewModel? focused = null;
vm.FocusRequested += b => focused = b;
var second = Bullets(vm)[1];
await vm.DeleteBulletCommand.ExecuteAsync(second);
Assert.Single(Bullets(vm));
Assert.Equal("eins", focused?.Text);
Assert.Single(api.Store);
}
[Fact]
public async Task DeleteBullet_on_the_first_note_of_the_day_does_not_reach_into_the_header()
{
var (vm, _) = await LoadedAsync(api => api.Seed(Today, "einzige"));
var focusCalls = 0;
vm.FocusRequested += _ => focusCalls++;
await vm.DeleteBulletCommand.ExecuteAsync(Bullets(vm)[0]);
Assert.Equal(0, focusCalls);
Assert.True(vm.IsEmpty);
} }
[Fact] [Fact]
public async Task CommitBullet_EmptyText_RemovesFromStoreAndList() public async Task CommitBullet_EmptyText_RemovesFromStoreAndList()
{ {
var api = new FakeNotes(); var (vm, api) = await LoadedAsync(api => api.Seed(Today, "weg damit"));
var vm = new NotesEditorViewModel(api);
await vm.LoadDayAsync(new DateOnly(2026, 6, 1));
vm.NewBulletText = "weg damit";
await vm.AddBulletCommand.ExecuteAsync(null);
vm.Bullets[0].Text = " "; var only = Bullets(vm)[0];
await vm.CommitBulletCommand.ExecuteAsync(vm.Bullets[0]); only.Text = " ";
await vm.CommitBulletCommand.ExecuteAsync(only);
Assert.Empty(vm.Bullets); Assert.Empty(Bullets(vm));
Assert.Empty(api.Store); Assert.Empty(api.Store);
} }
[Fact] [Fact]
public async Task CommitBullet_NonEmptyText_PersistsEdit() public async Task CommitBullet_NonEmptyText_PersistsEdit()
{ {
var api = new FakeNotes(); var (vm, api) = await LoadedAsync(api => api.Seed(Today, "original"));
var vm = new NotesEditorViewModel(api);
await vm.LoadDayAsync(new DateOnly(2026, 6, 1));
vm.NewBulletText = "original";
await vm.AddBulletCommand.ExecuteAsync(null);
vm.Bullets[0].Text = "geändert"; var only = Bullets(vm)[0];
await vm.CommitBulletCommand.ExecuteAsync(vm.Bullets[0]); only.Text = "geaendert";
await vm.CommitBulletCommand.ExecuteAsync(only);
Assert.Single(vm.Bullets); Assert.Single(Bullets(vm));
Assert.Equal("geändert", api.Store[0].Text); Assert.Equal("geaendert", api.Store[0].Text);
}
[Fact]
public async Task Agent_written_notes_are_marked_so_the_view_can_flag_them()
{
var (vm, _) = await LoadedAsync(api =>
{
api.Seed(Today, "von mir");
api.Seed(Today, "von Claude", fromAgent: true);
});
Assert.False(Bullets(vm)[0].FromAgent);
Assert.True(Bullets(vm)[1].FromAgent);
} }
} }
@@ -0,0 +1,77 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Tests.Infrastructure;
namespace ClaudeDo.Worker.Tests.External;
public sealed class NotesMcpToolsTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly DailyNoteRepository _notes;
private readonly NotesMcpTools _sut;
private static readonly DateOnly Today = DateOnly.FromDateTime(DateTime.Today);
public NotesMcpToolsTests()
{
_ctx = _db.CreateContext();
_notes = new DailyNoteRepository(_ctx);
_sut = new NotesMcpTools(_notes, new HubBroadcaster(new CapturingHubContext()));
}
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
[Fact]
public async Task AddNote_stores_it_on_today_flagged_as_agent_written()
{
var result = await _sut.AddNote(" Mika decided to keep the daily grouping. ");
Assert.True(result.Saved);
var row = Assert.Single(await _notes.ListByDayAsync(Today));
Assert.Equal("Mika decided to keep the daily grouping.", row.Text);
Assert.True(row.FromAgent);
Assert.Equal(NotesMcpTools.DailyAgentCap - 1, result.RemainingToday);
}
[Fact]
public async Task AddNote_refuses_once_the_daily_cap_is_used_up_and_saves_nothing()
{
for (var i = 0; i < NotesMcpTools.DailyAgentCap; i++)
await _sut.AddNote($"note {i}");
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => _sut.AddNote("one too many"));
Assert.Contains("cap", ex.Message, StringComparison.OrdinalIgnoreCase);
Assert.Equal(NotesMcpTools.DailyAgentCap, (await _notes.ListByDayAsync(Today)).Count);
}
[Fact]
public async Task Notes_the_user_wrote_do_not_count_against_the_agent_cap()
{
for (var i = 0; i < NotesMcpTools.DailyAgentCap; i++)
await _notes.AddAsync(Today, $"mine {i}");
var result = await _sut.AddNote("still allowed");
Assert.Equal(1, result.AgentNotesToday);
}
[Theory]
[InlineData("")]
[InlineData(" ")]
public async Task AddNote_rejects_empty_text(string text) =>
await Assert.ThrowsAsync<InvalidOperationException>(() => _sut.AddNote(text));
[Fact]
public async Task AddNote_rejects_text_past_the_length_limit()
{
var tooLong = new string('x', NotesMcpTools.MaxLength + 1);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => _sut.AddNote(tooLong));
Assert.Contains(NotesMcpTools.MaxLength.ToString(), ex.Message);
Assert.Empty(await _notes.ListByDayAsync(Today));
}
}
@@ -66,6 +66,7 @@ file sealed class NoActiveMergeState : IActiveMergeState
{ {
public static readonly NoActiveMergeState Instance = new(); public static readonly NoActiveMergeState Instance = new();
public bool HasActiveMerge(string taskId) => false; public bool HasActiveMerge(string taskId) => false;
public Task<bool> HasActiveMergeAsync(string taskId, CancellationToken ct) => Task.FromResult(false);
} }
// Never actually invoked: TicketSystemConfig.IsConfigured is false in every TaskStateServiceBuilder // Never actually invoked: TicketSystemConfig.IsConfigured is false in every TaskStateServiceBuilder
@@ -162,6 +162,36 @@ public sealed class PlanningMergeOrchestratorTests : IDisposable
Assert.Contains(spy, c => c.Method == "PlanningCompleted"); Assert.Contains(spy, c => c.Method == "PlanningCompleted");
} }
[Fact]
public async Task CancelAsync_UnitMergeParentMidMergeAfterSimulatedRestart_BlocksUntilAborted()
{
var db = NewDb();
var repo = NewRepo();
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
var (parentId, _, _, _) = await SeedPlanningThreeChildrenMiddleConflictsAsync(db, repo);
var (orch, _) = BuildOrchestrator(db);
var startResult = await orch.StartAsync(parentId, "main", CancellationToken.None);
Assert.Equal(TaskMergeService.StatusConflict, startResult.Status);
// Simulate a worker restart: a brand-new orchestrator instance has an empty in-memory
// _states dictionary, so the guard must fall back to the on-disk mid-merge check.
var (freshOrch, _) = BuildOrchestrator(db);
var cancelBuilt = TaskStateServiceBuilder.Build(db.CreateFactory(), () => freshOrch);
var blocked = await cancelBuilt.State.CancelAsync(parentId, DateTime.UtcNow, CancellationToken.None);
Assert.False(blocked.Ok);
using (var ctx = db.CreateContext())
Assert.Equal(TaskStatus.WaitingForReview, ctx.Tasks.Single(t => t.Id == parentId).Status);
GitRepoFixture.RunGit(repo.RepoDir, "merge", "--abort");
var ok = await cancelBuilt.State.CancelAsync(parentId, DateTime.UtcNow, CancellationToken.None);
Assert.True(ok.Ok);
}
private async Task<(string parentId, string subA, string subB, string subC)> SeedPlanningThreeChildrenMiddleConflictsAsync( private async Task<(string parentId, string subA, string subB, string subC)> SeedPlanningThreeChildrenMiddleConflictsAsync(
DbFixture db, GitRepoFixture repo) DbFixture db, GitRepoFixture repo)
{ {
@@ -0,0 +1,139 @@
using ClaudeDo.Data.Git;
using ClaudeDo.Worker.Tests.Infrastructure;
namespace ClaudeDo.Worker.Tests.Runner;
// Covers the empty-repo (unborn HEAD) hardening: HasHeadCommitAsync, EnsureHeadCommitAsync
// (plumbing-only bootstrap commit), the friendly RevParseHeadAsync unborn message, and the
// CommitAsync git-identity fallback. All tests run real git against throwaway repos.
public class GitServiceBootstrapTests : IDisposable
{
private readonly List<string> _tempDirs = new();
private readonly GitService _git = new();
private static bool GitAvailable => GitRepoFixture.IsGitAvailable();
// A repo straight out of `git init` — no commits, and deliberately NO identity
// configured: the bootstrap must not depend on machine-level git config.
private string InitEmptyRepo()
{
var dir = Path.Combine(Path.GetTempPath(), $"claudedo_emptyrepo_{Guid.NewGuid():N}");
Directory.CreateDirectory(dir);
GitRepoFixture.RunGit(dir, "init", "-b", "main");
_tempDirs.Add(dir);
return dir;
}
[Fact]
public async Task HasHeadCommit_FalseOnEmptyRepo_TrueAfterCommit()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var dir = InitEmptyRepo();
Assert.False(await _git.HasHeadCommitAsync(dir));
GitRepoFixture.RunGit(dir, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "--allow-empty", "-m", "x");
Assert.True(await _git.HasHeadCommitAsync(dir));
}
[Fact]
public async Task EnsureHeadCommit_BootstrapsEmptyRepo_Once()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var dir = InitEmptyRepo();
Assert.True(await _git.EnsureHeadCommitAsync(dir));
// HEAD now resolves and RevParseHeadAsync works — the original failure mode is gone.
var head = await _git.RevParseHeadAsync(dir);
Assert.Matches("^[0-9a-f]{40,64}$", head);
// The bootstrap commit is empty: nothing of the user's ends up in it.
var tree = GitRepoFixture.RunGit(dir, "ls-tree", "HEAD").Trim();
Assert.Equal(string.Empty, tree);
// Idempotent: a repo with a HEAD commit is left alone.
Assert.False(await _git.EnsureHeadCommitAsync(dir));
Assert.Equal(head, await _git.RevParseHeadAsync(dir));
}
[Fact]
public async Task EnsureHeadCommit_LeavesStagedFilesStaged()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var dir = InitEmptyRepo();
File.WriteAllText(Path.Combine(dir, "mine.txt"), "user work\n");
GitRepoFixture.RunGit(dir, "add", "mine.txt");
Assert.True(await _git.EnsureHeadCommitAsync(dir));
// Still staged (index vs HEAD shows it as pending), NOT committed.
var staged = GitRepoFixture.RunGit(dir, "diff", "--cached", "--name-only").Trim();
Assert.Equal("mine.txt", staged);
var tree = GitRepoFixture.RunGit(dir, "ls-tree", "HEAD").Trim();
Assert.Equal(string.Empty, tree);
}
[Fact]
public async Task EnsureHeadCommit_CorruptHead_ThrowsClearError()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var dir = InitEmptyRepo();
// Detach HEAD onto a commit that doesn't exist — not an unborn branch, a broken repo.
File.WriteAllText(Path.Combine(dir, ".git", "HEAD"), new string('f', 40) + "\n");
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => _git.EnsureHeadCommitAsync(dir));
Assert.Contains(dir, ex.Message);
}
[Fact]
public async Task RevParseHead_EmptyRepo_ReportsNoCommitsInsteadOfRawGitError()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var dir = InitEmptyRepo();
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => _git.RevParseHeadAsync(dir));
Assert.Contains("no commits yet", ex.Message);
Assert.Contains(dir, ex.Message);
Assert.DoesNotContain("ambiguous argument", ex.Message);
}
[Fact]
public async Task CommitAsync_MissingIdentity_FallsBackToClaudeDoIdentity()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var dir = InitEmptyRepo();
GitRepoFixture.RunGit(dir, "-c", "user.name=t", "-c", "user.email=t@t", "commit", "--allow-empty", "-m", "seed");
// Empty local identity overrides any machine-level config, so the first commit
// attempt fails with git's identity error no matter where this test runs.
GitRepoFixture.RunGit(dir, "config", "user.name", "");
GitRepoFixture.RunGit(dir, "config", "user.email", "");
File.WriteAllText(Path.Combine(dir, "work.txt"), "content\n");
await _git.AddAllAsync(dir);
await _git.CommitAsync(dir, "feat(test): identity fallback");
var author = GitRepoFixture.RunGit(dir, "log", "-1", "--format=%an <%ae>").Trim();
Assert.Equal("ClaudeDo <claudedo@local>", author);
}
public void Dispose()
{
foreach (var dir in _tempDirs)
{
try
{
if (!Directory.Exists(dir)) continue;
foreach (var file in Directory.EnumerateFiles(dir, "*", SearchOption.AllDirectories))
File.SetAttributes(file, FileAttributes.Normal);
Directory.Delete(dir, true);
}
catch { /* best effort */ }
}
}
}
@@ -191,6 +191,34 @@ public class WorktreeManagerTests : IDisposable
Assert.Contains("hello.txt", row.DiffStat); Assert.Contains("hello.txt", row.DiffStat);
} }
[Fact]
public async Task CreateAsync_EmptyRepo_BootstrapsInitialCommit()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
// Fresh `git init`, zero commits, no identity configured — the cross-PC failure
// that surfaced as "git rev-parse HEAD failed: ambiguous argument 'HEAD'".
var repoDir = Path.Combine(Path.GetTempPath(), $"claudedo_emptyrepo_{Guid.NewGuid():N}");
Directory.CreateDirectory(repoDir);
GitRepoFixture.RunGit(repoDir, "init", "-b", "main");
_tempDirs.Add(repoDir);
var (task, list) = MakeEntities(repoDir);
var (mgr, db) = await CreateManagerAsync(task, list);
var ctx = await mgr.CreateAsync(task, list, CancellationToken.None);
_worktreeCleanups.Add((repoDir, ctx.WorktreePath));
Assert.True(Directory.Exists(ctx.WorktreePath));
// The base commit is the auto-created bootstrap commit.
var head = GitRepoFixture.RunGit(repoDir, "rev-parse", "HEAD").Trim();
Assert.Equal(head, ctx.BaseCommit);
using var readCtx = db.CreateContext();
var row = await new WorktreeRepository(readCtx).GetByTaskIdAsync(task.Id);
Assert.Equal(WorktreeState.Active, row!.State);
}
[Fact] [Fact]
public async Task CreateAsync_NonGitDir_Throws_NoRow() public async Task CreateAsync_NonGitDir_Throws_NoRow()
{ {
@@ -1536,6 +1536,91 @@ public class TaskMergeServiceTests : IDisposable
Assert.Contains(proxy.Calls, c => c.Method == "WorktreeUpdated" && c.Args[0] is string s && s == task.Id); Assert.Contains(proxy.Calls, c => c.Method == "WorktreeUpdated" && c.Args[0] is string s && s == task.Id);
} }
[Fact]
public async Task ApproveAndMergeAsync_AfterRevert_BranchGone_ReturnsBlockedInsteadOfFakeMerge()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.WaitingForReview);
var wtMgr = BuildWorktreeManager(db);
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "new\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
var (svc, _) = BuildService(db);
var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
// Approve removes the worktree AND deletes its branch (removeWorktree: true), so after a
// later revert there is genuinely nothing left to re-merge.
var approveResult = await svc.ApproveAndMergeAsync(task.Id, currentBranch, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusMerged, approveResult.Status);
var revertResult = await svc.RevertMergeAsync(task.Id, currentBranch, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusReverted, revertResult.Status);
var reapproveResult = await svc.ApproveAndMergeAsync(task.Id, currentBranch, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusBlocked, reapproveResult.Status);
Assert.Contains("branch", reapproveResult.ErrorMessage ?? "", StringComparison.OrdinalIgnoreCase);
Assert.Contains("no longer exists", reapproveResult.ErrorMessage ?? "");
using var ctx = db.CreateContext();
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.WaitingForReview, updated!.Status);
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
Assert.Equal(WorktreeState.Kept, wt!.State);
}
[Fact]
public async Task ApproveAndMergeAsync_AfterRevert_BranchStillExists_ReallyMergesAgain()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.WaitingForReview);
var wtMgr = BuildWorktreeManager(db);
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "new\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
var (svc, _) = BuildService(db);
var currentBranch = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
// Merge (not approve) with removeWorktree: false, so the branch and worktree dir survive
// the merge -- e.g. a manual merge_task call, or a merge whose cleanup was skipped.
var mergeResult = await svc.MergeAsync(task.Id, currentBranch, removeWorktree: false,
commitMessage: "Merge task", ct: CancellationToken.None);
Assert.Equal(TaskMergeService.StatusMerged, mergeResult.Status);
string? firstMergeCommit;
using (var preRevertCtx = db.CreateContext())
firstMergeCommit = (await new WorktreeRepository(preRevertCtx).GetByTaskIdAsync(task.Id))!.MergeCommit;
var revertResult = await svc.RevertMergeAsync(task.Id, currentBranch, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusReverted, revertResult.Status);
Assert.False(File.Exists(Path.Combine(repo.RepoDir, "added.txt")));
var reapproveResult = await svc.ApproveAndMergeAsync(task.Id, currentBranch, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusMerged, reapproveResult.Status);
Assert.True(File.Exists(Path.Combine(repo.RepoDir, "added.txt")));
using var ctx = db.CreateContext();
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.Done, updated!.Status);
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
Assert.Equal(WorktreeState.Merged, wt!.State);
Assert.False(string.IsNullOrWhiteSpace(wt.MergeCommit));
Assert.NotEqual(firstMergeCommit, wt.MergeCommit);
}
[Fact] [Fact]
public async Task RevertMergeAsync_UncommittedChangesInTarget_ReturnsBlocked() public async Task RevertMergeAsync_UncommittedChangesInTarget_ReturnsBlocked()
{ {
@@ -15,6 +15,7 @@ file sealed class FakeActiveMergeState : IActiveMergeState
{ {
public HashSet<string> ActiveTaskIds { get; } = new(); public HashSet<string> ActiveTaskIds { get; } = new();
public bool HasActiveMerge(string taskId) => ActiveTaskIds.Contains(taskId); public bool HasActiveMerge(string taskId) => ActiveTaskIds.Contains(taskId);
public Task<bool> HasActiveMergeAsync(string taskId, CancellationToken ct) => Task.FromResult(HasActiveMerge(taskId));
} }
public sealed class TaskStateServiceTests : IDisposable public sealed class TaskStateServiceTests : IDisposable
@@ -112,6 +112,7 @@ sealed class FakeWorkerClient : IWorkerClient
public Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default) => Task.FromResult(0); public Task<int> GetPendingDraftCountAsync(string taskId, CancellationToken ct = default) => Task.FromResult(0);
public Task<IReadOnlyList<WorkerLogEntry>> GetRecentLogsAsync() => Task.FromResult<IReadOnlyList<WorkerLogEntry>>(System.Array.Empty<WorkerLogEntry>()); public Task<IReadOnlyList<WorkerLogEntry>> GetRecentLogsAsync() => Task.FromResult<IReadOnlyList<WorkerLogEntry>>(System.Array.Empty<WorkerLogEntry>());
public event Action? NotesUpdatedEvent;
public event Action? PrepStartedEvent; public event Action? PrepStartedEvent;
public event Action<string>? PrepLineEvent; public event Action<string>? PrepLineEvent;
public event Action<bool>? PrepFinishedEvent; public event Action<bool>? PrepFinishedEvent;
@@ -153,6 +154,7 @@ sealed class FakeWorkerClient : IWorkerClient
public Task<bool> RunDailyPrepNowAsync() => Task.FromResult(false); public Task<bool> RunDailyPrepNowAsync() => Task.FromResult(false);
public Task ClearMyDayAsync() => Task.CompletedTask; public Task ClearMyDayAsync() => Task.CompletedTask;
public Task<List<DailyNoteDto>> GetDailyNotesAsync(DateOnly day) => Task.FromResult(new List<DailyNoteDto>()); public Task<List<DailyNoteDto>> GetDailyNotesAsync(DateOnly day) => Task.FromResult(new List<DailyNoteDto>());
public Task<List<DailyNoteDto>> GetDailyNotesBetweenAsync(DateOnly start, DateOnly end) => Task.FromResult(new List<DailyNoteDto>());
public Task<DailyNoteDto?> AddDailyNoteAsync(DateOnly day, string text) => Task.FromResult<DailyNoteDto?>(null); public Task<DailyNoteDto?> AddDailyNoteAsync(DateOnly day, string text) => Task.FromResult<DailyNoteDto?>(null);
public Task UpdateDailyNoteAsync(string id, string text) => Task.CompletedTask; public Task UpdateDailyNoteAsync(string id, string text) => Task.CompletedTask;
public Task DeleteDailyNoteAsync(string id) => Task.CompletedTask; public Task DeleteDailyNoteAsync(string id) => Task.CompletedTask;