Compare commits
15
Commits
v2.11.0
...
f640580463
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f640580463 | ||
|
|
b90c5855f4 | ||
|
|
0f04b796f4 | ||
|
|
ae0c7d3771 | ||
|
|
cf093e3178 | ||
|
|
e0748ba351 | ||
|
|
de44196b9a | ||
|
|
c259254921 | ||
|
|
fea8236e8a | ||
|
|
1d6cd49beb | ||
|
|
c31f79d3d4 | ||
|
|
22e70ffcb4 | ||
|
|
8d597df448 | ||
|
|
1affb39716 | ||
|
|
bbdb022327 |
+136
@@ -1,5 +1,141 @@
|
||||
# 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
|
||||
|
||||
### Features
|
||||
|
||||
@@ -76,6 +76,13 @@ zugleich der erste Test dieser Annahme: schlägt einer davon mit einem Deseriali
|
||||
|
||||
## 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,
|
||||
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
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "8.0.418",
|
||||
"rollForward": "latestFeature"
|
||||
"rollForward": "latestMajor"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,13 +165,13 @@ sealed class Program
|
||||
sc.AddSingleton<TasksIslandViewModel>(sp =>
|
||||
new TasksIslandViewModel(
|
||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||
sp.GetRequiredService<IWorkerClient>()));
|
||||
sp.GetRequiredService<IWorkerClient>(),
|
||||
sp.GetRequiredService<INotesApi>()));
|
||||
sc.AddSingleton<DetailsIslandViewModel>(sp =>
|
||||
new DetailsIslandViewModel(
|
||||
sp.GetRequiredService<IDbContextFactory<ClaudeDoDbContext>>(),
|
||||
sp.GetRequiredService<IWorkerClient>(),
|
||||
sp,
|
||||
sp.GetRequiredService<INotesApi>(),
|
||||
sp.GetRequiredService<MergeCoordinator>()));
|
||||
sc.AddSingleton<UsagePillViewModel>(sp =>
|
||||
new UsagePillViewModel(sp.GetRequiredService<IWorkerClient>()));
|
||||
|
||||
@@ -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
|
||||
- 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
|
||||
- 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
|
||||
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
|
||||
|
||||
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.Text).HasColumnName("text").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.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.IsManual).HasColumnName("is_manual").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");
|
||||
|
||||
|
||||
@@ -25,7 +25,16 @@ public sealed class GitService
|
||||
{
|
||||
var (exitCode, stdout, stderr) = await RunGitAsync(dir, ["rev-parse", "HEAD"], ct);
|
||||
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}");
|
||||
}
|
||||
return stdout.Trim();
|
||||
}
|
||||
|
||||
@@ -55,6 +64,67 @@ public sealed class GitService
|
||||
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)
|
||||
{
|
||||
await WorktreeAddGate.WaitAsync(ct);
|
||||
@@ -135,10 +205,24 @@ public sealed class GitService
|
||||
{
|
||||
// Use -F - (read message from stdin) to handle multi-line messages safely.
|
||||
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)
|
||||
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(
|
||||
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}");
|
||||
}
|
||||
|
||||
/// <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)
|
||||
{
|
||||
var (exitCode, stdout, stderr) = await RunGitAsync(repoDir,
|
||||
@@ -552,7 +668,9 @@ public sealed class GitService
|
||||
CreateNoWindow = true,
|
||||
StandardOutputEncoding = 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(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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+982
@@ -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")
|
||||
.HasColumnName("note_date");
|
||||
|
||||
b.Property<bool>("FromAgent")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("from_agent");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER")
|
||||
.HasColumnName("sort_order");
|
||||
@@ -287,6 +291,10 @@ namespace ClaudeDo.Data.Migrations
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("findings_tracked");
|
||||
|
||||
b.Property<string>("Icon")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("icon");
|
||||
|
||||
b.Property<bool>("IsManual")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
|
||||
@@ -6,5 +6,9 @@ public sealed class DailyNoteEntity
|
||||
public DateOnly Date { get; set; }
|
||||
public string Text { get; set; } = string.Empty;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ public sealed class ListEntity
|
||||
// When true the project's .claudedo/ findings store is committed with the repo; when false it
|
||||
// is kept out of git via .git/info/exclude (see FindingsStore).
|
||||
public bool FindingsTracked { get; set; }
|
||||
// Icon key into the UI's icon library (e.g. "Star"); null = the default folder icon.
|
||||
public string? Icon { get; set; }
|
||||
|
||||
// Navigation properties
|
||||
public ListConfigEntity? Config { get; set; }
|
||||
|
||||
@@ -22,7 +22,13 @@ public sealed class DailyNoteRepository
|
||||
.OrderBy(n => n.Date).ThenBy(n => n.SortOrder)
|
||||
.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
|
||||
.Where(n => n.Date == day)
|
||||
@@ -34,6 +40,7 @@ public sealed class DailyNoteRepository
|
||||
Date = day,
|
||||
Text = text,
|
||||
SortOrder = nextOrder + 1,
|
||||
FromAgent = fromAgent,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
_context.DailyNotes.Add(note);
|
||||
|
||||
@@ -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 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
|
||||
// list-settings modal) must not be able to clear it by omission — SetConfigAsync copies the entity
|
||||
|
||||
@@ -177,7 +177,6 @@
|
||||
"listSettingsTip": "Listeneinstellungen",
|
||||
"addPlaceholder": "Aufgabe hinzufügen…",
|
||||
"enterKey": "ENTER",
|
||||
"notesPinnedRow": "Notizen (Tagesnotizen)",
|
||||
"clearDayTip": "Tag leeren",
|
||||
"planMyDayTip": "Meinen Tag planen",
|
||||
"overdue": "ÜBERFÄLLIG",
|
||||
@@ -261,7 +260,6 @@
|
||||
"killSessionTip": "Laufende Sitzung beenden",
|
||||
"sendToQueue": "In Warteschlange einreihen",
|
||||
"sendToQueueTip": "Diese Aufgabe in die Warteschlange einreihen, damit Claude sie beim nächsten Lauf übernimmt",
|
||||
"closeTip": "Schließen",
|
||||
"copyTaskIdTip": "Aufgaben-ID kopieren",
|
||||
"starTip": "Favorit",
|
||||
"agentSettingsTip": "Agent-Einstellungen",
|
||||
@@ -300,8 +298,11 @@
|
||||
},
|
||||
"notes": {
|
||||
"today": "Heute",
|
||||
"add": "Hinzufügen",
|
||||
"newNotePlaceholder": "Neue Notiz…"
|
||||
"yesterday": "Gestern",
|
||||
"newNotePlaceholder": "Neue Notiz…",
|
||||
"emptyHint": "Noch nichts notiert. Tippen, Enter, fertig.",
|
||||
"deleteTip": "Notiz löschen",
|
||||
"fromAgentTip": "Von Claude notiert"
|
||||
},
|
||||
"session": {
|
||||
"chipLive": "LIVE",
|
||||
@@ -320,9 +321,14 @@
|
||||
"newSession": "Neue Sitzung",
|
||||
"empty": "Keine laufenden Aufgaben",
|
||||
"settings": "Einstellungen",
|
||||
"queue": "Warteschlange",
|
||||
"activity": "Aktivität",
|
||||
"blocked": "Blockiert",
|
||||
"running": "Läuft",
|
||||
"review": "Im Review",
|
||||
"failed": "Fehlgeschlagen",
|
||||
"roadblock": "Blockade gemeldet",
|
||||
"openSession": "Sitzung öffnen",
|
||||
"browseForFolder": "Ordner wählen…",
|
||||
"focusMode": "Fokus",
|
||||
"overviewMode": "Übersicht",
|
||||
"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.",
|
||||
"findingsTracked": "Findings-Ordner .claudedo einchecken",
|
||||
"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",
|
||||
"resetAgentSettings": "Agent-Einstellungen zurücksetzen",
|
||||
"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}" },
|
||||
"mergeSection": { "openWorktreeFailed": "Worktree 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}" }
|
||||
},
|
||||
"ops": {
|
||||
|
||||
@@ -177,7 +177,6 @@
|
||||
"listSettingsTip": "List settings",
|
||||
"addPlaceholder": "Add a task…",
|
||||
"enterKey": "ENTER",
|
||||
"notesPinnedRow": "Notes (daily notes)",
|
||||
"clearDayTip": "Clear day",
|
||||
"planMyDayTip": "Plan My Day",
|
||||
"overdue": "OVERDUE",
|
||||
@@ -261,7 +260,6 @@
|
||||
"killSessionTip": "Kill the running session",
|
||||
"sendToQueue": "Send to queue",
|
||||
"sendToQueueTip": "Queue this task so Claude picks it up on the next run",
|
||||
"closeTip": "Close",
|
||||
"copyTaskIdTip": "Copy task ID",
|
||||
"starTip": "Star",
|
||||
"agentSettingsTip": "Agent settings",
|
||||
@@ -300,8 +298,11 @@
|
||||
},
|
||||
"notes": {
|
||||
"today": "Today",
|
||||
"add": "Add",
|
||||
"newNotePlaceholder": "New note…"
|
||||
"yesterday": "Yesterday",
|
||||
"newNotePlaceholder": "New note…",
|
||||
"emptyHint": "Nothing noted yet. Type, hit Enter, done.",
|
||||
"deleteTip": "Delete note",
|
||||
"fromAgentTip": "Written by Claude"
|
||||
},
|
||||
"session": {
|
||||
"chipLive": "LIVE",
|
||||
@@ -320,9 +321,14 @@
|
||||
"newSession": "New session",
|
||||
"empty": "No running tasks",
|
||||
"settings": "Settings",
|
||||
"queue": "Queue",
|
||||
"activity": "Activity",
|
||||
"blocked": "Blocked",
|
||||
"running": "Running",
|
||||
"review": "In review",
|
||||
"failed": "Failed",
|
||||
"roadblock": "Roadblock reported",
|
||||
"openSession": "Open session",
|
||||
"browseForFolder": "Choose folder…",
|
||||
"focusMode": "Focus",
|
||||
"overviewMode": "Overview",
|
||||
"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.",
|
||||
"findingsTracked": "Commit the .claudedo findings folder",
|
||||
"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",
|
||||
"resetAgentSettings": "Reset agent settings",
|
||||
"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}" },
|
||||
"mergeSection": { "openWorktreeFailed": "Couldn't open worktree: {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}" }
|
||||
},
|
||||
"ops": {
|
||||
|
||||
@@ -33,8 +33,9 @@
|
||||
<!-- All d-strings sourced from icons.jsx -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
<!-- Icon.Sun -->
|
||||
<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>
|
||||
<!-- Icon.Sun — filled disc + 8 rays. The old stroke-only version rendered as a bare dot in a
|
||||
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 -->
|
||||
<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 -->
|
||||
<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 -->
|
||||
<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>
|
||||
<!-- Icon.Calendar — solid header band, hollow body, two binder posts and a date block.
|
||||
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 -->
|
||||
<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 -->
|
||||
<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>
|
||||
<!-- Icon.Inbox — tray frame with an arrow dropping into the upper compartment.
|
||||
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 -->
|
||||
<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 -->
|
||||
<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>
|
||||
<!-- Icon.Search — hollow ring + handle. The old stroke version filled into a solid disc with
|
||||
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) -->
|
||||
<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) -->
|
||||
<StreamGeometry x:Key="Icon.Stop">M4 4 H20 V20 H4 Z</StreamGeometry>
|
||||
|
||||
<!-- Icon.Check -->
|
||||
<StreamGeometry x:Key="Icon.Check">M4 12l5 5 11-11</StreamGeometry>
|
||||
<!-- Icon.Check — filled tick. The old stroke version filled into a solid wedge. -->
|
||||
<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 -->
|
||||
<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 -->
|
||||
<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 -->
|
||||
<SolidColorBrush x:Key="DraftBadgeBrush" Color="{StaticResource TextMuteColor}"/>
|
||||
<SolidColorBrush x:Key="PlanningBadgeBrush" Color="{StaticResource PeatColor}"/>
|
||||
@@ -789,6 +862,11 @@
|
||||
<Setter Property="Background" Value="{StaticResource AccentSoftBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
|
||||
</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 -->
|
||||
<Style Selector="Border.list-item.active TextBlock.list-label">
|
||||
<Setter Property="Foreground" Value="{StaticResource TextBrush}" />
|
||||
|
||||
@@ -5,6 +5,8 @@ namespace ClaudeDo.Ui.Services.Interfaces;
|
||||
public interface INotesApi
|
||||
{
|
||||
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 UpdateAsync(string id, string text);
|
||||
Task DeleteAsync(string id);
|
||||
|
||||
@@ -30,6 +30,9 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
/// task to carry out nextPhase ("wait" | "merge" | "wait_final" | "merge_final").</summary>
|
||||
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<string>? PrepLineEvent;
|
||||
event Action<bool>? PrepFinishedEvent;
|
||||
@@ -172,6 +175,8 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
Task<AppSettingsDto?> GetAppSettingsAsync();
|
||||
Task UpdateAppSettingsAsync(AppSettingsDto dto);
|
||||
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 UpdateDailyNoteAsync(string id, string text);
|
||||
Task DeleteDailyNoteAsync(string id);
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
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);
|
||||
|
||||
@@ -54,6 +54,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
public event Action<string>? ListUpdatedEvent;
|
||||
public event Action<WorkerLogEntry>? WorkerLogReceivedEvent;
|
||||
|
||||
public event Action? NotesUpdatedEvent;
|
||||
public event Action? PrepStartedEvent;
|
||||
public event Action<string>? PrepLineEvent;
|
||||
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)));
|
||||
});
|
||||
|
||||
_hub.On("NotesUpdated", () => Dispatcher.UIThread.Post(() => NotesUpdatedEvent?.Invoke()));
|
||||
_hub.On("PrepStarted", () => Dispatcher.UIThread.Post(() => PrepStartedEvent?.Invoke()));
|
||||
_hub.On<string>("PrepLine", line => Dispatcher.UIThread.Post(() => PrepLineEvent?.Invoke(line)));
|
||||
_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)
|
||||
=> 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)
|
||||
=> await InvokeTimedAsync<DailyNoteDto>("AddDailyNote", () => _hub.InvokeAsync<DailyNoteDto>("AddDailyNote", IsoDay(day), text));
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ public sealed class WorkerNotesApi : INotesApi
|
||||
private readonly IWorkerClient _client;
|
||||
public WorkerNotesApi(IWorkerClient client) => _client = client;
|
||||
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 UpdateAsync(string id, string text) => _client.UpdateDailyNoteAsync(id, text);
|
||||
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 IWorkerClient _worker;
|
||||
private readonly IServiceProvider _services;
|
||||
private readonly INotesApi _notesApi;
|
||||
private readonly MergeCoordinator _merge;
|
||||
|
||||
// ── Section view models ───────────────────────────────────────────────────
|
||||
@@ -39,29 +38,20 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
private readonly Action<string, string?> _workerPlanningMergeAbortedHandler;
|
||||
private readonly Action<string> _workerPlanningCompletedHandler;
|
||||
|
||||
[ObservableProperty] private bool _isNotesMode;
|
||||
[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>
|
||||
public bool IsEmptyStateVisible => Task is null && IsTaskDetailVisible;
|
||||
|
||||
partial void OnIsNotesModeChanged(bool value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsTaskDetailVisible));
|
||||
OnPropertyChanged(nameof(IsEmptyStateVisible));
|
||||
}
|
||||
|
||||
partial void OnIsPrepModeChanged(bool value)
|
||||
{
|
||||
OnPropertyChanged(nameof(IsTaskDetailVisible));
|
||||
OnPropertyChanged(nameof(IsEmptyStateVisible));
|
||||
}
|
||||
|
||||
public NotesEditorViewModel Notes { get; private set; } = null!;
|
||||
|
||||
// Current task row (set by IslandsShellViewModel via Bind)
|
||||
[ObservableProperty]
|
||||
[NotifyCanExecuteChangedFor(nameof(EnqueueCommand))]
|
||||
@@ -295,7 +285,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
private bool _suppressTitleSave;
|
||||
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; }
|
||||
|
||||
// Set by shell so DeleteTaskCommand can remove from list
|
||||
@@ -324,13 +314,11 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
IWorkerClient worker,
|
||||
IServiceProvider services,
|
||||
INotesApi notesApi,
|
||||
MergeCoordinator merge)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_worker = worker;
|
||||
_services = services;
|
||||
_notesApi = notesApi;
|
||||
_merge = merge;
|
||||
|
||||
Monitor = new TaskMonitorViewModel(dbFactory, worker);
|
||||
@@ -374,8 +362,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
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 += (_, _) => Merge.SyncChildOutcomes(HasChildOutcomes, Subtasks.Count);
|
||||
Attachments.CollectionChanged += (_, _) => OnPropertyChanged(nameof(FilesBadge));
|
||||
@@ -592,18 +578,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
catch { }
|
||||
}
|
||||
|
||||
public void ShowNotes()
|
||||
{
|
||||
Bind(null, "notes");
|
||||
IsPrepMode = false;
|
||||
IsNotesMode = true;
|
||||
_ = Notes.LoadDayAsync(DateOnly.FromDateTime(DateTime.Today));
|
||||
}
|
||||
|
||||
public void ShowPrep()
|
||||
{
|
||||
Bind(null, "prep");
|
||||
IsNotesMode = false;
|
||||
IsPrepMode = true;
|
||||
_ = 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>
|
||||
public void Bind(TaskRowViewModel? row, string source = "?")
|
||||
{
|
||||
IsNotesMode = false;
|
||||
IsPrepMode = false;
|
||||
_loadCts?.Cancel();
|
||||
_loadCts?.Dispose();
|
||||
@@ -1008,9 +984,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void CloseDetails() => CloseDetail?.Invoke();
|
||||
|
||||
[RelayCommand]
|
||||
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
|
||||
// DropHintAbove/Below, which mark an insertion point for list reordering.
|
||||
[ObservableProperty] private bool _isTaskDropTarget;
|
||||
public string? IconKey { get; init; }
|
||||
public string? DotColorKey { get; init; }
|
||||
// Settable (not init) so a list-settings icon change refreshes the row in place.
|
||||
[ObservableProperty] private string? _iconKey;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,10 @@ public sealed record MergeHelperRequest(string ListId, IReadOnlyList<string> Tas
|
||||
|
||||
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 IServiceProvider? _services;
|
||||
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;
|
||||
var rawId = row.Id.StartsWith("user:", StringComparison.Ordinal) ? row.Id["user:".Length..] : row.Id;
|
||||
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
|
||||
await vm.LoadAsync(rawId, row.Name, row.WorkingDir, row.DefaultCommitType, row.IsManual, row.FindingsTracked);
|
||||
await vm.LoadAsync(rawId, row.Name, row.WorkingDir, row.DefaultCommitType, row.IsManual, row.FindingsTracked, row.IconKey);
|
||||
await Dialogs.ShowListSettingsAsync(vm);
|
||||
if (vm.Deleted) await LoadAsync();
|
||||
else await RefreshRowAsync(row.Id);
|
||||
@@ -255,6 +259,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
"smart:my-day" => "vm.lists.smartMyDay",
|
||||
"smart:important" => "vm.lists.smartImportant",
|
||||
"smart:planned" => "vm.lists.smartPlanned",
|
||||
NotesListId => "vm.lists.smartNotes",
|
||||
"virtual:queued" => "vm.lists.virtualQueue",
|
||||
"virtual:running" => "vm.lists.virtualRunning",
|
||||
"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: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 = 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: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" },
|
||||
@@ -293,8 +299,6 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
var lists = new ListRepository(ctx);
|
||||
var seedNames = new HashSet<string>(new[] { "My Day", "Important", "Planned" });
|
||||
var dotColors = new[] { "Moss", "Peat", "Sage" };
|
||||
int idx = 0;
|
||||
foreach (var l in await lists.GetAllAsync(ct))
|
||||
{
|
||||
if (seedNames.Contains(l.Name)) continue;
|
||||
@@ -304,8 +308,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
Id = $"user:{l.Id}",
|
||||
Name = l.Name,
|
||||
Kind = ListKind.User,
|
||||
IconKey = "Folder",
|
||||
DotColorKey = dotColors[idx % dotColors.Length],
|
||||
IconKey = l.Icon ?? "Folder",
|
||||
WorkingDir = l.WorkingDir,
|
||||
DefaultCommitType = l.DefaultCommitType,
|
||||
IsManual = l.IsManual,
|
||||
@@ -314,7 +317,6 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
};
|
||||
Items.Add(item);
|
||||
UserLists.Add(item);
|
||||
idx++;
|
||||
}
|
||||
ok = true;
|
||||
}
|
||||
@@ -395,7 +397,6 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
Name = entity.Name,
|
||||
Kind = ListKind.User,
|
||||
IconKey = "Folder",
|
||||
DotColorKey = "Moss",
|
||||
WorkingDir = entity.WorkingDir,
|
||||
DefaultCommitType = entity.DefaultCommitType,
|
||||
};
|
||||
@@ -407,7 +408,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
if (Dialogs is not null && _services is not null)
|
||||
{
|
||||
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
|
||||
await vm.LoadAsync(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType, entity.IsManual, entity.FindingsTracked);
|
||||
await vm.LoadAsync(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType, entity.IsManual, entity.FindingsTracked, entity.Icon);
|
||||
await Dialogs.ShowListSettingsAsync(vm);
|
||||
if (vm.Deleted) await LoadAsync();
|
||||
else await RefreshRowAsync(item.Id);
|
||||
@@ -507,6 +508,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
row.DefaultCommitType = entity.DefaultCommitType;
|
||||
row.IsManual = entity.IsManual;
|
||||
row.FindingsTracked = entity.FindingsTracked;
|
||||
row.IconKey = entity.Icon ?? "Folder";
|
||||
row.HasTicketProject = hasTicketProject;
|
||||
RecomputeHasNoLinkedRepo();
|
||||
}
|
||||
@@ -547,14 +549,12 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
/// </summary>
|
||||
private async Task AddRowAsync(ListRepository lists, ListEntity entity, bool hasTicketProject = false)
|
||||
{
|
||||
var dotColors = new[] { "Moss", "Peat", "Sage" };
|
||||
var item = new ListNavItemViewModel
|
||||
{
|
||||
Id = $"user:{entity.Id}",
|
||||
Name = entity.Name,
|
||||
Kind = ListKind.User,
|
||||
IconKey = "Folder",
|
||||
DotColorKey = dotColors[UserLists.Count % dotColors.Length],
|
||||
IconKey = entity.Icon ?? "Folder",
|
||||
WorkingDir = entity.WorkingDir,
|
||||
DefaultCommitType = entity.DefaultCommitType,
|
||||
IsManual = entity.IsManual,
|
||||
|
||||
@@ -1,25 +1,57 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.Services.Interfaces;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
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 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;
|
||||
|
||||
public NoteBulletViewModel(string id, string text)
|
||||
public NoteBulletViewModel(string id, DateOnly day, string text, bool fromAgent = false)
|
||||
{
|
||||
Id = id;
|
||||
Day = day;
|
||||
_text = text;
|
||||
FromAgent = fromAgent;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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).
|
||||
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 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);
|
||||
set { var d = DateOnly.FromDateTime(value); if (d != CurrentDay) _ = LoadDayAsync(d); }
|
||||
Today = DateOnly.FromDateTime(DateTime.Today);
|
||||
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)
|
||||
{
|
||||
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 static NoteBulletViewModel Bullet(DailyNoteDto n) =>
|
||||
new(n.Id, Day(n), n.Text, n.FromAgent);
|
||||
|
||||
private NoteBulletViewModel MakeBullet(string id, string text) =>
|
||||
new(id, text);
|
||||
private void RefreshEmpty() => IsEmpty = !Rows.OfType<NoteBulletViewModel>().Any();
|
||||
|
||||
/// <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]
|
||||
private async Task AddBullet()
|
||||
{
|
||||
@@ -60,9 +118,10 @@ public sealed partial class NotesEditorViewModel : ViewModelBase
|
||||
if (text.Length == 0) return;
|
||||
try
|
||||
{
|
||||
var dto = await _api.AddAsync(CurrentDay, text);
|
||||
if (dto is not null) Bullets.Add(MakeBullet(dto.Id, dto.Text));
|
||||
var dto = await _api.AddAsync(Today, text);
|
||||
if (dto is not null) Rows.Insert(1, Bullet(dto));
|
||||
NewBulletText = "";
|
||||
RefreshEmpty();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -70,21 +129,68 @@ public sealed partial class NotesEditorViewModel : ViewModelBase
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand] private Task PrevDay() => LoadDayAsync(CurrentDay.AddDays(-1));
|
||||
[RelayCommand] private Task NextDay() => LoadDayAsync(CurrentDay.AddDays(1));
|
||||
[RelayCommand] private Task Today() => LoadDayAsync(DateOnly.FromDateTime(DateTime.Today));
|
||||
/// <summary>Enter inside a note: save it, then open an empty row underneath and focus it.</summary>
|
||||
[RelayCommand]
|
||||
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]
|
||||
private async Task CommitBullet(NoteBulletViewModel? b)
|
||||
{
|
||||
if (b is null) return;
|
||||
var text = b.Text?.Trim() ?? "";
|
||||
if (text.Length == 0)
|
||||
try
|
||||
{
|
||||
await _api.DeleteAsync(b.Id);
|
||||
Bullets.Remove(b);
|
||||
return;
|
||||
if (text.Length == 0)
|
||||
{
|
||||
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.Ui.Localization;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.Services.Interfaces;
|
||||
using ClaudeDo.Ui.ViewModels.Modals;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
@@ -62,18 +63,10 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
|
||||
public event EventHandler? FocusAddTaskRequested;
|
||||
public event EventHandler? TasksChanged;
|
||||
public event Action? NotesRequested;
|
||||
public event Action? PrepRequested;
|
||||
public event Action<string>? ErrorReported;
|
||||
public void RequestFocusAddTask() => FocusAddTaskRequested?.Invoke(this, EventArgs.Empty);
|
||||
|
||||
[RelayCommand]
|
||||
private void OpenNotes()
|
||||
{
|
||||
SelectFrom(null, "notes");
|
||||
NotesRequested?.Invoke();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void ShowPrepLog() => PrepRequested?.Invoke();
|
||||
|
||||
@@ -118,8 +111,17 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
private bool _hasCompleted;
|
||||
[ObservableProperty] private bool _showOpenLabel;
|
||||
[ObservableProperty] private string _completedHeader = "";
|
||||
[ObservableProperty] private bool _showNotesRow;
|
||||
[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]
|
||||
[NotifyPropertyChangedFor(nameof(IsTasksEmptyRepoHintVisible))]
|
||||
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
|
||||
/// 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
|
||||
/// 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;
|
||||
|
||||
public TasksIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient? worker = null)
|
||||
public TasksIslandViewModel(
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
IWorkerClient? worker = null,
|
||||
INotesApi? notesApi = null)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_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 };
|
||||
CompletedHeader = Loc.T("vm.tasksIsland.completedHeader");
|
||||
if (_worker is not null)
|
||||
@@ -417,14 +429,14 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
HasOpen = false;
|
||||
HasCompleted = false;
|
||||
ShowOpenLabel = false;
|
||||
ShowNotesRow = false;
|
||||
if (listChanged) SelectFrom(null, "list-change");
|
||||
if (list is null) { IsLetClaudeVisible = false; IsQuickClaudeVisible = false; IsMergeReviewsVisible = false; LoadTask = Task.CompletedTask; return; }
|
||||
|
||||
HeaderTitle = list.Name;
|
||||
HeaderEyebrow = DateTime.Now.ToString("dddd · MMM dd", CultureInfo.InvariantCulture).ToUpperInvariant();
|
||||
ShowNotesRow = 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);
|
||||
IsQuickClaudeVisible = IsLetClaudeVisible;
|
||||
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 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)
|
||||
{
|
||||
|
||||
@@ -145,7 +145,14 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
[ObservableProperty]
|
||||
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;
|
||||
|
||||
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)
|
||||
{
|
||||
OnPropertyChanged(nameof(ShowDetails));
|
||||
NotifyDetailsVisibility();
|
||||
OnPropertyChanged(nameof(ShowLists));
|
||||
}
|
||||
|
||||
private void NotifyDetailsVisibility()
|
||||
{
|
||||
OnPropertyChanged(nameof(ShowDetails));
|
||||
OnPropertyChanged(nameof(TasksColumnSpan));
|
||||
}
|
||||
|
||||
public void OnWorkerLogReceived(WorkerLogEntry entry)
|
||||
{
|
||||
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 += (_, _) => _ = RefreshStaleWorkerCheckAsync();
|
||||
Tasks.SelectionChanged += (_, _) => Details.Bind(Tasks.SelectedTask, Tasks.SelectionSource);
|
||||
Tasks.NotesRequested += () => Details.ShowNotes();
|
||||
Tasks.PrepRequested += () => Details.ShowPrep();
|
||||
Tasks.PropertyChanged += (_, e) =>
|
||||
{
|
||||
if (e.PropertyName == nameof(TasksIslandViewModel.IsNotesList)) NotifyDetailsVisibility();
|
||||
};
|
||||
Tasks.ErrorReported += FlashFooterError;
|
||||
Lists.ErrorReported += FlashFooterError;
|
||||
Lists.SuccessReported += FlashFooterSuccess;
|
||||
|
||||
@@ -58,10 +58,20 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
|
||||
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 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 MissionControlViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient worker, UsagePillViewModel usagePill)
|
||||
@@ -89,6 +99,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
_worker.HandoffRequestedEvent += _onHandoffRequested;
|
||||
|
||||
_ = RefreshQueueAsync();
|
||||
_ = RefreshSessionTargetsAsync();
|
||||
}
|
||||
|
||||
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();
|
||||
var rows = await ctx.Tasks.AsNoTracking()
|
||||
.Where(t => t.Status == ClaudeDo.Data.Models.TaskStatus.Queued
|
||||
|| t.Status == ClaudeDo.Data.Models.TaskStatus.Running)
|
||||
.OrderBy(t => t.Status == ClaudeDo.Data.Models.TaskStatus.Running ? 0 : 1)
|
||||
|| t.Status == ClaudeDo.Data.Models.TaskStatus.Running
|
||||
|| 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)
|
||||
.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();
|
||||
|
||||
Queued.Clear();
|
||||
foreach (var r in rows)
|
||||
{
|
||||
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
|
||||
{
|
||||
Id = id,
|
||||
Title = r.Title ?? string.Empty,
|
||||
IsBlocked = r.BlockedByTaskId != null,
|
||||
IsRunning = r.Status == ClaudeDo.Data.Models.TaskStatus.Running,
|
||||
IsRunning = running,
|
||||
TintKey = tint,
|
||||
StatusColorKey = colorKey,
|
||||
StatusLabel = labelKey is null ? null : Loc.T(labelKey),
|
||||
OpenInAppCommand = new RelayCommand(() => OpenInApp?.Invoke(id)),
|
||||
OpenSessionCommand = new RelayCommand(() => _ = OpenConPtySessionAsync(id)),
|
||||
});
|
||||
}
|
||||
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)); }
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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
|
||||
@@ -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 required string Id { get; init; }
|
||||
public required string Title { get; init; }
|
||||
public bool IsBlocked { 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? 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.
|
||||
[ObservableProperty] private string _verifyCommand = "";
|
||||
// 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 TicketProjectOption? _selectedTicketProject;
|
||||
public ObservableCollection<TicketProjectOption> TicketProjects { get; } = new();
|
||||
|
||||
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),
|
||||
// scoped to this list (list → global inheritance).
|
||||
public AgentConfigEditorViewModel Agent { get; }
|
||||
@@ -68,12 +90,16 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
string defaultCommitType,
|
||||
bool isManual = false,
|
||||
bool findingsTracked = false,
|
||||
string? icon = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ListId = listId;
|
||||
Name = name;
|
||||
IsManual = isManual;
|
||||
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 ?? "";
|
||||
DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? CommitTypeRegistry.DefaultType : defaultCommitType;
|
||||
|
||||
@@ -111,7 +137,8 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
string.IsNullOrWhiteSpace(WorkingDir) ? null : WorkingDir,
|
||||
DefaultCommitType,
|
||||
IsManual,
|
||||
FindingsTracked));
|
||||
FindingsTracked,
|
||||
Icon));
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -10,12 +10,15 @@
|
||||
|
||||
<!-- Column 0: id badge + editable title -->
|
||||
<StackPanel Grid.Column="0" Spacing="0">
|
||||
<TextBlock Classes="meta"
|
||||
Text="{Binding TaskIdBadge}"
|
||||
Margin="0,0,0,4"
|
||||
Cursor="Hand"
|
||||
ToolTip.Tip="{loc:Tr details.copyTaskIdTip}"
|
||||
Tapped="OnTaskIdTapped"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8" Margin="0,0,0,4">
|
||||
<TextBlock Classes="meta"
|
||||
Text="{Binding TaskIdBadge}"
|
||||
Cursor="Hand"
|
||||
ToolTip.Tip="{loc:Tr details.copyTaskIdTip}"
|
||||
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}"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
|
||||
@@ -11,27 +11,6 @@
|
||||
<Panel>
|
||||
<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 ── -->
|
||||
<Border DockPanel.Dock="Top" Classes="island-header"
|
||||
IsVisible="{Binding IsTaskDetailVisible}">
|
||||
@@ -146,17 +125,12 @@
|
||||
DragCompleted="OnSplitterDragCompleted"/>
|
||||
</Grid>
|
||||
|
||||
<!-- Empty state: no task selected, outside Notes/Prep mode -->
|
||||
<!-- Empty state: no task selected, outside Prep mode -->
|
||||
<TextBlock IsVisible="{Binding IsEmptyStateVisible}"
|
||||
Text="{loc:Tr details.emptyState}"
|
||||
Foreground="{DynamicResource TextMuteBrush}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
|
||||
<!-- Notes mode -->
|
||||
<Panel IsVisible="{Binding IsNotesMode}">
|
||||
<islands:NotesEditorView DataContext="{Binding Notes}"/>
|
||||
</Panel>
|
||||
|
||||
<!-- Daily-prep mode -->
|
||||
<Panel IsVisible="{Binding IsPrepMode}">
|
||||
<DockPanel>
|
||||
|
||||
@@ -103,7 +103,6 @@
|
||||
<PathIcon Grid.Column="0" Classes="list-icon"
|
||||
Width="14" Height="14"
|
||||
Data="{Binding IconKey, Converter={StaticResource IconKey}}"
|
||||
Foreground="{DynamicResource TextMuteBrush}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
<!-- Name -->
|
||||
<TextBlock Grid.Column="1" Classes="list-label"
|
||||
@@ -177,11 +176,11 @@
|
||||
HorizontalAlignment="Left" VerticalAlignment="Center"
|
||||
Margin="-8,0,0,0"/>
|
||||
</Border>
|
||||
<!-- Color dot (6px circle, color from DotColorKey) -->
|
||||
<Ellipse Grid.Column="0"
|
||||
Width="6" Height="6"
|
||||
Fill="{Binding DotColorKey, Converter={StaticResource DotBrush}}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
<!-- Icon (list-settings → Icon tab; "Folder" when unset) -->
|
||||
<PathIcon Grid.Column="0" Classes="list-icon"
|
||||
Width="14" Height="14"
|
||||
Data="{Binding IconKey, Converter={StaticResource IconKey}}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||
<!-- Name -->
|
||||
<TextBlock Grid.Column="1" Classes="list-label"
|
||||
Text="{Binding Name}"
|
||||
|
||||
@@ -1,38 +1,113 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
|
||||
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
|
||||
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
||||
x:Class="ClaudeDo.Ui.Views.Islands.NotesEditorView"
|
||||
x:DataType="vm:NotesEditorViewModel">
|
||||
<DockPanel Margin="16">
|
||||
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="btn" Content="‹" Command="{Binding PrevDayCommand}"/>
|
||||
<ctl:ThemedDatePicker SelectedDate="{Binding CurrentDate, Mode=TwoWay}"/>
|
||||
<Button Classes="btn" Content="›" 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">
|
||||
<Button DockPanel.Dock="Right" Classes="btn" Content="{loc:Tr notes.add}" Margin="8,0,0,0"
|
||||
Command="{Binding AddBulletCommand}"/>
|
||||
<TextBox PlaceholderText="{loc:Tr notes.newNotePlaceholder}" Text="{Binding NewBulletText}">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding AddBulletCommand}"/>
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
</DockPanel>
|
||||
<UserControl.Styles>
|
||||
<!-- A note reads as text, not as a form field: the box only shows itself under the pointer
|
||||
and while it has focus. -->
|
||||
<Style Selector="TextBox.note">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
<Setter Property="Padding" Value="4,3"/>
|
||||
<Setter Property="MinHeight" Value="0"/>
|
||||
</Style>
|
||||
<Style Selector="TextBox.note /template/ Border#PART_BorderElement">
|
||||
<Setter Property="Background" Value="Transparent"/>
|
||||
<Setter Property="BorderThickness" Value="0"/>
|
||||
</Style>
|
||||
|
||||
<ScrollViewer>
|
||||
<ItemsControl ItemsSource="{Binding Bullets}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:NoteBulletViewModel">
|
||||
<TextBox Text="{Binding Text}" Margin="0,2"
|
||||
LostFocus="OnBulletLostFocus"/>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
<Style Selector="Border.note-row">
|
||||
<Setter Property="CornerRadius" Value="6"/>
|
||||
<Setter Property="Padding" Value="10,1,6,1"/>
|
||||
<Setter Property="Margin" Value="4,1"/>
|
||||
</Style>
|
||||
<Style Selector="Border.note-row:pointerover">
|
||||
<Setter Property="Background" Value="{DynamicResource PeatSoftBrush}"/>
|
||||
</Style>
|
||||
<!-- Row actions stay out of the way until the row is hovered. -->
|
||||
<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>
|
||||
</UserControl>
|
||||
|
||||
@@ -1,12 +1,74 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
|
||||
namespace ClaudeDo.Ui.Views.Islands;
|
||||
|
||||
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)
|
||||
{
|
||||
@@ -15,4 +77,11 @@ public partial class NotesEditorView : UserControl
|
||||
&& vm.CommitBulletCommand.CanExecute(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}"/>
|
||||
</Border>
|
||||
<Button Classes="icon-btn" Classes.active="{Binding IsShowingCompleted}"
|
||||
IsVisible="{Binding !IsNotesList}"
|
||||
Command="{Binding ToggleShowCompletedCommand}"
|
||||
ToolTip.Tip="{loc:Tr tasks.showCompletedTip}">
|
||||
<PathIcon Width="15" Height="15" Data="{StaticResource Icon.Eye}"/>
|
||||
@@ -60,7 +61,8 @@
|
||||
<Path Classes="icon-stroke" Data="{StaticResource Icon.PlanDay}"/>
|
||||
</Viewbox>
|
||||
</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}"/>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
@@ -68,7 +70,8 @@
|
||||
</Border>
|
||||
|
||||
<!-- 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">
|
||||
<Border Grid.Column="0" Classes="add-task-plus" VerticalAlignment="Center">
|
||||
<PathIcon Width="12" Height="12" Data="{StaticResource Icon.Plus}"
|
||||
@@ -90,14 +93,6 @@
|
||||
</Grid>
|
||||
</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
|
||||
headers are regular entries, not a separate ItemsControl per section, so a
|
||||
VirtualizingStackPanel actually bounds the realized container count.
|
||||
@@ -106,6 +101,7 @@
|
||||
unreachable. -->
|
||||
<Panel>
|
||||
<ListBox x:Name="RowsListBox"
|
||||
IsVisible="{Binding !IsNotesList}"
|
||||
ItemsSource="{Binding Rows}"
|
||||
Background="Transparent"
|
||||
BorderThickness="0">
|
||||
@@ -172,6 +168,11 @@
|
||||
Foreground="{DynamicResource TextMuteBrush}"
|
||||
TextAlignment="Center" TextWrapping="Wrap"/>
|
||||
</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>
|
||||
|
||||
</DockPanel>
|
||||
|
||||
@@ -144,7 +144,7 @@
|
||||
ResizeDirection="Columns"
|
||||
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}"/>
|
||||
</Border>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
xmlns:vmm="using:ClaudeDo.Ui.ViewModels.MissionControl"
|
||||
xmlns:mc="using:ClaudeDo.Ui.Views.MissionControl"
|
||||
xmlns:controls="using:ClaudeDo.Ui.Views.Controls"
|
||||
xmlns:conv="using:ClaudeDo.Ui.Converters"
|
||||
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
||||
x:DataType="vm:MissionControlViewModel"
|
||||
x:Class="ClaudeDo.Ui.Views.MissionControl.MissionControlView">
|
||||
@@ -33,9 +34,19 @@
|
||||
Margin="0,0,4,0"
|
||||
VerticalAlignment="Center"/>
|
||||
<Button Classes="icon-btn"
|
||||
Click="OnNewSessionClicked"
|
||||
ToolTip.Tip="{loc:Tr missionControl.newSession}">
|
||||
<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 Classes="icon-btn"
|
||||
Command="{Binding ToggleLayoutCommand}"
|
||||
@@ -100,7 +111,7 @@
|
||||
BorderThickness="1,0,0,0">
|
||||
<DockPanel LastChildFill="True" Margin="10,10">
|
||||
<TextBlock DockPanel.Dock="Top" Classes="eyebrow"
|
||||
Text="{loc:Tr missionControl.queue}"
|
||||
Text="{loc:Tr missionControl.activity}"
|
||||
Foreground="{DynamicResource TextMuteBrush}"
|
||||
LetterSpacing="1.4" Margin="0,0,0,8" />
|
||||
<ScrollViewer>
|
||||
@@ -112,27 +123,31 @@
|
||||
Background="Transparent" BorderThickness="0"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch"
|
||||
Cursor="Hand">
|
||||
<Button.ContextMenu>
|
||||
<ContextMenu>
|
||||
<MenuItem Header="{loc:Tr missionControl.openSession}"
|
||||
Command="{Binding OpenSessionCommand}"
|
||||
IsEnabled="{Binding CanOpenSession}" />
|
||||
</ContextMenu>
|
||||
</Button.ContextMenu>
|
||||
<Panel>
|
||||
<Border Background="{DynamicResource SurfaceBrush}"
|
||||
BorderBrush="{DynamicResource LineBrush}"
|
||||
BorderThickness="1" CornerRadius="6" />
|
||||
<Border Background="{DynamicResource RunningTintBrush}"
|
||||
BorderBrush="{DynamicResource RunningTintBorderBrush}"
|
||||
<!-- Single state tint; brush keys come from the VM (Tokens.axaml). -->
|
||||
<Border Background="{Binding TintKey, Converter={x:Static conv:DotBrushConverter.Instance}}"
|
||||
BorderBrush="{Binding TintBorderKey, Converter={x:Static conv:DotBrushConverter.Instance}}"
|
||||
BorderThickness="1" CornerRadius="6"
|
||||
IsVisible="{Binding IsRunning}" />
|
||||
IsVisible="{Binding TintKey, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||
<StackPanel Margin="8,6" Spacing="2">
|
||||
<TextBlock Text="{Binding Title}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
ToolTip.Tip="{Binding Title}"
|
||||
Foreground="{DynamicResource TextDimBrush}" />
|
||||
<TextBlock Classes="meta"
|
||||
Text="{loc:Tr missionControl.blocked}"
|
||||
IsVisible="{Binding IsBlocked}"
|
||||
Foreground="{DynamicResource AmberBrush}" />
|
||||
<TextBlock Classes="meta"
|
||||
Text="{loc:Tr missionControl.running}"
|
||||
IsVisible="{Binding IsRunning}"
|
||||
Foreground="{DynamicResource StatusRunningBrush}" />
|
||||
Text="{Binding StatusLabel}"
|
||||
IsVisible="{Binding StatusLabel, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
|
||||
Foreground="{Binding StatusColorKey, Converter={x:Static conv:DotBrushConverter.Instance}}" />
|
||||
</StackPanel>
|
||||
</Panel>
|
||||
</Button>
|
||||
|
||||
@@ -3,7 +3,6 @@ using System.Collections.Specialized;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Platform.Storage;
|
||||
@@ -30,8 +29,10 @@ public partial class MissionControlView : UserControl
|
||||
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.
|
||||
private async void OnNewSessionClicked(object? sender, RoutedEventArgs e)
|
||||
private async void BrowseForSessionAsync()
|
||||
{
|
||||
if (DataContext is not MissionControlViewModel vm) return;
|
||||
var topLevel = TopLevel.GetTopLevel(this);
|
||||
@@ -53,6 +54,7 @@ public partial class MissionControlView : UserControl
|
||||
{
|
||||
_vm.Panes.CollectionChanged -= OnPanesChanged;
|
||||
_vm.PropertyChanged -= OnViewModelPropertyChanged;
|
||||
_vm.BrowseForSessionRequested = null;
|
||||
}
|
||||
|
||||
_vm = DataContext as MissionControlViewModel;
|
||||
@@ -61,6 +63,7 @@ public partial class MissionControlView : UserControl
|
||||
{
|
||||
_vm.Panes.CollectionChanged += OnPanesChanged;
|
||||
_vm.PropertyChanged += OnViewModelPropertyChanged;
|
||||
_vm.BrowseForSessionRequested = BrowseForSessionAsync;
|
||||
}
|
||||
|
||||
RebuildOverviewGrid();
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
Background="Transparent" Margin="12,16,8,8"
|
||||
VerticalAlignment="Top">
|
||||
<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.sectionVerify}"/>
|
||||
</ListBox>
|
||||
@@ -113,6 +114,35 @@
|
||||
</ScrollViewer>
|
||||
</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 -->
|
||||
<TabItem Header="{loc:Tr modals.listSettings.sectionAgent}">
|
||||
<ScrollViewer>
|
||||
|
||||
+59
@@ -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) =>
|
||||
_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) =>
|
||||
_hub.Clients.All.SendAsync("ListUpdated", listId);
|
||||
|
||||
|
||||
@@ -543,6 +543,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
entity.DefaultCommitType = string.IsNullOrWhiteSpace(dto.DefaultCommitType) ? CommitTypeRegistry.DefaultType : dto.DefaultCommitType;
|
||||
entity.IsManual = dto.IsManual;
|
||||
entity.FindingsTracked = dto.FindingsTracked;
|
||||
entity.Icon = string.IsNullOrWhiteSpace(dto.Icon) ? null : dto.Icon;
|
||||
await repo.UpdateAsync(entity);
|
||||
|
||||
await _broadcaster.ListUpdated(dto.Id);
|
||||
@@ -969,14 +970,25 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
{
|
||||
using var ctx = _dbFactory.CreateDbContext();
|
||||
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)
|
||||
{
|
||||
using var ctx = _dbFactory.CreateDbContext();
|
||||
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)
|
||||
|
||||
@@ -986,6 +986,48 @@ public sealed class TaskMergeService
|
||||
if (task.Status != TaskStatus.WaitingForReview)
|
||||
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)
|
||||
{
|
||||
// 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);
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// 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
|
||||
|
||||
@@ -7,4 +7,9 @@ namespace ClaudeDo.Worker.Planning;
|
||||
public interface IActiveMergeState
|
||||
{
|
||||
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) =>
|
||||
_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
|
||||
/// 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
|
||||
|
||||
@@ -98,6 +98,9 @@ public sealed class PlanningSessionManager
|
||||
if (!await _git.IsGitRepoAsync(listWorkingDir, ct))
|
||||
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 worktreePath = WorktreePathFor(taskId, appSettings.WorktreeStrategy, appSettings.CentralWorktreeRoot, listWorkingDir);
|
||||
var branchName = BranchNameFor(taskId);
|
||||
|
||||
@@ -355,7 +355,9 @@ if (cfg.ExternalMcpPort > 0)
|
||||
externalBuilder.Services.AddScoped<TaskWaitMcpTools>();
|
||||
externalBuilder.Services.AddScoped<QueueStateMcpTools>();
|
||||
externalBuilder.Services.AddScoped<AttachmentMcpTools>();
|
||||
externalBuilder.Services.AddScoped<DailyNoteRepository>();
|
||||
externalBuilder.Services.AddScoped<FindingsMcpTools>();
|
||||
externalBuilder.Services.AddScoped<NotesMcpTools>();
|
||||
externalBuilder.Services.AddMcpServer()
|
||||
.WithHttpTransport(o => o.Stateless = true)
|
||||
.WithRequestFilters(f => f.AddCallToolFilter(ExternalMcpExceptionFilter.Wrap))
|
||||
@@ -371,7 +373,8 @@ if (cfg.ExternalMcpPort > 0)
|
||||
.WithTools<TaskWaitMcpTools>()
|
||||
.WithTools<QueueStateMcpTools>()
|
||||
.WithTools<AttachmentMcpTools>()
|
||||
.WithTools<FindingsMcpTools>();
|
||||
.WithTools<FindingsMcpTools>()
|
||||
.WithTools<NotesMcpTools>();
|
||||
externalBuilder.WebHost.UseUrls($"http://127.0.0.1:{cfg.ExternalMcpPort}");
|
||||
|
||||
externalApp = externalBuilder.Build();
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
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))
|
||||
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);
|
||||
// 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.
|
||||
|
||||
@@ -286,7 +286,7 @@ public sealed class TaskStateService : ITaskStateService
|
||||
// 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,
|
||||
// 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.");
|
||||
|
||||
List<string> cancelledChildIds;
|
||||
|
||||
@@ -26,6 +26,7 @@ public abstract class StubWorkerClient : IWorkerClient
|
||||
public event Action<string, string, string>? TaskQuestionAskedEvent;
|
||||
public event Action<string, string>? TaskQuestionResolvedEvent;
|
||||
public event Action<string, IReadOnlyList<string>, string>? HandoffRequestedEvent;
|
||||
public event Action? NotesUpdatedEvent;
|
||||
public event Action? PrepStartedEvent;
|
||||
public event Action<string>? PrepLineEvent;
|
||||
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<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>> 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 UpdateDailyNoteAsync(string id, string text) => Task.CompletedTask;
|
||||
public virtual Task DeleteDailyNoteAsync(string id) => Task.CompletedTask;
|
||||
|
||||
@@ -23,6 +23,7 @@ public sealed class NullServiceProvider : IServiceProvider
|
||||
public sealed class StubNotesApi : INotesApi
|
||||
{
|
||||
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 UpdateAsync(string id, string text) => Task.CompletedTask;
|
||||
public Task DeleteAsync(string id) => Task.CompletedTask;
|
||||
|
||||
@@ -48,7 +48,7 @@ public class DetailsIslandConflictSeamTests : IDisposable
|
||||
private DetailsIslandViewModel BuildVm(StubWorkerClient worker, MergeCoordinator merge)
|
||||
{
|
||||
var factory = new TestDbFactory(NewContext);
|
||||
return new DetailsIslandViewModel(factory, worker, new NullServiceProvider(), new StubNotesApi(), merge);
|
||||
return new DetailsIslandViewModel(factory, worker, new NullServiceProvider(), merge);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -56,7 +56,7 @@ public class DetailsIslandDeleteTaskTests : IDisposable
|
||||
private DetailsIslandViewModel BuildVm(RecordingWorkerClient worker)
|
||||
{
|
||||
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]
|
||||
|
||||
@@ -59,7 +59,7 @@ public class DetailsIslandDescriptionSaveRaceTests : IDisposable
|
||||
}
|
||||
|
||||
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());
|
||||
|
||||
private async Task<TaskEntity> ReadTaskAsync(string id)
|
||||
|
||||
@@ -38,7 +38,7 @@ public class DetailsIslandEmptyStateTests : IDisposable
|
||||
private DetailsIslandViewModel NewDetailsVm(StubWorkerClient stub)
|
||||
{
|
||||
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]
|
||||
@@ -60,17 +60,6 @@ public class DetailsIslandEmptyStateTests : IDisposable
|
||||
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]
|
||||
public void IsEmptyStateVisible_false_in_prep_mode_even_without_a_task()
|
||||
|
||||
@@ -86,7 +86,7 @@ public class DetailsIslandErrorFeedbackTests : IDisposable
|
||||
{
|
||||
var factory = new TestDbFactory(NewContext);
|
||||
return new DetailsIslandViewModel(
|
||||
factory, worker, new NullServiceProvider(), new StubNotesApi(), new ClaudeDo.Ui.Services.MergeCoordinator());
|
||||
factory, worker, new NullServiceProvider(), new ClaudeDo.Ui.Services.MergeCoordinator());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -48,7 +48,7 @@ public class DetailsIslandHandlerRangeTests : IDisposable
|
||||
{
|
||||
var factory = new TestDbFactory(NewContext);
|
||||
return new DetailsIslandViewModel(
|
||||
factory, new FakeWorkerClient(), new NullServiceProvider(), new StubNotesApi(), new MergeCoordinator());
|
||||
factory, new FakeWorkerClient(), new NullServiceProvider(), new MergeCoordinator());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -47,7 +47,7 @@ public class DetailsIslandPlanningTests : IDisposable
|
||||
private DetailsIslandViewModel BuildVm(StubWorkerClient worker)
|
||||
{
|
||||
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
|
||||
|
||||
@@ -37,7 +37,7 @@ public class DetailsIslandPrepModeTests : IDisposable
|
||||
private DetailsIslandViewModel NewDetailsVm(StubWorkerClient stub)
|
||||
{
|
||||
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]
|
||||
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());
|
||||
vm.ShowPrep();
|
||||
|
||||
Assert.True(vm.IsPrepMode);
|
||||
Assert.False(vm.IsNotesMode);
|
||||
Assert.False(vm.IsTaskDetailVisible);
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ public class DetailsIslandResetAndRetryTests : IDisposable
|
||||
{
|
||||
var factory = new TestDbFactory(NewContext);
|
||||
return new DetailsIslandViewModel(
|
||||
factory, worker, new NullServiceProvider(), new StubNotesApi(), new ClaudeDo.Ui.Services.MergeCoordinator());
|
||||
factory, worker, new NullServiceProvider(), new ClaudeDo.Ui.Services.MergeCoordinator());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -50,7 +50,7 @@ public class DetailsIslandReviewActionsTests : IDisposable
|
||||
private DetailsIslandViewModel BuildVm(StubWorkerClient worker)
|
||||
{
|
||||
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]
|
||||
|
||||
@@ -63,7 +63,7 @@ public class DetailsIslandRoadblockReplyTests : IDisposable
|
||||
private DetailsIslandViewModel BuildVm(StubWorkerClient worker)
|
||||
{
|
||||
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")
|
||||
|
||||
@@ -38,7 +38,7 @@ public class DetailsIslandTabsTests : IDisposable
|
||||
private DetailsIslandViewModel NewVm()
|
||||
{
|
||||
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]
|
||||
|
||||
@@ -40,7 +40,7 @@ public class DetailsIslandTaskIdBadgeTests : IDisposable
|
||||
private DetailsIslandViewModel NewVm()
|
||||
{
|
||||
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]
|
||||
|
||||
@@ -57,7 +57,7 @@ public class DetailsIslandTaskUpdatedTests : IDisposable
|
||||
}
|
||||
|
||||
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());
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -202,6 +202,66 @@ public class MissionControlViewModelTests : IDisposable
|
||||
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()
|
||||
{
|
||||
await using var db = NewContext();
|
||||
|
||||
@@ -6,42 +6,110 @@ namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||
|
||||
public class NotesEditorViewModelTests
|
||||
{
|
||||
private static readonly DateOnly Today = DateOnly.FromDateTime(DateTime.Today);
|
||||
|
||||
private sealed class FakeNotes : INotesApi
|
||||
{
|
||||
public readonly List<DailyNoteDto> Store = new();
|
||||
private int _seq;
|
||||
|
||||
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)
|
||||
{
|
||||
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);
|
||||
return Task.FromResult<DailyNoteDto?>(dto);
|
||||
}
|
||||
|
||||
public Task UpdateAsync(string id, string text)
|
||||
{
|
||||
var i = Store.FindIndex(n => n.Id == id);
|
||||
if (i >= 0) Store[i] = Store[i] with { Text = text };
|
||||
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
|
||||
{
|
||||
public string ExceptionMessage { get; init; } = "worker offline";
|
||||
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 UpdateAsync(string id, string text) => 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]
|
||||
public async Task AddBullet_WhenApiThrows_RaisesErrorReported_AndKeepsDraftText()
|
||||
{
|
||||
var api = new ThrowingNotes();
|
||||
var vm = new NotesEditorViewModel(api);
|
||||
await vm.LoadDayAsync(new DateOnly(2026, 6, 1));
|
||||
await vm.LoadAsync();
|
||||
|
||||
string? reportedError = null;
|
||||
vm.ErrorReported += msg => reportedError = msg;
|
||||
@@ -50,73 +118,123 @@ public class NotesEditorViewModelTests
|
||||
await vm.AddBulletCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal(api.ExceptionMessage, reportedError);
|
||||
Assert.Empty(vm.Bullets);
|
||||
Assert.Empty(Bullets(vm));
|
||||
Assert.Equal("Standup vorbereitet", vm.NewBulletText);
|
||||
}
|
||||
|
||||
[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 = new NotesEditorViewModel(api);
|
||||
await vm.LoadDayAsync(new DateOnly(2026, 6, 1));
|
||||
var (vm, api) = await LoadedAsync(api => api.Seed(Today, "erste"));
|
||||
NoteBulletViewModel? focused = null;
|
||||
vm.FocusRequested += b => focused = b;
|
||||
|
||||
vm.NewBulletText = "Standup vorbereitet";
|
||||
await vm.AddBulletCommand.ExecuteAsync(null);
|
||||
var first = Bullets(vm)[0];
|
||||
first.Text = "erste, bearbeitet";
|
||||
await vm.SplitBulletCommand.ExecuteAsync(first);
|
||||
|
||||
Assert.Single(vm.Bullets);
|
||||
Assert.Equal("Standup vorbereitet", vm.Bullets[0].Text);
|
||||
Assert.Equal("", vm.NewBulletText);
|
||||
var rows = Bullets(vm);
|
||||
Assert.Equal(2, rows.Count);
|
||||
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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PrevAndNextDay_NavigateAndReload()
|
||||
public async Task SplitBullet_on_an_emptied_note_deletes_it_and_adds_no_row()
|
||||
{
|
||||
var api = new FakeNotes();
|
||||
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);
|
||||
var (vm, api) = await LoadedAsync(api => api.Seed(Today, "weg damit"));
|
||||
|
||||
await vm.PrevDayCommand.ExecuteAsync(null);
|
||||
Assert.Equal(new DateOnly(2026, 5, 31), vm.CurrentDay);
|
||||
Assert.Single(vm.Bullets);
|
||||
var only = Bullets(vm)[0];
|
||||
only.Text = " ";
|
||||
await vm.SplitBulletCommand.ExecuteAsync(only);
|
||||
|
||||
await vm.NextDayCommand.ExecuteAsync(null);
|
||||
Assert.Equal(new DateOnly(2026, 6, 1), vm.CurrentDay);
|
||||
Assert.Empty(vm.Bullets);
|
||||
Assert.Empty(Bullets(vm));
|
||||
Assert.Empty(api.Store);
|
||||
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]
|
||||
public async Task CommitBullet_EmptyText_RemovesFromStoreAndList()
|
||||
{
|
||||
var api = new FakeNotes();
|
||||
var vm = new NotesEditorViewModel(api);
|
||||
await vm.LoadDayAsync(new DateOnly(2026, 6, 1));
|
||||
vm.NewBulletText = "weg damit";
|
||||
await vm.AddBulletCommand.ExecuteAsync(null);
|
||||
var (vm, api) = await LoadedAsync(api => api.Seed(Today, "weg damit"));
|
||||
|
||||
vm.Bullets[0].Text = " ";
|
||||
await vm.CommitBulletCommand.ExecuteAsync(vm.Bullets[0]);
|
||||
var only = Bullets(vm)[0];
|
||||
only.Text = " ";
|
||||
await vm.CommitBulletCommand.ExecuteAsync(only);
|
||||
|
||||
Assert.Empty(vm.Bullets);
|
||||
Assert.Empty(Bullets(vm));
|
||||
Assert.Empty(api.Store);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CommitBullet_NonEmptyText_PersistsEdit()
|
||||
{
|
||||
var api = new FakeNotes();
|
||||
var vm = new NotesEditorViewModel(api);
|
||||
await vm.LoadDayAsync(new DateOnly(2026, 6, 1));
|
||||
vm.NewBulletText = "original";
|
||||
await vm.AddBulletCommand.ExecuteAsync(null);
|
||||
var (vm, api) = await LoadedAsync(api => api.Seed(Today, "original"));
|
||||
|
||||
vm.Bullets[0].Text = "geändert";
|
||||
await vm.CommitBulletCommand.ExecuteAsync(vm.Bullets[0]);
|
||||
var only = Bullets(vm)[0];
|
||||
only.Text = "geaendert";
|
||||
await vm.CommitBulletCommand.ExecuteAsync(only);
|
||||
|
||||
Assert.Single(vm.Bullets);
|
||||
Assert.Equal("geändert", api.Store[0].Text);
|
||||
Assert.Single(Bullets(vm));
|
||||
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 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
|
||||
|
||||
@@ -162,6 +162,36 @@ public sealed class PlanningMergeOrchestratorTests : IDisposable
|
||||
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(
|
||||
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);
|
||||
}
|
||||
|
||||
[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]
|
||||
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);
|
||||
}
|
||||
|
||||
[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]
|
||||
public async Task RevertMergeAsync_UncommittedChangesInTarget_ReturnsBlocked()
|
||||
{
|
||||
|
||||
@@ -15,6 +15,7 @@ file sealed class FakeActiveMergeState : IActiveMergeState
|
||||
{
|
||||
public HashSet<string> ActiveTaskIds { get; } = new();
|
||||
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
|
||||
|
||||
@@ -112,6 +112,7 @@ sealed class FakeWorkerClient : IWorkerClient
|
||||
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 event Action? NotesUpdatedEvent;
|
||||
public event Action? PrepStartedEvent;
|
||||
public event Action<string>? PrepLineEvent;
|
||||
public event Action<bool>? PrepFinishedEvent;
|
||||
@@ -153,6 +154,7 @@ sealed class FakeWorkerClient : IWorkerClient
|
||||
public Task<bool> RunDailyPrepNowAsync() => Task.FromResult(false);
|
||||
public Task ClearMyDayAsync() => Task.CompletedTask;
|
||||
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 UpdateDailyNoteAsync(string id, string text) => Task.CompletedTask;
|
||||
public Task DeleteDailyNoteAsync(string id) => Task.CompletedTask;
|
||||
|
||||
Reference in New Issue
Block a user