Compare commits

...
49 Commits
Author SHA1 Message Date
mika kuns edcaee77dc Merge remote-tracking branch 'origin/main'
Changelog / changelog (push) Successful in 2s
Release / release (push) Successful in 42s
2026-09-10 15:41:57 +02:00
mika kuns bfb1c6523c feat(ui): Listen und Ordner in einer gemischten Sidebar-Reihenfolge
Die oberste Sidebar-Ebene ist jetzt eine gemischte TopLevel-Collection, so
dass eine Liste auch oberhalb eines Ordners stehen kann. DropAsync deckt
alle vier Quelle/Ziel-Kombinationen ab, ReorderSidebarAsync numeriert die
gesamte sichtbare Reihenfolge ueber lists und list_folders hinweg.
2026-09-10 15:40:10 +02:00
mika kuns 1770b67713 fix(tickets): Import broadcastet TaskUpdated fuer neue Tasks
ImportTickets sendete nur ListUpdated. Die UI liest das als Aenderung der
Listen-Metadaten und patcht nur bestehende Zeilen, also tauchten die
importierten Tasks erst nach Schliessen und Neuoeffnen der Liste auf.
TicketImportResult liefert jetzt die angelegten Task-Ids, der Hub
broadcastet pro Id ein TaskUpdated - derselbe Pfad, den jeder andere
out-of-band Creator nutzt.
2026-09-10 15:39:58 +02:00
mika kuns 3d09323ee1 fix(worker): ConnectedClientTracker in den externen MCP-Container teilen 2026-09-10 14:58:29 +02:00
mika kuns 7c399b26ba Merge branch 'claudedo/ea798a952ab54bcc95dbb442168ee0dd' 2026-09-10 13:59:50 +02:00
mika kuns d6696c3317 fix(localization): de.json-Konsistenz-Pass und restliche harte EN-Strings
Queue/Task(s)/Gate in deutschem Fliesstext auf Warteschlange/Aufgabe(n)/
Limit-Stopp vereinheitlicht, columnTask-Keys auf AUFGABE angeglichen,
Kill-Tonalitaet (closeSession/killSessionTip) ehrlich-destruktiv gemacht.
RepoImportModalViewModel.CreateButtonText und alle Installer-Check-Messages
(Git/GitIdentity/WriteAccess/ClaudeCli/ClaudeVersion/ClaudeAuth/
PermissionModeAuto/Ports) ueber Locale-Keys geroutet statt hartem Englisch,
mit dem bestehenden TrExtension.Localizer-Fallback-Muster.
2026-09-10 13:57:33 +02:00
mika kuns 73e038edd9 fix(claude-do): merge GitService stderr matches are English-only, same locale bug
ClaudeDo-Task: 66b7ad7b-ffc4-4ebd-8b21-54bba28e5b04
2026-09-10 13:56:14 +02:00
mika kuns 9d22c775f2 fix(claude-do): merge fix(ui,worker): Diff-Zähler locale-fest machen und toten Par
ClaudeDo-Task: 1085277d-ba7e-4e58-b06d-751e1b99b96f
2026-09-10 13:56:11 +02:00
mika kuns 407bc30554 feat(claude-do): merge feat(ui): Rückfrage bevor das X eine laufende ConPTY-Session
ClaudeDo-Task: 50c0d3ff-ad95-4612-8b16-344ac1c24f41
2026-09-10 13:54:26 +02:00
mika kuns 5ecf6f750a fix(data): force English git output for locale-fragile stderr matches
RevParseHeadAsync and CommitAsync's identity-error detection match
English-only substrings against git stderr. Under a non-English OS/git
locale these silently fail to match, falling through to a generic
error. Force LC_ALL=C and LANGUAGE=C on the git ProcessStartInfo so
git always emits English output regardless of ambient locale.
2026-09-10 13:54:14 +02:00
mika kuns eb62371430 feat(ui): confirm before closing a running ConPTY session
The tile's own X killed a live Claude session with no confirmation, unlike
the semantically similar Abandon-run action. Gate the close behind the same
ConfirmAsync mechanism, but only when Terminal.IsRunning -- a dead session
(StartError or exited-after-startup) still closes immediately since the X
there is pure cleanup.
2026-09-10 13:52:36 +02:00
mika kuns 395874d834 feat(claude-do): merge feat(ui): Worker-Menü "Shutdown" beendet nach Bestätigung Wo
ClaudeDo-Task: f97295c1e25a44db9db23df07a38062a
2026-09-10 13:52:30 +02:00
mika kuns 29ea18c427 fix(ui): make diff-stat parsing locale-safe and dedupe the two parsers
TaskRowViewModel had its own ParseDiffStat expecting "+12"/"-3" tokens that
`git diff --stat` never emits, so the row badge always showed 0/0. Meanwhile
DetailsIslandViewModel's parser matched only the English words
"insertion"/"deletion", so it broke on a localized git (e.g. German Windows:
"Einfügung(en)"/"Löschung(en)").

Row and detail badge now share one parser, anchored on git's untranslated
(+)/(-) suffix instead of the language-dependent word before it, so it reads
correctly in any locale. Also corrected the Worker CLAUDE.md's branch-name
claim (`claudedo/{taskId[:8]}`) to match the actual full-GUID branch name.
2026-09-10 13:50:35 +02:00
mika kuns 1bdf20272d feat(ui): worker-menu Shutdown ends worker + app after confirm
Adds a Shutdown entry after the Worker menu's Restart/Update Claude CLI
items. Confirms via IDialogService.ConfirmAsync, then kills this
installation's ClaudeDo.Worker process(es) (shared kill loop, extracted
from RestartWorkerService) and force-closes the window through the same
_forceClose path MainWindow already uses for the Mission-Control close
confirm, so no second confirmation dialog shows.
2026-09-10 13:48:50 +02:00
mika kuns 7eba0affbe feat(claude-do): merge feat(worker): normaler Parent mit Kindern — erst Parent ausf
ClaudeDo-Task: 646dd82f-4d3f-4aff-aef3-8e9f24b6e141
2026-09-10 13:36:24 +02:00
mika kuns d2648f965c feat(worker): chain a normal parent's children on WaitingForChildren
A standalone parent (PlanningPhase.None) with existing Idle/Queued children
now runs itself first, then sequences its children instead of flat-enqueuing
all of them at once: SubmitForChildrenAsync calls
PlanningChainCoordinator.SetupChainAsync(enqueue: true) right after the
Running -> WaitingForChildren transition, mirroring the planning path but
firing immediately instead of waiting for an explicit "Queue plan". Planning
parents are left untouched (they queue via QueuePlanAsync), and a child
already Running makes SetupChainAsync throw, which is now caught and logged
so it can't fail the parent's own transition.
2026-09-10 13:36:08 +02:00
mika kuns 897f287ee1 chore(claude-do): merge chore(ui): IWorkerClient.MergeProgressEvent-Forwarder entfer
ClaudeDo-Task: 2feaa8b5-0484-4b11-9519-6627f0bb95a3
2026-09-10 13:35:54 +02:00
mika kuns 1a5728749b fix(claude-do): merge fix(worker): max_turns-Fehlertext nicht fest deutsch — konsi
ClaudeDo-Task: 6549e3b5-cee3-4aa4-ba3a-6240bab952e6
2026-09-10 13:35:45 +02:00
mika kuns e628d97d29 fix(claude-do): merge fix(ui): fehlgeschlagenes Einfügen/Droppen im interaktiven T
ClaudeDo-Task: 81931eef-4d05-49d0-a476-7346cba7fbe5
2026-09-10 13:35:22 +02:00
mika kuns 0cec0a77ee fix(ui): report failed paste/drop in the interactive terminal
PasteAsync silently swallowed every exception and returned void, so a
package update renaming Iciclecreek's private write seam would break
Ctrl+V/drop with zero user-visible signal. PasteAsync/SendTextAsync now
return bool (mirroring SendLineAsync), and the view checks the result,
routing a failure through InteractiveTerminalViewModel.ErrorReported ->
ConPtyPaneViewModel.ErrorReported -> the existing footer error strip.
2026-09-10 13:35:16 +02:00
mika kuns acff815706 feat(claude-do): merge feat(ui): WorkConsole, DescriptionStepsCard und ROADBLOCK-La
ClaudeDo-Task: 6c91f950-f93b-44be-abb4-b12f65fb68ba
2026-09-10 13:35:14 +02:00
mika kuns 8e6f0d1605 feat(ui): localize WorkConsole, DescriptionStepsCard and the ROADBLOCK label
The review/merge cockpit was the last unlocalized surface in the detail
pane, mixing hard English with translated tooltips on the most-used screen.
Routes all literal strings through details.workConsole.*, details.stepsCard.*
and details.roadblockLabel, with en/de parity.
2026-09-10 13:35:07 +02:00
mika kuns bd393a0599 fix(claude-do): merge fix(ui): WorkerConnection-Modal meldet Fehlschläge sichtbar;
ClaudeDo-Task: 1db17881-7105-4f44-b035-0d13872bbfa0
2026-09-10 13:34:52 +02:00
mika kuns b4b6225432 fix(ui): surface worker connect/restart failures instead of no-opting
WorkerConnectionModalViewModel's StartWorker/RerunInstaller swallowed
missing-exe and Process.Start failures behind an empty catch and closed
the dialog anyway, looking like success. Now both show an inline error
and keep the dialog open; only a real launch closes it.

IslandsShellViewModel.RestartWorkerAsync never checked whether the
relaunched worker actually stayed up, and its status property was never
bound in any view. RestartWorkerService now fails if the new process
exits within a short survival window, and the outcome routes through
the footer error/success strip instead of the dead RestartWorkerStatus.

InstallArtifactLocator.Find() is now virtual and its two subclasses
unsealed so tests can fake exe resolution without touching the real
filesystem/registry.
2026-09-10 13:34:32 +02:00
mika kuns 46a0a27620 fix(claude-do): merge fix(ui): Settings-Politur — PowerUse-Zeitfenster validieren,
ClaudeDo-Task: f3f0a355-936c-4933-8236-d41c21fb73dc
2026-09-10 13:34:29 +02:00
mika kuns 423d39e9fb fix(claude-do): merge fix(worker): WeekReportService durch PermissionModeResolver
ClaudeDo-Task: b4198327-6045-451c-abf1-dbabad88e91a
2026-09-10 13:34:20 +02:00
mika kuns 951c2fe92a fix(ui): validate PowerUse window, explain effort and Prime Claude in Settings
Three usability-audit findings in Settings:
- An unparseable PowerUse window (e.g. "9:0") used to save silently as null
  (= round the clock), the opposite of what the typo implied. Validate() now
  rejects an unparseable start/end unless blank; the worker-side read-time
  tolerance is unchanged.
- "Effort" column header gets a tooltip explaining it's the model's reasoning
  effort (cost/depth tradeoff).
- The "Prime Claude" tab gets a tooltip explaining scheduled Claude runs
  (Ping / Fill My Day / custom prompt).

New locale keys added to en+de. Updated the PowerUse round-trip test that
asserted the old silent-normalize behavior.
2026-09-10 13:34:06 +02:00
mika kuns 6f1fbe5b93 fix(claude-do): merge fix(ui): Working-Dir beim Speichern der Listen-Settings als
ClaudeDo-Task: e8986885-add7-43b4-94bf-f12be5712848
2026-09-10 13:34:03 +02:00
mika kuns cfda3e9044 chore(ui): remove IWorkerClient.MergeProgressEvent forwarder
Group C of the operation-feedback batch is fully merged; the two consumers
(DetailsIslandViewModel.ApproveReviewAsync, MergeModalViewModel) now handle
the verifying phase from the generic OperationProgressEvent handler instead
of a separate forwarder event.
2026-09-10 13:33:57 +02:00
mika kuns 2a34d2453f fix(claude-do): merge fix(worker): start_list_handler ehrlich machen, wenn keine A
ClaudeDo-Task: 33e0f8f9-abac-4867-a873-ddc1c136a587
2026-09-10 13:33:40 +02:00
mika kuns 451c01848c fix(worker): start_list_handler ehrlich machen, wenn keine App verbunden ist
ConnectedClientTracker zaehlt verbundene UI-Clients (WorkerHub.OnConnectedAsync/
OnDisconnectedAsync). StartListHandler prueft ihn vor dem Broadcast und liefert
bei 0 verbundenen Clients Requested=false plus Reason statt unbedingtem true.
Tool-Beschreibung und external-mcp.md an das reale Verhalten angepasst.
2026-09-10 13:33:30 +02:00
mika kuns 0235b43bad fix(claude-do): merge fix(worker): Port-Konflikt beim Start laut melden statt stil
ClaudeDo-Task: 1eb4cfcd-a31e-4216-8c05-88cd662a211b
2026-09-10 13:33:28 +02:00
mika kuns 3348736e9d fix(ui): warn and block save when a list's working dir isn't a git repo
Reuses RepoScanner.IsRepo (now public) to validate WorkingDir whenever it
changes in ListSettingsModalViewModel — Browse or typing. A missing folder
or a non-repo folder shows an inline warning and gates Save via the same
CanExecute pattern the load-failure gate already uses; an empty WorkingDir
is untouched. Previously this only surfaced as a Failed task deep in
WorktreeManager on first run.

Also localizes the two hardcoded EN strings in the same file (delete
confirm, planning-sessions block) into vm.listSettings.* keys.
2026-09-10 13:32:52 +02:00
mika kuns 752cf0926b feat(claude-do): merge feat(ui): depends_on-Blockierung sichtbar machen (Status-Chi
ClaudeDo-Task: 701de3ab-c326-4152-9488-8ac21fa21ffc
2026-09-10 13:32:48 +02:00
mika kuns 4cf601e423 feat(ui): surface depends_on blocking in status chip, chain label, and detail pane
Usability-Audit 2026-09-10 found three gaps versus the dependency-chain-display
spec: a Queued task blocked on an unmet task_dependencies edge rendered the same
chip as one about to run, a cross-list predecessor silently dropped the
"after #N" chip, and the detail pane showed nothing about a wait at all.

- TaskRowViewModel gains IsBlockedByDependency/DependencyWaitLabel (extrinsic,
  assigned by TasksIslandViewModel like the existing chain fields); IsQueued/
  IsWaiting/StatusChipClass/StatusLabel/StatusChipTooltip now account for it
  alongside the existing BlockedByTaskId planning-chain link.
- TasksIslandViewModel.ApplyDependencyWaitState computes the blocked flag and
  wait label per row; a new RefreshExternalDependencyCacheAsync backfills
  Number/Title/Status for predecessors not loaded into the current list's
  Items (reusing the already-open DbContext, no new hub method), so
  BuildAfterLabel/blocking both resolve a listenfremder Vorgänger correctly.
- New "waiting" chip style (IslandStyles.axaml) and locale keys (en+de);
  TaskHeaderBar shows a "Waiting on #N" line bound straight to the same row.

Tests: TasksIslandChainTests (blocked/unblocked chip, cross-list predecessor),
DetailsIslandDependencyWaitTests (new), TaskRowViewModelNotificationsTests
updated for the new notification set.
2026-09-10 13:32:36 +02:00
mika kuns c1b133ff90 fix(claude-do): merge fix(ui): gestorbene ConPTY-Session auf der Kachel sichtbar m
ClaudeDo-Task: 4f97df3c-be1e-4513-9b86-742f243649f0
2026-09-10 13:32:34 +02:00
mika kuns 256bea5ffa fix(ui): show a banner when a ConPTY session dies after startup
InteractiveTerminalViewModel.HasExitedAfterStartup now covers every
post-startup death that ExitedAfterStartup alone missed (crash, clean
exit, quiet exit inside the grace window) -- previously the tile just
stayed looking alive/thinking with zero signal. ConPtyPaneView binds a
new banner to it (message + Retry + Close); CanRetry simplifies to
"any HasExited state" since the same descriptor factory can relaunch
either kind of death. HandlerRestartCoordinator's worker-side
auto-restart path is untouched.
2026-09-10 13:32:19 +02:00
mika kuns 2b0562b2ed fix(claude-do): merge fix(localization): doppelten Top-Level-Block "checks" in en.
ClaudeDo-Task: ba3b1423-fd00-4771-8b16-735cb441ebce
2026-09-10 13:32:16 +02:00
mika kuns 741f66fe14 fix(localization): merge duplicate top-level "checks" block in en/de.json
en.json and de.json each defined the checks object twice; JSON silently
kept the second block's values, so the first block's friendlier titles/hints
(e.g. "Permission Mode" over the raw "--permission-mode auto support" flag)
were dead text. Merged into one block per key, keeping the friendlier title
and the more actionable hint.

Added a Utf8JsonReader-based duplicate-top-level-key test, since
JsonDocument accepts duplicates without error and the existing parity test
can't see this failure mode either.
2026-09-10 13:31:01 +02:00
mika kuns 2d67ca3171 feat(claude-do): merge feat(ui): Worker-Menü "Update Claude CLI" führt `claude upda
ClaudeDo-Task: 7a02b6957f094dceb0f923c300c4675f
2026-09-10 13:30:58 +02:00
mika kuns 0fcfe7c524 fix(worker): translate max_turns failure text to English
TaskRunner.BuildFailureMarkdown returned a hardcoded German string for
TerminalReason == "max_turns", the only worker-generated string not in
English. Surfaced as the outcome/roadblock card for one of the most likely
first failures, looking broken to non-German users.
2026-09-10 13:22:30 +02:00
mika kuns 1c76852957 fix(worker): route WeekReportService permission-mode through PermissionModeResolver
Closes the last unrouted --permission-mode call site: haiku default models were
hardcoding raw "auto", which the CLI silently downgrades to interactive "default"
and hangs the report generation. Also drops the author's personal C:\Private
default exclude in favor of an empty default (config stays via ReportExcludedPaths).
2026-09-10 13:13:16 +02:00
mika kuns c9d4823f06 fix(worker): report startup port conflicts instead of crashing silently
Mutex was Local\ (per Windows session), so a second RDP/fast-user-switching
session bypassed the single-instance guard. A Kestrel bind failure on top of
that had no try/catch, so a second worker (or any process on 47821/47822)
died as an unhandled crash with nothing in the log to explain why the UI
never connects. Mutex is now Global\, bind failures and any last-resort
unhandled exception log a Fatal line and exit(1) instead of crashing raw.
2026-09-10 13:12:25 +02:00
mika kuns dbee4a0de9 feat(ui): Worker-Menü "Update Claude CLI" führt claude update aus
Neuer Eintrag im Worker-Menü der Titelleiste, der die Claude CLI über
das konfigurierte claude_bin (ExecutableResolver, inkl. Shim-Handling)
mit `claude update` aktualisiert. Ausgabe/Fehler landen im Footer-Strip
(FlashFooterSuccess/FlashFooterError), Menüpunkt ist während des Laufs
deaktiviert. 5-Minuten-Timeout tötet den Prozessbaum.
2026-09-10 13:10:30 +02:00
mika kuns 1177463a82 feat(ui): triage model picker (opus/fable) in the merge helper start modal 2026-09-10 09:42:55 +02:00
mika kuns 5477ae5302 docs: record list folders in the Data and Ui CLAUDE.md 2026-09-10 08:33:31 +02:00
mika kuns a0a85c5c24 test: cover list folders in the data and sidebar layers
Data: folder delete keeps its lists (ON DELETE SET NULL), rename patches
name/icon without resetting sort order or collapse state.
Sidebar: grouping on load, folder badge as the sum of its children, both
move routes, and the persisted collapse state.

Drops the LoadAsync/AddRowAsync normalization of a dangling folder_id:
FK enforcement is on for every connection, so the row it guarded against
cannot exist, and ContainerOf already falls back to the top level.
2026-09-10 08:32:52 +02:00
mika kuns 25c9a619dc feat(ui): collapsible folders for the list sidebar
Folders render above the top-level lists, each with its own collapsible
children; tapping a folder header folds it instead of selecting it.
Lists move in by dragging onto a folder header (or onto a row inside it)
and out by dropping between top-level rows or via the context menu.

Creating a folder is a toggle in the New-list modal, not a third sidebar
button: it reduces the modal to name + icon and writes a folder row
instead of the list row the create flow pre-created.
2026-09-10 08:29:56 +02:00
mika kuns 13e860f06b feat(data): list_folders table plus lists.folder_id
Sidebar folders get their own table rather than a flag on lists, so the
places that enumerate lists (MCP tools, move-to-list, queue picker, repo
import) need no folder filtering. ON DELETE SET NULL keeps a folder's
lists alive when the folder goes.
2026-09-10 08:29:56 +02:00
100 changed files with 4726 additions and 558 deletions
+8
View File
@@ -98,6 +98,7 @@ Daily prep: `GetDailyPrepCandidates`, `SetMyDay`.
| `TaskWaitMcpTools` | `WaitForTaskChange` | | `TaskWaitMcpTools` | `WaitForTaskChange` |
| `QueueStateMcpTools` | `GetQueueState` | | `QueueStateMcpTools` | `GetQueueState` |
| `AttachmentMcpTools` | `AddTaskAttachment`, `ListTaskAttachments`, `RemoveTaskAttachment` | | `AttachmentMcpTools` | `AddTaskAttachment`, `ListTaskAttachments`, `RemoveTaskAttachment` |
| `ListHandlerMcpTools` | `StartListHandler` |
## Per-tool behaviour worth knowing ## Per-tool behaviour worth knowing
@@ -235,6 +236,13 @@ task already outside those two (unknown ids reported as status `"NotFound"`, als
**`AttachmentMcpTools`** — re-attaching the same `fileName` overwrites. Add/remove refuse on a **`AttachmentMcpTools`** — re-attaching the same `fileName` overwrites. Add/remove refuse on a
`Running` task. `Running` task.
**`StartListHandler`** — the worker only broadcasts `ListHandlerStartRequested`; the connected
ClaudeDo App does the actual work (creates the handler task, opens the triage ConPTY tile). Before
broadcasting it checks `ConnectedClientTracker` (a `WorkerHub.OnConnectedAsync`/`OnDisconnectedAsync`-driven
singleton count) — with zero connected clients the broadcast would reach nobody, so the tool
returns `Requested: false` plus a `Reason` instead of claiming success. Does not queue or retry the
request for a later connect.
**`GetDailyPrepCandidates`** — Idle, non-blocked tasks in a git repo **not** excluded by **`GetDailyPrepCandidates`** — Idle, non-blocked tasks in a git repo **not** excluded by
`AppSettings.ReportExcludedPaths` and not already `IsMyDay`, plus the current Idle MyDay tasks `AppSettings.ReportExcludedPaths` and not already `IsMyDay`, plus the current Idle MyDay tasks
and `maxTasks` (= `DailyPrepMaxTasks`). Repo-exclusion logic lives in the `DailyPrepFilter` and `maxTasks` (= `DailyPrepMaxTasks`). Repo-exclusion logic lives in the `DailyPrepFilter`
+6 -2
View File
@@ -12,7 +12,9 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
- `InteractiveSessionId` = the claude session id an embedded ConPTY interactive task session runs under, persisted by `InteractiveLaunchSpecService` before launch so a closed/aborted session can be resumed → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md). - `InteractiveSessionId` = the claude session id an embedded ConPTY interactive task session runs under, persisted by `InteractiveLaunchSpecService` before launch so a closed/aborted session can be resumed → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md).
- `Number` (INTEGER NOT NULL, unique index `idx_tasks_number`) — a global, monotonically increasing display alias for tasks. **Never reused**: deleting a task leaves a gap in numbering, ensuring that a task number that appears in a log or report always points to the same task (if it exists). Allocated by `TaskNumberAllocator.AddWithNumberAsync` on every insert via a persistent counter (`app_settings.next_task_number`). **Why not `MAX(number) + 1`:** deleting the highest-numbered task would free its number for reuse, breaking the immutability guarantee. The allocator uses an `UPDATE…RETURNING` statement to claim numbers atomically, retrying up to 5 times if a uniqueness collision occurs (the counter advances on each attempt, so retries always allocate fresh numbers). The one-time backfill for pre-existing rows lived in the `AddTaskNumbers` migration, squashed away 2026-08-26. - `Number` (INTEGER NOT NULL, unique index `idx_tasks_number`) — a global, monotonically increasing display alias for tasks. **Never reused**: deleting a task leaves a gap in numbering, ensuring that a task number that appears in a log or report always points to the same task (if it exists). Allocated by `TaskNumberAllocator.AddWithNumberAsync` on every insert via a persistent counter (`app_settings.next_task_number`). **Why not `MAX(number) + 1`:** deleting the highest-numbered task would free its number for reuse, breaking the immutability guarantee. The allocator uses an `UPDATE…RETURNING` statement to claim numbers atomically, retrying up to 5 times if a uniqueness collision occurs (the counter advances on each attempt, so retries always allocate fresh numbers). The one-time backfill for pre-existing rows lived in the `AddTaskNumbers` migration, squashed away 2026-08-26.
- Legacy status values `Manual`/`Planning`/`Planned`/`Draft`/`Waiting` were retired. The backfill migration was squashed away (2026-08-26) — a database still holding those values can't be upgraded, see Schema below. - Legacy status values `Manual`/`Planning`/`Planned`/`Draft`/`Waiting` were retired. The backfill migration was squashed away (2026-08-26) — a database still holding those values can't be upgraded, see Schema below.
- **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt, IsManual (reminder list — tasks created here default to `IsManual`) - **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt, IsManual (reminder list — tasks created here default to `IsManual`), Icon, FolderId
- `FolderId` (`folder_id`, nullable FK → `list_folders.id`, `ON DELETE SET NULL`) — the sidebar folder the list sits in; null = top level. Written only by the UI (`ListsIslandViewModel` drag/context menu), never by MCP.
- **ListFolderEntity** — Id, Name, Icon, SortOrder, IsCollapsed, CreatedAt → `list_folders`. A pure sidebar grouping: no tasks, no config, no working dir, **one level** (folders never nest). Deliberately its own table rather than a flag on `lists`, so every caller that enumerates lists (MCP `list_task_lists`, "move to list", the queue picker, repo import) needs no folder filtering. Folder CRUD lives on `ListRepository`.
- **ListConfigEntity** — ListId (PK, 1:1), Model, SystemPrompt, AgentPath, MaxTurns, SessionSkills, VerifyCommand, PermissionMode, TicketProjectId (all nullable). `VerifyCommand` is an optional post-merge gate; null/blank = no gate → [review-merge](../../docs/explore-notes/review-merge.md). `PermissionMode` is null = inherit the global default; resolution order task → list → global lives in `EffectiveRunConfigResolver`. `TicketProjectId` (`ticket_project_id`) links the list to a project in the external ticket system; null = not linked → `ClaudeDo.Worker/CLAUDE.md` Tickets section. - **ListConfigEntity** — ListId (PK, 1:1), Model, SystemPrompt, AgentPath, MaxTurns, SessionSkills, VerifyCommand, PermissionMode, TicketProjectId (all nullable). `VerifyCommand` is an optional post-merge gate; null/blank = no gate → [review-merge](../../docs/explore-notes/review-merge.md). `PermissionMode` is null = inherit the global default; resolution order task → list → global lives in `EffectiveRunConfigResolver`. `TicketProjectId` (`ticket_project_id`) links the list to a project in the external ticket system; null = not linked → `ClaudeDo.Worker/CLAUDE.md` Tickets section.
- ⚠️ `ListRepository.SetConfigAsync` copies the entity **verbatim**, so every writer must carry the fields it doesn't own (`SessionSkills`, `PermissionMode`, `TicketProjectId`) or they silently reset. `UpdateListConfigDto.TicketProjectId` is tri-state (`null` = keep stored) for exactly that reason — only the list-settings modal sends an explicit value. This bit `PermissionMode` for real: its update-branch copy was missing from `SetConfigAsync` until `f0a3a186`, so a list's permission-mode override only ever stuck on the list's *first* save, never on a later one. - ⚠️ `ListRepository.SetConfigAsync` copies the entity **verbatim**, so every writer must carry the fields it doesn't own (`SessionSkills`, `PermissionMode`, `TicketProjectId`) or they silently reset. `UpdateListConfigDto.TicketProjectId` is tri-state (`null` = keep stored) for exactly that reason — only the list-settings modal sends an explicit value. This bit `PermissionMode` for real: its update-branch copy was missing from `SetConfigAsync` until `f0a3a186`, so a list's permission-mode override only ever stuck on the list's *first* save, never on a later one.
- **WorktreeEntity** — TaskId (PK, 1:1), Path, BranchName, BaseCommit, HeadCommit, DiffStat, MergeCommit (nullable — SHA of the merge commit this branch produced; the only thing making `revert_merge` possible without searching `git log`), State (`Active|Merged|Discarded|Kept`) - **WorktreeEntity** — TaskId (PK, 1:1), Path, BranchName, BaseCommit, HeadCommit, DiffStat, MergeCommit (nullable — SHA of the merge commit this branch produced; the only thing making `revert_merge` possible without searching `git log`), State (`Active|Merged|Discarded|Kept`)
@@ -65,7 +67,8 @@ All use EF Core LINQ via `ClaudeDoDbContext`. The atomic `Queued → Running` cl
Worker's `QueuePicker` (`FromSqlRaw`), **not** here. Worker's `QueuePicker` (`FromSqlRaw`), **not** here.
- **TaskRepository** — CRUD, planning helpers (`CreateChildAsync`, `SetPlanningStartedAsync`, `DiscardPlanningAsync`, `UpdateChildAsync`), `UpdateAgentSettingsAsync`. ⚠️ Status-mutation primitives (`MarkRunningAsync`/`MarkDoneAsync`/`MarkFailedAsync`/`FlipAllRunningToFailedAsync`) are **`internal`** — only the Worker's `TaskStateService` may call them. `CreateChildAsync` produces children with `Status=Idle, PlanningPhase=None`. **Task-number allocation:** `AddAsync` (line 20) and `CreateChildAsync` (line 276) are the two sole task-creation paths; both call `TaskNumberAllocator.AddWithNumberAsync` to allocate a number atomically during the same transaction as the insert. Every other creation path (UI, MCP `add_task`, `batch_add_tasks`, Online Inbox sync, merge-helper handler tasks, planning children) routes through one of these two. - **TaskRepository** — CRUD, planning helpers (`CreateChildAsync`, `SetPlanningStartedAsync`, `DiscardPlanningAsync`, `UpdateChildAsync`), `UpdateAgentSettingsAsync`. ⚠️ Status-mutation primitives (`MarkRunningAsync`/`MarkDoneAsync`/`MarkFailedAsync`/`FlipAllRunningToFailedAsync`) are **`internal`** — only the Worker's `TaskStateService` may call them. `CreateChildAsync` produces children with `Status=Idle, PlanningPhase=None`. **Task-number allocation:** `AddAsync` (line 20) and `CreateChildAsync` (line 276) are the two sole task-creation paths; both call `TaskNumberAllocator.AddWithNumberAsync` to allocate a number atomically during the same transaction as the insert. Every other creation path (UI, MCP `add_task`, `batch_add_tasks`, Online Inbox sync, merge-helper handler tasks, planning children) routes through one of these two.
- **ListRepository** — CRUD, `GetConfigAsync`/`SetConfigAsync` (upsert)/`DeleteConfigAsync` for `list_config` - **ListRepository** — CRUD, `GetConfigAsync`/`SetConfigAsync` (upsert)/`DeleteConfigAsync` for `list_config`, plus the `list_folders` set: `GetFoldersAsync`, `AddFolderAsync` (appends), `RenameFolderAsync`, `DeleteFolderAsync` (folder only — the FK releases its lists), `SetFolderCollapsedAsync`, `SetListFolderAsync`. The folder writers are `ExecuteUpdate` patches, not entity `Update()`s, so a rename can't reset `SortOrder`/`IsCollapsed` the way `SetConfigAsync`'s verbatim copy resets forgotten columns.
- ⚠️ **`lists.sort_order` and `list_folders.sort_order` share one number space** — the sidebar interleaves folders and top-level lists in a single order, so `ReorderSidebarAsync` numbers the whole visual sequence (each folder followed by its children) across both tables, and `AddAsync`/`AddFolderAsync` append via `MAX(sort_order)+1` over **both**. Never renumber one table on its own.
- **WorktreeRepository** — CRUD, `UpdateHeadAsync`, `SetStateAsync`, `SetMergedAsync` (atomically sets State=Merged **and** stamps MergeCommit in one update — the only writer of MergeCommit) - **WorktreeRepository** — CRUD, `UpdateHeadAsync`, `SetStateAsync`, `SetMergedAsync` (atomically sets State=Merged **and** stamps MergeCommit in one update — the only writer of MergeCommit)
- **TaskAttachmentRepository** — `AddAsync`, `UpdateAsync`, `GetAsync(taskId, fileName)`, `ListByTaskIdAsync`, `DeleteAsync(taskId, fileName)`. Bulk delete-per-task is the `ON DELETE CASCADE` on `task_attachments`, not a repository method. - **TaskAttachmentRepository** — `AddAsync`, `UpdateAsync`, `GetAsync(taskId, fileName)`, `ListByTaskIdAsync`, `DeleteAsync(taskId, fileName)`. Bulk delete-per-task is the `ON DELETE CASCADE` on `task_attachments`, not a repository method.
- **DailyNoteRepository**, **WeekReportRepository**, **TaskRunRepository**, **SubtaskRepository**, **AppSettingsRepository** - **DailyNoteRepository**, **WeekReportRepository**, **TaskRunRepository**, **SubtaskRepository**, **AppSettingsRepository**
@@ -116,6 +119,7 @@ Tables (one per line so parallel migrations don't collide on the same line):
- `week_reports` - `week_reports`
- `task_attachments` - `task_attachments`
- `task_dependencies` - `task_dependencies`
- `list_folders`
Managed by EF Core migrations in `Migrations/`. **The 40 incremental migrations were squashed into Managed by EF Core migrations in `Migrations/`. **The 40 incremental migrations were squashed into
a single `20260826094154_InitialCreate` on 2026-08-26** — the schema history is git history now, a single `20260826094154_InitialCreate` on 2026-08-26** — the schema history is git history now,
+1
View File
@@ -43,6 +43,7 @@ public class ClaudeDoDbContext : DbContext
public DbSet<TaskEntity> Tasks => Set<TaskEntity>(); public DbSet<TaskEntity> Tasks => Set<TaskEntity>();
public DbSet<TaskDependencyEntity> TaskDependencies => Set<TaskDependencyEntity>(); public DbSet<TaskDependencyEntity> TaskDependencies => Set<TaskDependencyEntity>();
public DbSet<ListEntity> Lists => Set<ListEntity>(); public DbSet<ListEntity> Lists => Set<ListEntity>();
public DbSet<ListFolderEntity> ListFolders => Set<ListFolderEntity>();
public DbSet<ListConfigEntity> ListConfigs => Set<ListConfigEntity>(); public DbSet<ListConfigEntity> ListConfigs => Set<ListConfigEntity>();
public DbSet<WorktreeEntity> Worktrees => Set<WorktreeEntity>(); public DbSet<WorktreeEntity> Worktrees => Set<WorktreeEntity>();
public DbSet<TaskRunEntity> TaskRuns => Set<TaskRunEntity>(); public DbSet<TaskRunEntity> TaskRuns => Set<TaskRunEntity>();
@@ -20,8 +20,17 @@ public class ListEntityConfiguration : IEntityTypeConfiguration<ListEntity>
builder.Property(l => l.IsManual).HasColumnName("is_manual").IsRequired().HasDefaultValue(false); builder.Property(l => l.IsManual).HasColumnName("is_manual").IsRequired().HasDefaultValue(false);
builder.Property(l => l.FindingsTracked).HasColumnName("findings_tracked").IsRequired().HasDefaultValue(false); builder.Property(l => l.FindingsTracked).HasColumnName("findings_tracked").IsRequired().HasDefaultValue(false);
builder.Property(l => l.Icon).HasColumnName("icon"); builder.Property(l => l.Icon).HasColumnName("icon");
builder.Property(l => l.FolderId).HasColumnName("folder_id");
builder.HasIndex(l => l.SortOrder).HasDatabaseName("idx_lists_sort"); builder.HasIndex(l => l.SortOrder).HasDatabaseName("idx_lists_sort");
builder.HasIndex(l => l.FolderId).HasDatabaseName("idx_lists_folder");
// No navigation property on either side: nothing needs the graph, and a nav would have to
// be Include'd (or silently come back empty) in every list query.
builder.HasOne<ListFolderEntity>()
.WithMany()
.HasForeignKey(l => l.FolderId)
.OnDelete(DeleteBehavior.SetNull);
builder.HasOne(l => l.Config) builder.HasOne(l => l.Config)
.WithOne(c => c.List) .WithOne(c => c.List)
@@ -0,0 +1,23 @@
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace ClaudeDo.Data.Configuration;
public class ListFolderEntityConfiguration : IEntityTypeConfiguration<ListFolderEntity>
{
public void Configure(EntityTypeBuilder<ListFolderEntity> builder)
{
builder.ToTable("list_folders");
builder.HasKey(f => f.Id);
builder.Property(f => f.Id).HasColumnName("id");
builder.Property(f => f.Name).HasColumnName("name").IsRequired();
builder.Property(f => f.CreatedAt).HasColumnName("created_at").IsRequired();
builder.Property(f => f.Icon).HasColumnName("icon");
builder.Property(f => f.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0);
builder.Property(f => f.IsCollapsed).HasColumnName("is_collapsed").IsRequired().HasDefaultValue(false);
builder.HasIndex(f => f.SortOrder).HasDatabaseName("idx_list_folders_sort");
}
}
+5
View File
@@ -704,6 +704,11 @@ public sealed class GitService
// (mktree "input format error") and would prepend to stdin commit messages. // (mktree "input format error") and would prepend to stdin commit messages.
StandardInputEncoding = stdinData is not null ? new UTF8Encoding(false) : null, StandardInputEncoding = stdinData is not null ? new UTF8Encoding(false) : null,
}; };
// Force English stderr/stdout so the English-only substring matches below
// (e.g. "ambiguous argument 'HEAD'", "tell me who you are") work regardless
// of the OS locale git for Windows picks up.
psi.Environment["LC_ALL"] = "C";
psi.Environment["LANGUAGE"] = "C";
psi.ArgumentList.Add("-C"); psi.ArgumentList.Add("-C");
psi.ArgumentList.Add(workDir); psi.ArgumentList.Add(workDir);
foreach (var a in args) psi.ArgumentList.Add(a); foreach (var a in args) psi.ArgumentList.Add(a);
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,74 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddListFolders : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "folder_id",
table: "lists",
type: "TEXT",
nullable: true);
migrationBuilder.CreateTable(
name: "list_folders",
columns: table => new
{
id = table.Column<string>(type: "TEXT", nullable: false),
name = table.Column<string>(type: "TEXT", nullable: false),
created_at = table.Column<DateTime>(type: "TEXT", nullable: false),
icon = table.Column<string>(type: "TEXT", nullable: true),
sort_order = table.Column<int>(type: "INTEGER", nullable: false, defaultValue: 0),
is_collapsed = table.Column<bool>(type: "INTEGER", nullable: false, defaultValue: false)
},
constraints: table =>
{
table.PrimaryKey("PK_list_folders", x => x.id);
});
migrationBuilder.CreateIndex(
name: "idx_lists_folder",
table: "lists",
column: "folder_id");
migrationBuilder.CreateIndex(
name: "idx_list_folders_sort",
table: "list_folders",
column: "sort_order");
migrationBuilder.AddForeignKey(
name: "FK_lists_list_folders_folder_id",
table: "lists",
column: "folder_id",
principalTable: "list_folders",
principalColumn: "id",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_lists_list_folders_folder_id",
table: "lists");
migrationBuilder.DropTable(
name: "list_folders");
migrationBuilder.DropIndex(
name: "idx_lists_folder",
table: "lists");
migrationBuilder.DropColumn(
name: "folder_id",
table: "lists");
}
}
}
@@ -324,6 +324,10 @@ namespace ClaudeDo.Data.Migrations
.HasDefaultValue(false) .HasDefaultValue(false)
.HasColumnName("findings_tracked"); .HasColumnName("findings_tracked");
b.Property<string>("FolderId")
.HasColumnType("TEXT")
.HasColumnName("folder_id");
b.Property<string>("Icon") b.Property<string>("Icon")
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("icon"); .HasColumnName("icon");
@@ -351,12 +355,54 @@ namespace ClaudeDo.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("FolderId")
.HasDatabaseName("idx_lists_folder");
b.HasIndex("SortOrder") b.HasIndex("SortOrder")
.HasDatabaseName("idx_lists_sort"); .HasDatabaseName("idx_lists_sort");
b.ToTable("lists", (string)null); b.ToTable("lists", (string)null);
}); });
modelBuilder.Entity("ClaudeDo.Data.Models.ListFolderEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("Icon")
.HasColumnType("TEXT")
.HasColumnName("icon");
b.Property<bool>("IsCollapsed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_collapsed");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.HasKey("Id");
b.HasIndex("SortOrder")
.HasDatabaseName("idx_list_folders_sort");
b.ToTable("list_folders", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b => modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -934,6 +980,14 @@ namespace ClaudeDo.Data.Migrations
b.Navigation("List"); b.Navigation("List");
}); });
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListFolderEntity", null)
.WithMany()
.HasForeignKey("FolderId")
.OnDelete(DeleteBehavior.SetNull);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b => modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{ {
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task") b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
+3
View File
@@ -16,6 +16,9 @@ public sealed class ListEntity
public bool FindingsTracked { get; set; } public bool FindingsTracked { get; set; }
// Icon key into the UI's icon library (e.g. "Star"); null = the default folder icon. // Icon key into the UI's icon library (e.g. "Star"); null = the default folder icon.
public string? Icon { get; set; } public string? Icon { get; set; }
// Sidebar folder this list sits in (ListFolderEntity.Id); null = top level. ON DELETE SET NULL,
// so deleting a folder moves its lists back to the top level instead of deleting them.
public string? FolderId { get; set; }
// Navigation properties // Navigation properties
public ListConfigEntity? Config { get; set; } public ListConfigEntity? Config { get; set; }
@@ -0,0 +1,20 @@
namespace ClaudeDo.Data.Models;
/// <summary>
/// A sidebar folder grouping user lists. Deliberately its own table rather than a flag on
/// <see cref="ListEntity"/>: every place that enumerates lists (MCP list_task_lists, "move to
/// list", the queue picker, repo import) would otherwise have to filter folders out.
/// One level only — a folder holds lists, never other folders.
/// </summary>
public sealed class ListFolderEntity
{
public required string Id { get; init; }
public required string Name { get; set; }
public required DateTime CreatedAt { get; init; }
// Icon key into the UI's icon library, same set as ListEntity.Icon; null = default folder icon.
public string? Icon { get; set; }
public int SortOrder { get; set; }
// Sidebar expand/collapse state. Lives here rather than in ui.config.json because the row is
// already being read for the folder itself.
public bool IsCollapsed { get; set; }
}
@@ -17,10 +17,20 @@ public sealed class ListRepository
public async Task AddAsync(ListEntity entity, CancellationToken ct = default) public async Task AddAsync(ListEntity entity, CancellationToken ct = default)
{ {
NormalizeWorkingDir(entity); NormalizeWorkingDir(entity);
if (entity.SortOrder == 0) entity.SortOrder = await NextSortOrderAsync(ct);
_context.Lists.Add(entity); _context.Lists.Add(entity);
await _context.SaveChangesAsync(ct); await _context.SaveChangesAsync(ct);
} }
/// <summary>One past the highest <c>sort_order</c> in use — across <b>both</b> tables, because
/// the sidebar interleaves folders and top-level lists in a single order sequence.</summary>
private async Task<int> NextSortOrderAsync(CancellationToken ct = default)
{
var maxList = await _context.Lists.MaxAsync(l => (int?)l.SortOrder, ct) ?? -1;
var maxFolder = await _context.ListFolders.MaxAsync(f => (int?)f.SortOrder, ct) ?? -1;
return Math.Max(maxList, maxFolder) + 1;
}
public async Task UpdateAsync(ListEntity entity, CancellationToken ct = default) public async Task UpdateAsync(ListEntity entity, CancellationToken ct = default)
{ {
NormalizeWorkingDir(entity); NormalizeWorkingDir(entity);
@@ -56,18 +66,83 @@ public sealed class ListRepository
return await _context.Lists.OrderBy(l => l.SortOrder).ThenBy(l => l.CreatedAt).ToListAsync(ct); return await _context.Lists.OrderBy(l => l.SortOrder).ThenBy(l => l.CreatedAt).ToListAsync(ct);
} }
public async Task ReorderAsync(IReadOnlyList<string> orderedListIds, CancellationToken ct = default) /// <summary>
/// Writes the sidebar's visual order as one sequence spanning both tables: folders and lists
/// share the <c>sort_order</c> number space, which is what lets a top-level list sit between
/// two folders. A folder's own children follow it in the sequence, so ordering folders by
/// <c>sort_order</c> and lists by <c>sort_order</c> independently still reproduces the sidebar.
/// </summary>
public async Task ReorderSidebarAsync(IReadOnlyList<(string Id, bool IsFolder)> ordered, CancellationToken ct = default)
{ {
var idSet = orderedListIds.ToHashSet(); var listIds = ordered.Where(o => !o.IsFolder).Select(o => o.Id).ToHashSet();
var entities = await _context.Lists.Where(l => idSet.Contains(l.Id)).ToListAsync(ct); var folderIds = ordered.Where(o => o.IsFolder).Select(o => o.Id).ToHashSet();
for (int i = 0; i < orderedListIds.Count; i++) var lists = await _context.Lists.Where(l => listIds.Contains(l.Id)).ToListAsync(ct);
var folders = await _context.ListFolders.Where(f => folderIds.Contains(f.Id)).ToListAsync(ct);
for (int i = 0; i < ordered.Count; i++)
{ {
var e = entities.FirstOrDefault(x => x.Id == orderedListIds[i]); var (id, isFolder) = ordered[i];
if (e is not null) e.SortOrder = i; if (isFolder)
{
if (folders.FirstOrDefault(f => f.Id == id) is { } folder) folder.SortOrder = i;
}
else if (lists.FirstOrDefault(l => l.Id == id) is { } list) list.SortOrder = i;
} }
await _context.SaveChangesAsync(ct); await _context.SaveChangesAsync(ct);
} }
// ---- Sidebar folders (list_folders) ----
// Folders live here rather than in their own repository because they exist only as a grouping
// of these lists — every caller that touches one already holds a ListRepository.
public async Task<List<ListFolderEntity>> GetFoldersAsync(CancellationToken ct = default)
{
return await _context.ListFolders
.OrderBy(f => f.SortOrder).ThenBy(f => f.CreatedAt)
.ToListAsync(ct);
}
public async Task AddFolderAsync(ListFolderEntity folder, CancellationToken ct = default)
{
folder.SortOrder = await NextSortOrderAsync(ct);
_context.ListFolders.Add(folder);
await _context.SaveChangesAsync(ct);
}
/// <summary>Patches just the two editable fields — a whole-entity Update would need
/// <c>CreatedAt</c>/<c>SortOrder</c>/<c>IsCollapsed</c> carried along and silently reset
/// whichever the caller forgot (see the SetConfigAsync note in CLAUDE.md).</summary>
public async Task RenameFolderAsync(string folderId, string name, string? icon, CancellationToken ct = default)
{
await _context.ListFolders
.Where(f => f.Id == folderId)
.ExecuteUpdateAsync(s => s
.SetProperty(f => f.Name, name)
.SetProperty(f => f.Icon, icon), ct);
}
/// <summary>Deletes the folder only — its lists survive and fall back to the top level via
/// the ON DELETE SET NULL on <c>lists.folder_id</c>.</summary>
public async Task DeleteFolderAsync(string folderId, CancellationToken ct = default)
{
await _context.ListFolders.Where(f => f.Id == folderId).ExecuteDeleteAsync(ct);
}
public async Task SetFolderCollapsedAsync(string folderId, bool collapsed, CancellationToken ct = default)
{
await _context.ListFolders
.Where(f => f.Id == folderId)
.ExecuteUpdateAsync(s => s.SetProperty(f => f.IsCollapsed, collapsed), ct);
}
/// <summary>Moves a list into <paramref name="folderId"/>, or back to the top level when null.</summary>
public async Task SetListFolderAsync(string listId, string? folderId, CancellationToken ct = default)
{
await _context.Lists
.Where(l => l.Id == listId)
.ExecuteUpdateAsync(s => s.SetProperty(l => l.FolderId, folderId), ct);
}
public async Task<ListConfigEntity?> GetConfigAsync(string listId, CancellationToken ct = default) public async Task<ListConfigEntity?> GetConfigAsync(string listId, CancellationToken ct = default)
{ {
return await _context.ListConfigs.FirstOrDefaultAsync(c => c.ListId == listId, ct); return await _context.ListConfigs.FirstOrDefaultAsync(c => c.ListId == listId, ct);
@@ -1,5 +1,6 @@
using System.Text.Json; using System.Text.Json;
using ClaudeDo.Installer.Core; using ClaudeDo.Installer.Core;
using ClaudeDo.Installer.Localization;
namespace ClaudeDo.Installer.Checks; namespace ClaudeDo.Installer.Checks;
@@ -23,11 +24,12 @@ public sealed class ClaudeAuthCheck : IEnvironmentCheck
public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct) public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
{ {
var loc = TrExtension.Localizer;
var lookup = await _lookup.ResolveAsync(ctx, ct); var lookup = await _lookup.ResolveAsync(ctx, ct);
if (lookup.Resolved is null) if (lookup.Resolved is null)
{ {
return CheckResult.Unknown(Id, Severity, "checks.claudeAuth.title", return CheckResult.Unknown(Id, Severity, "checks.claudeAuth.title",
"The claude CLI was not found — login could not be checked."); loc?["checks.claudeAuth.notFound"] ?? "The claude CLI was not found — login could not be checked.");
} }
var (fileName, arguments) = ProcessCommand.For(lookup.Resolved, "auth", "status", "--json"); var (fileName, arguments) = ProcessCommand.For(lookup.Resolved, "auth", "status", "--json");
@@ -36,19 +38,19 @@ public sealed class ClaudeAuthCheck : IEnvironmentCheck
if (exitCode != 0) if (exitCode != 0)
{ {
return CheckResult.Unknown(Id, Severity, "checks.claudeAuth.title", return CheckResult.Unknown(Id, Severity, "checks.claudeAuth.title",
"Login status could not be determined.", output); loc?["checks.claudeAuth.undetermined"] ?? "Login status could not be determined.", output);
} }
var loggedIn = TryParseLoggedIn(output); var loggedIn = TryParseLoggedIn(output);
if (loggedIn is null) if (loggedIn is null)
{ {
return CheckResult.Unknown(Id, Severity, "checks.claudeAuth.title", return CheckResult.Unknown(Id, Severity, "checks.claudeAuth.title",
"Login status could not be determined.", output); loc?["checks.claudeAuth.undetermined"] ?? "Login status could not be determined.", output);
} }
return loggedIn.Value return loggedIn.Value
? CheckResult.Ok(Id, Severity, "checks.claudeAuth.title", "Logged in.") ? CheckResult.Ok(Id, Severity, "checks.claudeAuth.title", loc?["checks.claudeAuth.loggedIn"] ?? "Logged in.")
: CheckResult.Fail(Id, Severity, "checks.claudeAuth.title", "Not logged in.", "checks.claudeAuth.hint"); : CheckResult.Fail(Id, Severity, "checks.claudeAuth.title", loc?["checks.claudeAuth.notLoggedIn"] ?? "Not logged in.", "checks.claudeAuth.hint");
} }
private static bool? TryParseLoggedIn(string output) private static bool? TryParseLoggedIn(string output)
@@ -1,6 +1,7 @@
using System.IO; using System.IO;
using ClaudeDo.Data.Environment; using ClaudeDo.Data.Environment;
using ClaudeDo.Installer.Core; using ClaudeDo.Installer.Core;
using ClaudeDo.Installer.Localization;
namespace ClaudeDo.Installer.Checks; namespace ClaudeDo.Installer.Checks;
@@ -21,20 +22,21 @@ public sealed class ClaudeCliCheck : IEnvironmentCheck
public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct) public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
{ {
var loc = TrExtension.Localizer;
var lookup = await _lookup.ResolveAsync(ctx, ct); var lookup = await _lookup.ResolveAsync(ctx, ct);
if (lookup.Resolved is null) if (lookup.Resolved is null)
{ {
var searched = string.Join(", ", SearchedLocations()); var searched = string.Join(", ", SearchedLocations());
return CheckResult.Fail(Id, Severity, "checks.claudeCli.title", return CheckResult.Fail(Id, Severity, "checks.claudeCli.title",
$"'{ctx.ClaudeBin}' was not found on PATH. Searched: {searched}.", loc?.Get("checks.claudeCli.notFound", ctx.ClaudeBin, searched) ?? $"'{ctx.ClaudeBin}' was not found on PATH. Searched: {searched}.",
"checks.claudeCli.hint"); "checks.claudeCli.hint");
} }
if (lookup.ExitCode != 0) if (lookup.ExitCode != 0)
{ {
return CheckResult.Fail(Id, Severity, "checks.claudeCli.title", return CheckResult.Fail(Id, Severity, "checks.claudeCli.title",
$"'{lookup.Resolved.Path}' exited with code {lookup.ExitCode}.", loc?.Get("checks.claudeCli.exitCode", lookup.Resolved.Path, lookup.ExitCode) ?? $"'{lookup.Resolved.Path}' exited with code {lookup.ExitCode}.",
"checks.claudeCli.hint", detail: lookup.Output); "checks.claudeCli.hint", detail: lookup.Output);
} }
@@ -42,7 +44,8 @@ public sealed class ClaudeCliCheck : IEnvironmentCheck
var detail = lookup.Resolved.IsShim var detail = lookup.Resolved.IsShim
? "Resolved via a shim (.cmd/.bat/.ps1), not a native .exe." ? "Resolved via a shim (.cmd/.bat/.ps1), not a native .exe."
: null; : null;
return CheckResult.Ok(Id, Severity, "checks.claudeCli.title", $"{lookup.Resolved.Path} — {version}", detail); return CheckResult.Ok(Id, Severity, "checks.claudeCli.title",
loc?.Get("checks.claudeCli.resolvedFormat", lookup.Resolved.Path, version) ?? $"{lookup.Resolved.Path} — {version}", detail);
} }
private IEnumerable<string> SearchedLocations() private IEnumerable<string> SearchedLocations()
@@ -1,4 +1,5 @@
using ClaudeDo.Installer.Core; using ClaudeDo.Installer.Core;
using ClaudeDo.Installer.Localization;
namespace ClaudeDo.Installer.Checks; namespace ClaudeDo.Installer.Checks;
@@ -27,28 +28,29 @@ public sealed class ClaudeVersionCheck : IEnvironmentCheck
public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct) public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
{ {
var loc = TrExtension.Localizer;
var lookup = await _lookup.ResolveAsync(ctx, ct); var lookup = await _lookup.ResolveAsync(ctx, ct);
if (lookup.Resolved is null) if (lookup.Resolved is null)
{ {
return CheckResult.Unknown(Id, Severity, "checks.claudeVersion.title", return CheckResult.Unknown(Id, Severity, "checks.claudeVersion.title",
"The claude CLI was not found — version could not be checked."); loc?["checks.claudeVersion.notFound"] ?? "The claude CLI was not found — version could not be checked.");
} }
if (lookup.ExitCode != 0 || lookup.ParsedVersion is null) if (lookup.ExitCode != 0 || lookup.ParsedVersion is null)
{ {
return CheckResult.Unknown(Id, Severity, "checks.claudeVersion.title", return CheckResult.Unknown(Id, Severity, "checks.claudeVersion.title",
$"Could not parse a version from '{lookup.Output.Trim()}'."); loc?.Get("checks.claudeVersion.parseFailed", lookup.Output.Trim()) ?? $"Could not parse a version from '{lookup.Output.Trim()}'.");
} }
if (lookup.ParsedVersion < MinimumVersion) if (lookup.ParsedVersion < MinimumVersion)
{ {
return CheckResult.Fail(Id, Severity, "checks.claudeVersion.title", return CheckResult.Fail(Id, Severity, "checks.claudeVersion.title",
$"claude {lookup.ParsedVersion} is older than the required {MinimumVersion}.", loc?.Get("checks.claudeVersion.tooOld", lookup.ParsedVersion, MinimumVersion) ?? $"claude {lookup.ParsedVersion} is older than the required {MinimumVersion}.",
"checks.claudeVersion.hint"); "checks.claudeVersion.hint");
} }
return CheckResult.Ok(Id, Severity, "checks.claudeVersion.title", return CheckResult.Ok(Id, Severity, "checks.claudeVersion.title",
$"claude {lookup.ParsedVersion} meets the minimum ({MinimumVersion})."); loc?.Get("checks.claudeVersion.ok", lookup.ParsedVersion, MinimumVersion) ?? $"claude {lookup.ParsedVersion} meets the minimum ({MinimumVersion}).");
} }
} }
+8 -3
View File
@@ -2,6 +2,7 @@ using System.Text.RegularExpressions;
using ClaudeDo.Data.Environment; using ClaudeDo.Data.Environment;
using ClaudeDo.Installer.Core; using ClaudeDo.Installer.Core;
using ClaudeDo.Installer.Core.Interfaces; using ClaudeDo.Installer.Core.Interfaces;
using ClaudeDo.Installer.Localization;
namespace ClaudeDo.Installer.Checks; namespace ClaudeDo.Installer.Checks;
@@ -27,10 +28,12 @@ public sealed class GitCheck : IEnvironmentCheck
public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct) public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
{ {
var loc = TrExtension.Localizer;
var resolved = ExecutableResolver.Resolve("git", _pathOverride, _pathExtOverride); var resolved = ExecutableResolver.Resolve("git", _pathOverride, _pathExtOverride);
if (resolved is null) if (resolved is null)
{ {
return CheckResult.Fail(Id, Severity, "checks.git.title", "git was not found on PATH.", return CheckResult.Fail(Id, Severity, "checks.git.title",
loc?["checks.git.notFound"] ?? "git was not found on PATH.",
"checks.git.hint", "https://git-scm.com/downloads"); "checks.git.hint", "https://git-scm.com/downloads");
} }
@@ -39,11 +42,13 @@ public sealed class GitCheck : IEnvironmentCheck
if (exitCode != 0) if (exitCode != 0)
{ {
return CheckResult.Fail(Id, Severity, "checks.git.title", $"'{resolved.Path}' exited with code {exitCode}.", return CheckResult.Fail(Id, Severity, "checks.git.title",
loc?.Get("checks.git.exitCode", resolved.Path, exitCode) ?? $"'{resolved.Path}' exited with code {exitCode}.",
"checks.git.hint", "https://git-scm.com/downloads", output); "checks.git.hint", "https://git-scm.com/downloads", output);
} }
var version = VersionPattern.Match(output) is { Success: true } match ? match.Value : output.Trim(); var version = VersionPattern.Match(output) is { Success: true } match ? match.Value : output.Trim();
return CheckResult.Ok(Id, Severity, "checks.git.title", $"{resolved.Path} — {version}"); return CheckResult.Ok(Id, Severity, "checks.git.title",
loc?.Get("checks.git.resolvedFormat", resolved.Path, version) ?? $"{resolved.Path} — {version}");
} }
} }
@@ -1,6 +1,7 @@
using ClaudeDo.Data.Environment; using ClaudeDo.Data.Environment;
using ClaudeDo.Installer.Core; using ClaudeDo.Installer.Core;
using ClaudeDo.Installer.Core.Interfaces; using ClaudeDo.Installer.Core.Interfaces;
using ClaudeDo.Installer.Localization;
namespace ClaudeDo.Installer.Checks; namespace ClaudeDo.Installer.Checks;
@@ -25,10 +26,12 @@ public sealed class GitIdentityCheck : IEnvironmentCheck
public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct) public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
{ {
var loc = TrExtension.Localizer;
var resolved = ExecutableResolver.Resolve("git", _pathOverride, _pathExtOverride); var resolved = ExecutableResolver.Resolve("git", _pathOverride, _pathExtOverride);
if (resolved is null) if (resolved is null)
{ {
return CheckResult.Unknown(Id, Severity, "checks.gitIdentity.title", "git was not found — identity could not be checked."); return CheckResult.Unknown(Id, Severity, "checks.gitIdentity.title",
loc?["checks.gitIdentity.notFound"] ?? "git was not found — identity could not be checked.");
} }
var name = await ReadConfigAsync(resolved, "user.name", ct); var name = await ReadConfigAsync(resolved, "user.name", ct);
@@ -48,7 +51,8 @@ public sealed class GitIdentityCheck : IEnvironmentCheck
_ => "user.email", _ => "user.email",
}; };
return CheckResult.Fail(Id, Severity, "checks.gitIdentity.title", $"Missing git identity: {missing}.", "checks.gitIdentity.hint"); return CheckResult.Fail(Id, Severity, "checks.gitIdentity.title",
loc?.Get("checks.gitIdentity.missing", missing) ?? $"Missing git identity: {missing}.", "checks.gitIdentity.hint");
} }
private async Task<string> ReadConfigAsync(ResolvedExecutable resolved, string key, CancellationToken ct) private async Task<string> ReadConfigAsync(ResolvedExecutable resolved, string key, CancellationToken ct)
@@ -1,5 +1,6 @@
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using ClaudeDo.Installer.Core; using ClaudeDo.Installer.Core;
using ClaudeDo.Installer.Localization;
namespace ClaudeDo.Installer.Checks; namespace ClaudeDo.Installer.Checks;
@@ -28,11 +29,12 @@ public sealed class PermissionModeAutoCheck : IEnvironmentCheck
public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct) public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
{ {
var loc = TrExtension.Localizer;
var lookup = await _lookup.ResolveAsync(ctx, ct); var lookup = await _lookup.ResolveAsync(ctx, ct);
if (lookup.Resolved is null) if (lookup.Resolved is null)
{ {
return CheckResult.Unknown(Id, Severity, "checks.permissionModeAuto.title", return CheckResult.Unknown(Id, Severity, "checks.permissionModeAuto.title",
"The claude CLI was not found — permission-mode support could not be checked."); loc?["checks.permissionModeAuto.notFound"] ?? "The claude CLI was not found — permission-mode support could not be checked.");
} }
var (fileName, arguments) = ProcessCommand.For(lookup.Resolved, "--help"); var (fileName, arguments) = ProcessCommand.For(lookup.Resolved, "--help");
@@ -41,17 +43,17 @@ public sealed class PermissionModeAutoCheck : IEnvironmentCheck
if (exitCode != 0 || string.IsNullOrWhiteSpace(output)) if (exitCode != 0 || string.IsNullOrWhiteSpace(output))
{ {
return CheckResult.Unknown(Id, Severity, "checks.permissionModeAuto.title", return CheckResult.Unknown(Id, Severity, "checks.permissionModeAuto.title",
"Could not determine whether --permission-mode auto is supported."); loc?["checks.permissionModeAuto.undetermined"] ?? "Could not determine whether --permission-mode auto is supported.");
} }
if (AutoIsListed(output)) if (AutoIsListed(output))
{ {
return CheckResult.Ok(Id, Severity, "checks.permissionModeAuto.title", return CheckResult.Ok(Id, Severity, "checks.permissionModeAuto.title",
"--permission-mode auto is recognized by this CLI."); loc?["checks.permissionModeAuto.ok"] ?? "--permission-mode auto is recognized by this CLI.");
} }
return CheckResult.Fail(Id, Severity, "checks.permissionModeAuto.title", return CheckResult.Fail(Id, Severity, "checks.permissionModeAuto.title",
"This claude CLI version does not list 'auto' as a --permission-mode choice.", loc?["checks.permissionModeAuto.notListed"] ?? "This claude CLI version does not list 'auto' as a --permission-mode choice.",
"checks.permissionModeAuto.hint"); "checks.permissionModeAuto.hint");
} }
+9 -3
View File
@@ -2,6 +2,7 @@ using System.Net;
using System.Net.Sockets; using System.Net.Sockets;
using ClaudeDo.Installer.Checks.Interfaces; using ClaudeDo.Installer.Checks.Interfaces;
using ClaudeDo.Installer.Core; using ClaudeDo.Installer.Core;
using ClaudeDo.Installer.Localization;
namespace ClaudeDo.Installer.Checks; namespace ClaudeDo.Installer.Checks;
@@ -19,6 +20,7 @@ public sealed class PortCheck : IEnvironmentCheck
public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct) public async Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
{ {
var loc = TrExtension.Localizer;
var ports = new (string Label, int Port)[] var ports = new (string Label, int Port)[]
{ {
("SignalR", ctx.SignalRPort), ("SignalR", ctx.SignalRPort),
@@ -39,7 +41,9 @@ public sealed class PortCheck : IEnvironmentCheck
continue; continue;
} }
blocked.Add(owner is null ? $"{label} port {port} is in use." : $"{label} port {port} is in use by '{owner}'."); blocked.Add(owner is null
? loc?.Get("checks.ports.inUse", label, port) ?? $"{label} port {port} is in use."
: loc?.Get("checks.ports.inUseBy", label, port, owner) ?? $"{label} port {port} is in use by '{owner}'.");
} }
if (blocked.Count > 0) if (blocked.Count > 0)
@@ -49,10 +53,12 @@ public sealed class PortCheck : IEnvironmentCheck
if (ownWorker.Count > 0) if (ownWorker.Count > 0)
{ {
return CheckResult.Ok(Id, Severity, "checks.ports.title", $"In use by the running ClaudeDo Worker: {string.Join(", ", ownWorker)}."); var ownWorkerList = string.Join(", ", ownWorker);
return CheckResult.Ok(Id, Severity, "checks.ports.title",
loc?.Get("checks.ports.inUseByOwnWorker", ownWorkerList) ?? $"In use by the running ClaudeDo Worker: {ownWorkerList}.");
} }
return CheckResult.Ok(Id, Severity, "checks.ports.title", "Ports are available."); return CheckResult.Ok(Id, Severity, "checks.ports.title", loc?["checks.ports.available"] ?? "Ports are available.");
} }
private static bool IsFree(int port) private static bool IsFree(int port)
@@ -1,6 +1,7 @@
using System.IO; using System.IO;
using ClaudeDo.Data; using ClaudeDo.Data;
using ClaudeDo.Installer.Core; using ClaudeDo.Installer.Core;
using ClaudeDo.Installer.Localization;
namespace ClaudeDo.Installer.Checks; namespace ClaudeDo.Installer.Checks;
@@ -14,17 +15,19 @@ public sealed class WriteAccessCheck : IEnvironmentCheck
public Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct) public Task<CheckResult> RunAsync(InstallContext ctx, CancellationToken ct)
{ {
var loc = TrExtension.Localizer;
foreach (var target in new[] { ctx.InstallDirectory, Paths.AppDataRoot() }) foreach (var target in new[] { ctx.InstallDirectory, Paths.AppDataRoot() })
{ {
var error = TryWrite(target); var error = TryWrite(target);
if (error is not null) if (error is not null)
{ {
return Task.FromResult(CheckResult.Fail(Id, Severity, "checks.writeAccess.title", return Task.FromResult(CheckResult.Fail(Id, Severity, "checks.writeAccess.title",
$"Cannot write to '{target}': {error}", "checks.writeAccess.hint")); loc?.Get("checks.writeAccess.cannotWrite", target, error) ?? $"Cannot write to '{target}': {error}", "checks.writeAccess.hint"));
} }
} }
return Task.FromResult(CheckResult.Ok(Id, Severity, "checks.writeAccess.title", "Install directory and data directory are writable.")); return Task.FromResult(CheckResult.Ok(Id, Severity, "checks.writeAccess.title",
loc?["checks.writeAccess.writable"] ?? "Install directory and data directory are writable."));
} }
private static string? TryWrite(string path) private static string? TryWrite(string path)
+102 -60
View File
@@ -11,6 +11,7 @@
"categoryGeneral": "Allgemein", "categoryGeneral": "Allgemein",
"categoryExecution": "Ausführung", "categoryExecution": "Ausführung",
"categoryPrime": "Prime Claude", "categoryPrime": "Prime Claude",
"categoryPrimeHint": "Geplante Claude-Läufe: Ping, My Day füllen oder ein eigener Prompt, zu festen Zeiten.",
"categoryWorktrees": "Worktrees", "categoryWorktrees": "Worktrees",
"categoryFiles": "Dateien & Prompts", "categoryFiles": "Dateien & Prompts",
"categorySkills": "Session Skills", "categorySkills": "Session Skills",
@@ -27,6 +28,7 @@
"model": "Modell", "model": "Modell",
"maxTurns": "Max. Durchläufe", "maxTurns": "Max. Durchläufe",
"effort": "Effort", "effort": "Effort",
"effortHint": "Denkaufwand, den das Modell anwendet. Höher kostet mehr und denkt tiefer.",
"modelPresets": "Vorgaben pro Modell", "modelPresets": "Vorgaben pro Modell",
"modelPresetsHint": "Effort und Durchlauf-Budget, mit denen eine Sitzung unter diesem Modell läuft. Overrides auf Listen- und Aufgabenebene gewinnen weiterhin.", "modelPresetsHint": "Effort und Durchlauf-Budget, mit denen eine Sitzung unter diesem Modell läuft. Overrides auf Listen- und Aufgabenebene gewinnen weiterhin.",
"maxTurnsCeiling": "Max. Durchläufe – Obergrenze", "maxTurnsCeiling": "Max. Durchläufe – Obergrenze",
@@ -36,11 +38,11 @@
"maxParallelExecutions": "Max. parallele Ausführungen", "maxParallelExecutions": "Max. parallele Ausführungen",
"maxParallelExecutionsHint": "Wie viele Aufgaben aus der Warteschlange der Worker gleichzeitig ausführt.", "maxParallelExecutionsHint": "Wie viele Aufgaben aus der Warteschlange der Worker gleichzeitig ausführt.",
"usageGateHeading": "Usage-Limit-Stopp", "usageGateHeading": "Usage-Limit-Stopp",
"usageGateHint": "Ab dieser Auslastung holt die Queue keine neuen Tasks mehr; laufende Tasks laufen zu Ende. 0 schaltet den Stopp ab.", "usageGateHint": "Ab dieser Auslastung holt die Warteschlange keine neuen Aufgaben mehr; laufende Aufgaben laufen zu Ende. 0 schaltet den Stopp ab.",
"usageGateFiveHourPct": "5-Stunden-Fenster (%)", "usageGateFiveHourPct": "5-Stunden-Fenster (%)",
"usageGateSevenDayPct": "7-Tage-Fenster (%)", "usageGateSevenDayPct": "7-Tage-Fenster (%)",
"autoContinueOnUsageLimit": "Nach Limit-Reset fortsetzen", "autoContinueOnUsageLimit": "Nach Limit-Reset fortsetzen",
"autoContinueOnUsageLimitHint": "Ist ein Run am Nutzungslimit abgebrochen, wird er nach Ablauf des 5-Stunden-Fensters automatisch fortgesetzt. Weckt außerdem die pausierte Queue genau zum Reset-Zeitpunkt statt erst beim nächsten Backstop-Check.", "autoContinueOnUsageLimitHint": "Ist ein Run am Nutzungslimit abgebrochen, wird er nach Ablauf des 5-Stunden-Fensters automatisch fortgesetzt. Weckt außerdem die pausierte Warteschlange genau zum Reset-Zeitpunkt statt erst beim nächsten Backstop-Check.",
"listHandlerMaxHandoffs": "Handoff-Budget des List-Handlers", "listHandlerMaxHandoffs": "Handoff-Budget des List-Handlers",
"listHandlerMaxHandoffsHint": "Wie viele Wait/Merge-Handoffs ein „Let Claude handle it\"-Run verketten darf. 4 entspricht zwei Merge-Runden. Für Läufe, die stundenlang durchlaufen sollen, höher setzen; der Wert begrenzt eine entlaufene Kette und darf daher nicht 0 sein.", "listHandlerMaxHandoffsHint": "Wie viele Wait/Merge-Handoffs ein „Let Claude handle it\"-Run verketten darf. 4 entspricht zwei Merge-Runden. Für Läufe, die stundenlang durchlaufen sollen, höher setzen; der Wert begrenzt eine entlaufene Kette und darf daher nicht 0 sein.",
"powerUseHeading": "PowerUse", "powerUseHeading": "PowerUse",
@@ -50,8 +52,9 @@
"powerUseWindowPlaceholder": "HH:mm", "powerUseWindowPlaceholder": "HH:mm",
"powerUseHandoffPct": "Übergabe bei (% von 5h)", "powerUseHandoffPct": "Übergabe bei (% von 5h)",
"powerUseWindowHint": "Beide Zeiten leer = rund um die Uhr. Das Fenster wird bei jedem Start einer Handler-Phase geprüft — eine Kette, die über das Ende hinausläuft, macht ohne PowerUse weiter.", "powerUseWindowHint": "Beide Zeiten leer = rund um die Uhr. Das Fenster wird bei jedem Start einer Handler-Phase geprüft — eine Kette, die über das Ende hinausläuft, macht ohne PowerUse weiter.",
"powerUseWindowInvalid": "PowerUse-Zeiten müssen im Format HH:mm sein oder leer bleiben.",
"throttleStagesHeading": "Throttle-Stufen", "throttleStagesHeading": "Throttle-Stufen",
"throttleStagesHint": "Die Parallelität wird vor dem harten Gate begrenzt: soft auf 2 Slots, hard auf 1. Eingestellt wird das durch Ziehen der Marker an den Gauges im Usage-Monitor. Der Monitor schließt diese Einstellungen — vorher speichern, wenn du etwas geändert hast.", "throttleStagesHint": "Die Parallelität wird vor dem harten Limit-Stopp begrenzt: soft auf 2 Slots, hard auf 1. Eingestellt wird das durch Ziehen der Marker an den Gauges im Usage-Monitor. Der Monitor schließt diese Einstellungen — vorher speichern, wenn du etwas geändert hast.",
"throttleStagesValues": "5h: soft {0}% / hard {1}% · 7d: soft {2}% / hard {3}%", "throttleStagesValues": "5h: soft {0}% / hard {1}% · 7d: soft {2}% / hard {3}%",
"openUsageMonitor": "Usage-Monitor öffnen...", "openUsageMonitor": "Usage-Monitor öffnen...",
"reportExcludedPaths": "Bericht: ausgeschlossene Pfade (einer pro Zeile)", "reportExcludedPaths": "Bericht: ausgeschlossene Pfade (einer pro Zeile)",
@@ -233,12 +236,13 @@
"reasonIsPlanningParent": "Teil eines Plans", "reasonIsPlanningParent": "Teil eines Plans",
"reasonIsChildTask": "Teilaufgaben haben keine eigene Planungssitzung", "reasonIsChildTask": "Teilaufgaben haben keine eigene Planungssitzung",
"reasonPlanAlreadyFinalized": "Plan ist bereits finalisiert", "reasonPlanAlreadyFinalized": "Plan ist bereits finalisiert",
"reasonNoWorktree": "Kein Worktree für diesen Task", "reasonNoWorktree": "Kein Worktree für diese Aufgabe",
"cancel": "Abbrechen", "cancel": "Abbrechen",
"removeFromQueueTip": "Aus Warteschlange entfernen", "removeFromQueueTip": "Aus Warteschlange entfernen",
"toggleSubtasksTip": "Unteraufgaben ein-/ausklappen", "toggleSubtasksTip": "Unteraufgaben ein-/ausklappen",
"agentSuggestedTip": "Vom Agenten vorgeschlagen", "agentSuggestedTip": "Vom Agenten vorgeschlagen",
"interactiveChipTip": "Interaktive Sitzung offen — klicken, um im Mission Control dorthin zu springen", "interactiveChipTip": "Interaktive Sitzung offen — klicken, um im Mission Control dorthin zu springen",
"waitingOnTip": "Wartet auf {0}",
"scheduleTitle": "Aufgabe planen", "scheduleTitle": "Aufgabe planen",
"scheduleWhen": "WANN", "scheduleWhen": "WANN",
"scheduleConfirm": "Planen", "scheduleConfirm": "Planen",
@@ -260,21 +264,24 @@
"contextOpenFindings": "Findings-Ordner öffnen", "contextOpenFindings": "Findings-Ordner öffnen",
"contextImportTickets": "Aus Ticketsystem aktualisieren", "contextImportTickets": "Aus Ticketsystem aktualisieren",
"contextSettings": "Einstellungen…", "contextSettings": "Einstellungen…",
"contextFolderSettings": "Ordner-Einstellungen…",
"contextRemoveFromFolder": "Aus Ordner herausnehmen",
"newList": "Neue Liste", "newList": "Neue Liste",
"addReposTip": "Repos als Listen hinzufügen", "addReposTip": "Repos als Listen hinzufügen",
"noRepoBannerText": "Noch kein Repo verknüpft. ClaudeDo führt Tasks in einem Git-Repo aus.", "noRepoBannerText": "Noch kein Repo verknüpft. ClaudeDo führt Aufgaben in einem Git-Repo aus.",
"noRepoBannerButton": "Repo importieren", "noRepoBannerButton": "Repo importieren",
"importTicketsResult": "{0} Tickets geprüft, {1} neue Tasks." "importTicketsResult": "{0} Tickets geprüft, {1} neue Tasks."
}, },
"details": { "details": {
"deleteTaskTip": "Aufgabe löschen", "deleteTaskTip": "Aufgabe löschen",
"killSessionTip": "Laufende Sitzung beenden", "killSessionTip": "Laufende Sitzung hart beenden",
"sendToQueue": "In Warteschlange einreihen", "sendToQueue": "In Warteschlange einreihen",
"sendToQueueTip": "Diese Aufgabe in die Warteschlange einreihen, damit Claude sie beim nächsten Lauf übernimmt", "sendToQueueTip": "Diese Aufgabe in die Warteschlange einreihen, damit Claude sie beim nächsten Lauf übernimmt",
"copyTaskIdTip": "Aufgaben-ID kopieren", "copyTaskIdTip": "Aufgaben-ID kopieren",
"starTip": "Favorit", "starTip": "Favorit",
"agentSettingsTip": "Agent-Einstellungen", "agentSettingsTip": "Agent-Einstellungen",
"agentSettingsHeading": "Agent-Einstellungen (Überschreibungen)", "agentSettingsHeading": "Agent-Einstellungen (Überschreibungen)",
"waitingOnPrefix": "Wartet auf",
"copyFormattedTip": "Titel, Beschreibung und offene Schritte kopieren", "copyFormattedTip": "Titel, Beschreibung und offene Schritte kopieren",
"toggleEditPreviewTip": "Bearbeiten/Vorschau umschalten", "toggleEditPreviewTip": "Bearbeiten/Vorschau umschalten",
"planDay": "Tag planen", "planDay": "Tag planen",
@@ -300,7 +307,36 @@
"steps": "Schritte", "steps": "Schritte",
"files": "Dateien" "files": "Dateien"
}, },
"emptyState": "Wähle eine Aufgabe aus, um Details zu sehen" "emptyState": "Wähle eine Aufgabe aus, um Details zu sehen",
"roadblockLabel": "ROADBLOCK",
"stepsCard": {
"preview": "Vorschau",
"edit": "Bearbeiten",
"addStepPlaceholder": "Schritt hinzufügen…"
},
"workConsole": {
"continue": "Fortsetzen",
"resetAndRetry": "Zurücksetzen & erneut versuchen",
"tabOutput": "Ausgabe",
"tabGit": "Git",
"tabSession": "Sitzung",
"outcomeLabel": "ERGEBNIS",
"reviewLabel": "REVIEW",
"mergeLabel": "MERGE",
"targetBranch": "Zielbranch",
"openDiff": "Diff öffnen",
"worktree": "Worktree",
"reviewCombinedDiff": "Kombinierten Diff prüfen",
"openDiffHint": "Diff öffnen, um Mergen freizuschalten",
"approveMerge": "Genehmigen & Mergen",
"park": "Parken",
"parkTip": "Zurückstellen — zurück auf Leerlauf, Worktree bleibt erhalten",
"cancel": "Abbrechen",
"resetDiscardBranch": "Zurücksetzen (Branch verwerfen)…",
"submitForReviewHint": "Von Hand daran gearbeitet? Den Worktree zum Review einreichen, um ihn zu mergen.",
"submitForReview": "Zum Review einreichen",
"outcomesLabel": "ERGEBNISSE"
}
}, },
"agent": { "agent": {
"openWorktreeTip": "Worktree im Datei-Explorer öffnen — benötigt einen aktiven Worktree", "openWorktreeTip": "Worktree im Datei-Explorer öffnen — benötigt einen aktiven Worktree",
@@ -343,10 +379,13 @@
"browseForFolder": "Ordner wählen…", "browseForFolder": "Ordner wählen…",
"focusMode": "Fokus", "focusMode": "Fokus",
"overviewMode": "Übersicht", "overviewMode": "Übersicht",
"closeSession": "Sitzung beenden", "closeSession": "Sitzung hart beenden",
"closeSessionConfirm": "Laufende Sitzung beenden? Der Claude-Prozess wird abgebrochen.",
"conptyLaunchFailed": "Interaktive Sitzung konnte nicht geöffnet werden: {0}", "conptyLaunchFailed": "Interaktive Sitzung konnte nicht geöffnet werden: {0}",
"conptyStarting": "Sitzung wird gestartet…", "conptyStarting": "Sitzung wird gestartet…",
"conptyExited": "Sitzung beendet (Code {0}).",
"usageWarningNotDelivered": "Die Listen-Handler-Sitzung konnte nicht vor dem Usage-Limit gewarnt werden.", "usageWarningNotDelivered": "Die Listen-Handler-Sitzung konnte nicht vor dem Usage-Limit gewarnt werden.",
"terminalSendFailed": "Konnte nicht an die Terminal-Sitzung gesendet werden.",
"mergeHelperTitle": "Merge-Helfer", "mergeHelperTitle": "Merge-Helfer",
"mergeHelperHandoffTitleSuffix": " (Übergabe)", "mergeHelperHandoffTitleSuffix": " (Übergabe)",
"mergeHelperTaskTitle": "Listen-Handler: {0}", "mergeHelperTaskTitle": "Listen-Handler: {0}",
@@ -364,7 +403,7 @@
"queueRefreshFailed": "Warteschlange konnte nicht aktualisiert werden: {0}", "queueRefreshFailed": "Warteschlange konnte nicht aktualisiert werden: {0}",
"abandonRun": "Lauf abbrechen", "abandonRun": "Lauf abbrechen",
"abandonRunTip": "Die noch offenen Tasks dieses Laufs abbrechen und die Sitzung schließen", "abandonRunTip": "Die noch offenen Tasks dieses Laufs abbrechen und die Sitzung schließen",
"abandonRunConfirm": "{0} offene(n) Task(s) abbrechen und diese Sitzung schließen? Tasks im Review oder bereits erledigt bleiben unangetastet.", "abandonRunConfirm": "{0} offene Aufgabe(n) abbrechen und diese Sitzung schließen? Aufgaben im Review oder bereits erledigt bleiben unangetastet.",
"abandonRunFailed": "Offene Tasks konnten nicht abgebrochen werden: {0}", "abandonRunFailed": "Offene Tasks konnten nicht abgebrochen werden: {0}",
"question": { "question": {
"title": "Claude fragt nach", "title": "Claude fragt nach",
@@ -399,11 +438,18 @@
"body": "ClaudeDo kann den Hintergrund-Worker nicht erreichen. Normalerweise wird er bei der Anmeldung automatisch gestartet. Du kannst ihn jetzt starten oder neu installieren, falls das Problem bestehen bleibt.", "body": "ClaudeDo kann den Hintergrund-Worker nicht erreichen. Normalerweise wird er bei der Anmeldung automatisch gestartet. Du kannst ihn jetzt starten oder neu installieren, falls das Problem bestehen bleibt.",
"dismiss": "Ausblenden", "dismiss": "Ausblenden",
"rerunInstaller": "Installer erneut ausführen", "rerunInstaller": "Installer erneut ausführen",
"startWorker": "Worker starten" "startWorker": "Worker starten",
"workerNotFound": "Worker-Programm nicht gefunden. Installation prüfen.",
"installerNotFound": "Installer nicht gefunden. Installation prüfen.",
"startFailed": "Start fehlgeschlagen: {0}"
}, },
"listSettings": { "listSettings": {
"title": "LISTENEINSTELLUNGEN", "title": "LISTENEINSTELLUNGEN",
"folderTitle": "ORDNER",
"deleteList": "Liste löschen", "deleteList": "Liste löschen",
"deleteFolder": "Ordner löschen",
"createAsFolder": "Stattdessen einen Ordner anlegen",
"createAsFolderHint": "Ein Ordner gruppiert nur Listen in der Seitenleiste — er enthält keine Aufgaben und führt nichts aus. Listen per Drag-and-Drop hineinziehen.",
"sectionGeneral": "ALLGEMEIN", "sectionGeneral": "ALLGEMEIN",
"name": "Name", "name": "Name",
"workingDirectory": "Arbeitsverzeichnis", "workingDirectory": "Arbeitsverzeichnis",
@@ -491,6 +537,8 @@
"windowTitle": "Claude machen lassen", "windowTitle": "Claude machen lassen",
"scopeList": "Liste: {0}", "scopeList": "Liste: {0}",
"scopeReview": "Alle Listen — Tasks im Review", "scopeReview": "Alle Listen — Tasks im Review",
"modelLabel": "Modell",
"modelTip": "Modell für die Triage-Session (Dedupe & Verfeinern). Spätere Merge-Phasen laufen immer auf Opus.",
"confirm": "Claude machen lassen", "confirm": "Claude machen lassen",
"cancel": "Abbrechen", "cancel": "Abbrechen",
"selectAll": "Alle auswählen", "selectAll": "Alle auswählen",
@@ -531,14 +579,14 @@
"noGauges": "Noch keine Nutzungslimits gemeldet.", "noGauges": "Noch keine Nutzungslimits gemeldet.",
"refreshHint": "Jetzt aktualisieren — Abruf alle 5 Min., während ein Task läuft, sonst alle 15 Min.", "refreshHint": "Jetzt aktualisieren — Abruf alle 5 Min., während ein Task läuft, sonst alle 15 Min.",
"staleFormat": "Werte veraltet (Stand {0})", "staleFormat": "Werte veraltet (Stand {0})",
"staleGateHint": "Das Gate greift in diesem Zustand nicht.", "staleGateHint": "Der Limit-Stopp greift in diesem Zustand nicht.",
"gateBlockedFormat": "Warteschlange pausiert — {0}", "gateBlockedFormat": "Warteschlange pausiert — {0}",
"throttleFormat": "Warteschlange gedrosselt: {0}/{1} Slots ({2})", "throttleFormat": "Warteschlange gedrosselt: {0}/{1} Slots ({2})",
"resetIn": "Reset in {0} ({1})", "resetIn": "Reset in {0} ({1})",
"dragHint": "Marker ziehen oder Wert unten eintragen.", "dragHint": "Marker ziehen oder Wert unten eintragen.",
"legendSoft": "Soft · 2 Slots", "legendSoft": "Soft · 2 Slots",
"legendHard": "Hard · 1 Slot", "legendHard": "Hard · 1 Slot",
"legendGate": "Gate · Pause", "legendGate": "Limit-Stopp · Pause",
"gaugeSession": "Session (5 Std.)", "gaugeSession": "Session (5 Std.)",
"gaugeWeeklyAll": "Woche (alle Modelle)", "gaugeWeeklyAll": "Woche (alle Modelle)",
"gaugeWeeklyScopedFormat": "Woche ({0})", "gaugeWeeklyScopedFormat": "Woche ({0})",
@@ -558,7 +606,7 @@
"columnOtherOut": "ÜBRIGES OUT", "columnOtherOut": "ÜBRIGES OUT",
"columnOtherCache": "ÜBRIGES CACHE", "columnOtherCache": "ÜBRIGES CACHE",
"columnShare": "ANTEIL", "columnShare": "ANTEIL",
"columnTask": "TASK", "columnTask": "AUFGABE",
"columnList": "LISTE", "columnList": "LISTE",
"columnModelShort": "MODELL", "columnModelShort": "MODELL",
"columnRuns": "RUNS", "columnRuns": "RUNS",
@@ -662,35 +710,61 @@
"checks": { "checks": {
"git": { "git": {
"title": "Git", "title": "Git",
"hint": "Installiere Git und stelle sicher, dass es im PATH liegt." "hint": "Installiere Git von https://git-scm.com/downloads und starte den Installer neu.",
"notFound": "git wurde nicht im PATH gefunden.",
"exitCode": "'{0}' wurde mit Exitcode {1} beendet.",
"resolvedFormat": "{0} — {1}"
}, },
"gitIdentity": { "gitIdentity": {
"title": "Git-Identität", "title": "Git-Identität",
"hint": "Lege deine Git-Identität fest: git config --global user.name \"...\" und user.email \"...\"." "hint": "Führe aus: git config --global user.name \"Dein Name\" und git config --global user.email \"du@example.com\"",
"notFound": "git wurde nicht gefunden — Identität konnte nicht geprüft werden.",
"missing": "Fehlende Git-Identität: {0}."
}, },
"ports": { "ports": {
"title": "Ports", "title": "Ports",
"hint": "Gib den Port frei oder ändere ihn nach der Installation auf der Worker-Seite." "hint": "Ändere die SignalR-/MCP-Ports auf der Dienste-Seite, oder beende den blockierenden Prozess.",
"inUse": "{0}-Port {1} ist belegt.",
"inUseBy": "{0}-Port {1} ist belegt von '{2}'.",
"inUseByOwnWorker": "Belegt vom laufenden ClaudeDo Worker: {0}.",
"available": "Ports sind verfügbar."
}, },
"writeAccess": { "writeAccess": {
"title": "Schreibzugriff", "title": "Schreibzugriff",
"hint": "Wähle ein anderes Installationsverzeichnis oder starte den Installer als Administrator." "hint": "Wähle ein anderes Installationsverzeichnis oder starte den Installer mit ausreichenden Rechten (z. B. als Administrator).",
"cannotWrite": "Kein Schreibzugriff auf '{0}': {1}",
"writable": "Installations- und Datenverzeichnis sind beschreibbar."
}, },
"claudeCli": { "claudeCli": {
"title": "Claude CLI", "title": "Claude CLI",
"hint": "Installiere die Claude CLI und stelle sicher, dass sie im PATH liegt." "hint": "Installiere die Claude CLI (npm install -g @anthropic-ai/claude-code) und starte danach dein Terminal/deine Anmeldesitzung neu, damit der aktualisierte PATH greift.",
"notFound": "'{0}' wurde nicht im PATH gefunden. Durchsucht: {1}.",
"exitCode": "'{0}' wurde mit Exitcode {1} beendet.",
"resolvedFormat": "{0} — {1}"
}, },
"claudeVersion": { "claudeVersion": {
"title": "Claude-CLI-Version", "title": "Claude-CLI-Version",
"hint": "Aktualisiere die Claude CLI auf eine neuere Version." "hint": "Aktualisiere die CLI: npm update -g @anthropic-ai/claude-code",
"notFound": "Die claude-CLI wurde nicht gefunden — Version konnte nicht geprüft werden.",
"parseFailed": "Konnte keine Version aus '{0}' auslesen.",
"tooOld": "claude {0} ist älter als die erforderliche Version {1}.",
"ok": "claude {0} erfüllt die Mindestanforderung ({1})."
}, },
"claudeAuth": { "claudeAuth": {
"title": "Claude-Anmeldung", "title": "Claude-CLI-Login",
"hint": "Führe 'claude auth login' aus, um dich anzumelden." "hint": "Starte 'claude' einmal und schließe den Login-Vorgang ab.",
"notFound": "Die claude-CLI wurde nicht gefunden — Login konnte nicht geprüft werden.",
"undetermined": "Login-Status konnte nicht ermittelt werden.",
"notLoggedIn": "Nicht angemeldet.",
"loggedIn": "Angemeldet."
}, },
"permissionModeAuto": { "permissionModeAuto": {
"title": "Berechtigungsmodus", "title": "Berechtigungsmodus",
"hint": "Aktualisiere die Claude CLI auf eine Version, die --permission-mode auto unterstützt." "hint": "Aktualisiere die CLI: npm update -g @anthropic-ai/claude-code. ClaudeDo funktioniert auch im Modus acceptEdits/default, dann mit mehr Rückfragen.",
"notFound": "Die claude-CLI wurde nicht gefunden — Unterstützung für permission-mode konnte nicht geprüft werden.",
"undetermined": "Konnte nicht ermitteln, ob --permission-mode auto unterstützt wird.",
"ok": "--permission-mode auto wird von dieser CLI erkannt.",
"notListed": "Diese claude-CLI-Version listet 'auto' nicht als --permission-mode-Option."
} }
}, },
"planning": { "planning": {
@@ -737,6 +811,8 @@
"repositories": "Repositories", "repositories": "Repositories",
"checkForUpdates": "Nach Updates suchen", "checkForUpdates": "Nach Updates suchen",
"restartWorker": "Worker neu starten", "restartWorker": "Worker neu starten",
"updateClaudeCli": "Claude CLI aktualisieren",
"shutdown": "Beenden",
"worktrees": "Worktrees…", "worktrees": "Worktrees…",
"weeklyReport": "Wochenbericht…", "weeklyReport": "Wochenbericht…",
"usageMonitor": "Usage Monitor…", "usageMonitor": "Usage Monitor…",
@@ -762,10 +838,10 @@
}, },
"vm": { "vm": {
"connection": { "online": "Online", "connecting": "Verbinden…", "offline": "Offline" }, "connection": { "online": "Online", "connecting": "Verbinden…", "offline": "Offline" },
"shell": { "restartingWorker": "Worker wird neu gestartet…", "unexpectedError": "Unerwarteter Fehler: {0}" }, "shell": { "unexpectedError": "Unerwarteter Fehler: {0}", "shutdownConfirm": "Dadurch wird der ClaudeDo-Worker beendet (laufende Task-Runs und interaktive Sitzungen brechen ab) und die App geschlossen. Fortfahren?" },
"mainWindow": { "closeConfirm": { "message": "{0} Mission-Control-Sitzung(en) laufen noch. Beim Beenden werden sie abgebrochen. Trotzdem schließen?" } }, "mainWindow": { "closeConfirm": { "message": "{0} Mission-Control-Sitzung(en) laufen noch. Beim Beenden werden sie abgebrochen. Trotzdem schließen?" } },
"agentStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "review": "Prüfung", "children": "Wartet auf Teilaufgaben", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen" }, "agentStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "review": "Prüfung", "children": "Wartet auf Teilaufgaben", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen" },
"taskStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "waitingForReview": "Wartet auf Prüfung", "waitingForChildren": "Wartet auf Teilaufgaben", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen", "parked": "Geparkt", "interactive": "Interaktiv" }, "taskStatus": { "idle": "Leerlauf", "queued": "In Warteschlange", "running": "Läuft", "waitingForReview": "Wartet auf Prüfung", "waitingForChildren": "Wartet auf Teilaufgaben", "done": "Fertig", "failed": "Fehlgeschlagen", "cancelled": "Abgebrochen", "parked": "Geparkt", "interactive": "Interaktiv", "waiting": "Wartet" },
"failureReason": { "maxTurns": "Turn-Limit erreicht", "timeout": "Zeitüberschreitung", "usageLimit": "Nutzungslimit erreicht", "error": "Fehler", "cancelled": "Abgebrochen", "unknown": "Grund unbekannt" }, "failureReason": { "maxTurns": "Turn-Limit erreicht", "timeout": "Zeitüberschreitung", "usageLimit": "Nutzungslimit erreicht", "error": "Fehler", "cancelled": "Abgebrochen", "unknown": "Grund unbekannt" },
"failureReasonTooltip": { "maxTurns": "Turn-Limit erreicht ({0}/{1} Turns) — der Worktree ist meist brauchbar; Task fortsetzen statt zurücksetzen." }, "failureReasonTooltip": { "maxTurns": "Turn-Limit erreicht ({0}/{1} Turns) — der Worktree ist meist brauchbar; Task fortsetzen statt zurücksetzen." },
"planningBadge": { "active": "PLANUNG", "finalized": "GEPLANT" }, "planningBadge": { "active": "PLANUNG", "finalized": "GEPLANT" },
@@ -793,12 +869,12 @@
"sessionSkillsTab": { "installed": "Installiert: {0}", "installFailed": "Installation fehlgeschlagen: {0}", "updated": "Aktualisiert.", "updateFailed": "Aktualisierung fehlgeschlagen: {0}", "removed": "Entfernt.", "removeFailed": "Entfernen fehlgeschlagen: {0}" }, "sessionSkillsTab": { "installed": "Installiert: {0}", "installFailed": "Installation fehlgeschlagen: {0}", "updated": "Aktualisiert.", "updateFailed": "Aktualisierung fehlgeschlagen: {0}", "removed": "Entfernt.", "removeFailed": "Entfernen fehlgeschlagen: {0}" },
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "{0} Worktree(s) entfernt.", "blocked": "Zwangsentfernung nicht möglich: {0} Aufgabe(n) laufen noch. Brich sie zuerst ab.", "removedFrom": "{0} Worktree(s) von {1} Aufgabe(n) entfernt.", "cleanupFailed": "Aufräumen fehlgeschlagen: {0}", "resetFailed": "Zurücksetzen fehlgeschlagen: {0}" }, "worktreesTab": { "workerOffline": "Worker offline.", "removed": "{0} Worktree(s) entfernt.", "blocked": "Zwangsentfernung nicht möglich: {0} Aufgabe(n) laufen noch. Brich sie zuerst ab.", "removedFrom": "{0} Worktree(s) von {1} Aufgabe(n) entfernt.", "cleanupFailed": "Aufräumen fehlgeschlagen: {0}", "resetFailed": "Zurücksetzen fehlgeschlagen: {0}" },
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "Liste", "cleanupFailed": "Aufräumen fehlgeschlagen.", "cleanupFailedDetailed": "Aufräumen fehlgeschlagen: {0}", "removed": "{0} Worktree(s) entfernt.", "discardConfirm": "Worktree für \"{0}\" verwerfen? Nicht committete Arbeit geht verloren.", "discardFailed": "Worktree konnte nicht verworfen werden.", "keepFailed": "Worktree konnte nicht behalten werden.", "cannotForceRunning": "Eine laufende Aufgabe kann nicht zwangsweise entfernt werden.", "forceRemoveFailed": "Zwangsentfernung fehlgeschlagen.", "forceRemoveFailedDetailed": "Zwangsentfernung fehlgeschlagen: {0}", "batchDone": "{0} gemergt, {1} zu lösen.", "openInExplorerFailed": "Pfad konnte nicht geöffnet werden: {0}" }, "worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "Liste", "cleanupFailed": "Aufräumen fehlgeschlagen.", "cleanupFailedDetailed": "Aufräumen fehlgeschlagen: {0}", "removed": "{0} Worktree(s) entfernt.", "discardConfirm": "Worktree für \"{0}\" verwerfen? Nicht committete Arbeit geht verloren.", "discardFailed": "Worktree konnte nicht verworfen werden.", "keepFailed": "Worktree konnte nicht behalten werden.", "cannotForceRunning": "Eine laufende Aufgabe kann nicht zwangsweise entfernt werden.", "forceRemoveFailed": "Zwangsentfernung fehlgeschlagen.", "forceRemoveFailedDetailed": "Zwangsentfernung fehlgeschlagen: {0}", "batchDone": "{0} gemergt, {1} zu lösen.", "openInExplorerFailed": "Pfad konnte nicht geöffnet werden: {0}" },
"listSettings": { "untitled": "Unbenannt", "ticketProjectNone": "— keins —", "loadFailed": "Listeneinstellungen konnten nicht geladen werden: {0}", "ticketProjectsLoadFailed": "Ticket-Projekte konnten nicht geladen werden: {0}" }, "listSettings": { "untitled": "Unbenannt", "ticketProjectNone": "— keins —", "loadFailed": "Listeneinstellungen konnten nicht geladen werden: {0}", "ticketProjectsLoadFailed": "Ticket-Projekte konnten nicht geladen werden: {0}", "confirmDeleteFolder": "Ordner \"{0}\" löschen? Die Listen darin bleiben erhalten und wandern auf die oberste Ebene.", "confirmDeleteList": "Liste \"{0}\" und alle ihre Aufgaben löschen? Das kann nicht rückgängig gemacht werden.", "deleteBlockedPlanning": "Diese Liste hat Planungssitzungen mit Kind-Aufgaben. Erst diese verwerfen, dann die Liste löschen.", "workingDirMissing": "Dieser Ordner existiert nicht.", "workingDirNotRepo": "Dieser Ordner ist noch kein Git-Repository. Führe darin zuerst `git init` aus, oder leere das Arbeitsverzeichnis, um dies als manuelle Liste zu speichern." },
"detailsIsland": { "verifyFailed": "Merge ist erfolgt, aber das Verifikationskommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf 'Erledigt' gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "stopOffline": "Worker offline — Task kann nicht gestoppt werden.", "stopFailed": "Stoppen fehlgeschlagen: {0}", "enqueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "dequeueFailed": "Aus der Warteschlange entfernen fehlgeschlagen: {0}", "resetAndRetryFailed": "Zurücksetzen & erneut versuchen fehlgeschlagen: {0}", "toggleDoneFailed": "Erledigt-Status konnte nicht aktualisiert werden: {0}" }, "detailsIsland": { "verifyFailed": "Merge ist erfolgt, aber das Verifikationskommando der Liste ist fehlgeschlagen — die Aufgabe wurde nicht auf 'Erledigt' gesetzt.", "untrackedCollision": "Merge abgelehnt — er würde eine unversionierte Datei im Ziel-Arbeitsverzeichnis überschreiben.", "stopOffline": "Worker offline — Task kann nicht gestoppt werden.", "stopFailed": "Stoppen fehlgeschlagen: {0}", "enqueueFailed": "In die Warteschlange stellen fehlgeschlagen: {0}", "dequeueFailed": "Aus der Warteschlange entfernen fehlgeschlagen: {0}", "resetAndRetryFailed": "Zurücksetzen & erneut versuchen fehlgeschlagen: {0}", "toggleDoneFailed": "Erledigt-Status konnte nicht aktualisiert werden: {0}" },
"mergeSection": { "openWorktreeFailed": "Worktree konnte nicht geöffnet werden: {0}" }, "mergeSection": { "openWorktreeFailed": "Worktree konnte nicht geöffnet werden: {0}" },
"about": { "openPathFailed": "Pfad konnte nicht geöffnet werden: {0}" }, "about": { "openPathFailed": "Pfad konnte nicht geöffnet werden: {0}" },
"lists": { "localSuffix": "{0} / lokal", "smartMyDay": "Mein Tag", "smartImportant": "Wichtig", "smartPlanned": "Geplant", "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}" }, "lists": { "localSuffix": "{0} / lokal", "smartMyDay": "Mein Tag", "smartImportant": "Wichtig", "smartPlanned": "Geplant", "smartNotes": "Notizen", "virtualQueue": "Warteschlange", "virtualRunning": "Läuft", "virtualReview": "Prüfung", "newList": "Neue Liste", "findingsNotFound": "Für diese Liste gibt es noch keine Findings.", "findingsOpenFailed": "Findings-Ordner konnte nicht geöffnet werden: {0}", "openInExplorerFailed": "Pfad konnte nicht geöffnet werden: {0}" },
"repoImport": { "loadFailed": "Gespeicherte Ordner konnten nicht geladen werden: {0}", "saveFailed": "Ordner konnten nicht gespeichert werden: {0}" } "repoImport": { "loadFailed": "Gespeicherte Ordner konnten nicht geladen werden: {0}", "saveFailed": "Ordner konnten nicht gespeichert werden: {0}", "createButton": "{0} Liste(n) erstellen" }
}, },
"ops": { "ops": {
"stalledHint": "Läuft weiter — Worker antwortet noch", "stalledHint": "Läuft weiter — Worker antwortet noch",
@@ -866,39 +942,5 @@
"lastError": "Letzter Fehler: {0}", "lastError": "Letzter Fehler: {0}",
"throttled": "Gedrosselt: {0}/{1} Slots ({2})" "throttled": "Gedrosselt: {0}/{1} Slots ({2})"
} }
},
"checks": {
"git": {
"title": "Git",
"hint": "Installiere Git von https://git-scm.com/downloads und starte den Installer neu."
},
"gitIdentity": {
"title": "Git-Identität",
"hint": "Führe aus: git config --global user.name \"Dein Name\" und git config --global user.email \"du@example.com\""
},
"ports": {
"title": "Ports",
"hint": "Ändere die SignalR-/MCP-Ports auf der Dienste-Seite, oder beende den blockierenden Prozess."
},
"writeAccess": {
"title": "Schreibzugriff",
"hint": "Wähle ein anderes Installationsverzeichnis oder starte den Installer mit ausreichenden Rechten (z. B. als Administrator)."
},
"claudeCli": {
"title": "Claude CLI",
"hint": "Installiere die Claude CLI (npm install -g @anthropic-ai/claude-code) und starte danach dein Terminal/deine Anmeldesitzung neu, damit der aktualisierte PATH greift."
},
"claudeVersion": {
"title": "Claude-CLI-Version",
"hint": "Aktualisiere die CLI: npm update -g @anthropic-ai/claude-code"
},
"claudeAuth": {
"title": "Claude-CLI-Login",
"hint": "Starte 'claude' einmal und schließe den Login-Vorgang ab."
},
"permissionModeAuto": {
"title": "Unterstützung für --permission-mode auto",
"hint": "Aktualisiere die CLI: npm update -g @anthropic-ai/claude-code. ClaudeDo funktioniert auch im Modus acceptEdits/default, dann mit mehr Rückfragen."
}
} }
} }
+91 -49
View File
@@ -11,6 +11,7 @@
"categoryGeneral": "General", "categoryGeneral": "General",
"categoryExecution": "Execution", "categoryExecution": "Execution",
"categoryPrime": "Prime Claude", "categoryPrime": "Prime Claude",
"categoryPrimeHint": "Scheduled Claude runs: Ping, Fill My Day, or a custom prompt, at fixed times.",
"categoryWorktrees": "Worktrees", "categoryWorktrees": "Worktrees",
"categoryFiles": "Files & Prompts", "categoryFiles": "Files & Prompts",
"categorySkills": "Session Skills", "categorySkills": "Session Skills",
@@ -27,6 +28,7 @@
"model": "Model", "model": "Model",
"maxTurns": "Max turns", "maxTurns": "Max turns",
"effort": "Effort", "effort": "Effort",
"effortHint": "Reasoning effort the model applies. Higher costs more and reasons deeper.",
"modelPresets": "Per-model defaults", "modelPresets": "Per-model defaults",
"modelPresetsHint": "Reasoning effort and turn budget used when a session runs under that model. List- and task-level max-turn overrides still win.", "modelPresetsHint": "Reasoning effort and turn budget used when a session runs under that model. List- and task-level max-turn overrides still win.",
"maxTurnsCeiling": "Max turns ceiling", "maxTurnsCeiling": "Max turns ceiling",
@@ -50,6 +52,7 @@
"powerUseWindowPlaceholder": "HH:mm", "powerUseWindowPlaceholder": "HH:mm",
"powerUseHandoffPct": "Hand off at (% of 5h)", "powerUseHandoffPct": "Hand off at (% of 5h)",
"powerUseWindowHint": "Leave both times empty for round the clock. A window is checked whenever a handler phase starts, so a chain that runs past the end time continues without PowerUse.", "powerUseWindowHint": "Leave both times empty for round the clock. A window is checked whenever a handler phase starts, so a chain that runs past the end time continues without PowerUse.",
"powerUseWindowInvalid": "PowerUse window times must be HH:mm, or left blank.",
"throttleStagesHeading": "Throttle stages", "throttleStagesHeading": "Throttle stages",
"throttleStagesHint": "Parallelism is capped before the hard gate: soft caps at 2 slots, hard at 1. Edited by dragging the markers on the usage-monitor gauges. Opening the monitor closes these settings — save first if you changed anything.", "throttleStagesHint": "Parallelism is capped before the hard gate: soft caps at 2 slots, hard at 1. Edited by dragging the markers on the usage-monitor gauges. Opening the monitor closes these settings — save first if you changed anything.",
"throttleStagesValues": "5h: soft {0}% / hard {1}% · 7d: soft {2}% / hard {3}%", "throttleStagesValues": "5h: soft {0}% / hard {1}% · 7d: soft {2}% / hard {3}%",
@@ -239,6 +242,7 @@
"toggleSubtasksTip": "Expand / collapse subtasks", "toggleSubtasksTip": "Expand / collapse subtasks",
"agentSuggestedTip": "Suggested by the agent", "agentSuggestedTip": "Suggested by the agent",
"interactiveChipTip": "Interactive session open — click to jump to it in Mission Control", "interactiveChipTip": "Interactive session open — click to jump to it in Mission Control",
"waitingOnTip": "Waiting on {0}",
"scheduleTitle": "Schedule task", "scheduleTitle": "Schedule task",
"scheduleWhen": "WHEN", "scheduleWhen": "WHEN",
"scheduleConfirm": "Schedule", "scheduleConfirm": "Schedule",
@@ -260,6 +264,8 @@
"contextOpenFindings": "Open findings folder", "contextOpenFindings": "Open findings folder",
"contextImportTickets": "Update from ticket system", "contextImportTickets": "Update from ticket system",
"contextSettings": "Settings…", "contextSettings": "Settings…",
"contextFolderSettings": "Folder settings…",
"contextRemoveFromFolder": "Move out of folder",
"newList": "New list", "newList": "New list",
"addReposTip": "Add repos as lists", "addReposTip": "Add repos as lists",
"noRepoBannerText": "No repo linked yet. ClaudeDo runs tasks in a git repo.", "noRepoBannerText": "No repo linked yet. ClaudeDo runs tasks in a git repo.",
@@ -275,6 +281,7 @@
"starTip": "Star", "starTip": "Star",
"agentSettingsTip": "Agent settings", "agentSettingsTip": "Agent settings",
"agentSettingsHeading": "Agent settings (overrides)", "agentSettingsHeading": "Agent settings (overrides)",
"waitingOnPrefix": "Waiting on",
"copyFormattedTip": "Copy title, description and open steps", "copyFormattedTip": "Copy title, description and open steps",
"toggleEditPreviewTip": "Toggle edit/preview", "toggleEditPreviewTip": "Toggle edit/preview",
"planDay": "Plan day", "planDay": "Plan day",
@@ -300,7 +307,36 @@
"steps": "Steps", "steps": "Steps",
"files": "Files" "files": "Files"
}, },
"emptyState": "Select a task to see its details" "emptyState": "Select a task to see its details",
"roadblockLabel": "ROADBLOCK",
"stepsCard": {
"preview": "Preview",
"edit": "Edit",
"addStepPlaceholder": "Add step…"
},
"workConsole": {
"continue": "Continue",
"resetAndRetry": "Reset & Retry",
"tabOutput": "Output",
"tabGit": "Git",
"tabSession": "Session",
"outcomeLabel": "OUTCOME",
"reviewLabel": "REVIEW",
"mergeLabel": "MERGE",
"targetBranch": "Target branch",
"openDiff": "Open Diff",
"worktree": "Worktree",
"reviewCombinedDiff": "Review Combined Diff",
"openDiffHint": "Open the diff to enable merge",
"approveMerge": "Approve & Merge",
"park": "Park",
"parkTip": "Set aside — back to Idle, keeps the worktree",
"cancel": "Cancel",
"resetDiscardBranch": "Reset (discard branch)…",
"submitForReviewHint": "Worked on this by hand? Submit the worktree for review to merge it.",
"submitForReview": "Submit for review",
"outcomesLabel": "OUTCOMES"
}
}, },
"agent": { "agent": {
"openWorktreeTip": "Open worktree in file explorer — requires an active worktree", "openWorktreeTip": "Open worktree in file explorer — requires an active worktree",
@@ -344,9 +380,12 @@
"focusMode": "Focus", "focusMode": "Focus",
"overviewMode": "Overview", "overviewMode": "Overview",
"closeSession": "Kill session", "closeSession": "Kill session",
"closeSessionConfirm": "End the running session? The Claude process will be aborted.",
"conptyLaunchFailed": "Couldn't open interactive session: {0}", "conptyLaunchFailed": "Couldn't open interactive session: {0}",
"conptyStarting": "Starting session…", "conptyStarting": "Starting session…",
"conptyExited": "Session ended (code {0}).",
"usageWarningNotDelivered": "Couldn't warn the list-handler session about the usage limit.", "usageWarningNotDelivered": "Couldn't warn the list-handler session about the usage limit.",
"terminalSendFailed": "Couldn't send that to the terminal session.",
"mergeHelperTitle": "Merge Helper", "mergeHelperTitle": "Merge Helper",
"mergeHelperHandoffTitleSuffix": " (Handoff)", "mergeHelperHandoffTitleSuffix": " (Handoff)",
"mergeHelperTaskTitle": "List handler: {0}", "mergeHelperTaskTitle": "List handler: {0}",
@@ -399,11 +438,18 @@
"body": "ClaudeDo can't reach the background worker. It is normally started automatically at logon. You can start it now, or reinstall if the problem persists.", "body": "ClaudeDo can't reach the background worker. It is normally started automatically at logon. You can start it now, or reinstall if the problem persists.",
"dismiss": "Dismiss", "dismiss": "Dismiss",
"rerunInstaller": "Rerun Installer", "rerunInstaller": "Rerun Installer",
"startWorker": "Start Worker" "startWorker": "Start Worker",
"workerNotFound": "Worker executable not found. Check the installation.",
"installerNotFound": "Installer not found. Check the installation.",
"startFailed": "Start failed: {0}"
}, },
"listSettings": { "listSettings": {
"title": "LIST SETTINGS", "title": "LIST SETTINGS",
"folderTitle": "FOLDER",
"deleteList": "Delete list", "deleteList": "Delete list",
"deleteFolder": "Delete folder",
"createAsFolder": "Create a folder instead",
"createAsFolderHint": "A folder just groups lists in the sidebar — it holds no tasks and runs nothing. Drag lists onto it to fill it.",
"sectionGeneral": "GENERAL", "sectionGeneral": "GENERAL",
"name": "Name", "name": "Name",
"workingDirectory": "Working directory", "workingDirectory": "Working directory",
@@ -491,6 +537,8 @@
"windowTitle": "Let Claude handle it", "windowTitle": "Let Claude handle it",
"scopeList": "List: {0}", "scopeList": "List: {0}",
"scopeReview": "All lists — tasks waiting for review", "scopeReview": "All lists — tasks waiting for review",
"modelLabel": "Model",
"modelTip": "Model for the triage session (dedupe & refine). Later merge phases always run on opus.",
"confirm": "Let Claude handle it", "confirm": "Let Claude handle it",
"cancel": "Cancel", "cancel": "Cancel",
"selectAll": "Select all", "selectAll": "Select all",
@@ -662,35 +710,61 @@
"checks": { "checks": {
"git": { "git": {
"title": "Git", "title": "Git",
"hint": "Install Git and make sure it is on PATH." "hint": "Install Git from https://git-scm.com/downloads, then restart the installer.",
"notFound": "git was not found on PATH.",
"exitCode": "'{0}' exited with code {1}.",
"resolvedFormat": "{0} — {1}"
}, },
"gitIdentity": { "gitIdentity": {
"title": "Git Identity", "title": "Git Identity",
"hint": "Set your git identity: git config --global user.name \"...\" and user.email \"...\"." "hint": "Run: git config --global user.name \"Your Name\" and git config --global user.email \"you@example.com\"",
"notFound": "git was not found — identity could not be checked.",
"missing": "Missing git identity: {0}."
}, },
"ports": { "ports": {
"title": "Ports", "title": "Ports",
"hint": "Free the port, or change it on the Worker page after install." "hint": "Change the SignalR/MCP ports on the Service page, or stop the process using them.",
"inUse": "{0} port {1} is in use.",
"inUseBy": "{0} port {1} is in use by '{2}'.",
"inUseByOwnWorker": "In use by the running ClaudeDo Worker: {0}.",
"available": "Ports are available."
}, },
"writeAccess": { "writeAccess": {
"title": "Write Access", "title": "Write Access",
"hint": "Choose a different install directory, or run the installer as administrator." "hint": "Choose a different install directory, or run the installer with sufficient permissions (e.g. as administrator).",
"cannotWrite": "Cannot write to '{0}': {1}",
"writable": "Install directory and data directory are writable."
}, },
"claudeCli": { "claudeCli": {
"title": "Claude CLI", "title": "Claude CLI",
"hint": "Install the Claude CLI and make sure it is on PATH." "hint": "Install the Claude CLI (npm install -g @anthropic-ai/claude-code), then restart your terminal/login session so the updated PATH takes effect.",
"notFound": "'{0}' was not found on PATH. Searched: {1}.",
"exitCode": "'{0}' exited with code {1}.",
"resolvedFormat": "{0} — {1}"
}, },
"claudeVersion": { "claudeVersion": {
"title": "Claude CLI Version", "title": "Claude CLI Version",
"hint": "Update the Claude CLI to a newer version." "hint": "Update the CLI: npm update -g @anthropic-ai/claude-code",
"notFound": "The claude CLI was not found — version could not be checked.",
"parseFailed": "Could not parse a version from '{0}'.",
"tooOld": "claude {0} is older than the required {1}.",
"ok": "claude {0} meets the minimum ({1})."
}, },
"claudeAuth": { "claudeAuth": {
"title": "Claude Login", "title": "Claude CLI Login",
"hint": "Run 'claude auth login' to sign in." "hint": "Run 'claude' once and complete the login flow.",
"notFound": "The claude CLI was not found — login could not be checked.",
"undetermined": "Login status could not be determined.",
"notLoggedIn": "Not logged in.",
"loggedIn": "Logged in."
}, },
"permissionModeAuto": { "permissionModeAuto": {
"title": "Permission Mode", "title": "Permission Mode",
"hint": "Update the Claude CLI to a version that supports --permission-mode auto." "hint": "Update the CLI: npm update -g @anthropic-ai/claude-code. ClaudeDo still works in acceptEdits/default mode, with more prompts.",
"notFound": "The claude CLI was not found — permission-mode support could not be checked.",
"undetermined": "Could not determine whether --permission-mode auto is supported.",
"ok": "--permission-mode auto is recognized by this CLI.",
"notListed": "This claude CLI version does not list 'auto' as a --permission-mode choice."
} }
}, },
"planning": { "planning": {
@@ -737,6 +811,8 @@
"repositories": "Repositories", "repositories": "Repositories",
"checkForUpdates": "Check for updates", "checkForUpdates": "Check for updates",
"restartWorker": "Restart worker", "restartWorker": "Restart worker",
"updateClaudeCli": "Update Claude CLI",
"shutdown": "Shutdown",
"worktrees": "Worktrees…", "worktrees": "Worktrees…",
"weeklyReport": "Weekly Report…", "weeklyReport": "Weekly Report…",
"usageMonitor": "Usage Monitor…", "usageMonitor": "Usage Monitor…",
@@ -762,10 +838,10 @@
}, },
"vm": { "vm": {
"connection": { "online": "Online", "connecting": "Connecting…", "offline": "Offline" }, "connection": { "online": "Online", "connecting": "Connecting…", "offline": "Offline" },
"shell": { "restartingWorker": "Restarting worker…", "unexpectedError": "Unexpected error: {0}" }, "shell": { "unexpectedError": "Unexpected error: {0}", "shutdownConfirm": "This stops the ClaudeDo worker (any running task or interactive session is interrupted) and closes the app. Continue?" },
"mainWindow": { "closeConfirm": { "message": "{0} Mission Control session(s) are still running. Closing the app will end them. Close anyway?" } }, "mainWindow": { "closeConfirm": { "message": "{0} Mission Control session(s) are still running. Closing the app will end them. Close anyway?" } },
"agentStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "review": "Review", "children": "Waiting for Subtasks", "done": "Done", "failed": "Failed", "cancelled": "Cancelled" }, "agentStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "review": "Review", "children": "Waiting for Subtasks", "done": "Done", "failed": "Failed", "cancelled": "Cancelled" },
"taskStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "waitingForReview": "Waiting for Review", "waitingForChildren": "Waiting for Subtasks", "done": "Done", "failed": "Failed", "cancelled": "Cancelled", "parked": "Parked", "interactive": "Interactive" }, "taskStatus": { "idle": "Idle", "queued": "Queued", "running": "Running", "waitingForReview": "Waiting for Review", "waitingForChildren": "Waiting for Subtasks", "done": "Done", "failed": "Failed", "cancelled": "Cancelled", "parked": "Parked", "interactive": "Interactive", "waiting": "Waiting" },
"failureReason": { "maxTurns": "Turn limit reached", "timeout": "Timed out", "usageLimit": "Usage limit reached", "error": "Error", "cancelled": "Cancelled", "unknown": "Unknown reason" }, "failureReason": { "maxTurns": "Turn limit reached", "timeout": "Timed out", "usageLimit": "Usage limit reached", "error": "Error", "cancelled": "Cancelled", "unknown": "Unknown reason" },
"failureReasonTooltip": { "maxTurns": "Turn limit reached ({0}/{1} turns) — the worktree is usually fine; continue the task instead of resetting it." }, "failureReasonTooltip": { "maxTurns": "Turn limit reached ({0}/{1} turns) — the worktree is usually fine; continue the task instead of resetting it." },
"planningBadge": { "active": "PLANNING", "finalized": "PLANNED" }, "planningBadge": { "active": "PLANNING", "finalized": "PLANNED" },
@@ -793,12 +869,12 @@
"sessionSkillsTab": { "installed": "Installed: {0}", "installFailed": "Install failed: {0}", "updated": "Updated.", "updateFailed": "Update failed: {0}", "removed": "Removed.", "removeFailed": "Remove failed: {0}" }, "sessionSkillsTab": { "installed": "Installed: {0}", "installFailed": "Install failed: {0}", "updated": "Updated.", "updateFailed": "Update failed: {0}", "removed": "Removed.", "removeFailed": "Remove failed: {0}" },
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "Removed {0} worktree(s).", "blocked": "Cannot force-remove: {0} task(s) still running. Cancel them first.", "removedFrom": "Removed {0} worktree(s) from {1} task(s).", "cleanupFailed": "Cleanup failed: {0}", "resetFailed": "Reset failed: {0}" }, "worktreesTab": { "workerOffline": "Worker offline.", "removed": "Removed {0} worktree(s).", "blocked": "Cannot force-remove: {0} task(s) still running. Cancel them first.", "removedFrom": "Removed {0} worktree(s) from {1} task(s).", "cleanupFailed": "Cleanup failed: {0}", "resetFailed": "Reset failed: {0}" },
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "list", "cleanupFailed": "Cleanup failed.", "cleanupFailedDetailed": "Cleanup failed: {0}", "removed": "Removed {0} worktree(s).", "discardConfirm": "Discard the worktree for \"{0}\"? Uncommitted work will be lost.", "discardFailed": "Failed to discard worktree.", "keepFailed": "Failed to keep worktree.", "cannotForceRunning": "Cannot force-remove a running task.", "forceRemoveFailed": "Force remove failed.", "forceRemoveFailedDetailed": "Force remove failed: {0}", "batchDone": "Merged {0}, {1} need resolution.", "openInExplorerFailed": "Couldn't open path: {0}" }, "worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "list", "cleanupFailed": "Cleanup failed.", "cleanupFailedDetailed": "Cleanup failed: {0}", "removed": "Removed {0} worktree(s).", "discardConfirm": "Discard the worktree for \"{0}\"? Uncommitted work will be lost.", "discardFailed": "Failed to discard worktree.", "keepFailed": "Failed to keep worktree.", "cannotForceRunning": "Cannot force-remove a running task.", "forceRemoveFailed": "Force remove failed.", "forceRemoveFailedDetailed": "Force remove failed: {0}", "batchDone": "Merged {0}, {1} need resolution.", "openInExplorerFailed": "Couldn't open path: {0}" },
"listSettings": { "untitled": "Untitled", "ticketProjectNone": "— none —", "loadFailed": "Couldn't load list settings: {0}", "ticketProjectsLoadFailed": "Couldn't load ticket projects: {0}" }, "listSettings": { "untitled": "Untitled", "ticketProjectNone": "— none —", "loadFailed": "Couldn't load list settings: {0}", "ticketProjectsLoadFailed": "Couldn't load ticket projects: {0}", "confirmDeleteFolder": "Delete folder \"{0}\"? The lists inside it are kept and move back to the top level.", "confirmDeleteList": "Delete list \"{0}\" and all its tasks? This cannot be undone.", "deleteBlockedPlanning": "This list has planning sessions with child tasks. Discard those first, then delete the list.", "workingDirMissing": "This folder doesn't exist.", "workingDirNotRepo": "This folder isn't a git repository yet. Run `git init` in it first, or clear the working directory to save this as a manual list." },
"detailsIsland": { "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "stopOffline": "Worker offline — can't stop the task.", "stopFailed": "Stop failed: {0}", "enqueueFailed": "Queue failed: {0}", "dequeueFailed": "Remove from queue failed: {0}", "resetAndRetryFailed": "Reset & retry failed: {0}", "toggleDoneFailed": "Couldn't update done state: {0}" }, "detailsIsland": { "verifyFailed": "Merge landed, but the list's verify command failed — the task was kept out of Done.", "untrackedCollision": "Merge refused — it would overwrite an untracked file in the target working directory.", "stopOffline": "Worker offline — can't stop the task.", "stopFailed": "Stop failed: {0}", "enqueueFailed": "Queue failed: {0}", "dequeueFailed": "Remove from queue failed: {0}", "resetAndRetryFailed": "Reset & retry failed: {0}", "toggleDoneFailed": "Couldn't update done state: {0}" },
"mergeSection": { "openWorktreeFailed": "Couldn't open worktree: {0}" }, "mergeSection": { "openWorktreeFailed": "Couldn't open worktree: {0}" },
"about": { "openPathFailed": "Couldn't open path: {0}" }, "about": { "openPathFailed": "Couldn't open path: {0}" },
"lists": { "localSuffix": "{0} / local", "smartMyDay": "My Day", "smartImportant": "Important", "smartPlanned": "Planned", "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}" }, "lists": { "localSuffix": "{0} / local", "smartMyDay": "My Day", "smartImportant": "Important", "smartPlanned": "Planned", "smartNotes": "Notes", "virtualQueue": "Queue", "virtualRunning": "Running", "virtualReview": "Review", "newList": "New list", "findingsNotFound": "No findings yet for this list.", "findingsOpenFailed": "Couldn't open findings folder: {0}", "openInExplorerFailed": "Couldn't open path: {0}" },
"repoImport": { "loadFailed": "Couldn't load remembered folders: {0}", "saveFailed": "Couldn't save folders: {0}" } "repoImport": { "loadFailed": "Couldn't load remembered folders: {0}", "saveFailed": "Couldn't save folders: {0}", "createButton": "Create {0} list(s)" }
}, },
"ops": { "ops": {
"stalledHint": "Still running — worker hasn't responded in a while", "stalledHint": "Still running — worker hasn't responded in a while",
@@ -866,39 +942,5 @@
"lastError": "Last error: {0}", "lastError": "Last error: {0}",
"throttled": "Throttled: {0}/{1} slots ({2})" "throttled": "Throttled: {0}/{1} slots ({2})"
} }
},
"checks": {
"git": {
"title": "Git",
"hint": "Install Git from https://git-scm.com/downloads, then restart the installer."
},
"gitIdentity": {
"title": "Git identity",
"hint": "Run: git config --global user.name \"Your Name\" and git config --global user.email \"you@example.com\""
},
"ports": {
"title": "Ports",
"hint": "Change the SignalR/MCP ports on the Service page, or stop the process using them."
},
"writeAccess": {
"title": "Write access",
"hint": "Choose a different install directory, or run the installer with sufficient permissions (e.g. as administrator)."
},
"claudeCli": {
"title": "Claude CLI",
"hint": "Install the Claude CLI (npm install -g @anthropic-ai/claude-code), then restart your terminal/login session so the updated PATH takes effect."
},
"claudeVersion": {
"title": "Claude CLI version",
"hint": "Update the CLI: npm update -g @anthropic-ai/claude-code"
},
"claudeAuth": {
"title": "Claude CLI login",
"hint": "Run 'claude' once and complete the login flow."
},
"permissionModeAuto": {
"title": "--permission-mode auto support",
"hint": "Update the CLI: npm update -g @anthropic-ai/claude-code. ClaudeDo still works in acceptEdits/default mode, with more prompts."
}
} }
} }
+2 -1
View File
@@ -41,6 +41,7 @@ Design/ — Tokens.axaml (design tokens; merged before styles)
- **IslandsShellViewModel** — root coordinator. Owns the three island VMs and the `WorkerClient`, wires cross-island events (selection, notes/prep mode, conflict resolution), connection state, the update banner, the inline worker-log strip (clickable → Log Visualizer overlay; `FlashFooterError` surfaces UI-action failures + the worker's Warn/Error there), responsive-layout flags (`ShowLists`/`ShowDetails` by window width), `PrimeStatus` flash, the modal openers, and `RestartWorkerAsync`/`CheckForUpdatesAsync`. Hosts `UpdateCheckService`. - **IslandsShellViewModel** — root coordinator. Owns the three island VMs and the `WorkerClient`, wires cross-island events (selection, notes/prep mode, conflict resolution), connection state, the update banner, the inline worker-log strip (clickable → Log Visualizer overlay; `FlashFooterError` surfaces UI-action failures + the worker's Warn/Error there), responsive-layout flags (`ShowLists`/`ShowDetails` by window width), `PrimeStatus` flash, the modal openers, and `RestartWorkerAsync`/`CheckForUpdatesAsync`. Hosts `UpdateCheckService`.
- **ListsIslandViewModel** — smart lists (My Day, Important, Planned, virtual queued/running/review), user lists, selection, list CRUD, drag-reorder, badge counts, opens list settings / repo import / worktrees overview, `OpenInExplorer`/`OpenInTerminal`. - **ListsIslandViewModel** — smart lists (My Day, Important, Planned, virtual queued/running/review), user lists, selection, list CRUD, drag-reorder, badge counts, opens list settings / repo import / worktrees overview, `OpenInExplorer`/`OpenInTerminal`.
- **Folders:** the sidebar's top level is **one mixed collection**, `TopLevel` (`ObservableCollection<object>`: `ListFolderNavItemViewModel` rows, each with its own `Lists`, interleaved with folder-less `ListNavItemViewModel` rows) — that's what lets a list sit *above* a folder. `Folders`/`RootLists` are `OfType` **projections** of it, not collections to keep in sync. `UserLists` stays the **flat superset** every count refresh, row refresh and the no-repo banner work over — a row lives in both. `ContainerOf(row)` resolves which collection renders it (non-generic `IList`, since the top level is mixed). The view is a single `ItemsControl` over `TopLevel` with **implicit, type-matched `DataTemplate`s in `UserControl.DataTemplates`** — no keyed row template, and the folder's child `ItemsControl` resolves the same list template by walking up to the UserControl. A folder is never selectable: tapping the header runs `ToggleFolderCommand` (persisted to `list_folders.is_collapsed`), and its badge is the sum of its children. `DropAsync(source, target, placeBelow)` handles all four source/target combinations and doubles as the move: a drop position implies a container, so dropping a list onto a row in another folder — or next to *anything* at the top level, folder headers included — reassigns `FolderId`. A folder dropped onto a row inside a folder is ignored; folders never nest. `MoveToFolderAsync` is the drop-into-the-header path (reaches collapsed/empty folders, bound to the middle half of the header — the outer quarters insert *next to* the folder instead); `RemoveFromFolderCommand` is its context-menu counterpart. Both end in `PersistOrderAsync` → `ListRepository.ReorderSidebarAsync`, one `sort_order` sequence spanning **both tables**. ⚠️ A **folder** drag starts from `PointerMoved` past a 4px threshold using the *stored* `PointerPressedEventArgs` (`DoDragDropAsync` only accepts those) — starting it on press would capture the pointer and swallow the `Tapped` that folds the folder.
- **TasksIslandViewModel** — open/overdue/completed groups for the selected list with hierarchy-aware regrouping; task CRUD, drag-reorder, toggle done/star, schedule, enqueue/dequeue, cancel; review actions; planning session lifecycle; `RefineTask`, `OpenConPtySessionRequested`, `ToggleManual`, `SyncInteractiveSessions`; MyDay extras (`IsMyDayList`, `ClearDayCommand`, `ShowPrepLogCommand`) and the pinned Notes pseudo-row. Raises `NotesRequested`/`PrepRequested` for the shell. - **TasksIslandViewModel** — open/overdue/completed groups for the selected list with hierarchy-aware regrouping; task CRUD, drag-reorder, toggle done/star, schedule, enqueue/dequeue, cancel; review actions; planning session lifecycle; `RefineTask`, `OpenConPtySessionRequested`, `ToggleManual`, `SyncInteractiveSessions`; MyDay extras (`IsMyDayList`, `ClearDayCommand`, `ShowPrepLogCommand`) and the pinned Notes pseudo-row. Raises `NotesRequested`/`PrepRequested` for the shell.
- **DetailsIslandViewModel** — the detail pane for a bound `TaskRowViewModel`. Owns live-log streaming (`Log` via `StreamLineFormatter`), debounced title/description editing, subtasks, the session-outcome/roadblock split, the three-tab work console (`output`/`git`/`session`), child surfacing (`ChildOutcomes` + `ChildrenNeedingAttention` drive an attention band on the Session tab), attachments, and the modes `IsNotesMode`/`IsPrepMode`/computed `IsTaskDetailVisible`. Failures raise `ErrorReported`, wired by the shell into `FlashFooterError`. - **DetailsIslandViewModel** — the detail pane for a bound `TaskRowViewModel`. Owns live-log streaming (`Log` via `StreamLineFormatter`), debounced title/description editing, subtasks, the session-outcome/roadblock split, the three-tab work console (`output`/`git`/`session`), child surfacing (`ChildOutcomes` + `ChildrenNeedingAttention` drive an attention band on the Session tab), attachments, and the modes `IsNotesMode`/`IsPrepMode`/computed `IsTaskDetailVisible`. Failures raise `ErrorReported`, wired by the shell into `FlashFooterError`.
- Three concerns are extracted into section VMs exposed as properties: `AgentSettings` (`AgentConfigEditorViewModel`, scope=Task), `MergeSectionViewModel`, `PrepPanelViewModel`. Helper rows live in the same file. - Three concerns are extracted into section VMs exposed as properties: `AgentSettings` (`AgentConfigEditorViewModel`, scope=Task), `MergeSectionViewModel`, `PrepPanelViewModel`. Helper rows live in the same file.
@@ -55,7 +56,7 @@ Design/ — Tokens.axaml (design tokens; merged before styles)
| VM | Notes | | VM | Notes |
|---|---| |---|---|
| `SettingsModalViewModel` | Four tabs: General, Worktrees, Files (prompt paths), Prime Claude. General hosts the per-model preset table (`ModelPresetRowViewModel`: effort + max turns per alias) which **replaced** the single global "Max turns" field. The Prime Claude tab gives each schedule row a three-way action radio group (Ping / Fill My Day / Custom) — `RadioButton.GroupName` is bound to a **per-row** `RadioGroup` string, since a shared group name would make every schedule share one selection. `DailyPrepMaxTasks` is disabled unless some row is `FillMyDay`. | | `SettingsModalViewModel` | Four tabs: General, Worktrees, Files (prompt paths), Prime Claude. General hosts the per-model preset table (`ModelPresetRowViewModel`: effort + max turns per alias) which **replaced** the single global "Max turns" field. The Prime Claude tab gives each schedule row a three-way action radio group (Ping / Fill My Day / Custom) — `RadioButton.GroupName` is bound to a **per-row** `RadioGroup` string, since a shared group name would make every schedule share one selection. `DailyPrepMaxTasks` is disabled unless some row is `FillMyDay`. |
| `ListSettingsModalViewModel` | Name, working dir, commit type, "manual list" flag, `VerifyCommand`, delete. Hosts the shared `AgentConfigEditorViewModel` as `Agent` (scope=List) — ⚠️ save delegates to `Agent.SaveAsync(verifyCommand)` because both land in the same `list_config` row via one `UpdateListConfig` call and would otherwise clobber each other. | | `ListSettingsModalViewModel` | Name, working dir, commit type, "manual list" flag, `VerifyCommand`, delete. Hosts the shared `AgentConfigEditorViewModel` as `Agent` (scope=List) — ⚠️ save delegates to `Agent.SaveAsync(verifyCommand)` because both land in the same `list_config` row via one `UpdateListConfig` call and would otherwise clobber each other. **Doubles as the folder editor:** `IsFolder` collapses the body to name + icon, and it's reached either via the create-only "create a folder instead" toggle (`CanToggleFolder`, set by `BeginLoad`) or via `BeginLoadFolder` on an existing folder. There is no list↔folder conversion. Creating a folder writes a `list_folders` row and **deletes the list row the create flow pre-created**, reporting it as `Deleted` so the caller does a full reload. |
| `WeeklyReportModalViewModel` | Range pickers default "since last standup weekday → today", cached per range. | | `WeeklyReportModalViewModel` | Range pickers default "since last standup weekday → today", cached per range. |
| `MergeHelperSelectionModalViewModel` | "Let Claude handle it" picker → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md). | | `MergeHelperSelectionModalViewModel` | "Let Claude handle it" picker → [conpty-sessions](../../docs/explore-notes/conpty-sessions.md). |
| `UsageMonitorModalViewModel` | Opened from the usage pill (shown **before** the data loads via `BeginLoad`); gauges are **dynamic** per `UsageSnapshotDto.Limits` row, and the 5h/7d ones carry three draggable stage markers (soft/hard/gate) via `UsageGaugeBar` + the pure `UsageThresholdDrag`, plus a colour-matched legend with a `NumericUpDown` per stage → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md). The **analytics** part (models/tasks/cost) comes from the external TokenTracker export, not from us: without the CLI the modal shows a hint card with an install button (gated on Node ≥ 20) instead of the totals header. Gauges, gate and throttle are unaffected by it. The modal is **display + threshold drag only** — the PowerUse switch/window/handoff % it used to host moved to Settings → Execution (`GeneralSettingsTabViewModel`), and "Refresh now" is an icon in the titlebar via `ModalShell.HeaderActions` rather than a body row. | | `UsageMonitorModalViewModel` | Opened from the usage pill (shown **before** the data loads via `BeginLoad`); gauges are **dynamic** per `UsageSnapshotDto.Limits` row, and the 5h/7d ones carry three draggable stage markers (soft/hard/gate) via `UsageGaugeBar` + the pure `UsageThresholdDrag`, plus a colour-matched legend with a `NumericUpDown` per stage → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md). The **analytics** part (models/tasks/cost) comes from the external TokenTracker export, not from us: without the CLI the modal shows a hint card with an install button (gated on Node ≥ 20) instead of the totals header. Gauges, gate and throttle are unaffected by it. The modal is **display + threshold drag only** — the PowerUse switch/window/handoff % it used to host moved to Settings → Execution (`GeneralSettingsTabViewModel`), and "Refresh now" is an icon in the titlebar via `ModalShell.HeaderActions` rather than a body row. |
+12
View File
@@ -297,6 +297,18 @@
<Setter Property="Foreground" Value="{StaticResource StatusQueuedBrush}" /> <Setter Property="Foreground" Value="{StaticResource StatusQueuedBrush}" />
</Style> </Style>
<!-- waiting → muted amber: a Queued task blocked on an unmet predecessor (a task_dependencies
edge, or the planning chain's BlockedByTaskId) — distinct from queued (unblocked, next up
to run) and from children (a parent waiting on its own subtasks). Mutually exclusive with
.queued on the same chip (IsQueued/IsWaiting), so no declaration-order fight between them. -->
<Style Selector="Border.chip.waiting">
<Setter Property="Background" Value="#332F1A" />
<Setter Property="BorderBrush" Value="#4D451A" />
</Style>
<Style Selector="Border.chip.waiting > TextBlock">
<Setter Property="Foreground" Value="#C9A227" />
</Style>
<!-- done → green (#6FA86B) --> <!-- done → green (#6FA86B) -->
<Style Selector="Border.chip.done"> <Style Selector="Border.chip.done">
<Setter Property="Background" Value="{StaticResource DoneTintBrush}" /> <Setter Property="Background" Value="{StaticResource DoneTintBrush}" />
@@ -1,9 +1,10 @@
namespace ClaudeDo.Ui.Services; namespace ClaudeDo.Ui.Services;
// Two named types because DI resolves them by type, not by key. // Two named types because DI resolves them by type, not by key. Not sealed: tests subclass
public sealed class InstallerLocator() : InstallArtifactLocator("uninstaller", "ClaudeDo.Installer.exe"); // these to fake Find() without touching the real filesystem/registry.
public class InstallerLocator() : InstallArtifactLocator("uninstaller", "ClaudeDo.Installer.exe");
public sealed class WorkerLocator() : InstallArtifactLocator("worker", "ClaudeDo.Worker.exe"); public class WorkerLocator() : InstallArtifactLocator("worker", "ClaudeDo.Worker.exe");
/// <summary> /// <summary>
/// Locates an executable inside a ClaudeDo install: walk up from the running /// Locates an executable inside a ClaudeDo install: walk up from the running
@@ -14,7 +15,7 @@ public abstract class InstallArtifactLocator(string subdir, string exeName)
{ {
private const string InstallJson = "install.json"; private const string InstallJson = "install.json";
public string? Find() public virtual string? Find()
=> FindByWalkingUp(AppContext.BaseDirectory) => FindByWalkingUp(AppContext.BaseDirectory)
?? (OperatingSystem.IsWindows() ? FindByRegistry() : null); ?? (OperatingSystem.IsWindows() ? FindByRegistry() : null);
@@ -52,13 +52,6 @@ public interface IWorkerClient : INotifyPropertyChanged
/// total.</summary> /// total.</summary>
event Action<string, string, int, int>? OperationProgressEvent; event Action<string, string, int, int>? OperationProgressEvent;
/// <summary>(taskId, phase, elapsedSeconds) — phase of an in-flight single-task merge
/// ("merging" | "verifying"). Fires while the MergeTask call itself is still pending, so the
/// waiting UI can show what it's blocked on; the verify phase re-fires every 30 s. A thin
/// forwarder over <see cref="OperationProgressEvent"/> kept for existing consumers (elapsed
/// seconds riding in the generic "current" slot).</summary>
event Action<string, string, int>? MergeProgressEvent;
event Action<string, string>? PlanningMergeStartedEvent; event Action<string, string>? PlanningMergeStartedEvent;
event Action<string, string>? PlanningSubtaskMergedEvent; event Action<string, string>? PlanningSubtaskMergedEvent;
/// <summary>(planningTaskId, subtaskId, conflictedFiles, externallyDriven). externallyDriven /// <summary>(planningTaskId, subtaskId, conflictedFiles, externallyDriven). externallyDriven
@@ -143,8 +136,9 @@ public interface IWorkerClient : INotifyPropertyChanged
/// <summary>Launch spec for an embedded ConPTY "merge helper" session that drives the given /// <summary>Launch spec for an embedded ConPTY "merge helper" session that drives the given
/// tasks to a merged/Done state. listId scopes the session (and cwd) to that list. /// tasks to a merged/Done state. listId scopes the session (and cwd) to that list.
/// handlerTaskId (from CreateMergeHelperTaskAsync) is rendered into the brief so the session /// handlerTaskId (from CreateMergeHelperTaskAsync) is rendered into the brief so the session
/// can call handoff_list_handler on its own handler task.</summary> /// can call handoff_list_handler on its own handler task. model overrides the triage
Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, CancellationToken ct = default); /// session's model (null = default).</summary>
Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, string? model = null, CancellationToken ct = default);
/// <summary>Creates the ClaudeDo task that owns a list-handler run (one per run, Idle/IsManual, /// <summary>Creates the ClaudeDo task that owns a list-handler run (one per run, Idle/IsManual,
/// never queued) so the ConPTY tile can be task-based instead of ad-hoc. Returns the new task id.</summary> /// never queued) so the ConPTY tile can be task-based instead of ad-hoc. Returns the new task id.</summary>
Task<string> CreateMergeHelperTaskAsync( Task<string> CreateMergeHelperTaskAsync(
@@ -162,15 +162,19 @@ public sealed class PtyTerminalSession : IDisposable
/// writing the clipboard text raw, because that is what honours bracketed-paste mode — without /// writing the clipboard text raw, because that is what honours bracketed-paste mode — without
/// it a multi-line paste is read line by line and submits on the first newline. /// it a multi-line paste is read line by line and submits on the first newline.
/// </summary> /// </summary>
public async Task PasteAsync() public async Task<bool> PasteAsync()
{ {
if (_control is not { } control || !IsRunning) return; if (_control is not { } control || !IsRunning) return false;
try try
{ {
if (s_terminalViewField?.GetValue(control) is TerminalView view) if (s_terminalViewField?.GetValue(control) is not TerminalView view) return false;
await view.PasteAsync(); await view.PasteAsync();
return true;
}
catch (Exception)
{
return false;
} }
catch (Exception) { /* best-effort, same as SendLineAsync */ }
} }
// Long enough that the two writes can't be read as one chunk by the child's stdin reader. // Long enough that the two writes can't be read as one chunk by the child's stdin reader.
+1 -1
View File
@@ -69,5 +69,5 @@ public static class RepoScanner
// A real clone has .git as a directory. Worktrees and submodules have it as a file // A real clone has .git as a directory. Worktrees and submodules have it as a file
// ("gitdir: ...") — those are not standalone repos and would flood the list. // ("gitdir: ...") — those are not standalone repos and would flood the list.
private static bool IsRepo(string dir) => Directory.Exists(Path.Combine(dir, ".git")); public static bool IsRepo(string dir) => Directory.Exists(Path.Combine(dir, ".git"));
} }
+3 -8
View File
@@ -67,7 +67,6 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public event Action<UsageSnapshotDto>? UsageUpdatedEvent; public event Action<UsageSnapshotDto>? UsageUpdatedEvent;
public event Action<string, string, int, int>? OperationProgressEvent; public event Action<string, string, int, int>? OperationProgressEvent;
public event Action<string, string, int>? MergeProgressEvent;
public event Action<string, string>? PlanningMergeStartedEvent; public event Action<string, string>? PlanningMergeStartedEvent;
public event Action<string, string>? PlanningSubtaskMergedEvent; public event Action<string, string>? PlanningSubtaskMergedEvent;
@@ -189,11 +188,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
_hub.On<string, string, int, int>("OperationProgress", (opKey, phase, current, total) => _hub.On<string, string, int, int>("OperationProgress", (opKey, phase, current, total) =>
{ {
Dispatcher.UIThread.Post(() => Dispatcher.UIThread.Post(() => OperationProgressEvent?.Invoke(opKey, phase, current, total));
{
OperationProgressEvent?.Invoke(opKey, phase, current, total);
MergeProgressEvent?.Invoke(opKey, phase, current);
});
}); });
_hub.On<string, string>("PlanningMergeStarted", (planningTaskId, targetBranch) => _hub.On<string, string>("PlanningMergeStarted", (planningTaskId, targetBranch) =>
@@ -627,8 +622,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public async Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default) public async Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
=> await InvokeTimedAsync<LaunchSpec>("GetAdHocLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetAdHocLaunchSpec", directory, ct)); => await InvokeTimedAsync<LaunchSpec>("GetAdHocLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetAdHocLaunchSpec", directory, ct));
public async Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, CancellationToken ct = default) public async Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, string? model = null, CancellationToken ct = default)
=> await InvokeTimedAsync<LaunchSpec>("GetMergeHelperLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetMergeHelperLaunchSpec", taskIds, listId, handlerTaskId, ct)); => await InvokeTimedAsync<LaunchSpec>("GetMergeHelperLaunchSpec", () => _hub.InvokeAsync<LaunchSpec>("GetMergeHelperLaunchSpec", taskIds, listId, handlerTaskId, model, ct));
public async Task<string> CreateMergeHelperTaskAsync( public async Task<string> CreateMergeHelperTaskAsync(
IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default) IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default)
@@ -1,6 +1,7 @@
using System; using System;
using System.Threading.Tasks; using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services; using ClaudeDo.Ui.Services;
using Iciclecreek.Terminal; using Iciclecreek.Terminal;
@@ -36,14 +37,29 @@ public sealed partial class InteractiveTerminalViewModel : ViewModelBase, IDispo
/// can show a spinner instead of an empty black pane.</summary> /// can show a spinner instead of an empty black pane.</summary>
public bool IsStarting => !IsRunning && !HasExited && StartError is null; public bool IsStarting => !IsRunning && !HasExited && StartError is null;
/// <summary>True once the process has died without a <see cref="StartError"/> already covering it —
/// i.e. every death that isn't a launch-time failure: a crash after running a while, a clean exit,
/// or a quiet exit inside the startup grace window. Drives the host's "session ended" banner so a
/// dead pane never looks like it's still running/thinking.</summary>
public bool HasExitedAfterStartup => HasExited && StartError is null;
partial void OnIsRunningChanged(bool value) partial void OnIsRunningChanged(bool value)
{ {
OnPropertyChanged(nameof(IsStarting)); OnPropertyChanged(nameof(IsStarting));
if (value) _startedAtUtc = _utcNow(); if (value) _startedAtUtc = _utcNow();
} }
partial void OnHasExitedChanged(bool value) => OnPropertyChanged(nameof(IsStarting)); partial void OnHasExitedChanged(bool value)
partial void OnStartErrorChanged(string? value) => OnPropertyChanged(nameof(IsStarting)); {
OnPropertyChanged(nameof(IsStarting));
OnPropertyChanged(nameof(HasExitedAfterStartup));
}
partial void OnStartErrorChanged(string? value)
{
OnPropertyChanged(nameof(IsStarting));
OnPropertyChanged(nameof(HasExitedAfterStartup));
}
public InteractiveTerminalViewModel() : this(() => DateTime.UtcNow) public InteractiveTerminalViewModel() : this(() => DateTime.UtcNow)
{ {
@@ -96,6 +112,15 @@ public sealed partial class InteractiveTerminalViewModel : ViewModelBase, IDispo
} }
} }
/// <summary>Raised when a user-initiated paste or drop couldn't reach the session (write seam
/// gone, no running process) — mirrors the island ViewModels' ErrorReported, forwarded by
/// <see cref="ClaudeDo.Ui.ViewModels.MissionControl.ConPtyPaneViewModel"/> into the footer error strip.</summary>
public event Action<string>? ErrorReported;
/// <summary>Called by the view after a failed <see cref="PasteAsync"/>/<see cref="SendTextAsync"/>
/// so the failure surfaces through <see cref="ErrorReported"/> instead of vanishing silently.</summary>
public void ReportSendFailed() => ErrorReported?.Invoke(Loc.T("missionControl.terminalSendFailed"));
/// <summary>Raised when the process died non-zero *after* clearing <see cref="StartupGraceWindow"/> — /// <summary>Raised when the process died non-zero *after* clearing <see cref="StartupGraceWindow"/> —
/// i.e. a session that ran, then broke. That used to be a silent dead tile with no signal at all; /// i.e. a session that ran, then broke. That used to be a silent dead tile with no signal at all;
/// the worker turns it into a usage-limit auto-restart for list-handler sessions /// the worker turns it into a usage-limit auto-restart for list-handler sessions
@@ -141,8 +166,9 @@ public sealed partial class InteractiveTerminalViewModel : ViewModelBase, IDispo
public Task<bool> SendTextAsync(string text) => _session.SendTextAsync(text); public Task<bool> SendTextAsync(string text) => _session.SendTextAsync(text);
/// <summary>Pastes the clipboard into the session (Ctrl+V) — see /// <summary>Pastes the clipboard into the session (Ctrl+V) — see
/// <see cref="PtyTerminalSession.PasteAsync"/>.</summary> /// <see cref="PtyTerminalSession.PasteAsync"/>. False when there is no running process to
public Task PasteAsync() => _session.PasteAsync(); /// paste into (or the write seam is gone).</summary>
public Task<bool> PasteAsync() => _session.PasteAsync();
public void Kill() => _session.Kill(); public void Kill() => _session.Kill();
@@ -1262,19 +1262,14 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
using var op = ApproveOp.Begin(Loc.T("ops.merge.merging")); using var op = ApproveOp.Begin(Loc.T("ops.merge.merging"));
// Subscribed only for the duration of the call: the worker broadcasts to every // Subscribed only for the duration of the call: the worker broadcasts to every
// client, and a VM left on this event would keep reacting to merges for other tasks. // client, and a VM left on this event would keep reacting to merges for other tasks.
void OnMergeProgress(string progressTaskId, string phase, int elapsedSeconds)
{
if (progressTaskId != taskId || phase != MergePhaseVerifying) return;
ApproveOp.Report(Loc.T("ops.merge.verifying", FormatElapsed(elapsedSeconds)));
}
// Separate event: MergeProgressEvent forwards elapsed-seconds-only (verifying), but the
// rebase phase needs the branch count too, which only rides the untrimmed OperationProgress.
void OnOperationProgress(string opKey, string phase, int current, int total) void OnOperationProgress(string opKey, string phase, int current, int total)
{ {
if (opKey != taskId || phase != MergePhaseRebasing) return; if (opKey != taskId) return;
ApproveOp.Report(FormatRebasing(current, total)); if (phase == MergePhaseVerifying)
ApproveOp.Report(Loc.T("ops.merge.verifying", FormatElapsed(current)));
else if (phase == MergePhaseRebasing)
ApproveOp.Report(FormatRebasing(current, total));
} }
_worker.MergeProgressEvent += OnMergeProgress;
_worker.OperationProgressEvent += OnOperationProgress; _worker.OperationProgressEvent += OnOperationProgress;
try try
{ {
@@ -1298,7 +1293,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
} }
finally finally
{ {
_worker.MergeProgressEvent -= OnMergeProgress;
_worker.OperationProgressEvent -= OnOperationProgress; _worker.OperationProgressEvent -= OnOperationProgress;
} }
} }
@@ -1509,12 +1503,17 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
catch { /* best-effort */ } catch { /* best-effort */ }
} }
// Shared by TaskRowViewModel.UpdateFromEntity — this is the one parser for
// `git diff --stat`'s summary line ("N insertions(+), M deletions(-)"). Anchored on the
// literal "(+)"/"(-)" markers rather than the English words: git for Windows localizes
// "insertion"/"deletion" to the OS language (e.g. German "Einfügung(en)"/"Löschung(en)"),
// but always keeps the untranslated (+)/(-) suffix, so this reads correctly in any locale.
internal static (int Additions, int Deletions) ParseDiffStat(string? stat) internal static (int Additions, int Deletions) ParseDiffStat(string? stat)
{ {
if (string.IsNullOrEmpty(stat)) return (0, 0); if (string.IsNullOrEmpty(stat)) return (0, 0);
int add = 0, del = 0; int add = 0, del = 0;
var m1 = System.Text.RegularExpressions.Regex.Match(stat, @"(\d+)\s+insertion"); var m1 = System.Text.RegularExpressions.Regex.Match(stat, @"(\d+)[^,\n]*\(\+\)");
var m2 = System.Text.RegularExpressions.Regex.Match(stat, @"(\d+)\s+deletion"); var m2 = System.Text.RegularExpressions.Regex.Match(stat, @"(\d+)[^,\n]*\(-\)");
if (m1.Success) int.TryParse(m1.Groups[1].Value, out add); if (m1.Success) int.TryParse(m1.Groups[1].Value, out add);
if (m2.Success) int.TryParse(m2.Groups[1].Value, out del); if (m2.Success) int.TryParse(m2.Groups[1].Value, out del);
return (add, del); return (add, del);
@@ -0,0 +1,27 @@
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClaudeDo.Ui.ViewModels.Islands;
/// <summary>
/// A sidebar folder row. Not a <see cref="ListNavItemViewModel"/> and not a
/// <see cref="ListKind"/> — a folder holds no tasks and is never selectable, so tapping it
/// toggles <see cref="IsCollapsed"/> instead of changing the selection.
/// </summary>
public sealed partial class ListFolderNavItemViewModel : ViewModelBase
{
public required string Id { get; init; }
[ObservableProperty] private string _name = "";
[ObservableProperty] private string _iconKey = "Folder";
[ObservableProperty] private bool _isCollapsed;
// Sum of the child lists' counts — a collapsed folder still shows what's inside it.
[ObservableProperty] private int _count;
// Set while a dragged list row hovers over the middle of this folder header (move-into-folder).
[ObservableProperty] private bool _isDropTarget;
// Insert-position hints for a drop next to the folder instead of into it. Drawn around the
// whole folder block (header + children), not just the header.
[ObservableProperty] private bool _dropHintAbove;
[ObservableProperty] private bool _dropHintBelow;
public ObservableCollection<ListNavItemViewModel> Lists { get; } = new();
}
@@ -26,4 +26,6 @@ public sealed partial class ListNavItemViewModel : ViewModelBase
[ObservableProperty] private bool _isTaskDropTarget; [ObservableProperty] private bool _isTaskDropTarget;
// Settable (not init) so a list-settings icon change refreshes the row in place. // Settable (not init) so a list-settings icon change refreshes the row in place.
[ObservableProperty] private string? _iconKey; [ObservableProperty] private string? _iconKey;
// The sidebar folder this row sits in (ListEntity.FolderId); null = top level.
[ObservableProperty] private string? _folderId;
} }
@@ -17,8 +17,9 @@ namespace ClaudeDo.Ui.ViewModels.Islands;
public enum ListKind { Smart, Virtual, User } public enum ListKind { Smart, Virtual, User }
/// <summary>Confirmed handler run: the scope list and the ordered selected task ids.</summary> /// <summary>Confirmed handler run: the scope list, the ordered selected task ids, and the model
public sealed record MergeHelperRequest(string ListId, IReadOnlyList<string> TaskIds); /// picked for the triage session (null = default; review-mode runs skip triage entirely).</summary>
public sealed record MergeHelperRequest(string ListId, IReadOnlyList<string> TaskIds, string? TriageModel = null);
public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
{ {
@@ -26,6 +27,9 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
/// island show the notes editor instead of rows.</summary> /// island show the notes editor instead of rows.</summary>
public const string NotesListId = "smart:notes"; public const string NotesListId = "smart:notes";
/// <summary>Icon key a folder falls back to when it has none of its own.</summary>
public const string ListFolderIcon = "Folder";
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory; private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly IServiceProvider? _services; private readonly IServiceProvider? _services;
private readonly IWorkerClient? _worker; private readonly IWorkerClient? _worker;
@@ -111,7 +115,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
await vm.LoadAsync(); await vm.LoadAsync();
var ids = await Dialogs.ShowMergeHelperSelectionAsync(vm); var ids = await Dialogs.ShowMergeHelperSelectionAsync(vm);
if (ids is { Count: > 0 }) if (ids is { Count: > 0 })
LetClaudeHandleRequested?.Invoke(new MergeHelperRequest(rawId, ids)); LetClaudeHandleRequested?.Invoke(new MergeHelperRequest(rawId, ids, vm.SelectedModel));
} }
/// <summary>Raised once per list after the review-scoped selection dialog is confirmed; /// <summary>Raised once per list after the review-scoped selection dialog is confirmed;
@@ -202,7 +206,22 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
public ObservableCollection<ListNavItemViewModel> Items { get; } = new(); public ObservableCollection<ListNavItemViewModel> Items { get; } = new();
public ObservableCollection<ListNavItemViewModel> SmartLists { get; } = new(); public ObservableCollection<ListNavItemViewModel> SmartLists { get; } = new();
/// <summary>Every user list, flat, regardless of folder — the set the count refresh, the
/// row refresh and the no-repo banner work over.</summary>
public ObservableCollection<ListNavItemViewModel> UserLists { get; } = new(); public ObservableCollection<ListNavItemViewModel> UserLists { get; } = new();
/// <summary>The sidebar's top level, in render order: folder rows
/// (<see cref="ListFolderNavItemViewModel"/>) and folder-less lists
/// (<see cref="ListNavItemViewModel"/>) interleaved, so a list can sit above a folder.
/// This is what the view binds to; <see cref="UserLists"/> stays the flat superset.</summary>
public ObservableCollection<object> TopLevel { get; } = new();
/// <summary>Sidebar folders in order — a projection of <see cref="TopLevel"/>, not a second
/// collection to keep in sync.</summary>
public IReadOnlyList<ListFolderNavItemViewModel> Folders =>
TopLevel.OfType<ListFolderNavItemViewModel>().ToList();
/// <summary>The user lists that sit outside any folder — likewise a projection.</summary>
public IReadOnlyList<ListNavItemViewModel> RootLists =>
TopLevel.OfType<ListNavItemViewModel>().ToList();
[ObservableProperty] private string _searchText = ""; [ObservableProperty] private string _searchText = "";
[ObservableProperty] private ListNavItemViewModel? _selectedList; [ObservableProperty] private ListNavItemViewModel? _selectedList;
@@ -279,6 +298,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
Items.Clear(); Items.Clear();
SmartLists.Clear(); SmartLists.Clear();
UserLists.Clear(); UserLists.Clear();
TopLevel.Clear();
var smart = new[] var smart = new[]
{ {
@@ -299,6 +319,24 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
var ticketsConfigured = await IsTicketsConfiguredAsync(); var ticketsConfigured = await IsTicketsConfiguredAsync();
await using var ctx = await _dbFactory.CreateDbContextAsync(ct); await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var lists = new ListRepository(ctx); var lists = new ListRepository(ctx);
// Folders and top-level lists share one sort_order space, so both go into a single
// ordered bucket and get merged by it — that's what interleaves them.
var topLevel = new List<(int Sort, object Item)>();
var foldersById = new Dictionary<string, ListFolderNavItemViewModel>();
foreach (var f in await lists.GetFoldersAsync(ct))
{
var folder = new ListFolderNavItemViewModel
{
Id = f.Id,
Name = f.Name,
IconKey = f.Icon ?? ListFolderIcon,
IsCollapsed = f.IsCollapsed,
};
foldersById[f.Id] = folder;
topLevel.Add((f.SortOrder, folder));
}
var seedNames = new HashSet<string>(new[] { "My Day", "Important", "Planned" }); var seedNames = new HashSet<string>(new[] { "My Day", "Important", "Planned" });
foreach (var l in await lists.GetAllAsync(ct)) foreach (var l in await lists.GetAllAsync(ct))
{ {
@@ -315,10 +353,20 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
IsManual = l.IsManual, IsManual = l.IsManual,
FindingsTracked = l.FindingsTracked, FindingsTracked = l.FindingsTracked,
HasTicketProject = ticketsConfigured && cfg?.TicketProjectId > 0, HasTicketProject = ticketsConfigured && cfg?.TicketProjectId > 0,
FolderId = l.FolderId,
}; };
Items.Add(item); Items.Add(item);
UserLists.Add(item); UserLists.Add(item);
if (item.FolderId is { } fid && foldersById.TryGetValue(fid, out var owner))
owner.Lists.Add(item);
else
topLevel.Add((l.SortOrder, item));
} }
// OrderBy is stable, so legacy rows (folders and lists each numbered from 0
// independently) keep the old look — folders first — until the first drag.
foreach (var (_, item) in topLevel.OrderBy(t => t.Sort))
TopLevel.Add(item);
ok = true; ok = true;
} }
finally { OperationTiming.Shared.Record("db", "ListsIsland.LoadAsync", sw.Elapsed, ok); } finally { OperationTiming.Shared.Record("db", "ListsIsland.LoadAsync", sw.Elapsed, ok); }
@@ -365,6 +413,8 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
var filter = _filters.Resolve(item.Id); var filter = _filters.Resolve(item.Id);
item.Count = filter is null ? 0 : all.Count(filter.ShouldCount); item.Count = filter is null ? 0 : all.Count(filter.ShouldCount);
} }
RecomputeFolderCounts();
ok = true; ok = true;
} }
catch (OperationCanceledException) { throw; } catch (OperationCanceledException) { throw; }
@@ -403,6 +453,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
}; };
Items.Add(item); Items.Add(item);
UserLists.Add(item); UserLists.Add(item);
TopLevel.Add(item);
SelectedList = item; SelectedList = item;
RecomputeHasNoLinkedRepo(); RecomputeHasNoLinkedRepo();
@@ -410,7 +461,8 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
{ {
var vm = _services.GetRequiredService<ListSettingsModalViewModel>(); var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
vm.ErrorReported += msg => ErrorReported?.Invoke(msg); vm.ErrorReported += msg => ErrorReported?.Invoke(msg);
vm.BeginLoad(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType, entity.IsManual, entity.FindingsTracked, entity.Icon); // canToggleFolder: only the create flow offers "make this a folder instead".
vm.BeginLoad(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType, entity.IsManual, entity.FindingsTracked, entity.Icon, canToggleFolder: true);
await Dialogs.ShowListSettingsAsync(vm); await Dialogs.ShowListSettingsAsync(vm);
if (!vm.Saved && !vm.Deleted) if (!vm.Saved && !vm.Deleted)
{ {
@@ -432,42 +484,186 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
r.DropHintAbove = false; r.DropHintAbove = false;
r.DropHintBelow = false; r.DropHintBelow = false;
} }
} foreach (var f in Folders)
public void SetDropHint(ListNavItemViewModel target, bool placeBelow)
{
foreach (var r in UserLists)
{ {
var isTarget = ReferenceEquals(r, target); f.IsDropTarget = false;
r.DropHintAbove = isTarget && !placeBelow; f.DropHintAbove = false;
r.DropHintBelow = isTarget && placeBelow; f.DropHintBelow = false;
} }
} }
public async Task ReorderAsync(ListNavItemViewModel source, ListNavItemViewModel target, bool placeBelow) /// <summary>Marks the insert position for a drop next to <paramref name="target"/> — either a
/// list row or a folder header (folders take the same two hint lines, drawn around the whole
/// folder block).</summary>
public void SetDropHint(object target, bool placeBelow)
{
ClearDropHints();
switch (target)
{
case ListNavItemViewModel row:
row.DropHintAbove = !placeBelow;
row.DropHintBelow = placeBelow;
break;
case ListFolderNavItemViewModel folder:
folder.DropHintAbove = !placeBelow;
folder.DropHintBelow = placeBelow;
break;
}
}
public void SetFolderDropTarget(ListFolderNavItemViewModel? target)
{
ClearDropHints();
if (target is not null) target.IsDropTarget = true;
}
/// <summary>The collection a row is rendered in: its folder's <c>Lists</c>, or
/// <see cref="TopLevel"/> when it has no folder. Non-generic because the top level is mixed.</summary>
public System.Collections.IList ContainerOf(ListNavItemViewModel row) =>
Folders.FirstOrDefault(f => f.Id == row.FolderId)?.Lists ?? (System.Collections.IList)TopLevel;
/// <summary>Kept for the list→list case; <see cref="DropAsync"/> covers all four combinations.</summary>
public Task ReorderAsync(ListNavItemViewModel source, ListNavItemViewModel target, bool placeBelow) =>
DropAsync(source, target, placeBelow);
/// <summary>
/// Drops <paramref name="source"/> (a list row or a folder) at <paramref name="target"/>'s
/// position. Since a drop position implies a container, dropping a list onto a row in a
/// different folder (or next to anything at the top level) also *moves* it there — that's the
/// only way to drag a list out of a folder. Folders live at the top level only, so a folder
/// dropped onto a row inside a folder is ignored rather than nested.
/// </summary>
public async Task DropAsync(object source, object target, bool placeBelow)
{ {
if (source.Kind != ListKind.User || target.Kind != ListKind.User) return;
if (ReferenceEquals(source, target)) return; if (ReferenceEquals(source, target)) return;
if (target is ListNavItemViewModel { Kind: not ListKind.User }) return;
MoveWithinCollection(UserLists, source, target, placeBelow); if (source is ListFolderNavItemViewModel folder)
{
if (target is ListNavItemViewModel { FolderId: not null }) return;
MoveWithinCollection(TopLevel, folder, target, placeBelow);
await PersistOrderAsync(null);
return;
}
if (source is not ListNavItemViewModel row || row.Kind != ListKind.User) return;
// Next to a folder *header* means top level, not inside it — MoveToFolderAsync is the
// drop-into-the-folder path.
var targetFolderId = target is ListNavItemViewModel t ? t.FolderId : null;
var folderChanged = row.FolderId != targetFolderId;
if (folderChanged)
{
ContainerOf(row).Remove(row);
row.FolderId = targetFolderId;
ContainerOf(row).Add(row);
}
MoveWithinCollection(ContainerOf(row), row, target, placeBelow);
await PersistOrderAsync(folderChanged ? row : null);
}
/// <summary>Appends a list to a folder (or to the top level when <paramref name="folder"/> is
/// null) — the drop-onto-the-folder-header path, which also reaches collapsed and empty folders.</summary>
public async Task MoveToFolderAsync(ListNavItemViewModel source, ListFolderNavItemViewModel? folder)
{
if (source.Kind != ListKind.User) return;
if (source.FolderId == folder?.Id) return;
ContainerOf(source).Remove(source);
source.FolderId = folder?.Id;
ContainerOf(source).Add(source);
await PersistOrderAsync(source);
}
/// <summary>
/// Writes the sidebar's visual order back as a single global <c>sort_order</c> sequence
/// (folders top to bottom, then the top-level lists) and, when a list changed folder, its new
/// <c>folder_id</c>.
/// </summary>
private async Task PersistOrderAsync(ListNavItemViewModel? movedRow)
{
var ordered = new List<(string Id, bool IsFolder)>();
foreach (var item in TopLevel)
{
switch (item)
{
case ListFolderNavItemViewModel f:
ordered.Add((f.Id, true));
ordered.AddRange(f.Lists.Select(l => (RawId(l.Id), false)));
break;
case ListNavItemViewModel l:
ordered.Add((RawId(l.Id), false));
break;
}
}
var orderedIds = UserLists.Select(i => i.Id["user:".Length..]).ToList();
var sw = System.Diagnostics.Stopwatch.StartNew(); var sw = System.Diagnostics.Stopwatch.StartNew();
var ok = false; var ok = false;
try try
{ {
await using var ctx = await _dbFactory.CreateDbContextAsync(); await using var ctx = await _dbFactory.CreateDbContextAsync();
var lists = new ListRepository(ctx); var lists = new ListRepository(ctx);
await lists.ReorderAsync(orderedIds); if (movedRow is not null)
await lists.SetListFolderAsync(RawId(movedRow.Id), movedRow.FolderId);
await lists.ReorderSidebarAsync(ordered);
ok = true; ok = true;
} }
finally { OperationTiming.Shared.Record("db", "ListsIsland.ReorderAsync", sw.Elapsed, ok); } finally { OperationTiming.Shared.Record("db", "ListsIsland.ReorderAsync", sw.Elapsed, ok); }
RecomputeFolderCounts();
} }
/// <summary>Context-menu counterpart to dragging a list out of a folder.</summary>
[RelayCommand]
private async Task RemoveFromFolderAsync(ListNavItemViewModel? row)
{
if (row is null) return;
await MoveToFolderAsync(row, null);
}
[RelayCommand]
private async Task ToggleFolderAsync(ListFolderNavItemViewModel? folder)
{
if (folder is null) return;
folder.IsCollapsed = !folder.IsCollapsed;
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
await new ListRepository(ctx).SetFolderCollapsedAsync(folder.Id, folder.IsCollapsed);
}
catch (Exception ex)
{
// The fold itself already happened in the UI; only its persistence failed.
ErrorReported?.Invoke(ex.Message);
}
}
[RelayCommand]
private async Task OpenFolderSettingsAsync(ListFolderNavItemViewModel? folder)
{
if (folder is null || Dialogs is null || _services is null) return;
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
vm.ErrorReported += msg => ErrorReported?.Invoke(msg);
vm.BeginLoadFolder(folder.Id, folder.Name, folder.IconKey);
await Dialogs.ShowListSettingsAsync(vm);
// Rename, re-icon and delete all change the folder header (and delete releases its lists
// back to the top level) — one reload covers every outcome.
if (vm.Saved || vm.Deleted) await LoadAsync();
}
private void RecomputeFolderCounts()
{
foreach (var f in Folders) f.Count = f.Lists.Sum(l => l.Count);
}
private static string RawId(string rowId) =>
rowId.StartsWith("user:", StringComparison.Ordinal) ? rowId["user:".Length..] : rowId;
private static void MoveWithinCollection( private static void MoveWithinCollection(
ObservableCollection<ListNavItemViewModel> coll, System.Collections.IList coll,
ListNavItemViewModel source, object source,
ListNavItemViewModel target, object target,
bool placeBelow) bool placeBelow)
{ {
var srcIdx = coll.IndexOf(source); var srcIdx = coll.IndexOf(source);
@@ -480,7 +676,8 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
if (finalIdx >= coll.Count) finalIdx = coll.Count - 1; if (finalIdx >= coll.Count) finalIdx = coll.Count - 1;
if (finalIdx == srcIdx) return; if (finalIdx == srcIdx) return;
coll.Move(srcIdx, finalIdx); coll.RemoveAt(srcIdx);
coll.Insert(finalIdx, source);
} }
partial void OnSelectedListChanged(ListNavItemViewModel? value) partial void OnSelectedListChanged(ListNavItemViewModel? value)
@@ -570,6 +767,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
IsManual = entity.IsManual, IsManual = entity.IsManual,
FindingsTracked = entity.FindingsTracked, FindingsTracked = entity.FindingsTracked,
HasTicketProject = hasTicketProject, HasTicketProject = hasTicketProject,
FolderId = entity.FolderId,
}; };
var seedNames = new HashSet<string>(new[] { "My Day", "Important", "Planned" }); var seedNames = new HashSet<string>(new[] { "My Day", "Important", "Planned" });
@@ -579,6 +777,22 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
UserLists.Insert(insertAt, item); UserLists.Insert(insertAt, item);
Items.Insert(SmartLists.Count + insertAt, item); Items.Insert(SmartLists.Count + insertAt, item);
// Inside a folder, the same DB-order position; at the top level just appended — the mixed
// top level has no index that a per-folder DB order maps onto, and AddAsync numbers a new
// list past everything anyway.
if (Folders.FirstOrDefault(f => f.Id == item.FolderId) is { } folder)
{
var containerIdx = ordered
.Where(l => l.FolderId == item.FolderId)
.Select(l => l.Id)
.ToList()
.IndexOf(entity.Id);
folder.Lists.Insert(
containerIdx < 0 || containerIdx > folder.Lists.Count ? folder.Lists.Count : containerIdx,
item);
}
else TopLevel.Add(item);
RecomputeHasNoLinkedRepo(); RecomputeHasNoLinkedRepo();
} }
} }
@@ -112,11 +112,31 @@ public sealed partial class TaskRowViewModel : ViewModelBase
[NotifyPropertyChangedFor(nameof(IsQueued))] [NotifyPropertyChangedFor(nameof(IsQueued))]
[NotifyPropertyChangedFor(nameof(IsWaiting))] [NotifyPropertyChangedFor(nameof(IsWaiting))]
[NotifyPropertyChangedFor(nameof(StatusChipClass))] [NotifyPropertyChangedFor(nameof(StatusChipClass))]
[NotifyPropertyChangedFor(nameof(StatusLabel))]
[NotifyPropertyChangedFor(nameof(StatusChipTooltip))]
[NotifyPropertyChangedFor(nameof(CanRemoveFromQueue))] [NotifyPropertyChangedFor(nameof(CanRemoveFromQueue))]
private string? _blockedByTaskId; private string? _blockedByTaskId;
[ObservableProperty] [ObservableProperty]
[NotifyPropertyChangedFor(nameof(DependsOnTaskId))] [NotifyPropertyChangedFor(nameof(DependsOnTaskId))]
private IReadOnlyList<string> _dependsOnTaskIds = []; private IReadOnlyList<string> _dependsOnTaskIds = [];
// Extrinsic — assigned by TasksIslandViewModel.ApplyDependencyWaitState, which alone has the
// cross-row graph (plus any cross-list predecessor lookup) needed to tell an unmet
// task_dependencies edge from a satisfied one. True only for the task_dependencies case;
// IsWaiting also ORs in the older BlockedByTaskId planning-chain link.
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsQueued))]
[NotifyPropertyChangedFor(nameof(IsWaiting))]
[NotifyPropertyChangedFor(nameof(StatusChipClass))]
[NotifyPropertyChangedFor(nameof(StatusLabel))]
[NotifyPropertyChangedFor(nameof(StatusChipTooltip))]
[NotifyPropertyChangedFor(nameof(CanRemoveFromQueue))]
private bool _isBlockedByDependency;
// The predecessor(s) this row is waiting on, rendered ("#42" or "#42, #57"), or null when not
// waiting. Prefers BlockedByTaskId's single predecessor (the planning chain "parent wins" the
// same way it does for the chain rail), else lists every unmet task_dependencies edge.
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(StatusChipTooltip))]
private string? _dependencyWaitLabel;
// The single predecessor, or null when the task has none or several. The chain rail can only // The single predecessor, or null when the task has none or several. The chain rail can only
// draw a line, so a task waiting on n predecessors gets a ChainAfterLabel instead — see // draw a line, so a task waiting on n predecessors gets a ChainAfterLabel instead — see
@@ -275,8 +295,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase
// task). A live ConPTY session is that same shape but not parked — it reads "Interactive". // task). A live ConPTY session is that same shape but not parked — it reads "Interactive".
public bool IsParked => Status == TaskStatus.Idle && WorktreeState == ClaudeDo.Data.Models.WorktreeState.Active public bool IsParked => Status == TaskStatus.Idle && WorktreeState == ClaudeDo.Data.Models.WorktreeState.Active
&& !HasInteractiveSession; && !HasInteractiveSession;
public bool IsQueued => Status == TaskStatus.Queued && string.IsNullOrEmpty(BlockedByTaskId); public bool IsQueued => Status == TaskStatus.Queued && string.IsNullOrEmpty(BlockedByTaskId) && !IsBlockedByDependency;
public bool IsWaiting => Status == TaskStatus.Queued && !string.IsNullOrEmpty(BlockedByTaskId); public bool IsWaiting => Status == TaskStatus.Queued && (!string.IsNullOrEmpty(BlockedByTaskId) || IsBlockedByDependency);
public bool CanRemoveFromQueue => IsQueued || IsWaiting || HasQueuedSubtasks; public bool CanRemoveFromQueue => IsQueued || IsWaiting || HasQueuedSubtasks;
// "Send to queue" is the single queue entry. On a finalized planning parent it queues the // "Send to queue" is the single queue entry. On a finalized planning parent it queues the
// plan (children) via CanQueuePlan; an Active (not-yet-finalized) planning parent is hidden — // plan (children) via CanQueuePlan; an Active (not-yet-finalized) planning parent is hidden —
@@ -412,9 +432,12 @@ public sealed partial class TaskRowViewModel : ViewModelBase
: FailureReasonLabel; : FailureReasonLabel;
// Drives the status chip tooltip: an open interactive session takes priority (it's tappable), // Drives the status chip tooltip: an open interactive session takes priority (it's tappable),
// otherwise a Failed task's reason, otherwise no tooltip. // then the predecessor(s) a waiting task is blocked on, then a Failed task's reason,
// otherwise no tooltip.
public string? StatusChipTooltip public string? StatusChipTooltip
=> HasInteractiveSession ? Loc.T("tasks.interactiveChipTip") : FailureReasonTooltip; => HasInteractiveSession ? Loc.T("tasks.interactiveChipTip")
: IsWaiting && DependencyWaitLabel is not null ? Loc.T("tasks.waitingOnTip", DependencyWaitLabel)
: FailureReasonTooltip;
public string DiffAdditionsText => $"+{DiffAdditions}"; public string DiffAdditionsText => $"+{DiffAdditions}";
public string DiffDeletionsText => $"−{DiffDeletions}"; public string DiffDeletionsText => $"−{DiffDeletions}";
@@ -422,7 +445,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase
public string StatusLabel public string StatusLabel
=> HasInteractiveSession ? Loc.T("vm.taskStatus.interactive") => HasInteractiveSession ? Loc.T("vm.taskStatus.interactive")
: IsParked ? Loc.T("vm.taskStatus.parked") : Status switch : IsParked ? Loc.T("vm.taskStatus.parked")
: IsWaiting ? Loc.T("vm.taskStatus.waiting") : Status switch
{ {
TaskStatus.Idle => Loc.T("vm.taskStatus.idle"), TaskStatus.Idle => Loc.T("vm.taskStatus.idle"),
TaskStatus.Queued => Loc.T("vm.taskStatus.queued"), TaskStatus.Queued => Loc.T("vm.taskStatus.queued"),
@@ -444,7 +468,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
public string StatusChipClass => HasInteractiveSession public string StatusChipClass => HasInteractiveSession
? "interactive" ? "interactive"
: (Status, IsBlocked: !string.IsNullOrEmpty(BlockedByTaskId)) switch : (Status, IsBlocked: !string.IsNullOrEmpty(BlockedByTaskId) || IsBlockedByDependency) switch
{ {
(TaskStatus.Running, _) => "running", (TaskStatus.Running, _) => "running",
(TaskStatus.WaitingForReview, _) => "review", (TaskStatus.WaitingForReview, _) => "review",
@@ -476,7 +500,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
// Any entity-backed refresh reflects a settled state, so the transient creation-phase // Any entity-backed refresh reflects a settled state, so the transient creation-phase
// banner (set from the OperationProgress broadcast) has nothing left to announce. // banner (set from the OperationProgress broadcast) has nothing left to announce.
CreationPhase = null; CreationPhase = null;
var (add, del) = ParseDiffStat(t.Worktree?.DiffStat); var (add, del) = DetailsIslandViewModel.ParseDiffStat(t.Worktree?.DiffStat);
Number = t.Number; Number = t.Number;
Title = t.Title; Title = t.Title;
ListName = t.List?.Name ?? ""; ListName = t.List?.Name ?? "";
@@ -505,18 +529,4 @@ public sealed partial class TaskRowViewModel : ViewModelBase
FailureTurnsUsed = t.FailureTurnsUsed; FailureTurnsUsed = t.FailureTurnsUsed;
FailureMaxTurns = t.FailureMaxTurns; FailureMaxTurns = t.FailureMaxTurns;
} }
// Best-effort parse of diff stat strings like "+12 -3" or "12 additions, 3 deletions".
private static (int add, int del) ParseDiffStat(string? s)
{
if (string.IsNullOrWhiteSpace(s)) return (0, 0);
int add = 0, del = 0;
var parts = s.Split(new[] { ' ', ',', '\t' }, StringSplitOptions.RemoveEmptyEntries);
foreach (var p in parts)
{
if (p.Length > 1 && p[0] == '+' && int.TryParse(p.AsSpan(1), out var a)) add = a;
else if (p.Length > 1 && (p[0] == '-' || p[0] == '\u2212') && int.TryParse(p.AsSpan(1), out var d)) del = d;
}
return (add, del);
}
} }
@@ -43,6 +43,12 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
// Two events (TaskUpdated + WorktreeUpdated) drive the same delta refresh, so two reads for // Two events (TaskUpdated + WorktreeUpdated) drive the same delta refresh, so two reads for
// one task can be in flight at once. Only the newest may write to the row. // one task can be in flight at once. Only the newest may write to the row.
private readonly Dictionary<string, long> _deltaSeq = new(); private readonly Dictionary<string, long> _deltaSeq = new();
// Number/Title/Status for a predecessor id that isn't loaded into Items (a different list, My
// Day, or the completed group) -- populated by RefreshExternalDependencyCacheAsync so
// ApplyChainGrouping/ApplyDependencyWaitState can still render "after #N" and the blocked
// chip for a cross-list predecessor instead of silently dropping it. Refreshed, never evicted:
// stale entries just get overwritten on the next load/delta that references the same id.
private readonly Dictionary<string, (int Number, string Title, TaskStatus Status)> _externalDependencyInfo = new();
private long _deltaCounter; private long _deltaCounter;
// Phase 3 reconcile tick — see the block at the end of this class. // Phase 3 reconcile tick — see the block at the end of this class.
private readonly System.Timers.Timer _reconcileTimer = new(4_000); private readonly System.Timers.Timer _reconcileTimer = new(4_000);
@@ -363,6 +369,12 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
r.ParentTaskId == parent.Id && (r.IsQueued || r.IsWaiting)); r.ParentTaskId == parent.Id && (r.IsQueued || r.IsWaiting));
} }
// entity is not null here whenever `existing` is still reachable (see the branching
// above: an early return covers every entity-null-but-existing case except delete, which
// needs no lookup refresh).
if (entity is not null && existing is not null)
await RefreshExternalDependencyCacheAsync(db, DependencyLookupIds([existing]));
Regroup(); Regroup();
UpdateSubtitle(); UpdateSubtitle();
} }
@@ -548,6 +560,8 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
r.ParentFinalized = !string.IsNullOrEmpty(r.ParentTaskId) r.ParentFinalized = !string.IsNullOrEmpty(r.ParentTaskId)
&& finalizedParents.Contains(r.ParentTaskId!); && finalizedParents.Contains(r.ParentTaskId!);
await RefreshExternalDependencyCacheAsync(db, DependencyLookupIds(Items), ct);
Regroup(); Regroup();
UpdateSubtitle(); UpdateSubtitle();
ok = true; ok = true;
@@ -556,6 +570,32 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
finally { OperationTiming.Shared.Record("db", "TasksIsland.LoadForListAsync", sw.Elapsed, ok); } finally { OperationTiming.Shared.Record("db", "TasksIsland.LoadForListAsync", sw.Elapsed, ok); }
} }
// Every predecessor id a row in `items` could need labelled/checked: its task_dependencies
// edges plus the planning chain's BlockedByTaskId (both resolved the same way in
// ApplyDependencyWaitState/BuildAfterLabel).
private static IEnumerable<string> DependencyLookupIds(IEnumerable<TaskRowViewModel> items) =>
items.SelectMany(r => string.IsNullOrEmpty(r.BlockedByTaskId)
? r.DependsOnTaskIds
: r.DependsOnTaskIds.Append(r.BlockedByTaskId));
// Resolves predecessor ids that aren't loaded into Items (a different list, My Day, or the
// completed group) so cross-list dependency display doesn't silently drop them. Cheap: reuses
// the caller's already-open context, one query for however many ids are actually missing.
private async Task RefreshExternalDependencyCacheAsync(
ClaudeDoDbContext db, IEnumerable<string> ids, CancellationToken ct = default)
{
var known = Items.Select(r => r.Id).ToHashSet();
var missing = ids.Where(id => !string.IsNullOrEmpty(id) && !known.Contains(id)).Distinct().ToList();
if (missing.Count == 0) return;
var rows = await db.Tasks
.Where(t => missing.Contains(t.Id))
.Select(t => new { t.Id, t.Number, t.Title, t.Status })
.ToListAsync(ct);
foreach (var r in rows)
_externalDependencyInfo[r.Id] = (r.Number, r.Title, r.Status);
}
internal void Regroup() internal void Regroup()
{ {
// Collapse parents that have children by default, so subtasks stay tucked away until // Collapse parents that have children by default, so subtasks stay tucked away until
@@ -691,13 +731,66 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
// with a label rather than an orphaned rail. Whether the head is resolvable at all // with a label rather than an orphaned rail. Whether the head is resolvable at all
// still uses the whole-Items graph (rowsById), independent of section. // still uses the whole-Items graph (rowsById), independent of section.
var rowsById = Items.ToDictionary(r => r.Id); var rowsById = Items.ToDictionary(r => r.Id);
overdue = ApplyChainGrouping(overdue, rowsById);
open = ApplyChainGrouping(open, rowsById); // Blocked-by-dependency is independent of section (a blocked row can be Overdue or Open
completed = ApplyChainGrouping(completed, rowsById); // alike) and independent of chain membership, so it's computed once over all of Items
// rather than per section.
foreach (var r in Items)
ApplyDependencyWaitState(r, rowsById, _externalDependencyInfo);
overdue = ApplyChainGrouping(overdue, rowsById, _externalDependencyInfo);
open = ApplyChainGrouping(open, rowsById, _externalDependencyInfo);
completed = ApplyChainGrouping(completed, rowsById, _externalDependencyInfo);
return (overdue, open, completed); return (overdue, open, completed);
} }
// Assigns IsBlockedByDependency/DependencyWaitLabel for one row. BlockedByTaskId (the planning
// chain's single link) wins the label the same way a planning child's parent indent wins over
// chain-rail membership -- "parent wins" is the existing precedent (ApplyChainGrouping below).
private static void ApplyDependencyWaitState(
TaskRowViewModel row, Dictionary<string, TaskRowViewModel> rowsById,
IReadOnlyDictionary<string, (int Number, string Title, TaskStatus Status)> external)
{
if (row.Status != TaskStatus.Queued)
{
row.IsBlockedByDependency = false;
row.DependencyWaitLabel = null;
return;
}
var unmet = row.DependsOnTaskIds.Where(id => IsUnmet(id, rowsById, external)).ToList();
row.IsBlockedByDependency = unmet.Count > 0;
var waitIds = !string.IsNullOrEmpty(row.BlockedByTaskId) ? [row.BlockedByTaskId] : unmet;
row.DependencyWaitLabel = waitIds.Count == 0 ? null
: string.Join(", ", waitIds.Select(id => ResolveLabel(id, rowsById, external)).Where(s => s is not null));
}
// A predecessor missing from both rowsById and the external cache counts as unmet -- mirrors
// QueuePicker/Worker's TaskBlocking.Unmet: an edge pointing at an unresolved/vanished task
// keeps blocking rather than being silently treated as satisfied.
private static bool IsUnmet(
string id, Dictionary<string, TaskRowViewModel> rowsById,
IReadOnlyDictionary<string, (int Number, string Title, TaskStatus Status)> external)
{
if (rowsById.TryGetValue(id, out var p)) return p.Status != TaskStatus.Done;
if (external.TryGetValue(id, out var e)) return e.Status != TaskStatus.Done;
return true;
}
// Resolves a predecessor id to its display label, checking the in-view rows first and falling
// back to the cross-list cache (RefreshExternalDependencyCacheAsync) -- null only when the
// predecessor is genuinely unresolvable anywhere (e.g. a deleted task).
private static string? ResolveLabel(
string id, Dictionary<string, TaskRowViewModel> rowsById,
IReadOnlyDictionary<string, (int Number, string Title, TaskStatus Status)> external)
{
if (rowsById.TryGetValue(id, out var p)) return p.Number > 0 ? $"#{p.Number}" : p.Title;
if (external.TryGetValue(id, out var e)) return e.Number > 0 ? $"#{e.Number}" : e.Title;
return null;
}
// Cycles are rejected by TaskStateService.SetDependsOnAsync, so this should always // Cycles are rejected by TaskStateService.SetDependsOnAsync, so this should always
// terminate quickly — capped anyway against a corrupt/legacy row forming a loop. // terminate quickly — capped anyway against a corrupt/legacy row forming a loop.
private const int MaxChainWalkDepth = 64; private const int MaxChainWalkDepth = 64;
@@ -724,14 +817,15 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
// Content only — "after " + localization is Slice 2's concern (the view), which is why this // Content only — "after " + localization is Slice 2's concern (the view), which is why this
// holds just the identifying fragment rather than an assembled sentence. Names every // holds just the identifying fragment rather than an assembled sentence. Names every
// predecessor it can resolve: a task can wait on n of them, and hiding all but the first // predecessor it can resolve (in-view first, then the cross-list external cache): a task can
// would misreport what the row is actually waiting for. // wait on n of them, and hiding all but the first would misreport what the row is waiting for.
private static string? BuildAfterLabel(TaskRowViewModel row, Dictionary<string, TaskRowViewModel> rowsById) private static string? BuildAfterLabel(
TaskRowViewModel row, Dictionary<string, TaskRowViewModel> rowsById,
IReadOnlyDictionary<string, (int Number, string Title, TaskStatus Status)> external)
{ {
var parts = row.DependsOnTaskIds var parts = row.DependsOnTaskIds
.Select(id => rowsById.TryGetValue(id, out var p) ? p : null) .Select(id => ResolveLabel(id, rowsById, external))
.Where(p => p is not null) .Where(p => p is not null)
.Select(p => p!.Number > 0 ? $"#{p.Number}" : p.Title)
.ToList(); .ToList();
return parts.Count == 0 ? null : string.Join(", ", parts); return parts.Count == 0 ? null : string.Join(", ", parts);
} }
@@ -741,7 +835,8 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
// (ascending ChainStep), regardless of their SortOrder-derived position — mirroring how // (ascending ChainStep), regardless of their SortOrder-derived position — mirroring how
// planning children already sit right after their parent regardless of Items order. // planning children already sit right after their parent regardless of Items order.
private static List<TaskRowViewModel> ApplyChainGrouping( private static List<TaskRowViewModel> ApplyChainGrouping(
List<TaskRowViewModel> section, Dictionary<string, TaskRowViewModel> rowsById) List<TaskRowViewModel> section, Dictionary<string, TaskRowViewModel> rowsById,
IReadOnlyDictionary<string, (int Number, string Title, TaskStatus Status)> external)
{ {
var sectionIds = section.Select(r => r.Id).ToHashSet(); var sectionIds = section.Select(r => r.Id).ToHashSet();
var headIdOf = new Dictionary<TaskRowViewModel, string>(); var headIdOf = new Dictionary<TaskRowViewModel, string>();
@@ -763,7 +858,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
{ {
r.ShowAsChainMember = false; r.ShowAsChainMember = false;
r.ChainStep = null; r.ChainStep = null;
r.ChainAfterLabel = BuildAfterLabel(r, rowsById); r.ChainAfterLabel = BuildAfterLabel(r, rowsById, external);
continue; continue;
} }
@@ -773,7 +868,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
{ {
r.ShowAsChainMember = false; r.ShowAsChainMember = false;
r.ChainStep = null; r.ChainStep = null;
r.ChainAfterLabel = BuildAfterLabel(r, rowsById); r.ChainAfterLabel = BuildAfterLabel(r, rowsById, external);
continue; continue;
} }
@@ -790,7 +885,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
{ {
r.ShowAsChainMember = false; r.ShowAsChainMember = false;
r.ChainStep = null; r.ChainStep = null;
r.ChainAfterLabel = BuildAfterLabel(r, rowsById); r.ChainAfterLabel = BuildAfterLabel(r, rowsById, external);
} }
} }
@@ -1739,7 +1834,12 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
// reflects any of that after a Regroup — without this the tick would heal the row's data // reflects any of that after a Regroup — without this the tick would heal the row's data
// while leaving a completed task sitting in the Open section under a stale count. Gated // while leaving a completed task sitting in the Open section under a stale count. Gated
// on a real change so an idle tick stays free. // on a real change so an idle tick stays free.
if (groupingChanged) Regroup(); if (groupingChanged)
{
await using var db2 = await _dbFactory.CreateDbContextAsync(ct);
await RefreshExternalDependencyCacheAsync(db2, DependencyLookupIds(Items), ct);
Regroup();
}
} }
// The slice of a row that Regroup reads: which section it lands in, where it sits inside it, // The slice of a row that Regroup reads: which section it lands in, where it sits inside it,
@@ -370,7 +370,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
Lists.LetClaudeHandleRequested += req => Lists.LetClaudeHandleRequested += req =>
{ {
OpenMissionControl(); OpenMissionControl();
_ = MissionControl.OpenMergeHelperConPtySessionAsync(req.ListId, req.TaskIds); _ = MissionControl.OpenMergeHelperConPtySessionAsync(req.ListId, req.TaskIds, req.TriageModel);
}; };
Lists.MergeReviewsRequested += req => Lists.MergeReviewsRequested += req =>
{ {
@@ -631,20 +631,25 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
await _updateCheck.CheckNowAsync(CancellationToken.None); await _updateCheck.CheckNowAsync(CancellationToken.None);
} }
[ObservableProperty] private string? _restartWorkerStatus; // How long to wait after launching the new worker process before deciding it survived
// (e.g. a port conflict kills it almost immediately — see #292 for the worker-side fix).
private const int RestartSurvivalCheckMs = 2000;
// Test seam: overrides the real kill-and-relaunch body, so tests can assert the
// success/failure footer routing without spawning a real worker process.
internal Func<Task>? RestartWorkerOperation { get; set; }
[RelayCommand] [RelayCommand]
private async Task RestartWorkerAsync() private async Task RestartWorkerAsync()
{ {
RestartWorkerStatus = Loc.T("vm.shell.restartingWorker");
try try
{ {
await Task.Run(RestartWorkerService); await (RestartWorkerOperation ?? (() => Task.Run(RestartWorkerService)))();
await FlashRestartStatusAsync("Worker restarted."); FlashFooterSuccess("Worker restarted.");
} }
catch (Exception ex) catch (Exception ex)
{ {
await FlashRestartStatusAsync($"Restart failed: {ex.Message}"); FlashFooterError($"Restart failed: {ex.Message} — check the worker log.");
} }
} }
@@ -653,8 +658,17 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
var exe = _workerLocator.Find(); var exe = _workerLocator.Find();
if (exe is null) throw new InvalidOperationException("Worker executable not found."); if (exe is null) throw new InvalidOperationException("Worker executable not found.");
// Only kill the worker belonging to THIS installation — not any other KillMatchingWorkerProcesses(exe);
// ClaudeDo.Worker on the machine (e.g. a second install).
var started = System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(exe) { UseShellExecute = true });
if (started is not null && started.WaitForExit(RestartSurvivalCheckMs))
throw new InvalidOperationException($"worker process exited immediately (code {started.ExitCode})");
}
// Only kill the worker belonging to THIS installation — not any other
// ClaudeDo.Worker on the machine (e.g. a second install).
private static void KillMatchingWorkerProcesses(string exe)
{
var exeFull = System.IO.Path.GetFullPath(exe); var exeFull = System.IO.Path.GetFullPath(exe);
foreach (var p in System.Diagnostics.Process.GetProcessesByName("ClaudeDo.Worker")) foreach (var p in System.Diagnostics.Process.GetProcessesByName("ClaudeDo.Worker"))
{ {
@@ -670,16 +684,121 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
catch { /* may have exited or be inaccessible */ } catch { /* may have exited or be inaccessible */ }
finally { p.Dispose(); } finally { p.Dispose(); }
} }
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(exe) { UseShellExecute = true });
} }
private async Task FlashRestartStatusAsync(string text) // Set by MainWindow: force-closes the window without re-triggering its own close
// confirmation (the shutdown dialog above already confirmed).
public Action? CloseWindow { get; set; }
// Test seam: overrides the real kill-worker + close-window body.
internal Action? ShutdownOperation { get; set; }
[RelayCommand]
private async Task ShutdownAsync()
{ {
RestartWorkerStatus = text; if (Dialogs is null) return;
await Task.Delay(3000); var confirmed = await Dialogs.ConfirmAsync(Loc.T("vm.shell.shutdownConfirm"));
if (RestartWorkerStatus == text) RestartWorkerStatus = null; if (!confirmed) return;
(ShutdownOperation ?? ShutdownWorkerAndCloseApp)();
} }
private void ShutdownWorkerAndCloseApp()
{
var exe = _workerLocator.Find();
if (exe is not null) KillMatchingWorkerProcesses(exe);
CloseWindow?.Invoke();
}
private bool _updatingClaudeCli;
private bool CanUpdateClaudeCli() => !_updatingClaudeCli;
[RelayCommand(CanExecute = nameof(CanUpdateClaudeCli))]
private async Task UpdateClaudeCliAsync()
{
_updatingClaudeCli = true;
UpdateClaudeCliCommand.NotifyCanExecuteChanged();
try
{
var dto = Worker is null ? null : await Worker.GetClaudeBinAsync();
if (dto is null)
{
FlashFooterError("Update failed: worker offline.");
return;
}
var (success, message) = await RunClaudeUpdateAsync(dto);
if (success) FlashFooterSuccess(message);
else FlashFooterError(message);
}
finally
{
_updatingClaudeCli = false;
UpdateClaudeCliCommand.NotifyCanExecuteChanged();
}
}
private static async Task<(bool Success, string Message)> RunClaudeUpdateAsync(ClaudeBinDto dto)
{
var resolved = ClaudeDo.Data.Environment.ExecutableResolver.Resolve(dto.Value);
if (resolved is null) return (false, $"Update failed: '{dto.Value}' not found.");
var psi = resolved.IsShim
? BuildShimUpdatePsi(resolved.Path)
: new System.Diagnostics.ProcessStartInfo { FileName = resolved.Path, ArgumentList = { "update" } };
psi.UseShellExecute = false;
psi.RedirectStandardOutput = true;
psi.RedirectStandardError = true;
psi.CreateNoWindow = true;
using var process = new System.Diagnostics.Process { StartInfo = psi };
var stdout = new System.Text.StringBuilder();
var stderr = new System.Text.StringBuilder();
process.OutputDataReceived += (_, e) => { if (e.Data is not null) stdout.AppendLine(e.Data); };
process.ErrorDataReceived += (_, e) => { if (e.Data is not null) stderr.AppendLine(e.Data); };
try { process.Start(); }
catch (Exception ex) { return (false, $"Update failed: {ex.Message}"); }
process.BeginOutputReadLine();
process.BeginErrorReadLine();
using var cts = new CancellationTokenSource(TimeSpan.FromMinutes(5));
var timedOut = false;
// On timeout: kill the tree, which unblocks WaitForExitAsync below and lets the
// async output readers drain/complete naturally (mirrors VerifyCommandRunner).
await using var ctr = cts.Token.Register(() =>
{
timedOut = true;
try { process.Kill(entireProcessTree: true); } catch { /* already exited */ }
});
await process.WaitForExitAsync(CancellationToken.None);
return timedOut
? (false, "Update failed: timed out after 5 minutes.")
: SummarizeClaudeUpdateOutcome(process.ExitCode, stdout.ToString(), stderr.ToString());
}
private static System.Diagnostics.ProcessStartInfo BuildShimUpdatePsi(string shimPath)
{
var shim = ClaudeDo.Data.Environment.ExecutableResolver.BuildShimStartInfo(shimPath, new[] { "update" });
return new System.Diagnostics.ProcessStartInfo { FileName = shim.FileName, Arguments = shim.Arguments };
}
// Pure so a test can drive it with fake process output instead of spawning a real `claude`.
internal static (bool Success, string Message) SummarizeClaudeUpdateOutcome(int exitCode, string stdout, string stderr)
{
if (exitCode == 0)
return (true, LastNonEmptyLine(stdout) ?? "Claude CLI is already up to date.");
var detail = LastNonEmptyLine(stderr) ?? LastNonEmptyLine(stdout) ?? "";
return (false, $"Update failed (exit {exitCode}): {detail}");
}
private static string? LastNonEmptyLine(string text) =>
text.Split('\n').Select(l => l.Trim()).LastOrDefault(l => l.Length > 0);
[RelayCommand] [RelayCommand]
private void DismissBanner() private void DismissBanner()
{ {
@@ -2,6 +2,7 @@ using System;
using System.ComponentModel; using System.ComponentModel;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services; using ClaudeDo.Ui.Services;
namespace ClaudeDo.Ui.ViewModels.MissionControl; namespace ClaudeDo.Ui.ViewModels.MissionControl;
@@ -88,14 +89,18 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
{ {
terminal.PropertyChanged += OnTerminalPropertyChanged; terminal.PropertyChanged += OnTerminalPropertyChanged;
terminal.ExitedAfterStartup += OnTerminalExitedAfterStartup; terminal.ExitedAfterStartup += OnTerminalExitedAfterStartup;
terminal.ErrorReported += OnTerminalErrorReported;
} }
private void Unsubscribe(InteractiveTerminalViewModel terminal) private void Unsubscribe(InteractiveTerminalViewModel terminal)
{ {
terminal.PropertyChanged -= OnTerminalPropertyChanged; terminal.PropertyChanged -= OnTerminalPropertyChanged;
terminal.ExitedAfterStartup -= OnTerminalExitedAfterStartup; terminal.ExitedAfterStartup -= OnTerminalExitedAfterStartup;
terminal.ErrorReported -= OnTerminalErrorReported;
} }
private void OnTerminalErrorReported(string message) => ErrorReported?.Invoke(message);
private void OnTerminalExitedAfterStartup(int exitCode) private void OnTerminalExitedAfterStartup(int exitCode)
{ {
if (TaskId is { } id) SessionDiedAfterStartup?.Invoke(id, exitCode); if (TaskId is { } id) SessionDiedAfterStartup?.Invoke(id, exitCode);
@@ -137,8 +142,18 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
SubmitForReviewCommand.NotifyCanExecuteChanged(); SubmitForReviewCommand.NotifyCanExecuteChanged();
RetryCommand.NotifyCanExecuteChanged(); RetryCommand.NotifyCanExecuteChanged();
} }
if (e.PropertyName is nameof(InteractiveTerminalViewModel.HasExitedAfterStartup)
or nameof(InteractiveTerminalViewModel.ExitCode))
{
OnPropertyChanged(nameof(ExitedBannerText));
}
} }
/// <summary>"Session ended (code N)" text for <see cref="InteractiveTerminalViewModel.HasExitedAfterStartup"/> —
/// computed here (rather than formatted in XAML) since <c>loc:Tr</c> has no binding-arg support.</summary>
public string ExitedBannerText => Loc.T("missionControl.conptyExited", Terminal.ExitCode ?? 0);
/// <summary>Types a message into this pane's live session and submits it (the worker's /// <summary>Types a message into this pane's live session and submits it (the worker's
/// pre-usage-limit warning). False when the session isn't running.</summary> /// pre-usage-limit warning). False when the session isn't running.</summary>
public System.Threading.Tasks.Task<bool> SendLineAsync(string text) => Terminal.SendLineAsync(text); public System.Threading.Tasks.Task<bool> SendLineAsync(string text) => Terminal.SendLineAsync(text);
@@ -167,9 +182,10 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IDisposable
partial void OnIsAbandonPendingChanged(bool value) => AbandonRunCommand.NotifyCanExecuteChanged(); partial void OnIsAbandonPendingChanged(bool value) => AbandonRunCommand.NotifyCanExecuteChanged();
// A launch failure permanently occupies the TaskId dedupe slot unless the user can retry — // A dead session permanently occupies the TaskId dedupe slot unless the user can retry — whether
// re-opening the same task would otherwise just re-focus a dead tile. // it died at launch (StartError) or after (HasExitedAfterStartup), the same descriptor factory
private bool CanRetry() => Terminal.HasExited && Terminal.StartError is not null; // can always relaunch it, so any HasExited state is retryable.
private bool CanRetry() => Terminal.HasExited;
[RelayCommand(CanExecute = nameof(CanRetry))] [RelayCommand(CanExecute = nameof(CanRetry))]
private void Retry() private void Retry()
@@ -331,10 +331,10 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
// creates one new ClaudeDo task per run to own the session (title/diff/result), deduped by // creates one new ClaudeDo task per run to own the session (title/diff/result), deduped by
// TaskId like OpenConPtySessionAsync. The handler still merges the tasks it handles itself // TaskId like OpenConPtySessionAsync. The handler still merges the tasks it handles itself
// (no worktree of its own) — see TaskEntity.HandlerBaseCommit/HandlerHeadCommit. // (no worktree of its own) — see TaskEntity.HandlerBaseCommit/HandlerHeadCommit.
public System.Threading.Tasks.Task OpenMergeHelperConPtySessionAsync(string listId, IReadOnlyList<string> taskIds) public System.Threading.Tasks.Task OpenMergeHelperConPtySessionAsync(string listId, IReadOnlyList<string> taskIds, string? triageModel = null)
=> OpenListHandlerConPtySessionAsync(listId, taskIds, => OpenListHandlerConPtySessionAsync(listId, taskIds,
"missionControl.mergeHelperTitle", "missionControl.mergeHelperTaskTitle", "missionControl.mergeHelperTitle", "missionControl.mergeHelperTaskTitle",
taskId => _worker.GetMergeHelperLaunchSpecAsync(taskIds, listId, taskId)); taskId => _worker.GetMergeHelperLaunchSpecAsync(taskIds, listId, taskId, triageModel));
// Merge-only list-handler session over a list's WaitingForReview tasks (the review list's // Merge-only list-handler session over a list's WaitingForReview tasks (the review list's
// "Let Claude merge the reviews"). Same host-task shape as the full run, but the session // "Let Claude merge the reviews"). Same host-task shape as the full run, but the session
@@ -449,7 +449,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
private void AddConPtyPane(ConPtyPaneViewModel pane) private void AddConPtyPane(ConPtyPaneViewModel pane)
{ {
pane.ErrorReported += OnConPtyPaneError; pane.ErrorReported += OnConPtyPaneError;
pane.CloseRequested += CloseConPtySession; pane.CloseRequested += OnPaneCloseRequested;
pane.SubmitForReviewRequested += OnPaneSubmitForReview; pane.SubmitForReviewRequested += OnPaneSubmitForReview;
pane.SessionDiedAfterStartup += OnPaneSessionDied; pane.SessionDiedAfterStartup += OnPaneSessionDied;
pane.AbandonRunRequested += OnPaneAbandonRun; pane.AbandonRunRequested += OnPaneAbandonRun;
@@ -543,11 +543,27 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
} }
} }
// The tile's own X: a reflex click must not silently kill a live session the way it would a
// regular window. Confirm only when the terminal is actually running -- a dead one (StartError,
// or HasExited i.e. the #290 exited banner) is pure cleanup and closes straight away, same as it
// always did. Every other close path (handoff replacing a pane, submit-for-review, abandon-run)
// still calls CloseConPtySession directly and is unaffected.
private async void OnPaneCloseRequested(ConPtyPaneViewModel pane)
{
if (pane.Terminal.IsRunning)
{
if (ConfirmAsync is null) return;
if (!await ConfirmAsync(Loc.T("missionControl.closeSessionConfirm"))) return;
}
CloseConPtySession(pane);
}
private void CloseConPtySession(ConPtyPaneViewModel pane) private void CloseConPtySession(ConPtyPaneViewModel pane)
{ {
if (!ConPtySessions.Contains(pane)) return; if (!ConPtySessions.Contains(pane)) return;
pane.ErrorReported -= OnConPtyPaneError; pane.ErrorReported -= OnConPtyPaneError;
pane.CloseRequested -= CloseConPtySession; pane.CloseRequested -= OnPaneCloseRequested;
pane.SubmitForReviewRequested -= OnPaneSubmitForReview; pane.SubmitForReviewRequested -= OnPaneSubmitForReview;
pane.SessionDiedAfterStartup -= OnPaneSessionDied; pane.SessionDiedAfterStartup -= OnPaneSessionDied;
pane.AbandonRunRequested -= OnPaneAbandonRun; pane.AbandonRunRequested -= OnPaneAbandonRun;
@@ -601,7 +617,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
foreach (var c in ConPtySessions.ToList()) foreach (var c in ConPtySessions.ToList())
{ {
c.ErrorReported -= OnConPtyPaneError; c.ErrorReported -= OnConPtyPaneError;
c.CloseRequested -= CloseConPtySession; c.CloseRequested -= OnPaneCloseRequested;
c.SubmitForReviewRequested -= OnPaneSubmitForReview; c.SubmitForReviewRequested -= OnPaneSubmitForReview;
c.SessionDiedAfterStartup -= OnPaneSessionDied; c.SessionDiedAfterStartup -= OnPaneSessionDied;
c.AbandonRunRequested -= OnPaneAbandonRun; c.AbandonRunRequested -= OnPaneAbandonRun;
@@ -39,6 +39,11 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
[ObservableProperty] private string _name = ""; [ObservableProperty] private string _name = "";
[ObservableProperty] private string _workingDir = ""; [ObservableProperty] private string _workingDir = "";
// Recomputed whenever WorkingDir changes (typing or Browse) — RepoScanner.IsRepo is the same
// check RepoImport uses, so a folder either scans in there or warns here, never neither.
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(SaveCommand))]
private string _workingDirWarning = "";
[ObservableProperty] private string _defaultCommitType = CommitTypeRegistry.DefaultType; [ObservableProperty] private string _defaultCommitType = CommitTypeRegistry.DefaultType;
// A manual list holds reminders: tasks created here start out manual (TaskEntity.IsManual). // A manual list holds reminders: tasks created here start out manual (TaskEntity.IsManual).
[ObservableProperty] private bool _isManual; [ObservableProperty] private bool _isManual;
@@ -55,7 +60,33 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
// Dropdown hidden entirely when no ticket-system base URL is configured (Settings → Files tab). // Dropdown hidden entirely when no ticket-system base URL is configured (Settings → Files tab).
// Icon key into the UI icon library (IconKeyConverter -> "Icon.<key>" StreamGeometry). // Icon key into the UI icon library (IconKeyConverter -> "Icon.<key>" StreamGeometry).
[ObservableProperty] private string _icon = DefaultIcon; [ObservableProperty] private string _icon = DefaultIcon;
[ObservableProperty] private bool _ticketsAvailable; [ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowTicketProject))]
private bool _ticketsAvailable;
/// <summary>The dropdown needs a configured ticket system *and* a list — folders link to nothing.</summary>
public bool ShowTicketProject => TicketsAvailable && !IsFolder;
// ---- Folder mode ----
// A folder is a sidebar grouping, not a list: it carries only a name and an icon, so every
// other field/section collapses. Reached two ways — the "create a folder instead" toggle in
// the create flow (FolderId still null), and editing an existing folder (FolderId set).
// There is no list <-> folder conversion: the toggle is offered on create only.
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HeaderTitle))]
[NotifyPropertyChangedFor(nameof(DeleteLabel))]
[NotifyPropertyChangedFor(nameof(ShowTicketProject))]
private bool _isFolder;
/// <summary>True only while creating — an existing list can't become a folder or back.</summary>
[ObservableProperty] private bool _canToggleFolder;
private string? _folderId;
// Resolved rather than a {loc:Tr} key because the text depends on IsFolder.
public string HeaderTitle => Loc.T(IsFolder ? "modals.listSettings.folderTitle" : "modals.listSettings.title");
public string DeleteLabel => Loc.T(IsFolder ? "modals.listSettings.deleteFolder" : "modals.listSettings.deleteList");
[ObservableProperty] private TicketProjectOption? _selectedTicketProject; [ObservableProperty] private TicketProjectOption? _selectedTicketProject;
public ObservableCollection<TicketProjectOption> TicketProjects { get; } = new(); public ObservableCollection<TicketProjectOption> TicketProjects { get; } = new();
@@ -123,8 +154,25 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
string defaultCommitType, string defaultCommitType,
bool isManual = false, bool isManual = false,
bool findingsTracked = false, bool findingsTracked = false,
string? icon = null) string? icon = null,
=> _ = LoadAsync(listId, name, workingDir, defaultCommitType, isManual, findingsTracked, icon); bool canToggleFolder = false)
{
CanToggleFolder = canToggleFolder;
_ = LoadAsync(listId, name, workingDir, defaultCommitType, isManual, findingsTracked, icon);
}
/// <summary>
/// Opens the modal on an existing folder: name + icon only, no list config to load and
/// nothing to fetch from the worker, so this needs no async counterpart.
/// </summary>
public void BeginLoadFolder(string folderId, string name, string? icon)
{
_folderId = folderId;
IsFolder = true;
CanToggleFolder = false;
Name = name;
Icon = IconOptions.Contains(icon) ? icon! : DefaultIcon;
}
public async Task LoadAsync( public async Task LoadAsync(
string listId, string listId,
@@ -207,12 +255,29 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
} }
} }
private bool CanSave() => !IsLoading && !_loadFailed; private bool CanSave() => !IsLoading && !_loadFailed && (IsFolder || string.IsNullOrEmpty(WorkingDirWarning));
private bool CanDelete() => !IsLoading; private bool CanDelete() => !IsLoading;
// A manual list without a WorkingDir is untouched (empty is always valid) — this only fires
// for the "picked a folder that turns out not to be a repo" case the audit found: it would
// otherwise only surface as a Failed task the first time the worker tries to use it.
partial void OnWorkingDirChanged(string value)
{
if (string.IsNullOrWhiteSpace(value)) { WorkingDirWarning = ""; return; }
WorkingDirWarning = !Directory.Exists(value)
? Loc.T("vm.listSettings.workingDirMissing")
: RepoScanner.IsRepo(value) ? "" : Loc.T("vm.listSettings.workingDirNotRepo");
}
[RelayCommand(CanExecute = nameof(CanSave))] [RelayCommand(CanExecute = nameof(CanSave))]
private async Task SaveAsync() private async Task SaveAsync()
{ {
if (IsFolder)
{
await SaveFolderAsync();
return;
}
await _worker.UpdateListAsync(new UpdateListDto( await _worker.UpdateListAsync(new UpdateListDto(
ListId, ListId,
string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name, string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name,
@@ -239,13 +304,49 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
CloseAction?.Invoke(); CloseAction?.Invoke();
} }
private async Task SaveFolderAsync()
{
var name = string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name;
await using var ctx = await _dbFactory.CreateDbContextAsync();
var lists = new ListRepository(ctx);
if (_folderId is null)
{
await lists.AddFolderAsync(new ListFolderEntity
{
Id = Guid.NewGuid().ToString("N"),
Name = name,
Icon = Icon,
CreatedAt = DateTime.UtcNow,
});
// The create flow pre-creates a list row for this modal to save against; a folder
// doesn't need it. Deleted=true (it genuinely is) makes the caller do a full reload,
// which is what picks the new folder up.
await lists.DeleteAsync(ListId);
Deleted = true;
}
else
{
await lists.RenameFolderAsync(_folderId, name, Icon);
}
Saved = true;
CloseAction?.Invoke();
}
[RelayCommand(CanExecute = nameof(CanDelete))] [RelayCommand(CanExecute = nameof(CanDelete))]
private async Task DeleteAsync() private async Task DeleteAsync()
{ {
if (IsFolder)
{
await DeleteFolderAsync();
return;
}
var displayName = string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name; var displayName = string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name;
if (ConfirmAsync is not null) if (ConfirmAsync is not null)
{ {
var ok = await ConfirmAsync($"Delete list \"{displayName}\" and all its tasks? This cannot be undone."); var ok = await ConfirmAsync(Loc.T("vm.listSettings.confirmDeleteList", displayName));
if (!ok) return; if (!ok) return;
} }
@@ -262,7 +363,35 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|| ex.InnerException?.Message.Contains("FOREIGN KEY", StringComparison.OrdinalIgnoreCase) == true)) || ex.InnerException?.Message.Contains("FOREIGN KEY", StringComparison.OrdinalIgnoreCase) == true))
{ {
if (ShowErrorAsync is not null) if (ShowErrorAsync is not null)
await ShowErrorAsync("This list has planning sessions with child tasks. Discard those first, then delete the list."); await ShowErrorAsync(Loc.T("vm.listSettings.deleteBlockedPlanning"));
return;
}
Deleted = true;
CloseAction?.Invoke();
}
private async Task DeleteFolderAsync()
{
// Create mode with the folder toggle on: nothing has been written yet, so this is a cancel
// — and the caller's rollback drops the pre-created list row.
if (_folderId is null) { CloseAction?.Invoke(); return; }
var displayName = string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name;
if (ConfirmAsync is not null)
{
var ok = await ConfirmAsync(Loc.T("vm.listSettings.confirmDeleteFolder", displayName));
if (!ok) return;
}
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
await new ListRepository(ctx).DeleteFolderAsync(_folderId);
}
catch (Exception ex)
{
ErrorReported?.Invoke(ex.Message);
return; return;
} }
@@ -67,6 +67,15 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
public ObservableCollection<MergeHelperTaskRowViewModel> Tasks { get; } = new(); public ObservableCollection<MergeHelperTaskRowViewModel> Tasks { get; } = new();
/// <summary>Model choices for the triage session. Only opus/fable are offered on purpose —
/// triage is the dedupe/refine leverage point; cheaper models aren't worth a picker slot.</summary>
public IReadOnlyList<string> ModelOptions { get; } = new[] { "opus", "fable" };
[ObservableProperty] private string _selectedModel = Data.Models.ModelRegistry.HandlerTriageAlias;
/// <summary>Hidden in review mode — those runs start directly at the merge phase, no triage.</summary>
public bool ShowModelPicker => !_reviewMode;
[ObservableProperty] private string _scopeLabel = ""; [ObservableProperty] private string _scopeLabel = "";
public bool HasTasks => Tasks.Count > 0; public bool HasTasks => Tasks.Count > 0;
@@ -100,6 +109,7 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
{ {
_reviewMode = true; _reviewMode = true;
ScopeLabel = Loc.T("modals.mergeHelper.scopeReview"); ScopeLabel = Loc.T("modals.mergeHelper.scopeReview");
OnPropertyChanged(nameof(ShowModelPicker));
} }
/// <summary>Groups a confirmed review-mode selection by owning list, preserving row order — /// <summary>Groups a confirmed review-mode selection by owning list, preserving row order —
@@ -94,7 +94,6 @@ public sealed partial class MergeModalViewModel : ViewModelBase
// Subscribed only for the duration of the call: the worker's broadcast reaches every // Subscribed only for the duration of the call: the worker's broadcast reaches every
// client, and a transient VM left on that event would outlive its window. // client, and a transient VM left on that event would outlive its window.
ProgressMessage = Loc.T("vm.merge.progressMerging"); ProgressMessage = Loc.T("vm.merge.progressMerging");
_worker.MergeProgressEvent += OnMergeProgress;
_worker.OperationProgressEvent += OnOperationProgress; _worker.OperationProgressEvent += OnOperationProgress;
try try
{ {
@@ -148,34 +147,23 @@ public sealed partial class MergeModalViewModel : ViewModelBase
} }
finally finally
{ {
_worker.MergeProgressEvent -= OnMergeProgress;
_worker.OperationProgressEvent -= OnOperationProgress; _worker.OperationProgressEvent -= OnOperationProgress;
ProgressMessage = null; ProgressMessage = null;
IsBusy = false; IsBusy = false;
} }
} }
private void OnMergeProgress(string taskId, string phase, int elapsedSeconds)
{
if (taskId != TaskId) return;
// Rebasing is handled by OnOperationProgress (needs the branch total, which this
// elapsed-seconds-only forwarder drops) -- don't overwrite it with the generic fallback.
if (phase == MergePhaseRebasing) return;
ProgressMessage = phase switch
{
MergePhaseVerifying => Loc.T("vm.merge.progressVerifying", FormatElapsed(elapsedSeconds)),
_ => Loc.T("vm.merge.progressMerging"),
};
}
// Separate event: MergeProgressEvent forwards elapsed-seconds-only (verifying), but the
// rebase phase needs the branch count too, which only rides the untrimmed OperationProgress.
private void OnOperationProgress(string opKey, string phase, int current, int total) private void OnOperationProgress(string opKey, string phase, int current, int total)
{ {
if (opKey != TaskId || phase != MergePhaseRebasing) return; if (opKey != TaskId) return;
ProgressMessage = total > 0 ProgressMessage = phase switch
? $"{Loc.T("ops.worker.rebasingAfterMerge")} ({current}/{total})" {
: Loc.T("ops.worker.rebasingAfterMerge"); MergePhaseRebasing => total > 0
? $"{Loc.T("ops.worker.rebasingAfterMerge")} ({current}/{total})"
: Loc.T("ops.worker.rebasingAfterMerge"),
MergePhaseVerifying => Loc.T("vm.merge.progressVerifying", FormatElapsed(current)),
_ => Loc.T("vm.merge.progressMerging"),
};
} }
/// Mirrors TaskMergeService.PhaseVerifying — a hub payload token, not a display string. /// Mirrors TaskMergeService.PhaseVerifying — a hub payload token, not a display string.
@@ -44,7 +44,7 @@ public sealed partial class RepoImportModalViewModel : ViewModelBase
public int CreateCount => Repos.Count(r => r.IsChecked && !r.AlreadyAdded); public int CreateCount => Repos.Count(r => r.IsChecked && !r.AlreadyAdded);
public bool CanCreate => CreateCount > 0; public bool CanCreate => CreateCount > 0;
public string CreateButtonText => $"Create {CreateCount} list(s)"; public string CreateButtonText => Loc.T("vm.repoImport.createButton", CreateCount);
public bool HasFolders => _folders.Count > 0; public bool HasFolders => _folders.Count > 0;
public RepoImportModalViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient workerClient) public RepoImportModalViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient workerClient)
@@ -119,8 +119,13 @@ public sealed partial class GeneralSettingsTabViewModel : ViewModelBase
foreach (var row in ModelPresets) foreach (var row in ModelPresets)
if (row.MaxTurns is < 1 or > 200) if (row.MaxTurns is < 1 or > 200)
return $"Max turns for {row.Model} must be between 1 and 200."; return $"Max turns for {row.Model} must be between 1 and 200.";
if (!IsValidWindowBound(PowerUseWindowStart) || !IsValidWindowBound(PowerUseWindowEnd))
return Loc.T("settings.general.powerUseWindowInvalid");
return null; return null;
} }
private static bool IsValidWindowBound(string? value)
=> string.IsNullOrWhiteSpace(value) || PowerUseWindow.Parse(value) is not null;
} }
/// <summary>One row of the per-model preset table. Single consumer, so it lives here.</summary> /// <summary>One row of the per-model preset table. Single consumer, so it lives here.</summary>
@@ -1,7 +1,9 @@
using System; using System;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.Services; using ClaudeDo.Ui.Services;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
namespace ClaudeDo.Ui.ViewModels.Modals; namespace ClaudeDo.Ui.ViewModels.Modals;
@@ -11,6 +13,9 @@ public sealed partial class WorkerConnectionModalViewModel : ViewModelBase
private readonly WorkerLocator _workerLocator; private readonly WorkerLocator _workerLocator;
private readonly InstallerLocator _installerLocator; private readonly InstallerLocator _installerLocator;
// Test seam: real Process.Start by default, swappable to simulate a blocked/failed launch.
internal Action<ProcessStartInfo> ProcessStarter { get; set; } = psi => Process.Start(psi);
public WorkerConnectionModalViewModel(WorkerLocator workerLocator, InstallerLocator installerLocator) public WorkerConnectionModalViewModel(WorkerLocator workerLocator, InstallerLocator installerLocator)
{ {
_workerLocator = workerLocator; _workerLocator = workerLocator;
@@ -19,15 +24,29 @@ public sealed partial class WorkerConnectionModalViewModel : ViewModelBase
public Action? CloseAction { get; set; } public Action? CloseAction { get; set; }
[ObservableProperty] private string? _errorText;
[RelayCommand] private void Close() => CloseAction?.Invoke(); [RelayCommand] private void Close() => CloseAction?.Invoke();
[RelayCommand] [RelayCommand]
private void StartWorker() private void StartWorker()
{ {
var exe = _workerLocator.Find(); var exe = _workerLocator.Find();
if (exe is null) return; if (exe is null)
try { Process.Start(new ProcessStartInfo(exe) { UseShellExecute = true }); } {
catch { /* nothing useful to show */ } ErrorText = Loc.T("modals.workerConnection.workerNotFound");
return;
}
try
{
ProcessStarter(new ProcessStartInfo(exe) { UseShellExecute = true });
}
catch (Exception ex)
{
ErrorText = Loc.T("modals.workerConnection.startFailed", ex.Message);
return;
}
ErrorText = null;
CloseAction?.Invoke(); CloseAction?.Invoke();
} }
@@ -35,18 +54,26 @@ public sealed partial class WorkerConnectionModalViewModel : ViewModelBase
private void RerunInstaller() private void RerunInstaller()
{ {
var path = _installerLocator.Find(); var path = _installerLocator.Find();
if (path is null) return; if (path is null)
{
ErrorText = Loc.T("modals.workerConnection.installerNotFound");
return;
}
try try
{ {
// See IslandsShellViewModel.UpdateNow: an inherited CWD inside the install dir // See IslandsShellViewModel.UpdateNow: an inherited CWD inside the install dir
// makes the installer block its own app\ rename. // makes the installer block its own app\ rename.
Process.Start(new ProcessStartInfo(path) ProcessStarter(new ProcessStartInfo(path)
{ {
UseShellExecute = true, UseShellExecute = true,
WorkingDirectory = Path.GetTempPath(), WorkingDirectory = Path.GetTempPath(),
}); });
Environment.Exit(0);
} }
catch { /* nothing useful to show */ } catch (Exception ex)
{
ErrorText = Loc.T("modals.workerConnection.startFailed", ex.Message);
return;
}
Environment.Exit(0);
} }
} }
@@ -43,7 +43,13 @@ public partial class InteractiveTerminalView : UserControl
if (DataContext is not InteractiveTerminalViewModel vm) return; if (DataContext is not InteractiveTerminalViewModel vm) return;
e.Handled = true; e.Handled = true;
_ = vm.PasteAsync(); _ = PasteAndReportAsync(vm);
}
private static async System.Threading.Tasks.Task PasteAndReportAsync(InteractiveTerminalViewModel vm)
{
if (!await vm.PasteAsync())
vm.ReportSendFailed();
} }
private static bool IsFilesDrop(DragEventArgs e) => e.DataTransfer?.Contains(DataFormat.File) == true; private static bool IsFilesDrop(DragEventArgs e) => e.DataTransfer?.Contains(DataFormat.File) == true;
@@ -62,7 +68,13 @@ public partial class InteractiveTerminalView : UserControl
e.Handled = true; e.Handled = true;
var text = FormatDroppedPaths(e.DataTransfer!.TryGetFiles()?.Select(i => i.TryGetLocalPath())); var text = FormatDroppedPaths(e.DataTransfer!.TryGetFiles()?.Select(i => i.TryGetLocalPath()));
if (text.Length > 0) _ = vm.SendTextAsync(text); if (text.Length > 0) _ = SendTextAndReportAsync(vm, text);
}
private static async System.Threading.Tasks.Task SendTextAndReportAsync(InteractiveTerminalViewModel vm, string text)
{
if (!await vm.SendTextAsync(text))
vm.ReportSendFailed();
} }
/// <summary>Joins dropped file paths into one insertable chunk: quoted when they contain a /// <summary>Joins dropped file paths into one insertable chunk: quoted when they contain a
@@ -88,8 +88,8 @@
ToolTip.Tip="{loc:Tr details.toggleEditPreviewTip}" ToolTip.Tip="{loc:Tr details.toggleEditPreviewTip}"
Command="{Binding ToggleEditDescriptionCommand}"> Command="{Binding ToggleEditDescriptionCommand}">
<Panel> <Panel>
<TextBlock Text="Preview" IsVisible="{Binding IsEditingDescription}"/> <TextBlock Text="{loc:Tr details.stepsCard.preview}" IsVisible="{Binding IsEditingDescription}"/>
<TextBlock Text="Edit" IsVisible="{Binding !IsEditingDescription}"/> <TextBlock Text="{loc:Tr details.stepsCard.edit}" IsVisible="{Binding !IsEditingDescription}"/>
</Panel> </Panel>
</Button> </Button>
@@ -123,7 +123,7 @@
<!-- Steps --> <!-- Steps -->
<StackPanel IsVisible="{Binding IsStepsSection}" Spacing="6"> <StackPanel IsVisible="{Binding IsStepsSection}" Spacing="6">
<TextBox Text="{Binding NewSubtaskTitle, Mode=TwoWay}" <TextBox Text="{Binding NewSubtaskTitle, Mode=TwoWay}"
PlaceholderText="Add step…" PlaceholderText="{loc:Tr details.stepsCard.addStepPlaceholder}"
Padding="8" Padding="8"
Background="{DynamicResource Surface3Brush}" Background="{DynamicResource Surface3Brush}"
BorderBrush="{DynamicResource LineBrush}" BorderBrush="{DynamicResource LineBrush}"
@@ -41,6 +41,14 @@
</Viewbox> </Viewbox>
<TextBlock Classes="meta" Text="{Binding Task.Branch}"/> <TextBlock Classes="meta" Text="{Binding Task.Branch}"/>
</StackPanel> </StackPanel>
<!-- Dependency-wait line: this task is Queued but blocked on an unmet predecessor (a
task_dependencies edge, or the planning chain's BlockedByTaskId). -->
<StackPanel Orientation="Horizontal" Spacing="4" Margin="0,4,0,0"
IsVisible="{Binding Task.IsWaiting, FallbackValue=False}">
<TextBlock Classes="meta" Text="{loc:Tr details.waitingOnPrefix}"/>
<TextBlock Classes="meta" Text="{Binding Task.DependencyWaitLabel}"/>
</StackPanel>
</StackPanel> </StackPanel>
<!-- Column 1: trash button (not running) --> <!-- Column 1: trash button (not running) -->
@@ -172,12 +172,12 @@
TextWrapping="Wrap" VerticalAlignment="Center" /> TextWrapping="Wrap" VerticalAlignment="Center" />
</StackPanel> </StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8"> <StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="btn accent" Content="Continue" <Button Classes="btn accent" Content="{loc:Tr details.workConsole.continue}"
Command="{Binding ContinueCommand}" Command="{Binding ContinueCommand}"
IsVisible="{Binding ShowContinue}" IsVisible="{Binding ShowContinue}"
ToolTip.Tip="{loc:Tr session.continueNoSessionTip}" ToolTip.Tip="{loc:Tr session.continueNoSessionTip}"
ToolTip.ShowOnDisabled="True" /> ToolTip.ShowOnDisabled="True" />
<Button Classes="btn" Content="Reset &amp; Retry" <Button Classes="btn" Content="{loc:Tr details.workConsole.resetAndRetry}"
Command="{Binding ResetAndRetryCommand}" Command="{Binding ResetAndRetryCommand}"
IsVisible="{Binding ShowResetAndRetry}" IsVisible="{Binding ShowResetAndRetry}"
ToolTip.Tip="{loc:Tr session.resetAndRetryInteractiveTip}" ToolTip.Tip="{loc:Tr session.resetAndRetryInteractiveTip}"
@@ -194,7 +194,7 @@
<StackPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal">
<Button Classes="tab-btn" <Button Classes="tab-btn"
Classes.active="{Binding IsOutputTab}" Classes.active="{Binding IsOutputTab}"
Content="Output" Content="{loc:Tr details.workConsole.tabOutput}"
Command="{Binding SelectTabCommand}" Command="{Binding SelectTabCommand}"
CommandParameter="output" /> CommandParameter="output" />
<Button Classes="tab-btn" <Button Classes="tab-btn"
@@ -202,7 +202,7 @@
Command="{Binding SelectTabCommand}" Command="{Binding SelectTabCommand}"
CommandParameter="git"> CommandParameter="git">
<StackPanel Orientation="Horizontal" Spacing="6"> <StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Text="Git" VerticalAlignment="Center" /> <TextBlock Text="{loc:Tr details.workConsole.tabGit}" VerticalAlignment="Center" />
<!-- Review-pending dot: where to act when a task awaits review --> <!-- Review-pending dot: where to act when a task awaits review -->
<Ellipse Width="6" Height="6" VerticalAlignment="Center" <Ellipse Width="6" Height="6" VerticalAlignment="Center"
Fill="{DynamicResource AccentBrush}" Fill="{DynamicResource AccentBrush}"
@@ -211,7 +211,7 @@
</Button> </Button>
<Button Classes="tab-btn" <Button Classes="tab-btn"
Classes.active="{Binding IsSessionTab}" Classes.active="{Binding IsSessionTab}"
Content="Session" Content="{loc:Tr details.workConsole.tabSession}"
IsVisible="{Binding HasChildOutcomes}" IsVisible="{Binding HasChildOutcomes}"
Command="{Binding SelectTabCommand}" Command="{Binding SelectTabCommand}"
CommandParameter="session" /> CommandParameter="session" />
@@ -233,7 +233,7 @@
BorderBrush="{DynamicResource LineBrush}" BorderBrush="{DynamicResource LineBrush}"
BorderThickness="1" CornerRadius="8"> BorderThickness="1" CornerRadius="8">
<StackPanel Spacing="6"> <StackPanel Spacing="6">
<TextBlock Classes="section-label" Text="OUTCOME" /> <TextBlock Classes="section-label" Text="{loc:Tr details.workConsole.outcomeLabel}" />
<ScrollViewer MaxHeight="160" VerticalScrollBarVisibility="Auto"> <ScrollViewer MaxHeight="160" VerticalScrollBarVisibility="Auto">
<SelectableTextBlock Text="{Binding SessionOutcome}" <SelectableTextBlock Text="{Binding SessionOutcome}"
TextWrapping="Wrap" TextWrapping="Wrap"
@@ -309,9 +309,9 @@
<!-- Merge controls — shown whenever there's a worktree / unit to merge. <!-- Merge controls — shown whenever there's a worktree / unit to merge.
Header reads REVIEW while a decision is pending, otherwise MERGE. --> Header reads REVIEW while a decision is pending, otherwise MERGE. -->
<StackPanel Spacing="14" IsVisible="{Binding Merge.ShowMergeSection}"> <StackPanel Spacing="14" IsVisible="{Binding Merge.ShowMergeSection}">
<TextBlock Classes="section-label" Text="REVIEW" <TextBlock Classes="section-label" Text="{loc:Tr details.workConsole.reviewLabel}"
IsVisible="{Binding IsWaitingForReview}" /> IsVisible="{Binding IsWaitingForReview}" />
<TextBlock Classes="section-label" Text="MERGE" <TextBlock Classes="section-label" Text="{loc:Tr details.workConsole.mergeLabel}"
IsVisible="{Binding !IsWaitingForReview}" /> IsVisible="{Binding !IsWaitingForReview}" />
<!-- Change summary (review only) --> <!-- Change summary (review only) -->
@@ -323,7 +323,7 @@
<!-- Target branch + pre-flight mergeability --> <!-- Target branch + pre-flight mergeability -->
<StackPanel Spacing="4"> <StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="Target branch" /> <TextBlock Classes="field-label" Text="{loc:Tr details.workConsole.targetBranch}" />
<ComboBox ItemsSource="{Binding Merge.MergeTargetBranches}" <ComboBox ItemsSource="{Binding Merge.MergeTargetBranches}"
SelectedItem="{Binding Merge.SelectedMergeTarget, Mode=TwoWay}" SelectedItem="{Binding Merge.SelectedMergeTarget, Mode=TwoWay}"
HorizontalAlignment="Stretch" /> HorizontalAlignment="Stretch" />
@@ -344,7 +344,7 @@
<!-- Inspect: diff / worktree / combined diff --> <!-- Inspect: diff / worktree / combined diff -->
<WrapPanel Orientation="Horizontal"> <WrapPanel Orientation="Horizontal">
<Button Classes="btn" Content="Open Diff" Margin="0,0,8,8" <Button Classes="btn" Content="{loc:Tr details.workConsole.openDiff}" Margin="0,0,8,8"
ToolTip.Tip="{loc:Tr agent.openDiffTip}" ToolTip.Tip="{loc:Tr agent.openDiffTip}"
ToolTip.ShowOnDisabled="True" ToolTip.ShowOnDisabled="True"
Command="{Binding Merge.OpenDiffCommand}" /> Command="{Binding Merge.OpenDiffCommand}" />
@@ -353,11 +353,11 @@
ToolTip.ShowOnDisabled="True" ToolTip.ShowOnDisabled="True"
Command="{Binding Merge.OpenWorktreeCommand}"> Command="{Binding Merge.OpenWorktreeCommand}">
<StackPanel Orientation="Horizontal" Spacing="5"> <StackPanel Orientation="Horizontal" Spacing="5">
<TextBlock Text="Worktree" /> <TextBlock Text="{loc:Tr details.workConsole.worktree}" />
<PathIcon Data="{StaticResource Icon.ArrowOut}" Width="11" Height="11" /> <PathIcon Data="{StaticResource Icon.ArrowOut}" Width="11" Height="11" />
</StackPanel> </StackPanel>
</Button> </Button>
<Button Classes="btn" Content="Review Combined Diff" Margin="0,0,8,8" <Button Classes="btn" Content="{loc:Tr details.workConsole.reviewCombinedDiff}" Margin="0,0,8,8"
ToolTip.Tip="{loc:Tr agent.reviewCombinedDiffTip}" ToolTip.Tip="{loc:Tr agent.reviewCombinedDiffTip}"
ToolTip.ShowOnDisabled="True" ToolTip.ShowOnDisabled="True"
Command="{Binding Merge.ReviewCombinedDiffCommand}" /> Command="{Binding Merge.ReviewCombinedDiffCommand}" />
@@ -377,7 +377,7 @@
Foreground="{DynamicResource AmberBrush}" VerticalAlignment="Center" /> Foreground="{DynamicResource AmberBrush}" VerticalAlignment="Center" />
<TextBlock Classes="meta" VerticalAlignment="Center" <TextBlock Classes="meta" VerticalAlignment="Center"
Foreground="{DynamicResource AmberBrush}" Foreground="{DynamicResource AmberBrush}"
Text="Open the diff to enable merge" /> Text="{loc:Tr details.workConsole.openDiffHint}" />
</StackPanel> </StackPanel>
<TextBlock Classes="meta" Margin="17,0,0,0" <TextBlock Classes="meta" Margin="17,0,0,0"
Text="{loc:Tr session.approveMergesUnitHint}" /> Text="{loc:Tr session.approveMergesUnitHint}" />
@@ -396,23 +396,23 @@
<WrapPanel Orientation="Horizontal"> <WrapPanel Orientation="Horizontal">
<StackPanel Orientation="Horizontal" Spacing="{StaticResource SpaceSm}" Margin="0,0,8,8"> <StackPanel Orientation="Horizontal" Spacing="{StaticResource SpaceSm}" Margin="0,0,8,8">
<Button Classes="btn accent" Content="Approve &amp; Merge" <Button Classes="btn accent" Content="{loc:Tr details.workConsole.approveMerge}"
Command="{Binding ApproveReviewCommand}" /> Command="{Binding ApproveReviewCommand}" />
<controls:OperationIndicator Status="{Binding ApproveOp}" /> <controls:OperationIndicator Status="{Binding ApproveOp}" />
</StackPanel> </StackPanel>
<StackPanel Orientation="Horizontal" Spacing="{StaticResource SpaceSm}" Margin="0,0,8,8"> <StackPanel Orientation="Horizontal" Spacing="{StaticResource SpaceSm}" Margin="0,0,8,8">
<Button Classes="btn" Content="Park" <Button Classes="btn" Content="{loc:Tr details.workConsole.park}"
ToolTip.Tip="Set aside — back to Idle, keeps the worktree" ToolTip.Tip="{loc:Tr details.workConsole.parkTip}"
Command="{Binding ParkReviewCommand}" /> Command="{Binding ParkReviewCommand}" />
<controls:OperationIndicator Status="{Binding ParkOp}" /> <controls:OperationIndicator Status="{Binding ParkOp}" />
</StackPanel> </StackPanel>
<Button Classes="btn" Content="Cancel" Margin="0,0,8,8" <Button Classes="btn" Content="{loc:Tr details.workConsole.cancel}" Margin="0,0,8,8"
ToolTip.Tip="{loc:Tr session.mergeDrainHint}" ToolTip.Tip="{loc:Tr session.mergeDrainHint}"
ToolTip.ShowOnDisabled="True" ToolTip.ShowOnDisabled="True"
Command="{Binding CancelReviewCommand}" /> Command="{Binding CancelReviewCommand}" />
</WrapPanel> </WrapPanel>
<Button Classes="prompt-action" Content="Reset (discard branch)…" <Button Classes="prompt-action" Content="{loc:Tr details.workConsole.resetDiscardBranch}"
ToolTip.Tip="{loc:Tr session.reviewResetTip}" ToolTip.Tip="{loc:Tr session.reviewResetTip}"
Command="{Binding ResetReviewCommand}" /> Command="{Binding ResetReviewCommand}" />
</StackPanel> </StackPanel>
@@ -434,9 +434,9 @@
<Border Height="1" Background="{DynamicResource LineBrush}" /> <Border Height="1" Background="{DynamicResource LineBrush}" />
<TextBlock Classes="meta" TextWrapping="Wrap" <TextBlock Classes="meta" TextWrapping="Wrap"
Foreground="{DynamicResource TextMuteBrush}" Foreground="{DynamicResource TextMuteBrush}"
Text="Worked on this by hand? Submit the worktree for review to merge it." /> Text="{loc:Tr details.workConsole.submitForReviewHint}" />
<StackPanel Orientation="Horizontal" Spacing="{StaticResource SpaceSm}"> <StackPanel Orientation="Horizontal" Spacing="{StaticResource SpaceSm}">
<Button Classes="btn accent" Content="Submit for review" HorizontalAlignment="Left" <Button Classes="btn accent" Content="{loc:Tr details.workConsole.submitForReview}" HorizontalAlignment="Left"
Command="{Binding SubmitForReviewCommand}" /> Command="{Binding SubmitForReviewCommand}" />
<controls:OperationIndicator Status="{Binding SubmitOp}" /> <controls:OperationIndicator Status="{Binding SubmitOp}" />
</StackPanel> </StackPanel>
@@ -466,7 +466,7 @@
<!-- Child outcomes --> <!-- Child outcomes -->
<StackPanel Spacing="6" IsVisible="{Binding HasChildOutcomes}"> <StackPanel Spacing="6" IsVisible="{Binding HasChildOutcomes}">
<TextBlock Classes="section-label" Text="OUTCOMES" /> <TextBlock Classes="section-label" Text="{loc:Tr details.workConsole.outcomesLabel}" />
<ItemsControl ItemsSource="{Binding ChildOutcomes}"> <ItemsControl ItemsSource="{Binding ChildOutcomes}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ChildOutcomeRowViewModel"> <DataTemplate x:DataType="vm:ChildOutcomeRowViewModel">
@@ -51,7 +51,7 @@
<PathIcon Data="{StaticResource Icon.Warning}" <PathIcon Data="{StaticResource Icon.Warning}"
Foreground="{DynamicResource StatusReviewBrush}" Foreground="{DynamicResource StatusReviewBrush}"
Width="14" Height="14" VerticalAlignment="Center"/> Width="14" Height="14" VerticalAlignment="Center"/>
<TextBlock Classes="section-label" Text="ROADBLOCK" <TextBlock Classes="section-label" Text="{loc:Tr details.roadblockLabel}"
Foreground="{DynamicResource StatusReviewBrush}" Foreground="{DynamicResource StatusReviewBrush}"
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
</StackPanel> </StackPanel>
@@ -5,6 +5,152 @@
xmlns:loc="using:ClaudeDo.Ui.Localization" xmlns:loc="using:ClaudeDo.Ui.Localization"
x:Class="ClaudeDo.Ui.Views.Islands.ListsIslandView" x:Class="ClaudeDo.Ui.Views.Islands.ListsIslandView"
x:DataType="vm:ListsIslandViewModel"> x:DataType="vm:ListsIslandViewModel">
<!-- Implicit, type-matched templates: the sidebar's top level is one mixed ItemsControl over
TopLevel (folders and folder-less lists interleaved), and each folder's children resolve the
list template the same way. -->
<UserControl.DataTemplates>
<!-- One user-list row. Shared by the top level and by each folder's children, so the
row (context menu, drag handlers, drop indicators) exists once. -->
<DataTemplate DataType="vm:ListNavItemViewModel">
<Grid RowDefinitions="Auto,Auto,Auto">
<!-- Above-row drop indicator -->
<Border Grid.Row="0" Height="2" VerticalAlignment="Center" Margin="4,0"
Background="{DynamicResource MossBrush}" CornerRadius="1"
IsVisible="{Binding DropHintAbove}"/>
<Border Grid.Row="1" Classes="list-item" Classes.active="{Binding IsActive}"
Classes.drop-target="{Binding IsTaskDropTarget}"
Tapped="OnItemTapped"
DragDrop.AllowDrop="True"
DragDrop.DragOver="OnListDragOver"
DragDrop.Drop="OnListDrop">
<Border.ContextMenu>
<ContextMenu>
<MenuItem Header="{loc:Tr lists.contextWorktrees}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenWorktreesOverviewCommand}"
CommandParameter="{Binding}"/>
<Separator IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<MenuItem Header="{loc:Tr lists.contextOpenExplorer}"
IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenInExplorerCommand}"
CommandParameter="{Binding}"/>
<MenuItem Header="{loc:Tr lists.contextOpenFindings}"
IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenFindingsCommand}"
CommandParameter="{Binding}"/>
<MenuItem Header="{loc:Tr lists.contextOpenTerminal}"
IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenInTerminalCommand}"
CommandParameter="{Binding}"/>
<MenuItem Header="{loc:Tr lists.contextImportTickets}"
IsVisible="{Binding HasTicketProject}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).ImportTicketsCommand}"
CommandParameter="{Binding}"/>
<Separator/>
<MenuItem Header="{loc:Tr lists.contextRemoveFromFolder}"
IsVisible="{Binding FolderId, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).RemoveFromFolderCommand}"
CommandParameter="{Binding}"/>
<MenuItem Header="{loc:Tr lists.contextSettings}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenListSettingsCommand}"
CommandParameter="{Binding}"/>
</ContextMenu>
</Border.ContextMenu>
<Grid ColumnDefinitions="20,*,Auto">
<!-- Left accent bar for active state -->
<Border Grid.Column="0" Grid.ColumnSpan="3"
Background="Transparent"
CornerRadius="8" IsHitTestVisible="False"
IsVisible="{Binding IsActive}">
<Border Width="2" Height="16"
Background="{DynamicResource AccentBrush}"
CornerRadius="1"
HorizontalAlignment="Left" VerticalAlignment="Center"
Margin="-8,0,0,0"/>
</Border>
<!-- 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}"
VerticalAlignment="Center" Margin="8,0"
Foreground="{DynamicResource TextDimBrush}" FontSize="{StaticResource FontSizeBody}"/>
<!-- Count -->
<TextBlock Grid.Column="2" Classes="list-count"
Text="{Binding Count}"/>
</Grid>
</Border>
<!-- Below-row drop indicator (last item only) -->
<Border Grid.Row="2" Height="2" VerticalAlignment="Center" Margin="4,0"
Background="{DynamicResource MossBrush}" CornerRadius="1"
IsVisible="{Binding DropHintBelow}"/>
</Grid>
</DataTemplate>
<!-- One folder: header (never selectable — tapping folds it) plus its child lists. The two
drop indicators sit around the whole block, so "below this folder" doesn't draw a line
between the header and its children. -->
<DataTemplate DataType="vm:ListFolderNavItemViewModel">
<Grid RowDefinitions="Auto,Auto,Auto">
<Border Grid.Row="0" Height="2" VerticalAlignment="Center" Margin="4,0"
Background="{DynamicResource MossBrush}" CornerRadius="1"
IsVisible="{Binding DropHintAbove}"/>
<StackPanel Grid.Row="1">
<Border Classes="list-item"
Classes.drop-target="{Binding IsDropTarget}"
Tapped="OnFolderTapped"
DragDrop.AllowDrop="True"
DragDrop.DragOver="OnFolderDragOver"
DragDrop.Drop="OnFolderDrop">
<Border.ContextMenu>
<ContextMenu>
<MenuItem Header="{loc:Tr lists.contextFolderSettings}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenFolderSettingsCommand}"
CommandParameter="{Binding}"/>
</ContextMenu>
</Border.ContextMenu>
<Grid ColumnDefinitions="12,20,*,Auto">
<PathIcon Grid.Column="0" Classes="list-icon"
Width="9" Height="9"
Data="{StaticResource Icon.ChevronRight}"
IsVisible="{Binding IsCollapsed}"
HorizontalAlignment="Left" VerticalAlignment="Center"/>
<PathIcon Grid.Column="0" Classes="list-icon"
Width="9" Height="9"
Data="{StaticResource Icon.ChevronDown}"
IsVisible="{Binding !IsCollapsed}"
HorizontalAlignment="Left" VerticalAlignment="Center"/>
<PathIcon Grid.Column="1" Classes="list-icon"
Width="14" Height="14"
Data="{Binding IconKey, Converter={StaticResource IconKey}}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
<TextBlock Grid.Column="2" Classes="list-label"
Text="{Binding Name}"
VerticalAlignment="Center" Margin="8,0"
Foreground="{DynamicResource TextDimBrush}" FontSize="{StaticResource FontSizeBody}"/>
<TextBlock Grid.Column="3" Classes="list-count"
Text="{Binding Count}"/>
</Grid>
</Border>
<ItemsControl ItemsSource="{Binding Lists}"
IsVisible="{Binding !IsCollapsed}"
Margin="14,0,0,0"/>
</StackPanel>
<Border Grid.Row="2" Height="2" VerticalAlignment="Center" Margin="4,0"
Background="{DynamicResource MossBrush}" CornerRadius="1"
IsVisible="{Binding DropHintBelow}"/>
</Grid>
</DataTemplate>
</UserControl.DataTemplates>
<DockPanel LastChildFill="True"> <DockPanel LastChildFill="True">
<!-- ── Header ── --> <!-- ── Header ── -->
@@ -133,86 +279,9 @@
<PathIcon Data="{StaticResource Icon.Folder}" Width="14" Height="14"/> <PathIcon Data="{StaticResource Icon.Folder}" Width="14" Height="14"/>
</Button> </Button>
</Grid> </Grid>
<ItemsControl ItemsSource="{Binding UserLists}"> <!-- Folders and folder-less lists in one sequence, so either can be dragged above the
<ItemsControl.ItemTemplate> other; each item picks its template from UserControl.DataTemplates by type. -->
<DataTemplate DataType="vm:ListNavItemViewModel"> <ItemsControl ItemsSource="{Binding TopLevel}"/>
<Grid RowDefinitions="Auto,Auto,Auto">
<!-- Above-row drop indicator -->
<Border Grid.Row="0" Height="2" VerticalAlignment="Center" Margin="4,0"
Background="{DynamicResource MossBrush}" CornerRadius="1"
IsVisible="{Binding DropHintAbove}"/>
<Border Grid.Row="1" Classes="list-item" Classes.active="{Binding IsActive}"
Classes.drop-target="{Binding IsTaskDropTarget}"
Tapped="OnItemTapped"
DragDrop.AllowDrop="True"
DragDrop.DragOver="OnListDragOver"
DragDrop.Drop="OnListDrop">
<Border.ContextMenu>
<ContextMenu>
<MenuItem Header="{loc:Tr lists.contextWorktrees}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenWorktreesOverviewCommand}"
CommandParameter="{Binding}"/>
<Separator IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<MenuItem Header="{loc:Tr lists.contextOpenExplorer}"
IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenInExplorerCommand}"
CommandParameter="{Binding}"/>
<MenuItem Header="{loc:Tr lists.contextOpenFindings}"
IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenFindingsCommand}"
CommandParameter="{Binding}"/>
<MenuItem Header="{loc:Tr lists.contextOpenTerminal}"
IsVisible="{Binding WorkingDir, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenInTerminalCommand}"
CommandParameter="{Binding}"/>
<MenuItem Header="{loc:Tr lists.contextImportTickets}"
IsVisible="{Binding HasTicketProject}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).ImportTicketsCommand}"
CommandParameter="{Binding}"/>
<Separator/>
<MenuItem Header="{loc:Tr lists.contextSettings}"
Command="{Binding $parent[UserControl].((vm:ListsIslandViewModel)DataContext).OpenListSettingsCommand}"
CommandParameter="{Binding}"/>
</ContextMenu>
</Border.ContextMenu>
<Grid ColumnDefinitions="20,*,Auto">
<!-- Left accent bar for active state -->
<Border Grid.Column="0" Grid.ColumnSpan="3"
Background="Transparent"
CornerRadius="8" IsHitTestVisible="False"
IsVisible="{Binding IsActive}">
<Border Width="2" Height="16"
Background="{DynamicResource AccentBrush}"
CornerRadius="1"
HorizontalAlignment="Left" VerticalAlignment="Center"
Margin="-8,0,0,0"/>
</Border>
<!-- 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}"
VerticalAlignment="Center" Margin="8,0"
Foreground="{DynamicResource TextDimBrush}" FontSize="{StaticResource FontSizeBody}"/>
<!-- Count -->
<TextBlock Grid.Column="2" Classes="list-count"
Text="{Binding Count}"/>
</Grid>
</Border>
<!-- Below-row drop indicator (last item only) -->
<Border Grid.Row="2" Height="2" VerticalAlignment="Center" Margin="4,0"
Background="{DynamicResource MossBrush}" CornerRadius="1"
IsVisible="{Binding DropHintBelow}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>
@@ -17,11 +17,28 @@ public partial class ListsIslandView : UserControl
{ {
private static readonly DataFormat<string> ListRowFormat = private static readonly DataFormat<string> ListRowFormat =
DataFormat.CreateStringApplicationFormat("claudedo-list-row"); DataFormat.CreateStringApplicationFormat("claudedo-list-row");
private static readonly DataFormat<string> FolderRowFormat =
DataFormat.CreateStringApplicationFormat("claudedo-folder-row");
// A folder drag only starts once the pointer has moved: DoDragDropAsync captures the pointer,
// and starting it on press would swallow the Tapped that folds the folder. It only takes the
// *pressed* args, so the press is kept around until the threshold is crossed.
private ListFolderNavItemViewModel? _pendingFolderDrag;
private PointerPressedEventArgs? _pendingFolderDragPress;
private Point _folderDragOrigin;
private void CancelPendingFolderDrag()
{
_pendingFolderDrag = null;
_pendingFolderDragPress = null;
}
public ListsIslandView() public ListsIslandView()
{ {
InitializeComponent(); InitializeComponent();
AddHandler(PointerPressedEvent, OnTunnelPointerPressed, RoutingStrategies.Tunnel); AddHandler(PointerPressedEvent, OnTunnelPointerPressed, RoutingStrategies.Tunnel);
AddHandler(PointerMovedEvent, OnTunnelPointerMoved, RoutingStrategies.Tunnel);
AddHandler(PointerReleasedEvent, (_, _) => CancelPendingFolderDrag(), RoutingStrategies.Tunnel);
DataContextChanged += (_, _) => DataContextChanged += (_, _) =>
{ {
if (DataContext is ListsIslandViewModel vm) if (DataContext is ListsIslandViewModel vm)
@@ -42,9 +59,19 @@ public partial class ListsIslandView : UserControl
if (e.Source is not Visual src) return; if (e.Source is not Visual src) return;
var border = FindListItemBorder(src); var border = FindListItemBorder(src);
if (border?.DataContext is not ListNavItemViewModel row || row.Kind != ListKind.User) return; if (border is null) return;
if (!e.GetCurrentPoint(border).Properties.IsLeftButtonPressed) return; if (!e.GetCurrentPoint(border).Properties.IsLeftButtonPressed) return;
if (border.DataContext is ListFolderNavItemViewModel folder)
{
_pendingFolderDrag = folder;
_pendingFolderDragPress = e;
_folderDragOrigin = e.GetPosition(this);
return;
}
if (border.DataContext is not ListNavItemViewModel row || row.Kind != ListKind.User) return;
// Double-click opens the list's settings instead of starting a drag. Handled here // Double-click opens the list's settings instead of starting a drag. Handled here
// because DoDragDropAsync captures the pointer and would swallow a DoubleTapped event. // because DoDragDropAsync captures the pointer and would swallow a DoubleTapped event.
if (e.ClickCount == 2) if (e.ClickCount == 2)
@@ -69,50 +96,149 @@ public partial class ListsIslandView : UserControl
} }
} }
private async void OnTunnelPointerMoved(object? sender, PointerEventArgs e)
{
if (_pendingFolderDrag is not { } folder || _pendingFolderDragPress is not { } press) return;
if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) { CancelPendingFolderDrag(); return; }
var delta = e.GetPosition(this) - _folderDragOrigin;
if (Math.Abs(delta.X) < 4 && Math.Abs(delta.Y) < 4) return;
CancelPendingFolderDrag();
if (DataContext is not ListsIslandViewModel vm) return;
var data = new DataTransfer();
data.Add(DataTransferItem.Create(FolderRowFormat, folder.Id));
try
{
await DragDrop.DoDragDropAsync(press, data, DragDropEffects.Move);
}
finally
{
vm.ClearDropHints();
}
}
private void OnFolderTapped(object? sender, RoutedEventArgs e)
{
if (sender is Border { DataContext: ListFolderNavItemViewModel folder }
&& DataContext is ListsIslandViewModel vm)
vm.ToggleFolderCommand.Execute(folder);
}
/// <summary>The dragged row or folder, resolved from whichever of the two formats is present.</summary>
private static object? DragSource(ListsIslandViewModel vm, DragEventArgs e)
{
if (e.DataTransfer is not { } data) return null;
if (data.Contains(ListRowFormat))
return data.TryGetValue(ListRowFormat) is { } listId
? vm.UserLists.FirstOrDefault(r => r.Id == listId) : null;
if (data.Contains(FolderRowFormat))
return data.TryGetValue(FolderRowFormat) is { } folderId
? vm.Folders.FirstOrDefault(f => f.Id == folderId) : null;
return null;
}
private enum FolderDrop { None, Into, Beside }
/// <summary>
/// What a drop on a folder header means. For a list the middle half moves it *into* the folder
/// (the only route into a collapsed or empty one) while the outer quarters insert next to the
/// folder at the top level; a folder source has no "into" — folders never nest — so it splits
/// in half.
/// </summary>
private static (FolderDrop Kind, bool PlaceBelow) ClassifyFolderDrop(
object source, ListFolderNavItemViewModel folder, double y, double height)
{
if (source is ListFolderNavItemViewModel dragged)
return ReferenceEquals(dragged, folder)
? (FolderDrop.None, false)
: (FolderDrop.Beside, y > height / 2);
if (source is not ListNavItemViewModel row) return (FolderDrop.None, false);
if (y < height * 0.25) return (FolderDrop.Beside, false);
if (y > height * 0.75) return (FolderDrop.Beside, true);
return row.FolderId == folder.Id ? (FolderDrop.None, false) : (FolderDrop.Into, false);
}
private void OnFolderDragOver(object? sender, DragEventArgs e)
{
if (DataContext is not ListsIslandViewModel vm) { e.DragEffects = DragDropEffects.None; return; }
if (sender is not Border b || b.DataContext is not ListFolderNavItemViewModel folder)
{
Reject(vm, e);
return;
}
var source = DragSource(vm, e);
if (source is null) { Reject(vm, e); return; }
var (kind, placeBelow) = ClassifyFolderDrop(source, folder, e.GetPosition(b).Y, b.Bounds.Height);
switch (kind)
{
case FolderDrop.Into:
vm.SetFolderDropTarget(folder);
e.DragEffects = DragDropEffects.Move;
break;
case FolderDrop.Beside:
vm.SetDropHint(folder, placeBelow);
e.DragEffects = DragDropEffects.Move;
break;
default:
Reject(vm, e);
break;
}
}
private async void OnFolderDrop(object? sender, DragEventArgs e)
{
if (DataContext is not ListsIslandViewModel vm) return;
try
{
if (sender is not Border b || b.DataContext is not ListFolderNavItemViewModel folder) return;
var source = DragSource(vm, e);
if (source is null) return;
var (kind, placeBelow) = ClassifyFolderDrop(source, folder, e.GetPosition(b).Y, b.Bounds.Height);
vm.ClearDropHints();
if (kind == FolderDrop.Into && source is ListNavItemViewModel row)
await vm.MoveToFolderAsync(row, folder);
else if (kind == FolderDrop.Beside)
await vm.DropAsync(source, folder, placeBelow);
}
catch
{
vm.ClearDropHints();
throw;
}
}
private void OnListDragOver(object? sender, DragEventArgs e) private void OnListDragOver(object? sender, DragEventArgs e)
{ {
if (DataContext is not ListsIslandViewModel vm) { e.DragEffects = DragDropEffects.None; return; } if (DataContext is not ListsIslandViewModel vm) { e.DragEffects = DragDropEffects.None; return; }
if (!e.DataTransfer?.Contains(ListRowFormat) ?? true)
{
e.DragEffects = DragDropEffects.None;
vm.ClearDropHints();
return;
}
if (sender is not Border b || b.DataContext is not ListNavItemViewModel target || target.Kind != ListKind.User) if (sender is not Border b || b.DataContext is not ListNavItemViewModel target || target.Kind != ListKind.User)
{ {
e.DragEffects = DragDropEffects.None; Reject(vm, e);
vm.ClearDropHints();
return; return;
} }
var sourceId = e.DataTransfer?.TryGetValue(ListRowFormat); var source = DragSource(vm, e);
if (string.IsNullOrEmpty(sourceId) || sourceId == target.Id) if (source is null || ReferenceEquals(source, target)) { Reject(vm, e); return; }
{ // A folder can only be dropped at the top level, so a row inside a folder is no target.
e.DragEffects = DragDropEffects.None; if (source is ListFolderNavItemViewModel && target.FolderId is not null) { Reject(vm, e); return; }
vm.ClearDropHints();
return;
}
var placeBelow = e.GetPosition(b).Y > b.Bounds.Height / 2;
// Canonicalize: "drop below X" == "drop above X+1". Only the last row shows a below-line. // Canonicalize: "drop below X" == "drop above X+1". Only the last row shows a below-line.
ListNavItemViewModel hintRow = target; object hintTarget = target;
bool hintBelow = false; var hintBelow = false;
if (placeBelow) if (e.GetPosition(b).Y > b.Bounds.Height / 2)
{ {
var next = FindNextUserList(vm, target); if (FindNextInContainer(vm, target) is { } next) hintTarget = next;
if (next is not null) { hintRow = next; hintBelow = false; } else hintBelow = true;
else { hintRow = target; hintBelow = true; }
} }
if (hintRow.Id == sourceId) if (ReferenceEquals(hintTarget, source)) { Reject(vm, e); return; }
{
e.DragEffects = DragDropEffects.None;
vm.ClearDropHints();
return;
}
vm.SetDropHint(hintRow, hintBelow); vm.SetDropHint(hintTarget, hintBelow);
e.DragEffects = DragDropEffects.Move; e.DragEffects = DragDropEffects.Move;
} }
@@ -123,15 +249,13 @@ public partial class ListsIslandView : UserControl
{ {
if (sender is not Border b || b.DataContext is not ListNavItemViewModel target || target.Kind != ListKind.User) return; if (sender is not Border b || b.DataContext is not ListNavItemViewModel target || target.Kind != ListKind.User) return;
var sourceId = e.DataTransfer?.TryGetValue(ListRowFormat); var source = DragSource(vm, e);
if (string.IsNullOrEmpty(sourceId) || sourceId == target.Id) return; if (source is null || ReferenceEquals(source, target)) return;
if (source is ListFolderNavItemViewModel && target.FolderId is not null) return;
var source = vm.UserLists.FirstOrDefault(r => r.Id == sourceId);
if (source is null) return;
var placeBelow = e.GetPosition(b).Y > b.Bounds.Height / 2; var placeBelow = e.GetPosition(b).Y > b.Bounds.Height / 2;
vm.ClearDropHints(); vm.ClearDropHints();
await vm.ReorderAsync(source, target, placeBelow); await vm.DropAsync(source, target, placeBelow);
} }
catch catch
{ {
@@ -140,6 +264,12 @@ public partial class ListsIslandView : UserControl
} }
} }
private static void Reject(ListsIslandViewModel vm, DragEventArgs e)
{
e.DragEffects = DragDropEffects.None;
vm.ClearDropHints();
}
private static Border? FindListItemBorder(Visual? v) private static Border? FindListItemBorder(Visual? v)
{ {
while (v is not null) while (v is not null)
@@ -150,11 +280,15 @@ public partial class ListsIslandView : UserControl
return null; return null;
} }
private static ListNavItemViewModel? FindNextUserList(ListsIslandViewModel vm, ListNavItemViewModel row) // Next item *within the same container* — the row below the last list of a folder belongs to
// another container, so it is not a "drop above" candidate here. At the top level the next
// item can be a folder, which is a valid hint target.
private static object? FindNextInContainer(ListsIslandViewModel vm, ListNavItemViewModel row)
{ {
var idx = vm.UserLists.IndexOf(row); var container = vm.ContainerOf(row);
var idx = container.IndexOf(row);
if (idx < 0) return null; if (idx < 0) return null;
return idx + 1 < vm.UserLists.Count ? vm.UserLists[idx + 1] : null; return idx + 1 < container.Count ? container[idx + 1] : null;
} }
} }
@@ -142,7 +142,8 @@
Classes.children="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=WaitingForChildren}" Classes.children="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=WaitingForChildren}"
Classes.done="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Done}" Classes.done="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Done}"
Classes.error="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Failed}" Classes.error="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Failed}"
Classes.queued="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Queued}"> Classes.queued="{Binding IsQueued}"
Classes.waiting="{Binding IsWaiting}">
<TextBlock Text="{Binding StatusLabel}"/> <TextBlock Text="{Binding StatusLabel}"/>
</Border> </Border>
+5
View File
@@ -66,6 +66,11 @@
Command="{Binding RestartWorkerCommand}"/> Command="{Binding RestartWorkerCommand}"/>
<MenuItem Header="{loc:Tr shell.menu.usageMonitor}" <MenuItem Header="{loc:Tr shell.menu.usageMonitor}"
Command="{Binding OpenUsageMonitorCommand}"/> Command="{Binding OpenUsageMonitorCommand}"/>
<MenuItem Header="{loc:Tr shell.menu.updateClaudeCli}"
Command="{Binding UpdateClaudeCliCommand}"/>
<Separator/>
<MenuItem Header="{loc:Tr shell.menu.shutdown}"
Command="{Binding ShutdownCommand}"/>
</MenuItem> </MenuItem>
<MenuItem Header="{loc:Tr shell.menu.repositories}" <MenuItem Header="{loc:Tr shell.menu.repositories}"
FontSize="{StaticResource FontSizeMono}" FontSize="{StaticResource FontSizeMono}"
@@ -80,6 +80,11 @@ public partial class MainWindow : Window
WindowState = WindowState.Normal; WindowState = WindowState.Normal;
Activate(); Activate();
}; };
vm.CloseWindow = () =>
{
_forceClose = true;
Close();
};
if (vm.Tasks is not null) if (vm.Tasks is not null)
vm.Tasks.IsWindowVisible = WindowState != WindowState.Minimized; vm.Tasks.IsWindowVisible = WindowState != WindowState.Minimized;
} }
@@ -55,6 +55,27 @@
</Grid> </Grid>
</Border> </Border>
<!-- Session-ended banner: process died without a start-failure already covering it -->
<Border DockPanel.Dock="Top"
IsVisible="{Binding Terminal.HasExitedAfterStartup}"
Background="{DynamicResource ErrorTintBrush}"
BorderBrush="{DynamicResource BloodBrush}"
BorderThickness="0,0,0,1" Padding="12,6">
<Grid ColumnDefinitions="*,Auto,Auto">
<TextBlock Grid.Column="0" Classes="meta" Text="{Binding ExitedBannerText}"
Foreground="{DynamicResource BloodBrush}"
TextWrapping="Wrap" VerticalAlignment="Center" />
<Button Grid.Column="1" Classes="btn" Margin="12,0,0,0"
Content="{loc:Tr missionControl.retry}"
ToolTip.Tip="{loc:Tr missionControl.retryTip}"
Command="{Binding RetryCommand}" />
<Button Grid.Column="2" Classes="btn" Margin="12,0,0,0"
Content="{loc:Tr missionControl.closeSession}"
ToolTip.Tip="{loc:Tr missionControl.closeSession}"
Command="{Binding CloseCommand}" />
</Grid>
</Border>
<!-- Embedded ConPTY terminal, with a starting overlay until the session is spawned --> <!-- Embedded ConPTY terminal, with a starting overlay until the session is spawned -->
<Panel> <Panel>
<views:InteractiveTerminalView DataContext="{Binding Terminal}" /> <views:InteractiveTerminalView DataContext="{Binding Terminal}" />
@@ -20,10 +20,10 @@
<KeyBinding Gesture="Enter" Command="{Binding SaveCommand}"/> <KeyBinding Gesture="Enter" Command="{Binding SaveCommand}"/>
</Window.KeyBindings> </Window.KeyBindings>
<ctl:ModalShell Title="{loc:Tr modals.listSettings.title}" CloseCommand="{Binding CancelCommand}"> <ctl:ModalShell Title="{Binding HeaderTitle}" CloseCommand="{Binding CancelCommand}">
<ctl:ModalShell.Footer> <ctl:ModalShell.Footer>
<Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center"> <Grid ColumnDefinitions="Auto,*,Auto" VerticalAlignment="Center">
<Button Grid.Column="0" Content="{loc:Tr modals.listSettings.deleteList}" Classes="danger" <Button Grid.Column="0" Content="{Binding DeleteLabel}" Classes="danger"
Command="{Binding DeleteCommand}" MinWidth="90"/> Command="{Binding DeleteCommand}" MinWidth="90"/>
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8"> <StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="8">
<Button Classes="btn" Content="{loc:Tr settings.cancel}" Command="{Binding CancelCommand}" MinWidth="90"/> <Button Classes="btn" Content="{loc:Tr settings.cancel}" Command="{Binding CancelCommand}" MinWidth="90"/>
@@ -40,8 +40,12 @@
VerticalAlignment="Top"> VerticalAlignment="Top">
<ListBoxItem Content="{loc:Tr modals.listSettings.sectionGeneral}"/> <ListBoxItem Content="{loc:Tr modals.listSettings.sectionGeneral}"/>
<ListBoxItem Content="{loc:Tr modals.listSettings.sectionIcon}"/> <ListBoxItem Content="{loc:Tr modals.listSettings.sectionIcon}"/>
<ListBoxItem Content="{loc:Tr modals.listSettings.sectionAgent}"/> <!-- A folder runs nothing, so it has neither agent config nor a verify command. The
<ListBoxItem Content="{loc:Tr modals.listSettings.sectionVerify}"/> TabItems stay in place (index-driven host) and just become unreachable. -->
<ListBoxItem Content="{loc:Tr modals.listSettings.sectionAgent}"
IsVisible="{Binding !IsFolder}"/>
<ListBoxItem Content="{loc:Tr modals.listSettings.sectionVerify}"
IsVisible="{Binding !IsFolder}"/>
</ListBox> </ListBox>
<Border Grid.Column="1" Background="{DynamicResource LineBrush}" Width="1" Margin="0,8"/> <Border Grid.Column="1" Background="{DynamicResource LineBrush}" Width="1" Margin="0,8"/>
@@ -69,48 +73,61 @@
<TabItem Header="{loc:Tr modals.listSettings.sectionGeneral}"> <TabItem Header="{loc:Tr modals.listSettings.sectionGeneral}">
<ScrollViewer> <ScrollViewer>
<StackPanel Spacing="12" Margin="0,8,0,0"> <StackPanel Spacing="12" Margin="0,8,0,0">
<!-- Create flow only: turns this into a sidebar folder (name + icon, nothing else).
Deliberately not a second sidebar button — the "+" and the repo-import button
already sit there. -->
<StackPanel Spacing="4" IsVisible="{Binding CanToggleFolder}">
<CheckBox IsChecked="{Binding IsFolder, Mode=TwoWay}"
Content="{loc:Tr modals.listSettings.createAsFolder}"/>
<TextBlock Text="{loc:Tr modals.listSettings.createAsFolderHint}"
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
</StackPanel>
<StackPanel Spacing="4"> <StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.name}"/> <TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.name}"/>
<TextBox Text="{Binding Name, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" /> <TextBox Text="{Binding Name, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel> </StackPanel>
<StackPanel Spacing="4"> <StackPanel Spacing="4" IsVisible="{Binding !IsFolder}">
<TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.workingDirectory}"/> <TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.workingDirectory}"/>
<Grid ColumnDefinitions="*,Auto"> <Grid ColumnDefinitions="*,Auto">
<TextBox Grid.Column="0" Text="{Binding WorkingDir}" PlaceholderText="{loc:Tr modals.listSettings.workingDirectoryPlaceholder}" /> <TextBox Grid.Column="0" Text="{Binding WorkingDir}" PlaceholderText="{loc:Tr modals.listSettings.workingDirectoryPlaceholder}" />
<Button Classes="btn" Grid.Column="1" Content="{loc:Tr modals.listSettings.browse}" Margin="8,0,0,0" Click="BrowseClicked" /> <Button Classes="btn" Grid.Column="1" Content="{loc:Tr modals.listSettings.browse}" Margin="8,0,0,0" Click="BrowseClicked" />
</Grid> </Grid>
<TextBlock Text="{Binding WorkingDirWarning}"
IsVisible="{Binding WorkingDirWarning, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
Foreground="{DynamicResource StatusErrorBrush}" FontSize="12" TextWrapping="Wrap"/>
</StackPanel> </StackPanel>
<StackPanel Spacing="4"> <StackPanel Spacing="4" IsVisible="{Binding !IsFolder}">
<TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.defaultCommitType}"/> <TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.defaultCommitType}"/>
<ComboBox ItemsSource="{Binding CommitTypeOptions}" <ComboBox ItemsSource="{Binding CommitTypeOptions}"
SelectedItem="{Binding DefaultCommitType, Mode=TwoWay}" SelectedItem="{Binding DefaultCommitType, Mode=TwoWay}"
HorizontalAlignment="Left" MinWidth="160" /> HorizontalAlignment="Left" MinWidth="160" />
</StackPanel> </StackPanel>
<StackPanel Spacing="4"> <StackPanel Spacing="4" IsVisible="{Binding !IsFolder}">
<CheckBox IsChecked="{Binding IsManual, Mode=TwoWay}" <CheckBox IsChecked="{Binding IsManual, Mode=TwoWay}"
Content="{loc:Tr modals.listSettings.manualList}"/> Content="{loc:Tr modals.listSettings.manualList}"/>
<TextBlock Text="{loc:Tr modals.listSettings.manualListHint}" <TextBlock Text="{loc:Tr modals.listSettings.manualListHint}"
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/> Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
</StackPanel> </StackPanel>
<StackPanel Spacing="4"> <StackPanel Spacing="4" IsVisible="{Binding !IsFolder}">
<CheckBox IsChecked="{Binding FindingsTracked, Mode=TwoWay}" <CheckBox IsChecked="{Binding FindingsTracked, Mode=TwoWay}"
Content="{loc:Tr modals.listSettings.findingsTracked}"/> Content="{loc:Tr modals.listSettings.findingsTracked}"/>
<TextBlock Text="{loc:Tr modals.listSettings.findingsTrackedHint}" <TextBlock Text="{loc:Tr modals.listSettings.findingsTrackedHint}"
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/> Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
</StackPanel> </StackPanel>
<StackPanel Spacing="4"> <StackPanel Spacing="4" IsVisible="{Binding !IsFolder}">
<CheckBox IsChecked="{Binding PowerUseAllowed, Mode=TwoWay}" <CheckBox IsChecked="{Binding PowerUseAllowed, Mode=TwoWay}"
Content="{loc:Tr modals.listSettings.powerUseAllowed}"/> Content="{loc:Tr modals.listSettings.powerUseAllowed}"/>
<TextBlock Text="{loc:Tr modals.listSettings.powerUseAllowedHint}" <TextBlock Text="{loc:Tr modals.listSettings.powerUseAllowedHint}"
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/> Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
</StackPanel> </StackPanel>
<StackPanel Spacing="4" IsVisible="{Binding TicketsAvailable}"> <StackPanel Spacing="4" IsVisible="{Binding ShowTicketProject}">
<TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.ticketProject}"/> <TextBlock Classes="field-label" Text="{loc:Tr modals.listSettings.ticketProject}"/>
<ComboBox ItemsSource="{Binding TicketProjects}" <ComboBox ItemsSource="{Binding TicketProjects}"
SelectedItem="{Binding SelectedTicketProject, Mode=TwoWay}" SelectedItem="{Binding SelectedTicketProject, Mode=TwoWay}"
@@ -29,11 +29,18 @@
<!-- Body: scope + select-all/none toolbar, headers, checkbox rows --> <!-- Body: scope + select-all/none toolbar, headers, checkbox rows -->
<DockPanel> <DockPanel>
<Grid DockPanel.Dock="Top" ColumnDefinitions="*,Auto,Auto" Margin="20,12,20,6"> <Grid DockPanel.Dock="Top" ColumnDefinitions="*,Auto,Auto,Auto" Margin="20,12,20,6">
<TextBlock Grid.Column="0" Classes="meta" Text="{Binding ScopeLabel}" VerticalAlignment="Center"/> <TextBlock Grid.Column="0" Classes="meta" Text="{Binding ScopeLabel}" VerticalAlignment="Center"/>
<Button Grid.Column="1" Classes="btn" Content="{loc:Tr modals.mergeHelper.selectAll}" <StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="6" Margin="0,0,12,0"
IsVisible="{Binding ShowModelPicker}"
ToolTip.Tip="{loc:Tr modals.mergeHelper.modelTip}">
<TextBlock Classes="meta" Text="{loc:Tr modals.mergeHelper.modelLabel}" VerticalAlignment="Center"/>
<ComboBox MinWidth="90" ItemsSource="{Binding ModelOptions}"
SelectedItem="{Binding SelectedModel, Mode=TwoWay}"/>
</StackPanel>
<Button Grid.Column="2" Classes="btn" Content="{loc:Tr modals.mergeHelper.selectAll}"
Command="{Binding SelectAllCommand}" IsEnabled="{Binding HasTasks}"/> Command="{Binding SelectAllCommand}" IsEnabled="{Binding HasTasks}"/>
<Button Grid.Column="2" Classes="btn" Content="{loc:Tr modals.mergeHelper.selectNone}" <Button Grid.Column="3" Classes="btn" Content="{loc:Tr modals.mergeHelper.selectNone}"
Command="{Binding SelectNoneCommand}" IsEnabled="{Binding HasTasks}" Margin="8,0,0,0"/> Command="{Binding SelectNoneCommand}" IsEnabled="{Binding HasTasks}" Margin="8,0,0,0"/>
</Grid> </Grid>
@@ -190,7 +190,8 @@
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/> Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
<Grid ColumnDefinitions="90,12,*,12,110" Margin="0,4,0,0"> <Grid ColumnDefinitions="90,12,*,12,110" Margin="0,4,0,0">
<TextBlock Grid.Column="0" Classes="eyebrow" Text="{loc:Tr settings.general.model}"/> <TextBlock Grid.Column="0" Classes="eyebrow" Text="{loc:Tr settings.general.model}"/>
<TextBlock Grid.Column="2" Classes="eyebrow" Text="{loc:Tr settings.general.effort}"/> <TextBlock Grid.Column="2" Classes="eyebrow" Text="{loc:Tr settings.general.effort}"
ToolTip.Tip="{loc:Tr settings.general.effortHint}"/>
<TextBlock Grid.Column="4" Classes="eyebrow" Text="{loc:Tr settings.general.maxTurns}"/> <TextBlock Grid.Column="4" Classes="eyebrow" Text="{loc:Tr settings.general.maxTurns}"/>
</Grid> </Grid>
<ItemsControl ItemsSource="{Binding General.ModelPresets}"> <ItemsControl ItemsSource="{Binding General.ModelPresets}">
@@ -307,7 +308,8 @@
</ScrollViewer> </ScrollViewer>
</TabItem> </TabItem>
<TabItem Header="{loc:Tr settings.sidebar.categoryPrime}"> <TabItem Header="{loc:Tr settings.sidebar.categoryPrime}"
ToolTip.Tip="{loc:Tr settings.sidebar.categoryPrimeHint}">
<ScrollViewer> <ScrollViewer>
<StackPanel Spacing="12" Margin="0,8,0,0"> <StackPanel Spacing="12" Margin="0,8,0,0">
<Border IsVisible="{Binding !HasLinkedRepo}" <Border IsVisible="{Binding !HasLinkedRepo}"
@@ -16,10 +16,14 @@
</Window.KeyBindings> </Window.KeyBindings>
<ctl:ModalShell Title="{loc:Tr modals.workerConnection.title}" CloseCommand="{Binding CloseCommand}"> <ctl:ModalShell Title="{loc:Tr modals.workerConnection.title}" CloseCommand="{Binding CloseCommand}">
<Grid RowDefinitions="*,Auto" Margin="20,16"> <Grid RowDefinitions="*,Auto,Auto" Margin="20,16">
<TextBlock Grid.Row="0" Classes="meta" TextWrapping="Wrap" <TextBlock Grid.Row="0" Classes="meta" TextWrapping="Wrap"
Text="{loc:Tr modals.workerConnection.body}"/> Text="{loc:Tr modals.workerConnection.body}"/>
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8" <TextBlock Grid.Row="1" TextWrapping="Wrap" Margin="0,8,0,0"
Foreground="{DynamicResource StatusErrorBrush}"
IsVisible="{Binding ErrorText, Converter={x:Static ObjectConverters.IsNotNull}}"
Text="{Binding ErrorText}"/>
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="8"
HorizontalAlignment="Right" Margin="0,16,0,0"> HorizontalAlignment="Right" Margin="0,16,0,0">
<Button Classes="btn" Content="{loc:Tr modals.workerConnection.dismiss}" Command="{Binding CloseCommand}"/> <Button Classes="btn" Content="{loc:Tr modals.workerConnection.dismiss}" Command="{Binding CloseCommand}"/>
<Button Classes="btn" Content="{loc:Tr modals.workerConnection.rerunInstaller}" Command="{Binding RerunInstallerCommand}"/> <Button Classes="btn" Content="{loc:Tr modals.workerConnection.rerunInstaller}" Command="{Binding RerunInstallerCommand}"/>
+4 -5
View File
@@ -30,7 +30,7 @@ Worker/
Findings/ — FindingsStore + FindingsStoreLocator: the per-project `.claudedo/` trap store Findings/ — FindingsStore + FindingsStoreLocator: the per-project `.claudedo/` trap store
Git/ — GitExcludeWriter (`.git/info/exclude`) + GitHead, shared by skill seeding and findings Git/ — GitExcludeWriter (`.git/info/exclude`) + GitHead, shared by skill seeding and findings
Config/ — WorkerConfig Config/ — WorkerConfig
Hub/ — WorkerHub, HubBroadcaster Hub/ — WorkerHub, HubBroadcaster, ConnectedClientTracker (connected-UI-client count)
Logging/ — LogRingBuffer (30-min window) + BroadcastLogSink (Serilog → footer + overlay) Logging/ — LogRingBuffer (30-min window) + BroadcastLogSink (Serilog → footer + overlay)
Report/ — ClaudeHistoryReader, WeekReportPromptBuilder, WeekReportService Report/ — ClaudeHistoryReader, WeekReportPromptBuilder, WeekReportService
Prime/ — "Prime Claude" schedules: PrimeScheduler, PrimeRunner, PrimePrompts, Prime/ — "Prime Claude" schedules: PrimeScheduler, PrimeRunner, PrimePrompts,
@@ -50,7 +50,7 @@ subfolder within their area; the namespace stays the area namespace.
## Architecture ## Architecture
- **Program.cs** — loads config, inits schema, registers DI, configures SignalR on `/hub`, binds to `127.0.0.1:47821` - **Program.cs** — loads config, inits schema, registers DI, configures SignalR on `/hub`, binds to `127.0.0.1:47821`
- **TaskStateService** — the **only** component that writes `Status`, `PlanningPhase`, `BlockedByTaskId`, and the `task_dependencies` edges. All transitions return a `TransitionResult` (no exceptions on invalid moves). Wakes the queue and advances the planning chain on child terminal transitions. Every transition broadcasts via a private `NotifyAsync(taskId)` — never call `_broadcaster.TaskUpdated` directly from this class, `NotifyAsync` also drives `TicketStatusSync.SyncAsync` (see Tickets below) and a direct call skips that transition's ticket write-back. `SetDependsOnAsync` replaces the task's whole dependency **set** (empty list clears it) and rejects a self-reference, an unknown dependency id, or any link that would create a cycle — a BFS over the whole `task_dependencies` edge set from every proposed predecessor, so a multi-edge loop (A→B→C→A) is caught too, and one bad edge leaves the entire set unwritten. - **TaskStateService** — the **only** component that writes `Status`, `PlanningPhase`, `BlockedByTaskId`, and the `task_dependencies` edges. All transitions return a `TransitionResult` (no exceptions on invalid moves). Wakes the queue and advances the planning chain on child terminal transitions. `SubmitForChildrenAsync` (Running → WaitingForChildren) also chains and immediately queues a normal parent's own Idle/Queued children via `PlanningChainCoordinator.SetupChainAsync(enqueue: true)` — unlike a planning parent (`PlanningPhase != None`), whose children stay untouched here and wait for the user's explicit "Queue plan" (`QueuePlanAsync`); a child already `Running` makes `SetupChainAsync` throw, which is caught and logged rather than failing the parent's own transition. Every transition broadcasts via a private `NotifyAsync(taskId)` — never call `_broadcaster.TaskUpdated` directly from this class, `NotifyAsync` also drives `TicketStatusSync.SyncAsync` (see Tickets below) and a direct call skips that transition's ticket write-back. `SetDependsOnAsync` replaces the task's whole dependency **set** (empty list clears it) and rejects a self-reference, an unknown dependency id, or any link that would create a cycle — a BFS over the whole `task_dependencies` edge set from every proposed predecessor, so a multi-edge loop (A→B→C→A) is caught too, and one bad edge leaves the entire set unwritten.
- **IQueueWaker / IQueuePicker / QueueService** — waker is a singleton `SemaphoreSlim`; picker performs the atomic `Queued → Running` claim filtered by `BlockedByTaskId IS NULL`, `is_manual = 0`, schedule, and "no `task_dependencies` edge whose predecessor isn't `done`" (doubly negated on purpose: zero edges stays eligible, all edges must be done, and an edge pointing at a vanished task keeps blocking); QueueService is a thin `BackgroundService` looping on the waker, dispatching via `TaskRunner`. Per tick it also applies the usage throttle and gate → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md). - **IQueueWaker / IQueuePicker / QueueService** — waker is a singleton `SemaphoreSlim`; picker performs the atomic `Queued → Running` claim filtered by `BlockedByTaskId IS NULL`, `is_manual = 0`, schedule, and "no `task_dependencies` edge whose predecessor isn't `done`" (doubly negated on purpose: zero edges stays eligible, all edges must be done, and an edge pointing at a vanished task keeps blocking); QueueService is a thin `BackgroundService` looping on the waker, dispatching via `TaskRunner`. Per tick it also applies the usage throttle and gate → [usage-monitoring](../../docs/explore-notes/usage-monitoring.md).
- **RunCancellationRegistry** — taskId → running-run CTS. Lets `TaskStateService.CancelAsync` kill a cancelled task's process without a DI cycle. - **RunCancellationRegistry** — taskId → running-run CTS. Lets `TaskStateService.CancelAsync` kill a cancelled task's process without a DI cycle.
- **OverrideSlotService** — owns `RunNow` / `ContinueTask`; goes through `TaskStateService.StartRunningAsync` (caller-driven, serialized by slot lock). - **OverrideSlotService** — owns `RunNow` / `ContinueTask`; goes through `TaskStateService.StartRunningAsync` (caller-driven, serialized by slot lock).
@@ -130,7 +130,7 @@ Full flow, invariants, and model/effort/max-turns resolution (including the low-
- **ClaudeProcess** — spawns `claude -p --output-format stream-json --verbose --permission-mode auto` (or whatever app settings specify). Prompt via stdin, NDJSON from stdout. CancellationToken kills the process tree. Resolves `_cfg.ClaudeBin` via `ExecutableResolver` (`ClaudeDo.Data`, shared with `ClaudeCliPreflight` and the Installer's checks) before spawning — a `.cmd`/`.bat` shim (e.g. an npm-installed `claude`) is launched through `cmd.exe /c` since `UseShellExecute = false` can't exec a shim directly; a resolved `.exe` starts exactly as before. Throws if nothing resolves. *(Not yet on `main` — see `Environment Checks` in `ClaudeDo.Installer/CLAUDE.md`.)* - **ClaudeProcess** — spawns `claude -p --output-format stream-json --verbose --permission-mode auto` (or whatever app settings specify). Prompt via stdin, NDJSON from stdout. CancellationToken kills the process tree. Resolves `_cfg.ClaudeBin` via `ExecutableResolver` (`ClaudeDo.Data`, shared with `ClaudeCliPreflight` and the Installer's checks) before spawning — a `.cmd`/`.bat` shim (e.g. an npm-installed `claude`) is launched through `cmd.exe /c` since `UseShellExecute = false` can't exec a shim directly; a resolved `.exe` starts exactly as before. Throws if nothing resolves. *(Not yet on `main` — see `Environment Checks` in `ClaudeDo.Installer/CLAUDE.md`.)*
- **ClaudeArgsBuilder** — `--model`, `--effort`, `--max-turns`, `--append-system-prompt`, `--agents`, `--json-schema`, `--resume` - **ClaudeArgsBuilder** — `--model`, `--effort`, `--max-turns`, `--append-system-prompt`, `--agents`, `--json-schema`, `--resume`
- **StreamAnalyzer** — parses NDJSON; extracts session_id, token counts, turn counts, result text, structured output. Replaced MessageParser. - **StreamAnalyzer** — parses NDJSON; extracts session_id, token counts, turn counts, result text, structured output. Replaced MessageParser.
- **WorktreeManager** — worktrees on `claudedo/{taskId[:8]}` branches; commits with semantic messages, updates DB with head commit + diff stats - **WorktreeManager** — worktrees on `claudedo/{id}` branches (full task GUID, dashes stripped — a prefix isn't enough to rule out collisions); commits with semantic messages, updates DB with head commit + diff stats
- **CommitMessageBuilder** — `{commitType}(slug): title\n\ndescription\n\nClaudeDo-Task: taskId`; `BuildMerge` is the merge-commit variant (`{commitType}(slug): merge title` + trailer). **Every merge caller passes a blank commit message on purpose** — `TaskMergeService` fills in `BuildMerge` from the task's commit type and its list's name, which is the only place that knows both. Don't reintroduce a caller-side literal. - **CommitMessageBuilder** — `{commitType}(slug): title\n\ndescription\n\nClaudeDo-Task: taskId`; `BuildMerge` is the merge-commit variant (`{commitType}(slug): merge title` + trailer). **Every merge caller passes a blank commit message on purpose** — `TaskMergeService` fills in `BuildMerge` from the task's commit type and its list's name, which is the only place that knows both. Don't reintroduce a caller-side literal.
- **TaskResetService** — discards a failed task's worktree, resets the row to Idle, preserves run history - **TaskResetService** — discards a failed task's worktree, resets the row to Idle, preserves run history
- **AgentFileService** — manages `~/.claudeDo/agents/*.md`; list/refresh via SignalR - **AgentFileService** — manages `~/.claudeDo/agents/*.md`; list/refresh via SignalR
@@ -202,8 +202,7 @@ launch specs · worktrees · agents/settings/lists · reports/notes/prep · diag
- `PrepLine` - `PrepLine`
- `PrepFinished` - `PrepFinished`
- `OperationProgress` (generic `(opKey, phase, current, total)` channel; merge phases are its - `OperationProgress` (generic `(opKey, phase, current, total)` channel; merge phases are its
first producer — `IWorkerClient.MergeProgressEvent` on the Ui side is a thin forwarder over it first producer)
for existing consumers, elapsed seconds riding in the `current` slot)
- `PlanningMergeStarted` - `PlanningMergeStarted`
- `PlanningSubtaskMerged` - `PlanningSubtaskMerged`
- `PlanningMergeConflict` - `PlanningMergeConflict`
+17 -6
View File
@@ -7,32 +7,36 @@ using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.External; namespace ClaudeDo.Worker.External;
public sealed record StartListHandlerResult(bool Requested, string ListId, string ListName, int TaskCount, IReadOnlyList<int> TaskNumbers); public sealed record StartListHandlerResult(bool Requested, string ListId, string ListName, int TaskCount, IReadOnlyList<int> TaskNumbers, string? Reason = null);
// The "Let Claude handle it" button, reachable from outside the UI. The worker only broadcasts; // The "Let Claude handle it" button, reachable from outside the UI. The worker only broadcasts;
// the App owns the ConPTY tile and creates the handler task exactly as the button does // the App owns the ConPTY tile and creates the handler task exactly as the button does
// (MissionControlViewModel.OpenMergeHelperConPtySessionAsync), so there is one code path for // (MissionControlViewModel.OpenMergeHelperConPtySessionAsync), so there is one code path for
// starting a run, not two. Without a connected App nothing happens -- the result says so. // starting a run, not two. Without a connected App the broadcast has nobody to receive it, so
// ConnectedClientTracker is checked first and Requested comes back false with a Reason instead of
// an unconditional true.
[McpServerToolType] [McpServerToolType]
public sealed class ListHandlerMcpTools public sealed class ListHandlerMcpTools
{ {
private readonly ListRepository _lists; private readonly ListRepository _lists;
private readonly TaskRepository _tasks; private readonly TaskRepository _tasks;
private readonly HubBroadcaster _broadcaster; private readonly HubBroadcaster _broadcaster;
private readonly ConnectedClientTracker _clients;
public ListHandlerMcpTools(ListRepository lists, TaskRepository tasks, HubBroadcaster broadcaster) public ListHandlerMcpTools(ListRepository lists, TaskRepository tasks, HubBroadcaster broadcaster, ConnectedClientTracker clients)
{ {
_lists = lists; _lists = lists;
_tasks = tasks; _tasks = tasks;
_broadcaster = broadcaster; _broadcaster = broadcaster;
_clients = clients;
} }
[McpServerTool, Description( [McpServerTool, Description(
"Start a list-handler run (\"Let Claude handle it\") for a list, exactly like the button in " + "Start a list-handler run (\"Let Claude handle it\") for a list, exactly like the button in " +
"Mission Control: the connected ClaudeDo App creates the handler task and opens the triage " + "Mission Control: the connected ClaudeDo App creates the handler task and opens the triage " +
"ConPTY tile for the given tasks. taskIds defaults to every Idle, non-manual task of the list. " + "ConPTY tile for the given tasks. taskIds defaults to every Idle, non-manual task of the list. " +
"Requires the App to be running and connected to this worker -- the worker only broadcasts " + "Requires the App to be running and connected to this worker -- if it isn't, Requested comes " +
"the request. taskIds accept task numbers (#123 or bare 123).")] "back false with a reason and nothing is started. taskIds accept task numbers (#123 or bare 123).")]
public async Task<StartListHandlerResult> StartListHandler( public async Task<StartListHandlerResult> StartListHandler(
[Description("The list to run the handler on.")] string listId, [Description("The list to run the handler on.")] string listId,
[Description("Tasks to hand to the run, in the order to triage them. Omit for all Idle, non-manual tasks of the list.")] IReadOnlyList<string>? taskIds = null, [Description("Tasks to hand to the run, in the order to triage them. Omit for all Idle, non-manual tasks of the list.")] IReadOnlyList<string>? taskIds = null,
@@ -65,8 +69,15 @@ public sealed class ListHandlerMcpTools
if (chosen.Count == 0) if (chosen.Count == 0)
throw new InvalidOperationException($"No tasks to hand to the list handler for {list.Name}."); throw new InvalidOperationException($"No tasks to hand to the list handler for {list.Name}.");
var numbers = chosen.Select(t => t.Number).ToList();
if (!_clients.AnyConnected)
{
return new StartListHandlerResult(false, listId, list.Name, chosen.Count, numbers,
"No ClaudeDo App is connected to this worker, so the request could not be delivered. Open the app and retry.");
}
var ids = chosen.Select(t => t.Id).ToList(); var ids = chosen.Select(t => t.Id).ToList();
await _broadcaster.ListHandlerStartRequested(listId, ids); await _broadcaster.ListHandlerStartRequested(listId, ids);
return new StartListHandlerResult(true, listId, list.Name, ids.Count, chosen.Select(t => t.Number).ToList()); return new StartListHandlerResult(true, listId, list.Name, ids.Count, numbers);
} }
} }
@@ -0,0 +1,19 @@
namespace ClaudeDo.Worker.Hub;
/// <summary>
/// Counts SignalR clients (the ClaudeDo App) currently connected to <see cref="WorkerHub"/>.
/// A <c>Hub</c> instance is transient per connection, so this lives as a DI singleton and is
/// updated from <see cref="WorkerHub.OnConnectedAsync"/>/<see cref="WorkerHub.OnDisconnectedAsync"/>
/// — the only way an out-of-band caller (external MCP's start_list_handler, which only
/// broadcasts) can tell whether anything is listening before claiming it did something.
/// </summary>
public sealed class ConnectedClientTracker
{
private int _count;
public bool AnyConnected => Volatile.Read(ref _count) > 0;
public void Increment() => Interlocked.Increment(ref _count);
public void Decrement() => Interlocked.Decrement(ref _count);
}
+27 -4
View File
@@ -74,6 +74,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
private readonly TicketImportService _ticketImport; private readonly TicketImportService _ticketImport;
private readonly HandlerRestartCoordinator? _handlerRestart; private readonly HandlerRestartCoordinator? _handlerRestart;
private readonly HandlerRoundState? _roundState; private readonly HandlerRoundState? _roundState;
private readonly ConnectedClientTracker? _clientTracker;
public WorkerHub( public WorkerHub(
QueueService queue, QueueService queue,
@@ -114,7 +115,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
TicketClientFactory ticketClients = null!, TicketClientFactory ticketClients = null!,
TicketImportService ticketImport = null!, TicketImportService ticketImport = null!,
HandlerRestartCoordinator? handlerRestart = null, HandlerRestartCoordinator? handlerRestart = null,
HandlerRoundState? roundState = null) HandlerRoundState? roundState = null,
ConnectedClientTracker? clientTracker = null)
{ {
_queue = queue; _queue = queue;
_waker = waker; _waker = waker;
@@ -155,6 +157,21 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
_ticketImport = ticketImport; _ticketImport = ticketImport;
_handlerRestart = handlerRestart; _handlerRestart = handlerRestart;
_roundState = roundState; _roundState = roundState;
_clientTracker = clientTracker;
}
// Tracks connected UI clients so an out-of-band caller (external MCP's start_list_handler)
// can tell whether the broadcast it just sent had anyone to receive it.
public override Task OnConnectedAsync()
{
_clientTracker?.Increment();
return base.OnConnectedAsync();
}
public override Task OnDisconnectedAsync(Exception? exception)
{
_clientTracker?.Decrement();
return base.OnDisconnectedAsync(exception);
} }
// Persistence boundary for the session_skills JSON-array columns (task/list/global). // Persistence boundary for the session_skills JSON-array columns (task/list/global).
@@ -770,12 +787,13 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
// Builds the launch spec for an embedded ConPTY "merge helper" session that drives the given // Builds the launch spec for an embedded ConPTY "merge helper" session that drives the given
// tasks to a merged/Done state via the mcp__claudedo__* tools. listId scopes the brief label // tasks to a merged/Done state via the mcp__claudedo__* tools. listId scopes the brief label
// and cwd to that list. handlerTaskId is the id returned by CreateMergeHelperTask -- it is // and cwd to that list. handlerTaskId is the id returned by CreateMergeHelperTask -- it is
// rendered into the brief so the session can call handoff_list_handler on itself. // rendered into the brief so the session can call handoff_list_handler on itself. model
public Task<LaunchSpec> GetMergeHelperLaunchSpec(string[] taskIds, string listId, string handlerTaskId) => HubGuard(() => // overrides the triage session's model (null = default opus).
public Task<LaunchSpec> GetMergeHelperLaunchSpec(string[] taskIds, string listId, string handlerTaskId, string? model) => HubGuard(() =>
{ {
if (_interactiveLaunchSpec is null) if (_interactiveLaunchSpec is null)
throw new InvalidOperationException("Interactive launch spec service is not configured."); throw new InvalidOperationException("Interactive launch spec service is not configured.");
return _interactiveLaunchSpec.BuildForMergeHelperAsync(taskIds, listId, handlerTaskId, Context.ConnectionAborted); return _interactiveLaunchSpec.BuildForMergeHelperAsync(taskIds, listId, handlerTaskId, model, Context.ConnectionAborted);
}); });
// Creates the ClaudeDo task that owns a list-handler run, before the ConPTY tile opens -- // Creates the ClaudeDo task that owns a list-handler run, before the ConPTY tile opens --
@@ -1376,6 +1394,11 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
try try
{ {
var result = await _ticketImport.ImportAsync(listId, Context.ConnectionAborted); var result = await _ticketImport.ImportAsync(listId, Context.ConnectionAborted);
// ListUpdated allein reicht nicht: die UI liest das als "Listen-Metadaten geaendert"
// und patcht nur bestehende Zeilen. Neue Tasks tauchen nur ueber TaskUpdated auf, wie
// bei jedem anderen out-of-band Creator (add_task und Co.).
foreach (var taskId in result.CreatedTaskIds)
await _broadcaster.TaskUpdated(taskId);
await _broadcaster.ListUpdated(listId); await _broadcaster.ListUpdated(listId);
return new TicketImportResultDto(result.Examined, result.Created); return new TicketImportResultDto(result.Examined, result.Created);
} }
@@ -0,0 +1,19 @@
namespace ClaudeDo.Worker.Lifecycle;
/// <summary>
/// Formats the Fatal log line for a startup/bind failure (e.g. a port already in use) so
/// Program.cs can log a diagnosable message and exit cleanly instead of crashing unhandled.
/// </summary>
public static class WorkerStartupFailure
{
public const int ExitCode = 1;
public static string FormatBindFailureMessage(Exception ex, int signalRPort, int externalMcpPort)
{
var ports = externalMcpPort > 0
? $"ports {signalRPort} and {externalMcpPort}"
: $"port {signalRPort}";
return $"ClaudeDo.Worker failed to start on {ports}: {ex.Message} " +
"Is another worker instance already running, or is another process using this port?";
}
}
+30 -8
View File
@@ -31,10 +31,11 @@ using ClaudeDo.Worker.Worktrees;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Serilog; using Serilog;
// Single-instance per user session. Multiple launch paths exist (logon task, // Single instance machine-wide. Multiple launch paths exist (logon task, app
// app ensure-running, Restart button); a second instance exits cleanly instead // ensure-running, Restart button) and a Remote Desktop / Fast User Switching session
// of fighting over the SignalR port. // gets its own `Local\` namespace, so `Global\` is required or a second session's
var mutex = new Mutex(true, @"Local\ClaudeDoWorker", out var createdNew); // worker silently fights the first one over the SignalR port instead of exiting.
var mutex = new Mutex(true, @"Global\ClaudeDoWorker", out var createdNew);
if (!createdNew) if (!createdNew)
return; // another instance already owns the port; exit 0 return; // another instance already owns the port; exit 0
@@ -65,6 +66,14 @@ builder.Host.UseSerilog((ctx, lc) => lc
.WriteTo.Sink(broadcastSink)); .WriteTo.Sink(broadcastSink));
builder.Services.AddSingleton(logBuffer); builder.Services.AddSingleton(logBuffer);
// Last-resort net: any exception that misses every other handler (e.g. a crash on a
// background thread) still gets one Fatal line in the log before the process dies.
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
{
Log.Fatal(e.ExceptionObject as Exception, "Unhandled exception, terminating (IsTerminating: {IsTerminating})", e.IsTerminating);
Log.CloseAndFlush();
};
builder.Services.AddDbContextFactory<ClaudeDoDbContext>(opt => builder.Services.AddDbContextFactory<ClaudeDoDbContext>(opt =>
opt.UseSqlite($"Data Source={cfg.DbPath}")); opt.UseSqlite($"Data Source={cfg.DbPath}"));
@@ -90,6 +99,7 @@ builder.Services.AddSignalR(options => options.MaximumParallelInvocationsPerClie
// Runner stack. // Runner stack.
builder.Services.AddSingleton<IClaudeProcess, ClaudeProcess>(); builder.Services.AddSingleton<IClaudeProcess, ClaudeProcess>();
builder.Services.AddSingleton<HubBroadcaster>(); builder.Services.AddSingleton<HubBroadcaster>();
builder.Services.AddSingleton<ConnectedClientTracker>();
builder.Services.AddSingleton<GitService>(); builder.Services.AddSingleton<GitService>();
builder.Services.AddSingleton<WorktreeManager>(); builder.Services.AddSingleton<WorktreeManager>();
builder.Services.AddSingleton<ClaudeArgsBuilder>(); builder.Services.AddSingleton<ClaudeArgsBuilder>();
@@ -328,6 +338,7 @@ if (cfg.ExternalMcpPort > 0)
externalBuilder.Services.AddSingleton(cfg); externalBuilder.Services.AddSingleton(cfg);
Share<HubBroadcaster>(); Share<HubBroadcaster>();
Share<ConnectedClientTracker>();
Share<QueueService>(); Share<QueueService>();
Share<OverrideSlotService>(); Share<OverrideSlotService>();
Share<IDbContextFactory<ClaudeDoDbContext>>(); Share<IDbContextFactory<ClaudeDoDbContext>>();
@@ -404,13 +415,24 @@ if (cfg.ExternalMcpPort > 0)
string.IsNullOrEmpty(cfg.ExternalMcpApiKey) ? "loopback-only" : "X-ClaudeDo-Key"); string.IsNullOrEmpty(cfg.ExternalMcpApiKey) ? "loopback-only" : "X-ClaudeDo-Key");
} }
if (externalApp is null) try
{ {
await app.RunAsync(); if (externalApp is null)
{
await app.RunAsync();
}
else
{
await Task.WhenAll(app.RunAsync(), externalApp.RunAsync());
}
} }
else catch (Exception ex)
{ {
await Task.WhenAll(app.RunAsync(), externalApp.RunAsync()); // Most commonly a Kestrel bind failure (port already in use by a second session's
// worker or an unrelated process) — log it plainly instead of an unhandled crash.
Log.Fatal(ex, "{Message}", WorkerStartupFailure.FormatBindFailureMessage(ex, cfg.SignalRPort, cfg.ExternalMcpPort));
Log.CloseAndFlush();
Environment.Exit(WorkerStartupFailure.ExitCode);
} }
GC.KeepAlive(mutex); GC.KeepAlive(mutex);
@@ -10,7 +10,7 @@ namespace ClaudeDo.Worker.Report;
public sealed class WeekReportService public sealed class WeekReportService
{ {
private static readonly string[] DefaultExcludes = { @"C:\Private" }; private static readonly string[] DefaultExcludes = Array.Empty<string>();
private const string NoActivity = "_No activity in this period._"; private const string NoActivity = "_No activity in this period._";
private readonly IClaudeHistoryReader _reader; private readonly IClaudeHistoryReader _reader;
@@ -72,7 +72,7 @@ public sealed class WeekReportService
IReadOnlyList<string> args = IReadOnlyList<string> args =
[ [
"-p", "--output-format", "stream-json", "--verbose", "-p", "--output-format", "stream-json", "--verbose",
"--permission-mode", "auto", "--permission-mode", PermissionModeResolver.Resolve(safeModel, "auto"),
"--model", safeModel, "--model", safeModel,
]; ];
var result = await _claude.RunAsync(args, prompt, Path.GetTempPath(), _ => Task.CompletedTask, ct); var result = await _claude.RunAsync(args, prompt, Path.GetTempPath(), _ => Task.CompletedTask, ct);
@@ -290,8 +290,10 @@ public sealed class InteractiveLaunchSpecService
/// handlerTaskId (the id returned by CreateMergeHelperTaskAsync) is rendered into the brief so /// handlerTaskId (the id returned by CreateMergeHelperTaskAsync) is rendered into the brief so
/// the session can call handoff_list_handler/submit_task_for_review on its own handler task. /// the session can call handoff_list_handler/submit_task_for_review on its own handler task.
/// Throws KeyNotFoundException if the list doesn't exist; InvalidOperationException if /// Throws KeyNotFoundException if the list doesn't exist; InvalidOperationException if
/// taskIds is empty or the list has no existing working directory.</summary> /// taskIds is empty or the list has no existing working directory.
public async Task<LaunchSpec> BuildForMergeHelperAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, CancellationToken ct) /// model overrides the triage session's model (start-modal dropdown); null/unknown falls
/// back to <see cref="ModelRegistry.HandlerTriageAlias"/>. Handoff phases are unaffected.</summary>
public async Task<LaunchSpec> BuildForMergeHelperAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, string? model, CancellationToken ct)
{ {
if (taskIds.Count == 0) if (taskIds.Count == 0)
throw new InvalidOperationException("No tasks selected for the list handler."); throw new InvalidOperationException("No tasks selected for the list handler.");
@@ -344,12 +346,13 @@ public sealed class InteractiveLaunchSpecService
// longer influences a handler session at all, it would otherwise apply the LIST's effort // longer influences a handler session at all, it would otherwise apply the LIST's effort
// preset to a completely different model. // preset to a completely different model.
var settings = await new AppSettingsRepository(ctx).GetAsync(ct); var settings = await new AppSettingsRepository(ctx).GetAsync(ct);
var triageModel = ModelRegistry.TryNormalizeAlias(model) ?? ModelRegistry.HandlerTriageAlias;
var args = new List<string> var args = new List<string>
{ {
"--model", ModelRegistry.HandlerTriageAlias, "--model", triageModel,
"--effort", EffortFor(settings, ModelRegistry.HandlerTriageAlias), "--effort", EffortFor(settings, triageModel),
"--permission-mode", PermissionModeResolver.Resolve(ModelRegistry.HandlerTriageAlias, "auto"), "--permission-mode", PermissionModeResolver.Resolve(triageModel, "auto"),
"--allowedTools", MergeHelperAllowedTools, "--allowedTools", MergeHelperAllowedTools,
"--add-dir", sessionDir, repoDir, "--add-dir", sessionDir, repoDir,
"--append-system-prompt-file", systemPromptPath, "--append-system-prompt-file", systemPromptPath,
+4 -6
View File
@@ -553,8 +553,6 @@ public sealed class TaskRunner
var transition = await _state.SubmitForChildrenAsync(task.Id, finishedAt, reviewResult, CancellationToken.None); var transition = await _state.SubmitForChildrenAsync(task.Id, finishedAt, reviewResult, CancellationToken.None);
if (!transition.Ok) if (!transition.Ok)
_logger.LogWarning("SubmitForChildrenAsync rejected for task {TaskId}: {Reason}", task.Id, transition.Reason); _logger.LogWarning("SubmitForChildrenAsync rejected for task {TaskId}: {Reason}", task.Id, transition.Reason);
foreach (var child in pendingChildren)
await _state.EnqueueAsync(child.Id, CancellationToken.None);
await _broadcaster.WorkerLog( await _broadcaster.WorkerLog(
$"Finished #{task.Number} \"{task.Title}\" (waiting on {pendingChildren.Count} improvement(s))", $"Finished #{task.Number} \"{task.Title}\" (waiting on {pendingChildren.Count} improvement(s))",
WorkerLogLevel.Success, DateTime.UtcNow); WorkerLogLevel.Success, DateTime.UtcNow);
@@ -791,10 +789,10 @@ public sealed class TaskRunner
switch (result.TerminalReason) switch (result.TerminalReason)
{ {
case "max_turns": case "max_turns":
var ofConfigured = configuredMaxTurns is int m ? $" von {m}" : ""; var ofConfigured = configuredMaxTurns is int m ? $" of {m}" : "";
return $"Turn-Budget erschöpft ({result.TurnCount}{ofConfigured} Turns). " return $"Turn budget exhausted ({result.TurnCount}{ofConfigured} turns). "
+ "Erhöhe `maxTurns` am Task (`set_task_config`) und stelle den Task erneut in die Queue " + "Raise `maxTurns` on the task (`set_task_config`) and re-queue it "
+ "– der Worktree bleibt erhalten."; + "- the worktree is kept.";
case "api_error": case "api_error":
if (!string.IsNullOrWhiteSpace(result.ResultMarkdown)) return result.ResultMarkdown; if (!string.IsNullOrWhiteSpace(result.ResultMarkdown)) return result.ResultMarkdown;
+33 -9
View File
@@ -159,16 +159,40 @@ public sealed class TaskStateService : ITaskStateService
public async Task<TransitionResult> SubmitForChildrenAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct) public async Task<TransitionResult> SubmitForChildrenAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct)
{ {
await using var ctx = await _dbFactory.CreateDbContextAsync(ct); PlanningPhase planningPhase;
var affected = await ctx.Tasks await using (var ctx = await _dbFactory.CreateDbContextAsync(ct))
.Where(t => t.Id == taskId && t.Status == TaskStatus.Running) {
.ExecuteUpdateAsync(s => s var affected = await ctx.Tasks
.SetProperty(t => t.Status, TaskStatus.WaitingForChildren) .Where(t => t.Id == taskId && t.Status == TaskStatus.Running)
.SetProperty(t => t.FinishedAt, finishedAt) .ExecuteUpdateAsync(s => s
.SetProperty(t => t.Result, result), ct); .SetProperty(t => t.Status, TaskStatus.WaitingForChildren)
.SetProperty(t => t.FinishedAt, finishedAt)
.SetProperty(t => t.Result, result), ct);
if (affected == 0) if (affected == 0)
return new TransitionResult(false, "Task not running; cannot submit for children."); return new TransitionResult(false, "Task not running; cannot submit for children.");
planningPhase = await ctx.Tasks.AsNoTracking()
.Where(t => t.Id == taskId)
.Select(t => t.PlanningPhase)
.FirstAsync(ct);
}
// A normal (non-planning) parent queues its own children as a sequential chain right
// here; a planning parent's children wait for the user's explicit "Queue plan" action
// (PlanningChainCoordinator.QueuePlanAsync), so leave those untouched. A child that's
// already Running makes SetupChainAsync throw — log it rather than fail this transition.
if (planningPhase == PlanningPhase.None)
{
try
{
await _chain.SetupChainAsync(taskId, enqueue: true, ct);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "SetupChainAsync failed for parent {TaskId}", taskId);
}
}
await NotifyAsync(taskId); await NotifyAsync(taskId);
return new TransitionResult(true, null); return new TransitionResult(true, null);
@@ -6,7 +6,7 @@ using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tickets; namespace ClaudeDo.Worker.Tickets;
public sealed record TicketImportResult(int Examined, int Created); public sealed record TicketImportResult(int Examined, int Created, IReadOnlyList<string> CreatedTaskIds);
/// <summary> /// <summary>
/// Holt das Board des verknüpften Ticket-Projekts und legt für jedes offene, dem Token-Inhaber /// Holt das Board des verknüpften Ticket-Projekts und legt für jedes offene, dem Token-Inhaber
@@ -61,16 +61,17 @@ public sealed class TicketImportService
var knownSet = known.ToHashSet(StringComparer.OrdinalIgnoreCase); var knownSet = known.ToHashSet(StringComparer.OrdinalIgnoreCase);
var repo = new TaskRepository(ctx); var repo = new TaskRepository(ctx);
var created = 0; var created = new List<string>();
foreach (var ticket in mine) foreach (var ticket in mine)
{ {
var reference = $"bandel:{ticket.Id}"; var reference = $"bandel:{ticket.Id}";
if (!knownSet.Add(reference)) continue; if (!knownSet.Add(reference)) continue;
var taskId = Guid.NewGuid().ToString();
await repo.AddAsync(new TaskEntity await repo.AddAsync(new TaskEntity
{ {
Id = Guid.NewGuid().ToString(), Id = taskId,
ListId = listId, ListId = listId,
Title = ticket.Title, Title = ticket.Title,
Description = BuildDescription(ticket), Description = BuildDescription(ticket),
@@ -79,14 +80,14 @@ public sealed class TicketImportService
TicketRef = reference, TicketRef = reference,
CreatedBy = "ticketsystem", CreatedBy = "ticketsystem",
}, ct); }, ct);
created++; created.Add(taskId);
} }
_logger.LogInformation( _logger.LogInformation(
"Ticket-Import für Liste {list_id}: {examined} geprüft, {created} neue Tasks", "Ticket-Import für Liste {list_id}: {examined} geprüft, {created} neue Tasks",
listId, board.Count, created); listId, board.Count, created.Count);
return new TicketImportResult(board.Count, created); return new TicketImportResult(board.Count, created.Count, created);
} }
/// <summary> /// <summary>
@@ -0,0 +1,147 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Data.Tests;
public sealed class ListFolderTests : IDisposable
{
private readonly string _dbPath;
private readonly DbContextOptions<ClaudeDoDbContext> _options;
public ListFolderTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_folders_{Guid.NewGuid():N}.db");
_options = new DbContextOptionsBuilder<ClaudeDoDbContext>()
.UseSqlite($"Data Source={_dbPath}")
.Options;
using var ctx = new ClaudeDoDbContext(_options);
ctx.Database.EnsureCreated();
}
public void Dispose()
{
foreach (var suffix in new[] { "", "-wal", "-shm" })
try { File.Delete(_dbPath + suffix); } catch { }
}
private ClaudeDoDbContext Open() => new(_options);
private static ListFolderEntity NewFolder(string id, string name = "Group") =>
new() { Id = id, Name = name, CreatedAt = DateTime.UtcNow };
[Fact]
public async Task Deleting_a_folder_keeps_its_lists_and_moves_them_to_the_top_level()
{
await using (var ctx = Open())
{
var repo = new ListRepository(ctx);
await repo.AddFolderAsync(NewFolder("f1"));
await repo.AddAsync(new ListEntity { Id = "l1", Name = "Inside", CreatedAt = DateTime.UtcNow, FolderId = "f1" });
}
await using (var ctx = Open())
await new ListRepository(ctx).DeleteFolderAsync("f1");
await using (var ctx = Open())
{
var list = await ctx.Lists.AsNoTracking().FirstAsync(l => l.Id == "l1");
Assert.Null(list.FolderId);
Assert.Empty(await ctx.ListFolders.AsNoTracking().ToListAsync());
}
}
[Fact]
public async Task RenameFolderAsync_keeps_sort_order_and_collapse_state()
{
// The whole reason it patches instead of Update()-ing a rebuilt entity: a rename must not
// silently unfold the folder or shuffle it to the top.
await using (var ctx = Open())
{
var repo = new ListRepository(ctx);
await repo.AddFolderAsync(NewFolder("f1", "First"));
await repo.AddFolderAsync(NewFolder("f2", "Second"));
await repo.SetFolderCollapsedAsync("f2", true);
}
await using (var ctx = Open())
await new ListRepository(ctx).RenameFolderAsync("f2", "Renamed", "Star");
await using (var ctx = Open())
{
var folder = await ctx.ListFolders.AsNoTracking().FirstAsync(f => f.Id == "f2");
Assert.Equal("Renamed", folder.Name);
Assert.Equal("Star", folder.Icon);
Assert.True(folder.IsCollapsed);
Assert.Equal(1, folder.SortOrder);
}
}
[Fact]
public async Task SetListFolderAsync_moves_a_list_in_and_back_out()
{
await using (var ctx = Open())
{
var repo = new ListRepository(ctx);
await repo.AddFolderAsync(NewFolder("f1"));
await repo.AddAsync(new ListEntity { Id = "l1", Name = "Roaming", CreatedAt = DateTime.UtcNow });
}
await using (var ctx = Open())
await new ListRepository(ctx).SetListFolderAsync("l1", "f1");
await using (var ctx = Open())
Assert.Equal("f1", (await ctx.Lists.AsNoTracking().FirstAsync(l => l.Id == "l1")).FolderId);
await using (var ctx = Open())
await new ListRepository(ctx).SetListFolderAsync("l1", null);
await using (var ctx = Open())
Assert.Null((await ctx.Lists.AsNoTracking().FirstAsync(l => l.Id == "l1")).FolderId);
}
[Fact]
public async Task ReorderSidebarAsync_numbers_folders_and_lists_in_one_sequence()
{
await using var ctx = Open();
var repo = new ListRepository(ctx);
await repo.AddFolderAsync(NewFolder("f1"));
await repo.AddAsync(new ListEntity { Id = "inside", Name = "Inside", CreatedAt = DateTime.UtcNow, FolderId = "f1" });
await repo.AddAsync(new ListEntity { Id = "top", Name = "Top", CreatedAt = DateTime.UtcNow });
// Sidebar: the top-level list above the folder, whose one child follows the folder.
await repo.ReorderSidebarAsync([("top", false), ("f1", true), ("inside", false)]);
Assert.Equal(0, (await repo.GetByIdAsync("top"))!.SortOrder);
Assert.Equal(1, (await repo.GetFoldersAsync()).Single().SortOrder);
Assert.Equal(2, (await repo.GetByIdAsync("inside"))!.SortOrder);
}
[Fact]
public async Task AddAsync_appends_past_the_folders_too()
{
await using var ctx = Open();
var repo = new ListRepository(ctx);
await repo.AddFolderAsync(NewFolder("f1"));
await repo.AddFolderAsync(NewFolder("f2"));
await repo.AddAsync(new ListEntity { Id = "l1", Name = "Fresh", CreatedAt = DateTime.UtcNow });
// 2, not 0 — a new list must not jump above the existing folders on the next load.
Assert.Equal(2, (await repo.GetByIdAsync("l1"))!.SortOrder);
}
[Fact]
public async Task AddFolderAsync_appends_in_creation_order()
{
await using var ctx = Open();
var repo = new ListRepository(ctx);
await repo.AddFolderAsync(NewFolder("f1", "One"));
await repo.AddFolderAsync(NewFolder("f2", "Two"));
var folders = await repo.GetFoldersAsync();
Assert.Equal(["One", "Two"], folders.Select(f => f.Name));
Assert.Equal([0, 1], folders.Select(f => f.SortOrder));
}
}
@@ -0,0 +1,84 @@
using System.Text;
using System.Text.Json;
namespace ClaudeDo.Localization.Tests;
/// <summary>
/// JsonDocument/JsonElement silently keep the last value on a duplicate key instead of throwing,
/// so a repeated top-level block (e.g. two "checks" objects) is invisible to JSON-validity and
/// parity tests alike. This walks the raw token stream instead.
/// </summary>
public class DuplicateLocaleKeysTests
{
private static string LocalesDir()
{
var dir = AppContext.BaseDirectory;
while (dir is not null)
{
var candidate = Path.Combine(dir, "src", "ClaudeDo.Localization", "locales");
if (Directory.Exists(candidate)) return candidate;
dir = Path.GetDirectoryName(dir);
}
throw new DirectoryNotFoundException("Could not locate src/ClaudeDo.Localization/locales");
}
private static List<string> FindDuplicateTopLevelKeys(string json)
{
var reader = new Utf8JsonReader(Encoding.UTF8.GetBytes(json));
var seen = new HashSet<string>(StringComparer.Ordinal);
var duplicates = new List<string>();
var depth = 0;
while (reader.Read())
{
switch (reader.TokenType)
{
case JsonTokenType.StartObject:
case JsonTokenType.StartArray:
depth++;
break;
case JsonTokenType.EndObject:
case JsonTokenType.EndArray:
depth--;
break;
case JsonTokenType.PropertyName:
if (depth == 1)
{
var name = reader.GetString()!;
if (!seen.Add(name)) duplicates.Add(name);
}
break;
}
}
return duplicates;
}
[Fact]
public void Detects_duplicate_top_level_key_in_fixture()
{
const string json = """
{
"checks": { "a": "1" },
"other": { "b": "2" },
"checks": { "c": "3" }
}
""";
var duplicates = FindDuplicateTopLevelKeys(json);
Assert.Contains("checks", duplicates);
}
[Fact]
public void Locale_files_have_no_duplicate_top_level_keys()
{
var localesDir = LocalesDir();
foreach (var path in Directory.EnumerateFiles(localesDir, "*.json"))
{
var duplicates = FindDuplicateTopLevelKeys(File.ReadAllText(path));
Assert.True(duplicates.Count == 0,
$"{Path.GetFileName(path)} has duplicate top-level keys: {string.Join(", ", duplicates)}");
}
}
}
@@ -0,0 +1,41 @@
using System;
using System.Threading.Tasks;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.ViewModels;
namespace ClaudeDo.Ui.Tests;
// Covers the "restart worker" footer feedback fix: RestartWorkerStatus used to be set but was
// never bound in any view, so a died-on-launch worker (e.g. a port conflict, see #292) restarted
// silently. RestartWorkerOperation is a test seam standing in for the real kill-and-relaunch +
// short-timeout survival check, so these tests only assert the success/failure routing to the
// shared footer strip (WorkerLogText/WorkerLogLevel), not real process spawning.
public class IslandsShellViewModelRestartWorkerTests
{
[Fact]
public async Task ProcessSurvives_FlashesFooterSuccess()
{
var vm = new IslandsShellViewModel { RestartWorkerOperation = () => Task.CompletedTask };
await vm.RestartWorkerCommand.ExecuteAsync(null);
Assert.Equal(WorkerLogLevel.Success, vm.WorkerLogLevel);
Assert.Contains("restarted", vm.WorkerLogText);
Assert.True(vm.IsWorkerLogVisible);
}
[Fact]
public async Task ProcessDiesWithinTimeout_FlashesFooterError()
{
var vm = new IslandsShellViewModel
{
RestartWorkerOperation = () => throw new InvalidOperationException("worker process exited immediately (code 1)")
};
await vm.RestartWorkerCommand.ExecuteAsync(null);
Assert.Equal(WorkerLogLevel.Error, vm.WorkerLogLevel);
Assert.Contains("worker process exited immediately", vm.WorkerLogText);
Assert.True(vm.IsWorkerLogVisible);
}
}
@@ -0,0 +1,64 @@
using System;
using System.Threading.Tasks;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels;
using ClaudeDo.Ui.ViewModels.Conflicts;
using ClaudeDo.Ui.ViewModels.Modals;
namespace ClaudeDo.Ui.Tests;
// Covers the worker-menu "Shutdown" command: a confirm dialog gates the actual kill-worker +
// close-app action. ShutdownOperation is a test seam standing in for the real body, so these
// tests only assert the confirm gate, never a real process kill.
public class IslandsShellViewModelShutdownTests
{
[Fact]
public async Task Declined_DoesNotInvokeShutdownOperation()
{
var invoked = false;
var vm = new IslandsShellViewModel
{
Dialogs = new FakeDialogService(confirmResult: false),
ShutdownOperation = () => invoked = true,
};
await vm.ShutdownCommand.ExecuteAsync(null);
Assert.False(invoked);
}
[Fact]
public async Task Confirmed_InvokesShutdownOperation()
{
var invoked = false;
var vm = new IslandsShellViewModel
{
Dialogs = new FakeDialogService(confirmResult: true),
ShutdownOperation = () => invoked = true,
};
await vm.ShutdownCommand.ExecuteAsync(null);
Assert.True(invoked);
}
private sealed class FakeDialogService(bool confirmResult) : IDialogService
{
public Task<bool> ConfirmAsync(string message) => Task.FromResult(confirmResult);
public Task ShowAboutAsync(AboutModalViewModel vm) => throw new NotImplementedException();
public Task ShowWeeklyReportAsync(WeeklyReportModalViewModel vm) => throw new NotImplementedException();
public Task ShowUsageMonitorAsync(UsageMonitorModalViewModel vm) => throw new NotImplementedException();
public Task ShowSettingsAsync(SettingsModalViewModel vm) => throw new NotImplementedException();
public Task ShowListSettingsAsync(ListSettingsModalViewModel vm) => throw new NotImplementedException();
public Task ShowRepoImportAsync(RepoImportModalViewModel vm) => throw new NotImplementedException();
public Task ShowWorktreesOverviewAsync(WorktreesOverviewModalViewModel vm) => throw new NotImplementedException();
public Task<IReadOnlyList<string>?> ShowMergeHelperSelectionAsync(MergeHelperSelectionModalViewModel vm) => throw new NotImplementedException();
public Task ShowWorkerConnectionAsync(WorkerConnectionModalViewModel vm) => throw new NotImplementedException();
public Task ShowConflictResolverAsync(ConflictResolverViewModel vm) => throw new NotImplementedException();
public Task ShowLogVisualizerAsync(LogVisualizerViewModel vm) => throw new NotImplementedException();
public Task ShowErrorAsync(string message) => Task.CompletedTask;
public void ShowMissionControl(MissionControlViewModel vm) => throw new NotImplementedException();
public void ShowCheatSheets(CheatSheetsViewModel vm) => throw new NotImplementedException();
}
}
@@ -0,0 +1,46 @@
using ClaudeDo.Ui.ViewModels;
using Xunit;
namespace ClaudeDo.Ui.Tests;
public class IslandsShellViewModelUpdateClaudeCliTests
{
[Fact]
public void Success_uses_last_non_empty_output_line()
{
var (success, message) = IslandsShellViewModel.SummarizeClaudeUpdateOutcome(
0, "Checking for updates...\nSuccessfully updated to 2.1.4\n", "");
Assert.True(success);
Assert.Equal("Successfully updated to 2.1.4", message);
}
[Fact]
public void Success_with_no_output_falls_back_to_default_message()
{
var (success, message) = IslandsShellViewModel.SummarizeClaudeUpdateOutcome(0, "", "");
Assert.True(success);
Assert.Equal("Claude CLI is already up to date.", message);
}
[Fact]
public void Failure_reports_exit_code_and_last_stderr_line()
{
var (success, message) = IslandsShellViewModel.SummarizeClaudeUpdateOutcome(
1, "", "network error\nrequest failed\n");
Assert.False(success);
Assert.Equal("Update failed (exit 1): request failed", message);
}
[Fact]
public void Failure_falls_back_to_stdout_when_stderr_is_empty()
{
var (success, message) = IslandsShellViewModel.SummarizeClaudeUpdateOutcome(
2, "some diagnostic line\n", "");
Assert.False(success);
Assert.Equal("Update failed (exit 2): some diagnostic line", message);
}
}
@@ -35,4 +35,14 @@ public class PtyTerminalSessionTests
Assert.Equal(0, gate.CurrentCount); // acquired Assert.Equal(0, gate.CurrentCount); // acquired
} }
// Fix: PasteAsync used to be void and swallow every failure. Not-started is the cheap case to
// cover without a real TerminalControl/ConPTY spawn.
[Fact]
public async Task PasteAsync_ReturnsFalse_WhenNotStarted()
{
using var session = new PtyTerminalSession();
Assert.False(await session.PasteAsync());
}
} }
+1 -3
View File
@@ -35,7 +35,6 @@ public abstract class StubWorkerClient : IWorkerClient
public event Action<string>? RefineStartedEvent; public event Action<string>? RefineStartedEvent;
public event Action<string, bool, string?>? RefineFinishedEvent; public event Action<string, bool, string?>? RefineFinishedEvent;
public event Action<string, string, int, int>? OperationProgressEvent; public event Action<string, string, int, int>? OperationProgressEvent;
public event Action<string, string, int>? MergeProgressEvent;
public event Action<string, string>? PlanningMergeStartedEvent; public event Action<string, string>? PlanningMergeStartedEvent;
public event Action<string, string>? PlanningSubtaskMergedEvent; public event Action<string, string>? PlanningSubtaskMergedEvent;
public event Action<string, string, IReadOnlyList<string>, bool>? PlanningMergeConflictEvent; public event Action<string, string, IReadOnlyList<string>, bool>? PlanningMergeConflictEvent;
@@ -64,7 +63,6 @@ public abstract class StubWorkerClient : IWorkerClient
public void RaisePlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList<string> files, bool externallyDriven) public void RaisePlanningMergeConflict(string planningTaskId, string subtaskId, IReadOnlyList<string> files, bool externallyDriven)
=> PlanningMergeConflictEvent?.Invoke(planningTaskId, subtaskId, files, externallyDriven); => PlanningMergeConflictEvent?.Invoke(planningTaskId, subtaskId, files, externallyDriven);
public void RaiseOperationProgress(string opKey, string phase, int current, int total) => OperationProgressEvent?.Invoke(opKey, phase, current, total); public void RaiseOperationProgress(string opKey, string phase, int current, int total) => OperationProgressEvent?.Invoke(opKey, phase, current, total);
public void RaiseMergeProgress(string taskId, string phase, int elapsedSeconds) => MergeProgressEvent?.Invoke(taskId, phase, elapsedSeconds);
public void RaisePlanningMergeStarted(string planningTaskId, string targetBranch) => PlanningMergeStartedEvent?.Invoke(planningTaskId, targetBranch); public void RaisePlanningMergeStarted(string planningTaskId, string targetBranch) => PlanningMergeStartedEvent?.Invoke(planningTaskId, targetBranch);
public void RaisePlanningMergeAborted(string planningTaskId, string? reason = null) => PlanningMergeAbortedEvent?.Invoke(planningTaskId, reason); public void RaisePlanningMergeAborted(string planningTaskId, string? reason = null) => PlanningMergeAbortedEvent?.Invoke(planningTaskId, reason);
public void RaisePlanningCompleted(string planningTaskId) => PlanningCompletedEvent?.Invoke(planningTaskId); public void RaisePlanningCompleted(string planningTaskId) => PlanningCompletedEvent?.Invoke(planningTaskId);
@@ -128,7 +126,7 @@ public abstract class StubWorkerClient : IWorkerClient
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>())); => Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default) public virtual Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(directory, "claude", Array.Empty<string>(), new Dictionary<string, string>())); => Task.FromResult(new LaunchSpec(directory, "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, CancellationToken ct = default) public virtual Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, string? model = null, CancellationToken ct = default)
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>())); => Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
public virtual Task<string> CreateMergeHelperTaskAsync( public virtual Task<string> CreateMergeHelperTaskAsync(
IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default) IReadOnlyList<string> taskIds, string listId, string title, string descriptionHeader, CancellationToken ct = default)
@@ -0,0 +1,72 @@
using ClaudeDo.Data;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Islands;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Ui.Tests.ViewModels;
// Usability-Audit 2026-09-10: the detail pane showed nothing when a task was blocked on a
// predecessor -- the header bar now renders a "Waiting on #N" line bound straight to the same
// Task.IsWaiting/DependencyWaitLabel TasksIslandViewModel already computes (no new VM logic here).
public class DetailsIslandDependencyWaitTests : IDisposable
{
private readonly string _dbPath;
public DetailsIslandDependencyWaitTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_dep_wait_test_{Guid.NewGuid():N}.db");
using var ctx = NewContext();
ctx.Database.EnsureCreated();
}
public void Dispose()
{
try { File.Delete(_dbPath); } catch { }
try { File.Delete(_dbPath + "-wal"); } catch { }
try { File.Delete(_dbPath + "-shm"); } catch { }
}
private ClaudeDoDbContext NewContext()
{
var opts = new DbContextOptionsBuilder<ClaudeDoDbContext>()
.UseSqlite($"Data Source={_dbPath}")
.Options;
return new ClaudeDoDbContext(opts);
}
private DetailsIslandViewModel NewVm()
{
var factory = new TestDbFactory(NewContext);
return new DetailsIslandViewModel(factory, new DefaultStub(), new NullServiceProvider(), new ClaudeDo.Ui.Services.MergeCoordinator());
}
[Fact]
public void UnblockedTask_HasNoWaitingLine()
{
var vm = NewVm();
vm.Bind(new TaskRowViewModel { Id = Guid.NewGuid().ToString("N"), Number = 1, Status = TaskStatus.Queued });
Assert.False(vm.Task!.IsWaiting);
Assert.Null(vm.Task.DependencyWaitLabel);
}
[Fact]
public void BlockedTask_ExposesWaitingLineViaBoundRow()
{
var vm = NewVm();
var row = new TaskRowViewModel
{
Id = Guid.NewGuid().ToString("N"),
Number = 2,
Status = TaskStatus.Queued,
IsBlockedByDependency = true,
DependencyWaitLabel = "#42",
};
vm.Bind(row);
Assert.True(vm.Task!.IsWaiting);
Assert.Equal("#42", vm.Task.DependencyWaitLabel);
}
}
@@ -170,14 +170,6 @@ public class DetailsIslandReviewActionsTests : IDisposable
public override Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) => Gate.Task; public override Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) => Gate.Task;
} }
private static int MergeProgressSubscriberCount(StubWorkerClient worker)
{
var field = typeof(StubWorkerClient).GetField("MergeProgressEvent",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
var del = (Delegate?)field!.GetValue(worker);
return del?.GetInvocationList().Length ?? 0;
}
[Fact] [Fact]
public async Task ApproveReview_SetsIsRunning_AndLocksTheButton_WhileThePendingCallIsInFlight() public async Task ApproveReview_SetsIsRunning_AndLocksTheButton_WhileThePendingCallIsInFlight()
{ {
@@ -209,10 +201,10 @@ public class DetailsIslandReviewActionsTests : IDisposable
var approve = vm.ApproveReviewCommand.ExecuteAsync(null); var approve = vm.ApproveReviewCommand.ExecuteAsync(null);
var merging = vm.ApproveOp.Label; var merging = vm.ApproveOp.Label;
worker.RaiseMergeProgress("some-other-task", "verifying", 30); worker.RaiseOperationProgress("some-other-task", "verifying", 30, 0);
Assert.Equal(merging, vm.ApproveOp.Label); Assert.Equal(merging, vm.ApproveOp.Label);
worker.RaiseMergeProgress("task-approve-2", "verifying", 30); worker.RaiseOperationProgress("task-approve-2", "verifying", 30, 0);
Assert.NotEqual(merging, vm.ApproveOp.Label); Assert.NotEqual(merging, vm.ApproveOp.Label);
worker.Gate.SetResult(new MergeResultDto("merged", new List<string>(), null)); worker.Gate.SetResult(new MergeResultDto("merged", new List<string>(), null));
@@ -220,7 +212,7 @@ public class DetailsIslandReviewActionsTests : IDisposable
} }
[Fact] [Fact]
public async Task ApproveReview_UnsubscribesFromMergeProgress_AfterTheCallCompletes() public async Task ApproveReview_UnsubscribesFromOperationProgress_AfterTheCallCompletes()
{ {
var worker = new BlockingApproveWorkerClient(); var worker = new BlockingApproveWorkerClient();
var vm = BuildVm(worker); var vm = BuildVm(worker);
@@ -228,12 +220,12 @@ public class DetailsIslandReviewActionsTests : IDisposable
vm.Monitor.ApplyState(TaskStatus.WaitingForReview); vm.Monitor.ApplyState(TaskStatus.WaitingForReview);
var approve = vm.ApproveReviewCommand.ExecuteAsync(null); var approve = vm.ApproveReviewCommand.ExecuteAsync(null);
Assert.Equal(1, MergeProgressSubscriberCount(worker)); Assert.Equal(1, OperationProgressSubscriberCount(worker));
worker.Gate.SetResult(new MergeResultDto("merged", new List<string>(), null)); worker.Gate.SetResult(new MergeResultDto("merged", new List<string>(), null));
await approve; await approve;
Assert.Equal(0, MergeProgressSubscriberCount(worker)); Assert.Equal(0, OperationProgressSubscriberCount(worker));
} }
private static int OperationProgressSubscriberCount(StubWorkerClient worker) private static int OperationProgressSubscriberCount(StubWorkerClient worker)
@@ -255,8 +247,7 @@ public class DetailsIslandReviewActionsTests : IDisposable
var approve = vm.ApproveReviewCommand.ExecuteAsync(null); var approve = vm.ApproveReviewCommand.ExecuteAsync(null);
var merging = vm.ApproveOp.Label; var merging = vm.ApproveOp.Label;
// The rebase phase needs the branch total, which only rides OperationProgress // The rebase phase needs the branch total — ignored for other tasks.
// (MergeProgressEvent forwards elapsed-seconds only) — ignored for other tasks.
worker.RaiseOperationProgress("some-other-task", "rebasing", 1, 3); worker.RaiseOperationProgress("some-other-task", "rebasing", 1, 3);
Assert.Equal(merging, vm.ApproveOp.Label); Assert.Equal(merging, vm.ApproveOp.Label);
@@ -30,4 +30,31 @@ public class GeneralSettingsTabViewModelTests
Assert.Equal(expectedValid, vm.Validate() is null); Assert.Equal(expectedValid, vm.Validate() is null);
} }
[Theory]
[InlineData(null, true)]
[InlineData("", true)]
[InlineData("09:00", true)]
[InlineData("23:59", true)]
[InlineData("9:0", false)]
[InlineData("abc", false)]
[InlineData("25:00", false)]
public void Validate_PowerUseWindowStart_rejectsUnparseable(string? value, bool expectedValid)
{
var vm = new GeneralSettingsTabViewModel { PowerUseWindowStart = value };
Assert.Equal(expectedValid, vm.Validate() is null);
}
[Theory]
[InlineData(null, true)]
[InlineData("09:00", true)]
[InlineData("9:0", false)]
[InlineData("abc", false)]
public void Validate_PowerUseWindowEnd_rejectsUnparseable(string? value, bool expectedValid)
{
var vm = new GeneralSettingsTabViewModel { PowerUseWindowEnd = value };
Assert.Equal(expectedValid, vm.Validate() is null);
}
} }
@@ -161,7 +161,9 @@ public class ListSettingsModalViewModelTests
string? reported = null; string? reported = null;
vm.ErrorReported += m => reported = m; vm.ErrorReported += m => reported = m;
await vm.LoadAsync("l1", "List One", "/repo", "feat"); // Empty working dir: this test is about ticket-project load isolation, not the
// working-dir-is-a-repo gate added for the "not a git repo" Save check.
await vm.LoadAsync("l1", "List One", "", "feat");
Assert.Equal("make test", vm.VerifyCommand); Assert.Equal("make test", vm.VerifyCommand);
Assert.True(vm.PowerUseAllowed); Assert.True(vm.PowerUseAllowed);
@@ -169,4 +171,45 @@ public class ListSettingsModalViewModelTests
Assert.NotNull(reported); Assert.NotNull(reported);
Assert.Contains("ticket system down", reported); Assert.Contains("ticket system down", reported);
} }
[Fact]
public async Task LoadAsync_WorkingDirNotAGitRepo_WarnsAndBlocksSave()
{
var plainDir = Directory.CreateTempSubdirectory().FullName;
var worker = new CountingWorker { Cfg = new ListConfigDto("opus", null, null) };
var vm = new ListSettingsModalViewModel(worker, NewFactory());
await vm.LoadAsync("l1", "List One", plainDir, "feat");
Assert.NotEmpty(vm.WorkingDirWarning);
Assert.False(vm.SaveCommand.CanExecute(null));
}
[Fact]
public async Task LoadAsync_WorkingDirIsAGitRepo_NoWarningAndSaveAllowed()
{
var repoDir = Directory.CreateTempSubdirectory().FullName;
Directory.CreateDirectory(Path.Combine(repoDir, ".git"));
var worker = new CountingWorker { Cfg = new ListConfigDto("opus", null, null) };
var vm = new ListSettingsModalViewModel(worker, NewFactory());
await vm.LoadAsync("l1", "List One", repoDir, "feat");
Assert.Empty(vm.WorkingDirWarning);
Assert.True(vm.SaveCommand.CanExecute(null));
}
[Fact]
public void WorkingDir_ClearedAfterNotARepoWarning_ClearsWarningAndAllowsSave()
{
var plainDir = Directory.CreateTempSubdirectory().FullName;
var worker = new CountingWorker { Cfg = new ListConfigDto("opus", null, null) };
var vm = new ListSettingsModalViewModel(worker, NewFactory()) { WorkingDir = plainDir };
Assert.NotEmpty(vm.WorkingDirWarning);
vm.WorkingDir = "";
Assert.Empty(vm.WorkingDirWarning);
Assert.True(vm.SaveCommand.CanExecute(null));
}
} }
@@ -0,0 +1,292 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Localization;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.ViewModels.Islands;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Ui.Tests.ViewModels;
/// <summary>
/// Sidebar folders: grouping on load, the folder badge, and the two ways a list changes folder
/// (dropped onto a row in another container, dropped onto a folder header).
/// </summary>
public class ListsIslandFolderTests : IDisposable
{
private readonly string _dbPath;
public ListsIslandFolderTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_lists_folders_test_{Guid.NewGuid():N}.db");
using var ctx = NewContext();
ctx.Database.EnsureCreated();
var dir = AppContext.BaseDirectory;
while (dir is not null && !Directory.Exists(Path.Combine(dir, "src", "ClaudeDo.Localization", "locales")))
dir = Path.GetDirectoryName(dir);
Loc.Current = new Localizer(
LocaleStore.Load(Path.Combine(dir!, "src", "ClaudeDo.Localization", "locales")), "en");
}
public void Dispose()
{
foreach (var suffix in new[] { "", "-wal", "-shm" })
try { File.Delete(_dbPath + suffix); } catch { }
}
private ClaudeDoDbContext NewContext()
{
var opts = new DbContextOptionsBuilder<ClaudeDoDbContext>()
.UseSqlite($"Data Source={_dbPath}")
.Options;
return new ClaudeDoDbContext(opts);
}
private sealed class FakeWorker : StubWorkerClient
{
public override bool IsConnected => true;
}
private async Task SeedAsync(string? folderId = null, params (string Id, string Name, int Sort)[] lists) =>
await SeedAsync(folderId, 0, lists);
private async Task SeedAsync(string? folderId, int folderSort, params (string Id, string Name, int Sort)[] lists)
{
await using var db = NewContext();
if (folderId is not null)
db.ListFolders.Add(new ListFolderEntity { Id = folderId, Name = $"Folder {folderId}", SortOrder = folderSort, CreatedAt = DateTime.UtcNow });
foreach (var (id, name, sort) in lists)
db.Lists.Add(new ListEntity { Id = id, Name = name, SortOrder = sort, CreatedAt = DateTime.UtcNow, FolderId = folderId });
await db.SaveChangesAsync();
}
private async Task SeedTaskAsync(string listId, int number)
{
await using var db = NewContext();
db.Tasks.Add(new TaskEntity
{
Id = Guid.NewGuid().ToString("N"),
Number = number,
ListId = listId,
Title = "T",
Status = Data.Models.TaskStatus.Idle,
CreatedAt = DateTime.UtcNow,
});
await db.SaveChangesAsync();
}
private ListsIslandViewModel NewVm() => new(new TestDbFactory(NewContext), worker: new FakeWorker());
[Fact]
public async Task LoadAsync_puts_lists_into_their_folder_and_leaves_the_rest_at_the_top_level()
{
await SeedAsync("f1", ("inside", "Inside", 0));
await SeedAsync(null, ("outside", "Outside", 1));
var vm = NewVm();
await vm.LoadAsync();
var folder = Assert.Single(vm.Folders);
Assert.Equal("Inside", Assert.Single(folder.Lists).Name);
Assert.Equal("Outside", Assert.Single(vm.RootLists).Name);
// UserLists stays the flat superset — counts and row refreshes work over it.
Assert.Equal(2, vm.UserLists.Count);
}
[Fact]
public async Task Folder_count_is_the_sum_of_its_lists()
{
await SeedAsync("f1", ("a", "A", 0), ("b", "B", 1));
await SeedTaskAsync("a", 1);
await SeedTaskAsync("b", 2);
await SeedTaskAsync("b", 3);
var vm = NewVm();
await vm.LoadAsync();
Assert.Equal(3, Assert.Single(vm.Folders).Count);
}
[Fact]
public async Task MoveToFolderAsync_moves_a_top_level_list_into_the_folder_and_persists_it()
{
await SeedAsync("f1", ("inside", "Inside", 0));
await SeedAsync(null, ("mover", "Mover", 1));
var vm = NewVm();
await vm.LoadAsync();
var mover = vm.RootLists.Single(r => r.Id == "user:mover");
await vm.MoveToFolderAsync(mover, vm.Folders[0]);
Assert.Empty(vm.RootLists);
Assert.Equal(2, vm.Folders[0].Lists.Count);
Assert.Equal("f1", mover.FolderId);
await using var db = NewContext();
Assert.Equal("f1", (await db.Lists.AsNoTracking().FirstAsync(l => l.Id == "mover")).FolderId);
}
[Fact]
public async Task MoveToFolderAsync_with_no_folder_takes_a_list_back_out()
{
await SeedAsync("f1", ("inside", "Inside", 0));
var vm = NewVm();
await vm.LoadAsync();
var row = vm.Folders[0].Lists.Single();
await vm.MoveToFolderAsync(row, null);
Assert.Empty(vm.Folders[0].Lists);
Assert.Same(row, Assert.Single(vm.RootLists));
Assert.Null(row.FolderId);
await using var db = NewContext();
Assert.Null((await db.Lists.AsNoTracking().FirstAsync(l => l.Id == "inside")).FolderId);
}
[Fact]
public async Task ReorderAsync_onto_a_row_in_another_container_also_changes_the_folder()
{
await SeedAsync("f1", ("inside", "Inside", 0));
await SeedAsync(null, ("mover", "Mover", 1));
var vm = NewVm();
await vm.LoadAsync();
var mover = vm.RootLists.Single();
var inside = vm.Folders[0].Lists.Single();
await vm.ReorderAsync(mover, inside, placeBelow: false);
Assert.Empty(vm.RootLists);
Assert.Equal(["Mover", "Inside"], vm.Folders[0].Lists.Select(l => l.Name));
Assert.Equal("f1", mover.FolderId);
await using var db = NewContext();
var stored = await db.Lists.AsNoTracking().ToListAsync();
Assert.Equal("f1", stored.Single(l => l.Id == "mover").FolderId);
// sort_order is one sequence over the whole sidebar, folders included: f1 takes 0, its
// two children 1 and 2.
Assert.Equal(0, (await db.ListFolders.AsNoTracking().FirstAsync(f => f.Id == "f1")).SortOrder);
Assert.Equal(1, stored.Single(l => l.Id == "mover").SortOrder);
Assert.Equal(2, stored.Single(l => l.Id == "inside").SortOrder);
}
[Fact]
public async Task DropAsync_reorders_folders_among_each_other_and_persists_it()
{
await SeedAsync("f1", folderSort: 0);
await SeedAsync("f2", folderSort: 1);
var vm = NewVm();
await vm.LoadAsync();
var (first, second) = (vm.Folders[0], vm.Folders[1]);
await vm.DropAsync(second, first, placeBelow: false);
Assert.Equal([second, first], vm.Folders);
await using var db = NewContext();
var stored = await db.ListFolders.AsNoTracking().ToListAsync();
Assert.Equal(0, stored.Single(f => f.Id == second.Id).SortOrder);
Assert.Equal(1, stored.Single(f => f.Id == first.Id).SortOrder);
// And it survives a reload — the load path merges folders and lists by sort_order.
var reloaded = NewVm();
await reloaded.LoadAsync();
Assert.Equal([second.Id, first.Id], reloaded.Folders.Select(f => f.Id));
}
[Fact]
public async Task DropAsync_puts_a_list_above_a_folder_at_the_top_level()
{
await SeedAsync("f1", ("inside", "Inside", 1));
await SeedAsync(null, ("mover", "Mover", 2));
var vm = NewVm();
await vm.LoadAsync();
var folder = vm.Folders[0];
var mover = vm.RootLists.Single();
await vm.DropAsync(mover, folder, placeBelow: false);
Assert.Equal(new object[] { mover, folder }, vm.TopLevel);
Assert.Null(mover.FolderId);
var reloaded = NewVm();
await reloaded.LoadAsync();
Assert.Equal(["user:mover", "f1"], reloaded.TopLevel.Select(i =>
i is ListNavItemViewModel l ? l.Id : ((ListFolderNavItemViewModel)i).Id));
}
[Fact]
public async Task DropAsync_never_nests_a_folder_in_a_folder()
{
await SeedAsync("f1", 0, ("inside", "Inside", 1));
await SeedAsync("f2", folderSort: 2);
var vm = NewVm();
await vm.LoadAsync();
var inside = vm.Folders[0].Lists.Single();
var f2 = vm.Folders[1];
await vm.DropAsync(f2, inside, placeBelow: true);
Assert.Equal(2, vm.TopLevel.Count);
Assert.Single(vm.Folders[0].Lists);
}
[Fact]
public async Task ReorderAsync_within_a_folder_keeps_the_folder()
{
await SeedAsync("f1", ("a", "A", 0), ("b", "B", 1));
var vm = NewVm();
await vm.LoadAsync();
var a = vm.Folders[0].Lists[0];
var b = vm.Folders[0].Lists[1];
await vm.ReorderAsync(b, a, placeBelow: false);
Assert.Equal(["B", "A"], vm.Folders[0].Lists.Select(l => l.Name));
Assert.All(vm.Folders[0].Lists, l => Assert.Equal("f1", l.FolderId));
}
[Fact]
public async Task ToggleFolder_flips_and_persists_the_collapse_state()
{
await SeedAsync("f1", ("inside", "Inside", 0));
var vm = NewVm();
await vm.LoadAsync();
var folder = vm.Folders[0];
Assert.False(folder.IsCollapsed);
await vm.ToggleFolderCommand.ExecuteAsync(folder);
Assert.True(folder.IsCollapsed);
await using var db = NewContext();
Assert.True((await db.ListFolders.AsNoTracking().FirstAsync(f => f.Id == "f1")).IsCollapsed);
// And the state survives a reload.
var reloaded = NewVm();
await reloaded.LoadAsync();
Assert.True(reloaded.Folders[0].IsCollapsed);
}
[Fact]
public async Task RemoveFromFolder_command_is_the_context_menu_route_out()
{
await SeedAsync("f1", ("inside", "Inside", 0));
var vm = NewVm();
await vm.LoadAsync();
var row = vm.Folders[0].Lists.Single();
await vm.RemoveFromFolderCommand.ExecuteAsync(row);
Assert.Null(row.FolderId);
Assert.Single(vm.RootLists);
}
}
@@ -79,7 +79,7 @@ public class MergeModalViewModelTests
// The verify gate starts — a distinct phase, so the user sees why this is taking minutes. // The verify gate starts — a distinct phase, so the user sees why this is taking minutes.
// (The elapsed time rides in a format argument, which the test localizer drops.) // (The elapsed time rides in a format argument, which the test localizer drops.)
worker.RaiseMergeProgress("task-1", "verifying", 90); worker.RaiseOperationProgress("task-1", "verifying", 90, 0);
Assert.NotEqual(merging, vm.ProgressMessage); Assert.NotEqual(merging, vm.ProgressMessage);
worker.MergeGate.SetResult(new MergeResultDto("merged", new List<string>(), null)); worker.MergeGate.SetResult(new MergeResultDto("merged", new List<string>(), null));
@@ -100,7 +100,7 @@ public class MergeModalViewModelTests
var submit = vm.SubmitCommand.ExecuteAsync(null); var submit = vm.SubmitCommand.ExecuteAsync(null);
var merging = vm.ProgressMessage; var merging = vm.ProgressMessage;
worker.RaiseMergeProgress("some-other-task", "verifying", 42); worker.RaiseOperationProgress("some-other-task", "verifying", 42, 0);
Assert.Equal(merging, vm.ProgressMessage); Assert.Equal(merging, vm.ProgressMessage);
@@ -115,7 +115,7 @@ public class MergeModalViewModelTests
await vm.InitializeAsync("task-1", "do the thing"); await vm.InitializeAsync("task-1", "do the thing");
await vm.SubmitCommand.ExecuteAsync(null); await vm.SubmitCommand.ExecuteAsync(null);
worker.RaiseMergeProgress("task-1", "verifying", 10); worker.RaiseOperationProgress("task-1", "verifying", 10, 0);
Assert.Null(vm.ProgressMessage); Assert.Null(vm.ProgressMessage);
} }
@@ -130,8 +130,7 @@ public class MergeModalViewModelTests
var submit = vm.SubmitCommand.ExecuteAsync(null); var submit = vm.SubmitCommand.ExecuteAsync(null);
var merging = vm.ProgressMessage; var merging = vm.ProgressMessage;
// Needs the branch total, which only rides OperationProgress (MergeProgressEvent // Needs the branch total, which only OperationProgress carries.
// forwards elapsed-seconds only) -- and must not be clobbered by that other forwarder.
worker.RaiseOperationProgress("task-1", "rebasing", 1, 2); worker.RaiseOperationProgress("task-1", "rebasing", 1, 2);
Assert.NotEqual(merging, vm.ProgressMessage); Assert.NotEqual(merging, vm.ProgressMessage);
Assert.Contains("(1/2)", vm.ProgressMessage); Assert.Contains("(1/2)", vm.ProgressMessage);
@@ -151,6 +151,30 @@ public class ConPtyPaneViewModelTests
Assert.True(pane.RetryCommand.CanExecute(null)); Assert.True(pane.RetryCommand.CanExecute(null));
} }
// ── HasExitedAfterStartup banner (fix: a mid-run death used to leave a silent dead tile — no
// StartError, no signal at all) ─────────────────────────────────────────────────────────
[Fact]
public void RetryCommand_Enabled_AfterDeathAfterStartup()
{
using var pane = NewTaskPane(NeverCompletes);
DieAfterRunning(pane, exitCode: 1);
Assert.True(pane.Terminal.HasExitedAfterStartup);
Assert.True(pane.RetryCommand.CanExecute(null));
}
[Fact]
public void ExitedBannerText_IncludesExitCode()
{
using var pane = NewTaskPane(NeverCompletes);
DieAfterRunning(pane, exitCode: 42);
Assert.Contains("42", pane.ExitedBannerText);
}
// ── SessionDiedAfterStartup (feeds the worker's usage-limit auto-restart) ──────────────────── // ── SessionDiedAfterStartup (feeds the worker's usage-limit auto-restart) ────────────────────
[Fact] [Fact]
@@ -195,6 +219,35 @@ public class ConPtyPaneViewModelTests
Assert.True(raised); Assert.True(raised);
} }
// ── Terminal.ErrorReported forwarding (fix: a failed paste/drop had no path to the footer
// error strip) ────────────────────────────────────────────────────────────────────────────
[Fact]
public void TerminalErrorReported_Forwards_ToPaneErrorReported()
{
using var pane = NewTaskPane(NeverCompletes);
string? error = null;
pane.ErrorReported += msg => error = msg;
pane.Terminal.ReportSendFailed();
Assert.NotNull(error);
}
[Fact]
public void TerminalErrorReported_StillWiredAfterRetry()
{
using var pane = NewTaskPane(() => Failing());
pane.Start();
pane.RetryCommand.Execute(null);
string? error = null;
pane.ErrorReported += msg => error = msg;
pane.Terminal.ReportSendFailed();
Assert.NotNull(error);
}
private static void SetRunning(ConPtyPaneViewModel pane) => pane.Terminal.IsRunning = true; private static void SetRunning(ConPtyPaneViewModel pane) => pane.Terminal.IsRunning = true;
// Drives the terminal past its startup grace window, then kills it: IsRunning stamps the launch // Drives the terminal past its startup grace window, then kills it: IsRunning stamps the launch
@@ -36,6 +36,7 @@ public class InteractiveTerminalViewModelTests
Assert.True(terminal.HasExited); Assert.True(terminal.HasExited);
Assert.Null(terminal.StartError); Assert.Null(terminal.StartError);
Assert.True(terminal.HasExitedAfterStartup); // no StartError banner, so the host needs this signal instead
} }
[Fact] [Fact]
@@ -48,6 +49,30 @@ public class InteractiveTerminalViewModelTests
Assert.True(terminal.HasExited); Assert.True(terminal.HasExited);
Assert.Null(terminal.StartError); Assert.Null(terminal.StartError);
Assert.True(terminal.HasExitedAfterStartup);
}
// ── HasExitedAfterStartup (drives the host's "session ended" banner — every death that
// ISN'T already covered by StartError) ─────────────────────────────────────────────────
[Fact]
public void FreshTerminal_HasExitedAfterStartupIsFalse()
{
using var terminal = new InteractiveTerminalViewModel(() => LaunchedAt);
Assert.False(terminal.HasExitedAfterStartup);
}
[Fact]
public void ProcessExitsNonzero_WithinGraceWindow_HasExitedAfterStartupIsFalse()
{
using var terminal = new InteractiveTerminalViewModel(() => LaunchedAt);
terminal.IsRunning = true;
terminal.OnSessionProcessExited(null, 1); // startup failure — StartError covers it instead
Assert.NotNull(terminal.StartError);
Assert.False(terminal.HasExitedAfterStartup);
} }
// ── ExitedAfterStartup (a session that ran, then broke -- used to be a silent dead tile; // ── ExitedAfterStartup (a session that ran, then broke -- used to be a silent dead tile;
@@ -99,4 +124,35 @@ public class InteractiveTerminalViewModelTests
terminal.IsRunning = true; // records LaunchedAt via the first clock call terminal.IsRunning = true; // records LaunchedAt via the first clock call
return terminal; return terminal;
} }
// ── Paste/drop failure reporting (fix: PasteAsync used to swallow every exception and return
// void, so a failed Ctrl+V or file drop gave the user no signal at all) ────────────────────
[Fact]
public async Task PasteAsync_ReturnsFalse_WhenSessionNotRunning()
{
using var terminal = new InteractiveTerminalViewModel();
Assert.False(await terminal.PasteAsync());
}
[Fact]
public async Task SendTextAsync_ReturnsFalse_WhenSessionNotRunning()
{
using var terminal = new InteractiveTerminalViewModel();
Assert.False(await terminal.SendTextAsync("dropped/path"));
}
[Fact]
public void ReportSendFailed_RaisesErrorReported()
{
using var terminal = new InteractiveTerminalViewModel();
string? error = null;
terminal.ErrorReported += msg => error = msg;
terminal.ReportSendFailed();
Assert.NotNull(error);
}
} }
@@ -556,7 +556,7 @@ public class MissionControlViewModelTests : IDisposable
private sealed class ThrowingMergeHelperLaunchSpecWorker : StubWorkerClient private sealed class ThrowingMergeHelperLaunchSpecWorker : StubWorkerClient
{ {
public override Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, CancellationToken ct = default) public override Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, string handlerTaskId, string? model = null, CancellationToken ct = default)
=> throw new InvalidOperationException("spec failed"); => throw new InvalidOperationException("spec failed");
} }
@@ -1072,4 +1072,59 @@ public class MissionControlViewModelTests : IDisposable
Assert.Equal(new[] { "t1", "t2" }, worker.Cancelled); Assert.Equal(new[] { "t1", "t2" }, worker.Cancelled);
Assert.Empty(vm.ConPtySessions); Assert.Empty(vm.ConPtySessions);
} }
// The tile's own X must not silently kill a live session -- a reflex click on a running pane
// has to go through the same confirm mechanism as Abandon Run.
[Fact]
public async Task Close_RunningSession_ConfirmDeclined_LeavesSessionOpen()
{
var worker = new FakeWorker();
using var vm = BuildVm(worker);
await vm.OpenConPtySessionAsync("t1");
var pane = vm.ConPtySessions[0];
pane.Terminal.IsRunning = true;
string? confirmMessage = null;
vm.ConfirmAsync = msg => { confirmMessage = msg; return Task.FromResult(false); };
pane.CloseCommand.Execute(null);
await Task.Delay(20);
Assert.NotNull(confirmMessage);
Assert.Single(vm.ConPtySessions);
}
[Fact]
public async Task Close_RunningSession_ConfirmAccepted_ClosesPane()
{
var worker = new FakeWorker();
using var vm = BuildVm(worker);
await vm.OpenConPtySessionAsync("t1");
var pane = vm.ConPtySessions[0];
pane.Terminal.IsRunning = true;
vm.ConfirmAsync = _ => Task.FromResult(true);
pane.CloseCommand.Execute(null);
await Task.Delay(20);
Assert.Empty(vm.ConPtySessions);
}
// A dead session (start failure or exited-after-startup) is pure cleanup -- no confirm needed.
[Fact]
public async Task Close_DeadSession_ClosesWithoutConfirming()
{
var worker = new FakeWorker();
using var vm = BuildVm(worker);
await vm.OpenConPtySessionAsync("t1");
var pane = vm.ConPtySessions[0];
pane.Terminal.ReportStartFailure("boom");
var confirmCalled = false;
vm.ConfirmAsync = _ => { confirmCalled = true; return Task.FromResult(true); };
pane.CloseCommand.Execute(null);
await Task.Delay(20);
Assert.False(confirmCalled);
Assert.Empty(vm.ConPtySessions);
}
} }
@@ -153,10 +153,17 @@ public class SettingsModalViewModelTests : IDisposable
Assert.Equal("18:00", vm.General.PowerUseWindowStart); Assert.Equal("18:00", vm.General.PowerUseWindowStart);
Assert.Equal(88, vm.General.PowerUseHandoffPct); Assert.Equal(88, vm.General.PowerUseHandoffPct);
// A typo in the window is normalized to null on save, not stored as a fake window. // A typo in the window blocks Save instead of silently becoming "round the clock".
vm.General.PowerUseWindowEnd = "not a time"; vm.General.PowerUseWindowEnd = "not a time";
await vm.SaveCommand.ExecuteAsync(null); await vm.SaveCommand.ExecuteAsync(null);
Assert.Null(worker.Saved);
Assert.NotEqual("", vm.ValidationError);
// Clearing it back out is still a valid "round the clock" window and saves.
vm.General.PowerUseWindowEnd = "";
await vm.SaveCommand.ExecuteAsync(null);
Assert.True(worker.Saved!.PowerUseEnabled); Assert.True(worker.Saved!.PowerUseEnabled);
Assert.Equal("18:00", worker.Saved.PowerUseWindowStart); Assert.Equal("18:00", worker.Saved.PowerUseWindowStart);
Assert.Null(worker.Saved.PowerUseWindowEnd); Assert.Null(worker.Saved.PowerUseWindowEnd);
@@ -266,4 +266,93 @@ public class TasksIslandChainTests : IDisposable
Assert.Contains(numbers["a"].ToString(), wiring.ChainAfterLabel); Assert.Contains(numbers["a"].ToString(), wiring.ChainAfterLabel);
Assert.Contains(numbers["b"].ToString(), wiring.ChainAfterLabel); Assert.Contains(numbers["b"].ToString(), wiring.ChainAfterLabel);
} }
// Usability-Audit 2026-09-10: a Queued task blocked on an unmet task_dependencies edge used to
// render the same "queued" chip as one that's actually next up to run.
[Fact]
public async Task QueuedTaskWithUnmetDependency_ShowsWaitingChipAndTooltip()
{
var numbers = await SeedAsync(
("predecessor", TaskStatus.Idle, null, null, 0),
("dependent", TaskStatus.Queued, "predecessor", null, 1));
var vm = BuildViewModel();
await LoadAndWaitAsync(vm, UserList("list1", "Default"));
var dependent = vm.Items.First(r => r.Id == "dependent");
Assert.True(dependent.IsWaiting);
Assert.False(dependent.IsQueued);
Assert.Equal("waiting", dependent.StatusChipClass);
Assert.NotNull(dependent.StatusChipTooltip);
Assert.Contains(numbers["predecessor"].ToString(), dependent.StatusChipTooltip);
}
[Fact]
public async Task QueuedTaskWithSatisfiedDependency_ShowsPlainQueuedChip()
{
await SeedAsync(
("predecessor", TaskStatus.Done, null, null, 0),
("dependent", TaskStatus.Queued, "predecessor", null, 1));
var vm = BuildViewModel();
await LoadAndWaitAsync(vm, UserList("list1", "Default"));
var dependent = vm.Items.First(r => r.Id == "dependent");
Assert.False(dependent.IsWaiting);
Assert.True(dependent.IsQueued);
Assert.Equal("queued", dependent.StatusChipClass);
Assert.Null(dependent.DependencyWaitLabel);
}
// Usability-Audit 2026-09-10: BuildAfterLabel only resolved predecessors already loaded into
// Items, so a listenfremder Vorgänger (different list) silently dropped both the "after #N"
// chip and the blocked chip. RefreshExternalDependencyCacheAsync backfills Number/Title/Status
// for exactly those ids via one extra query on the already-open DbContext.
[Fact]
public async Task CrossListPredecessor_BlocksAndRendersAfterLabel()
{
var headNumber = TestTaskNumbers.Next();
await using (var db = NewContext())
{
db.Lists.Add(new ListEntity { Id = "list1", Name = "Default", CreatedAt = DateTime.UtcNow });
db.Lists.Add(new ListEntity { Id = "list2", Name = "Other", CreatedAt = DateTime.UtcNow });
db.Tasks.Add(new TaskEntity
{
Number = headNumber,
Id = "head",
ListId = "list1",
Title = "head",
CreatedAt = DateTime.UtcNow,
Status = TaskStatus.Idle,
SortOrder = 0,
});
db.Tasks.Add(new TaskEntity
{
Number = TestTaskNumbers.Next(),
Id = "dependent",
ListId = "list2",
Title = "dependent",
CreatedAt = DateTime.UtcNow,
Status = TaskStatus.Queued,
SortOrder = 0,
});
db.TaskDependencies.Add(new TaskDependencyEntity { TaskId = "dependent", DependsOnTaskId = "head" });
await db.SaveChangesAsync();
}
var vm = BuildViewModel();
await LoadAndWaitAsync(vm, UserList("list2", "Other"));
var dependent = vm.Items.First(r => r.Id == "dependent");
Assert.True(dependent.IsWaiting);
Assert.False(dependent.IsQueued);
Assert.Equal("waiting", dependent.StatusChipClass);
Assert.NotNull(dependent.ChainAfterLabel);
Assert.Contains($"#{headNumber}", dependent.ChainAfterLabel);
Assert.NotNull(dependent.DependencyWaitLabel);
Assert.Contains($"#{headNumber}", dependent.DependencyWaitLabel);
}
} }
@@ -0,0 +1,85 @@
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Modals;
namespace ClaudeDo.Ui.Tests.ViewModels;
public sealed class WorkerConnectionModalViewModelTests
{
private sealed class FakeWorkerLocator(string? path) : WorkerLocator
{
public override string? Find() => path;
}
private sealed class FakeInstallerLocator(string? path) : InstallerLocator
{
public override string? Find() => path;
}
[Fact]
public void StartWorker_ExeNotFound_ShowsErrorAndKeepsDialogOpen()
{
var vm = new WorkerConnectionModalViewModel(new FakeWorkerLocator(null), new FakeInstallerLocator(null));
var closed = false;
vm.CloseAction = () => closed = true;
vm.StartWorkerCommand.Execute(null);
Assert.False(closed);
Assert.False(string.IsNullOrEmpty(vm.ErrorText));
}
[Fact]
public void StartWorker_ProcessStartThrows_ShowsErrorAndKeepsDialogOpen()
{
var vm = new WorkerConnectionModalViewModel(new FakeWorkerLocator(@"C:\fake\ClaudeDo.Worker.exe"), new FakeInstallerLocator(null))
{
ProcessStarter = _ => throw new InvalidOperationException("blocked by AV")
};
var closed = false;
vm.CloseAction = () => closed = true;
vm.StartWorkerCommand.Execute(null);
Assert.False(closed);
Assert.Contains("blocked by AV", vm.ErrorText);
}
[Fact]
public void StartWorker_Success_ClearsErrorAndClosesDialog()
{
var vm = new WorkerConnectionModalViewModel(new FakeWorkerLocator(@"C:\fake\ClaudeDo.Worker.exe"), new FakeInstallerLocator(null))
{
ProcessStarter = _ => { }
};
var closed = false;
vm.CloseAction = () => closed = true;
vm.StartWorkerCommand.Execute(null);
Assert.True(closed);
Assert.Null(vm.ErrorText);
}
[Fact]
public void RerunInstaller_NotFound_ShowsErrorAndDoesNotThrow()
{
var vm = new WorkerConnectionModalViewModel(new FakeWorkerLocator(null), new FakeInstallerLocator(null));
vm.RerunInstallerCommand.Execute(null);
Assert.False(string.IsNullOrEmpty(vm.ErrorText));
}
[Fact]
public void RerunInstaller_ProcessStartThrows_ShowsError()
{
var vm = new WorkerConnectionModalViewModel(new FakeWorkerLocator(null), new FakeInstallerLocator(@"C:\fake\ClaudeDo.Installer.exe"))
{
ProcessStarter = _ => throw new InvalidOperationException("blocked by AV")
};
vm.RerunInstallerCommand.Execute(null);
Assert.Contains("blocked by AV", vm.ErrorText);
}
}
@@ -0,0 +1,113 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Tests.Infrastructure;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.External;
public sealed class ListHandlerMcpToolsTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly TaskRepository _tasks;
private readonly ListRepository _lists;
private readonly CapturingHubContext _hubContext = new();
public ListHandlerMcpToolsTests()
{
_ctx = _db.CreateContext();
_tasks = new TaskRepository(_ctx);
_lists = new ListRepository(_ctx);
}
public void Dispose()
{
_ctx.Dispose();
_db.Dispose();
}
private ListHandlerMcpTools BuildSut(ConnectedClientTracker tracker) =>
new(_lists, _tasks, new HubBroadcaster(_hubContext), tracker);
private async Task<string> SeedListAsync()
{
var listId = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
return listId;
}
private async Task<TaskEntity> SeedTaskAsync(string listId, TaskStatus status = TaskStatus.Idle, string title = "t")
{
var task = new TaskEntity
{
Id = Guid.NewGuid().ToString(), ListId = listId, Title = title,
Status = status, CreatedAt = DateTime.UtcNow, CommitType = "chore",
};
await _tasks.AddAsync(task);
return task;
}
[Fact]
public async Task StartListHandler_NoConnectedClient_DoesNotBroadcastAndReportsNotRequested()
{
var listId = await SeedListAsync();
await SeedTaskAsync(listId);
var tracker = new ConnectedClientTracker();
var result = await BuildSut(tracker).StartListHandler(listId, cancellationToken: CancellationToken.None);
Assert.False(result.Requested);
Assert.False(string.IsNullOrWhiteSpace(result.Reason));
Assert.Equal(1, result.TaskCount);
Assert.Empty(_hubContext.Proxy.Calls);
}
[Fact]
public async Task StartListHandler_ConnectedClient_BroadcastsAndReportsRequested()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId);
var tracker = new ConnectedClientTracker();
tracker.Increment();
var result = await BuildSut(tracker).StartListHandler(listId, cancellationToken: CancellationToken.None);
Assert.True(result.Requested);
Assert.Null(result.Reason);
Assert.Equal(1, result.TaskCount);
Assert.Equal(new[] { task.Number }, result.TaskNumbers);
var call = Assert.Single(_hubContext.Proxy.Calls);
Assert.Equal("ListHandlerStartRequested", call.Method);
Assert.Equal(listId, call.Args[0]);
}
[Fact]
public async Task StartListHandler_LastClientDisconnects_StopsReportingRequested()
{
var listId = await SeedListAsync();
await SeedTaskAsync(listId);
var tracker = new ConnectedClientTracker();
tracker.Increment();
tracker.Decrement();
var result = await BuildSut(tracker).StartListHandler(listId, cancellationToken: CancellationToken.None);
Assert.False(result.Requested);
Assert.Empty(_hubContext.Proxy.Calls);
}
[Fact]
public async Task StartListHandler_NoTasksToHandle_ThrowsRegardlessOfConnection()
{
var listId = await SeedListAsync();
var tracker = new ConnectedClientTracker();
tracker.Increment();
await Assert.ThrowsAsync<InvalidOperationException>(() =>
BuildSut(tracker).StartListHandler(listId, cancellationToken: CancellationToken.None));
}
}
@@ -0,0 +1,38 @@
using ClaudeDo.Worker.Lifecycle;
using Xunit;
namespace ClaudeDo.Worker.Tests.Lifecycle;
public sealed class WorkerStartupFailureTests
{
[Fact]
public void FormatBindFailureMessage_IncludesBothPorts_WhenExternalMcpEnabled()
{
var message = WorkerStartupFailure.FormatBindFailureMessage(
new IOException("Failed to bind to address http://127.0.0.1:47821: address already in use."),
signalRPort: 47821,
externalMcpPort: 47822);
Assert.Contains("47821", message);
Assert.Contains("47822", message);
Assert.Contains("address already in use", message);
}
[Fact]
public void FormatBindFailureMessage_OmitsExternalPort_WhenDisabled()
{
var message = WorkerStartupFailure.FormatBindFailureMessage(
new IOException("boom"),
signalRPort: 47821,
externalMcpPort: 0);
Assert.Contains("47821", message);
Assert.DoesNotContain("0", message);
}
[Fact]
public void ExitCode_IsNonZero()
{
Assert.NotEqual(0, WorkerStartupFailure.ExitCode);
}
}
@@ -26,9 +26,10 @@ public class WeekReportServiceTests : IDisposable
private sealed class FakeClaude : IClaudeProcess private sealed class FakeClaude : IClaudeProcess
{ {
public int Calls; public int Calls;
public IReadOnlyList<string>? LastArgs;
public RunResult Next = new() { ExitCode = 0, ResultMarkdown = "## Bericht" }; public RunResult Next = new() { ExitCode = 0, ResultMarkdown = "## Bericht" };
public Task<RunResult> RunAsync(IReadOnlyList<string> args, string prompt, string wd, Func<string, Task> onLine, CancellationToken ct) public Task<RunResult> RunAsync(IReadOnlyList<string> args, string prompt, string wd, Func<string, Task> onLine, CancellationToken ct)
{ Calls++; return Task.FromResult(Next); } { Calls++; LastArgs = args; return Task.FromResult(Next); }
} }
private WeekReportService Make(FakeReader reader, FakeClaude claude) => private WeekReportService Make(FakeReader reader, FakeClaude claude) =>
@@ -65,6 +66,32 @@ public class WeekReportServiceTests : IDisposable
Assert.Equal(md, await svc.GetStoredAsync(Start, End)); Assert.Equal(md, await svc.GetStoredAsync(Start, End));
} }
[Fact]
public async Task HaikuDefaultModel_RoutesPermissionModeThroughResolver()
{
using (var ctx = _db.CreateContext())
{
var repo = new AppSettingsRepository(ctx);
var settings = await repo.GetAsync();
settings.DefaultModel = "haiku";
await repo.UpdateAsync(settings);
}
var activityRepo = new RepoActivity { RepoPath = @"C:\Dev\App" };
var day = new DayActivity { Date = new DateOnly(2026, 6, 1) };
day.Prompts.Add("x");
activityRepo.Days.Add(day);
var claude = new FakeClaude();
var svc = Make(new FakeReader { Result = new[] { activityRepo } }, claude);
await svc.GenerateAsync(Start, End);
Assert.NotNull(claude.LastArgs);
var modeIndex = claude.LastArgs!.ToList().IndexOf("--permission-mode") + 1;
Assert.Equal(PermissionModeResolver.Resolve("haiku", "auto"), claude.LastArgs[modeIndex]);
Assert.NotEqual("auto", claude.LastArgs[modeIndex]);
}
[Fact] [Fact]
public async Task ClaudeFailure_Throws_AndDoesNotStore() public async Task ClaudeFailure_Throws_AndDoesNotStore()
{ {
@@ -23,7 +23,7 @@ public class FailureDiagnosisTests
Assert.Contains("55", message); Assert.Contains("55", message);
Assert.Contains("60", message); Assert.Contains("60", message);
Assert.Contains("maxTurns", message); Assert.Contains("maxTurns", message);
Assert.Contains("Queue", message); Assert.Contains("re-queue", message);
Assert.DoesNotContain(ClaudeProcess.NoResultPrefix, message); Assert.DoesNotContain(ClaudeProcess.NoResultPrefix, message);
} }
@@ -174,12 +174,12 @@ public sealed class FailureDiagnosisEndToEndTests : IDisposable
using var verify = _db.CreateContext(); using var verify = _db.CreateContext();
var task = await new TaskRepository(verify).GetByIdAsync("t1"); var task = await new TaskRepository(verify).GetByIdAsync("t1");
Assert.Equal(TaskStatus.Failed, task!.Status); Assert.Equal(TaskStatus.Failed, task!.Status);
Assert.Contains("Turn-Budget", task.Result); Assert.Contains("Turn budget", task.Result);
Assert.Contains("60", task.Result); Assert.Contains("60", task.Result);
Assert.DoesNotContain(ClaudeProcess.NoResultPrefix, task.Result); Assert.DoesNotContain(ClaudeProcess.NoResultPrefix, task.Result);
var run = await new TaskRunRepository(verify).GetLatestByTaskIdAsync("t1"); var run = await new TaskRunRepository(verify).GetLatestByTaskIdAsync("t1");
Assert.Contains("Turn-Budget", run!.ErrorMarkdown); Assert.Contains("Turn budget", run!.ErrorMarkdown);
} }
[Fact] [Fact]
@@ -122,6 +122,61 @@ public class GitServiceBootstrapTests : IDisposable
Assert.Equal("ClaudeDo <claudedo@local>", author); Assert.Equal("ClaudeDo <claudedo@local>", author);
} }
[Fact]
public async Task RevParseHead_EmptyRepo_FriendlyMessage_EvenUnderNonEnglishLocale()
{
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
var dir = InitEmptyRepo();
var previousLanguage = Environment.GetEnvironmentVariable("LANGUAGE");
var previousLcAll = Environment.GetEnvironmentVariable("LC_ALL");
// Simulate a non-English OS/git locale on the *ambient* process environment.
// GitService must override it per-call so its English-only stderr matches
// (e.g. "ambiguous argument 'HEAD'") keep working regardless.
Environment.SetEnvironmentVariable("LANGUAGE", "de");
Environment.SetEnvironmentVariable("LC_ALL", "de_DE.UTF-8");
try
{
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => _git.RevParseHeadAsync(dir));
Assert.Contains("no commits yet", ex.Message);
}
finally
{
Environment.SetEnvironmentVariable("LANGUAGE", previousLanguage);
Environment.SetEnvironmentVariable("LC_ALL", previousLcAll);
}
}
[Fact]
public async Task CommitAsync_MissingIdentity_FallsBackToClaudeDoIdentity_EvenUnderNonEnglishLocale()
{
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");
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);
var previousLanguage = Environment.GetEnvironmentVariable("LANGUAGE");
var previousLcAll = Environment.GetEnvironmentVariable("LC_ALL");
Environment.SetEnvironmentVariable("LANGUAGE", "de");
Environment.SetEnvironmentVariable("LC_ALL", "de_DE.UTF-8");
try
{
await _git.CommitAsync(dir, "feat(test): identity fallback under non-English locale");
var author = GitRepoFixture.RunGit(dir, "log", "-1", "--format=%an <%ae>").Trim();
Assert.Equal("ClaudeDo <claudedo@local>", author);
}
finally
{
Environment.SetEnvironmentVariable("LANGUAGE", previousLanguage);
Environment.SetEnvironmentVariable("LC_ALL", previousLcAll);
}
}
public void Dispose() public void Dispose()
{ {
foreach (var dir in _tempDirs) foreach (var dir in _tempDirs)
@@ -721,7 +721,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
{ {
var svc = BuildService(now: now); var svc = BuildService(now: now);
var spec = await svc.BuildForMergeHelperAsync( var spec = await svc.BuildForMergeHelperAsync(
new[] { taskId }, listId, Guid.NewGuid().ToString(), CancellationToken.None); new[] { taskId }, listId, Guid.NewGuid().ToString(), null, CancellationToken.None);
var sessionDir = TrackSessionDir(spec); var sessionDir = TrackSessionDir(spec);
return await File.ReadAllTextAsync(Path.Combine(sessionDir, "system-prompt.md")); return await File.ReadAllTextAsync(Path.Combine(sessionDir, "system-prompt.md"));
} }
@@ -851,7 +851,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
var listId = await SeedListAsync(workingDir: _tempDir); var listId = await SeedListAsync(workingDir: _tempDir);
var svc = BuildService(); var svc = BuildService();
await Assert.ThrowsAsync<InvalidOperationException>( await Assert.ThrowsAsync<InvalidOperationException>(
() => svc.BuildForMergeHelperAsync(Array.Empty<string>(), listId, Guid.NewGuid().ToString(), CancellationToken.None)); () => svc.BuildForMergeHelperAsync(Array.Empty<string>(), listId, Guid.NewGuid().ToString(), null, CancellationToken.None));
} }
[Fact] [Fact]
@@ -863,7 +863,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
var svc = BuildService(); var svc = BuildService();
var ex = await Assert.ThrowsAsync<InvalidOperationException>( var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => svc.BuildForMergeHelperAsync(new[] { taskId }, listId, Guid.NewGuid().ToString(), CancellationToken.None)); () => svc.BuildForMergeHelperAsync(new[] { taskId }, listId, Guid.NewGuid().ToString(), null, CancellationToken.None));
Assert.Contains("working directory", ex.Message); Assert.Contains("working directory", ex.Message);
} }
@@ -876,7 +876,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
var svc = BuildService(); var svc = BuildService();
await Assert.ThrowsAsync<KeyNotFoundException>( await Assert.ThrowsAsync<KeyNotFoundException>(
() => svc.BuildForMergeHelperAsync(new[] { taskId }, "no-such-list", Guid.NewGuid().ToString(), CancellationToken.None)); () => svc.BuildForMergeHelperAsync(new[] { taskId }, "no-such-list", Guid.NewGuid().ToString(), null, CancellationToken.None));
} }
[Fact] [Fact]
@@ -892,7 +892,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
await SeedTaskAsync(t2, listId, TaskStatus.Idle, title: "Second task"); await SeedTaskAsync(t2, listId, TaskStatus.Idle, title: "Second task");
var svc = BuildService(); var svc = BuildService();
var spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2 }, listId, Guid.NewGuid().ToString(), CancellationToken.None); var spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2 }, listId, Guid.NewGuid().ToString(), null, CancellationToken.None);
var sessionDir = TrackSessionDir(spec); var sessionDir = TrackSessionDir(spec);
Assert.Equal(repo, spec.Cwd); Assert.Equal(repo, spec.Cwd);
@@ -934,6 +934,27 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
Assert.Equal(InteractiveLaunchSpecService.McpToolTimeoutMs, spec.Env["MCP_TOOL_TIMEOUT"]); Assert.Equal(InteractiveLaunchSpecService.McpToolTimeoutMs, spec.Env["MCP_TOOL_TIMEOUT"]);
} }
[Theory]
[InlineData("fable", "fable")]
[InlineData("opus", "opus")]
[InlineData("not-a-model", ModelRegistry.HandlerTriageAlias)] // unknown → default, never blocks the launch
public async Task BuildForMergeHelperAsync_ModelOverride_DrivesModelAndEffortArgs(string requested, string expected)
{
var repo = Path.Combine(_tempDir, $"repoModel-{requested}");
Directory.CreateDirectory(repo);
var listId = await SeedListAsync(workingDir: repo, name: "Alpha");
var t1 = Guid.NewGuid().ToString();
await SeedTaskAsync(t1, listId, TaskStatus.Idle, title: "First task");
var svc = BuildService();
var spec = await svc.BuildForMergeHelperAsync(new[] { t1 }, listId, Guid.NewGuid().ToString(), requested, CancellationToken.None);
TrackSessionDir(spec);
var args = spec.Args.ToList();
Assert.Equal(expected, args[args.IndexOf("--model") + 1]);
Assert.Equal(ModelPresets.For(ModelPresets.Defaults, expected).Effort, args[args.IndexOf("--effort") + 1]);
}
// A directory argument that keeps its trailing separator escapes its own closing quote once the // A directory argument that keeps its trailing separator escapes its own closing quote once the
// ConPTY host flattens Args into a single Windows command line ("C:\repo\" -> \" is a literal // ConPTY host flattens Args into a single Windows command line ("C:\repo\" -> \" is a literal
// quote), so --add-dir's variadic list swallows every following argument -- including the // quote), so --add-dir's variadic list swallows every following argument -- including the
@@ -949,7 +970,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
await SeedTaskAsync(t1, listId, TaskStatus.WaitingForReview, title: "First task"); await SeedTaskAsync(t1, listId, TaskStatus.WaitingForReview, title: "First task");
var svc = BuildService(); var svc = BuildService();
var spec = await svc.BuildForMergeHelperAsync(new[] { t1 }, listId, Guid.NewGuid().ToString(), CancellationToken.None); var spec = await svc.BuildForMergeHelperAsync(new[] { t1 }, listId, Guid.NewGuid().ToString(), null, CancellationToken.None);
var sessionDir = TrackSessionDir(spec); var sessionDir = TrackSessionDir(spec);
var args = spec.Args.ToList(); var args = spec.Args.ToList();
@@ -972,7 +993,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
await SeedTaskAsync(t2, listId, TaskStatus.Idle, title: "Second task"); await SeedTaskAsync(t2, listId, TaskStatus.Idle, title: "Second task");
var svc = BuildService(); var svc = BuildService();
var spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2 }, listId, Guid.NewGuid().ToString(), CancellationToken.None); var spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2 }, listId, Guid.NewGuid().ToString(), null, CancellationToken.None);
var sessionDir = TrackSessionDir(spec); var sessionDir = TrackSessionDir(spec);
var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md")); var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md"));
@@ -999,7 +1020,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
var handlerTaskId = Guid.NewGuid().ToString(); var handlerTaskId = Guid.NewGuid().ToString();
var svc = BuildService(); var svc = BuildService();
var spec = await svc.BuildForMergeHelperAsync(new[] { t1 }, listId, handlerTaskId, CancellationToken.None); var spec = await svc.BuildForMergeHelperAsync(new[] { t1 }, listId, handlerTaskId, null, CancellationToken.None);
var sessionDir = TrackSessionDir(spec); var sessionDir = TrackSessionDir(spec);
var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md")); var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md"));
@@ -1018,7 +1039,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
description: "Do the thing carefully and report back."); description: "Do the thing carefully and report back.");
var svc = BuildService(); var svc = BuildService();
var spec = await svc.BuildForMergeHelperAsync(new[] { t1 }, listId, Guid.NewGuid().ToString(), CancellationToken.None); var spec = await svc.BuildForMergeHelperAsync(new[] { t1 }, listId, Guid.NewGuid().ToString(), null, CancellationToken.None);
var sessionDir = TrackSessionDir(spec); var sessionDir = TrackSessionDir(spec);
var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md")); var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md"));
@@ -1036,7 +1057,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
await SeedTaskAsync(t1, listId, TaskStatus.Idle, title: "No description task", description: null); await SeedTaskAsync(t1, listId, TaskStatus.Idle, title: "No description task", description: null);
var svc = BuildService(); var svc = BuildService();
var spec = await svc.BuildForMergeHelperAsync(new[] { t1 }, listId, Guid.NewGuid().ToString(), CancellationToken.None); var spec = await svc.BuildForMergeHelperAsync(new[] { t1 }, listId, Guid.NewGuid().ToString(), null, CancellationToken.None);
var sessionDir = TrackSessionDir(spec); var sessionDir = TrackSessionDir(spec);
var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md")); var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md"));
@@ -1067,7 +1088,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
await SeedTaskAsync(t2, listId, TaskStatus.WaitingForReview, title: "Second task", description: "plain description"); await SeedTaskAsync(t2, listId, TaskStatus.WaitingForReview, title: "Second task", description: "plain description");
var svc = BuildService(); var svc = BuildService();
var spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2 }, listId, Guid.NewGuid().ToString(), CancellationToken.None); var spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2 }, listId, Guid.NewGuid().ToString(), null, CancellationToken.None);
var sessionDir = TrackSessionDir(spec); var sessionDir = TrackSessionDir(spec);
var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md")); var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md"));
@@ -1402,7 +1423,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
var svc = BuildService(); var svc = BuildService();
var triageSpec = await svc.BuildForMergeHelperAsync(new[] { survivor }, listId, handlerTaskId, CancellationToken.None); var triageSpec = await svc.BuildForMergeHelperAsync(new[] { survivor }, listId, handlerTaskId, null, CancellationToken.None);
var triageDir = TrackSessionDir(triageSpec); var triageDir = TrackSessionDir(triageSpec);
var executeSpec = await svc.BuildForMergeHelperHandoffAsync(handlerTaskId, new[] { survivor }, "merge", CancellationToken.None); var executeSpec = await svc.BuildForMergeHelperHandoffAsync(handlerTaskId, new[] { survivor }, "merge", CancellationToken.None);
var executeDir = TrackSessionDir(executeSpec); var executeDir = TrackSessionDir(executeSpec);
@@ -1453,7 +1474,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
ClaudeSessionId: "sess-42", Token: "tok-2", WorktreePath: _worktreeDir); ClaudeSessionId: "sess-42", Token: "tok-2", WorktreePath: _worktreeDir);
var svc = BuildService(); var svc = BuildService();
var mergeHelperSpec = await svc.BuildForMergeHelperAsync(new[] { survivor }, listId, handlerTaskId, CancellationToken.None); var mergeHelperSpec = await svc.BuildForMergeHelperAsync(new[] { survivor }, listId, handlerTaskId, null, CancellationToken.None);
TrackSessionDir(mergeHelperSpec); TrackSessionDir(mergeHelperSpec);
var handoffSpec = await svc.BuildForMergeHelperHandoffAsync(handlerTaskId, new[] { survivor }, "wait", CancellationToken.None); var handoffSpec = await svc.BuildForMergeHelperHandoffAsync(handlerTaskId, new[] { survivor }, "wait", CancellationToken.None);
TrackSessionDir(handoffSpec); TrackSessionDir(handoffSpec);
@@ -43,6 +43,14 @@ public sealed class TicketImportServiceTests : IDisposable
.ToListAsync(); .ToListAsync();
Assert.Equal(["bandel:1", "bandel:3"], refs.Order()); Assert.Equal(["bandel:1", "bandel:3"], refs.Order());
// Ohne die Ids kann der Hub kein TaskUpdated broadcasten und die offene Liste
// zeigt die neuen Tasks erst nach Schließen/Öffnen.
var createdIds = await ctx.Tasks
.Where(t => t.CreatedBy == "ticketsystem")
.Select(t => t.Id)
.ToListAsync();
Assert.Equal(createdIds.Order(), result.CreatedTaskIds.Order());
var created = await ctx.Tasks.Where(t => t.TicketRef == "bandel:1").SingleAsync(); var created = await ctx.Tasks.Where(t => t.TicketRef == "bandel:1").SingleAsync();
Assert.Equal("Fix X", created.Title); Assert.Equal("Fix X", created.Title);
Assert.Equal(TaskStatus.Idle, created.Status); Assert.Equal(TaskStatus.Idle, created.Status);
@@ -1,4 +1,6 @@
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.ViewModels.Islands; using ClaudeDo.Ui.ViewModels.Islands;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.UiVm; namespace ClaudeDo.Worker.Tests.UiVm;
@@ -35,4 +37,59 @@ public class ParseDiffStatTests
Assert.Equal(5, add); Assert.Equal(5, add);
Assert.Equal(0, del); Assert.Equal(0, del);
} }
[Fact]
public void Deletions_Only_Returns_Zero_Insertions()
{
var (add, del) = DetailsIslandViewModel.ParseDiffStat("1 file changed, 4 deletions(-)");
Assert.Equal(0, add);
Assert.Equal(4, del);
}
// German-Windows git localizes "insertion(s)"/"deletion(s)" (e.g. "Einfügung(en)"/
// "Löschung(en)") but keeps the (+)/(-) suffix untranslated — the regex anchors on that.
[Fact]
public void German_Locale_Stat_Parses_Both()
{
var (add, del) = DetailsIslandViewModel.ParseDiffStat("2 Dateien geändert, 10 Einfügungen(+), 3 Löschungen(-)");
Assert.Equal(10, add);
Assert.Equal(3, del);
}
[Fact]
public void German_Locale_Insertions_Only_Returns_Zero_Deletions()
{
var (add, del) = DetailsIslandViewModel.ParseDiffStat("1 Datei geändert, 5 Einfügung(en)(+)");
Assert.Equal(5, add);
Assert.Equal(0, del);
}
// TaskRowViewModel used to run its own broken parser (expecting "+12"/"-3" tokens that
// `git diff --stat` never emits) — pin it to the same shared, correct parser as the detail badge.
[Fact]
public void TaskRowViewModel_UpdateFromEntity_Parses_Real_DiffStat_Format()
{
var task = new TaskEntity
{
Id = "t1",
ListId = "l1",
Title = "task",
CreatedAt = DateTime.UtcNow,
Status = TaskStatus.Done,
Worktree = new WorktreeEntity
{
TaskId = "t1",
Path = "/tmp/wt",
BranchName = "claudedo/t1",
BaseCommit = "abc",
DiffStat = "2 files changed, 10 insertions(+), 3 deletions(-)",
CreatedAt = DateTime.UtcNow,
},
};
var row = TaskRowViewModel.FromEntity(task);
Assert.Equal(10, row.DiffAdditions);
Assert.Equal(3, row.DiffDeletions);
}
} }
@@ -281,10 +281,41 @@ public class TaskRowViewModelNotificationsTests
nameof(TaskRowViewModel.IsQueued), nameof(TaskRowViewModel.IsQueued),
nameof(TaskRowViewModel.IsWaiting), nameof(TaskRowViewModel.IsWaiting),
nameof(TaskRowViewModel.StatusChipClass), nameof(TaskRowViewModel.StatusChipClass),
nameof(TaskRowViewModel.StatusLabel),
nameof(TaskRowViewModel.StatusChipTooltip),
nameof(TaskRowViewModel.CanRemoveFromQueue), nameof(TaskRowViewModel.CanRemoveFromQueue),
}, raised); }, raised);
} }
[Fact]
public void IsBlockedByDependencyChanged_Notifies_Dependents()
{
var vm = new TaskRowViewModel { Id = "t" };
var raised = Raised(vm, () => vm.IsBlockedByDependency = true);
Assert.Equal(new HashSet<string?>
{
nameof(TaskRowViewModel.IsBlockedByDependency),
nameof(TaskRowViewModel.IsQueued),
nameof(TaskRowViewModel.IsWaiting),
nameof(TaskRowViewModel.StatusChipClass),
nameof(TaskRowViewModel.StatusLabel),
nameof(TaskRowViewModel.StatusChipTooltip),
nameof(TaskRowViewModel.CanRemoveFromQueue),
}, raised);
}
[Fact]
public void DependencyWaitLabelChanged_Notifies_Dependents()
{
var vm = new TaskRowViewModel { Id = "t" };
var raised = Raised(vm, () => vm.DependencyWaitLabel = "#42");
Assert.Equal(new HashSet<string?>
{
nameof(TaskRowViewModel.DependencyWaitLabel),
nameof(TaskRowViewModel.StatusChipTooltip),
}, raised);
}
[Fact] [Fact]
public void HasPlanningChildrenChanged_Notifies_Dependents() public void HasPlanningChildrenChanged_Notifies_Dependents()
{ {
@@ -54,6 +54,81 @@ public sealed class WaitingForChildrenLifecycleTests : IDisposable
Assert.False(second.Ok); Assert.False(second.Ok);
} }
private async Task<string> SeedRunningParentWithChildrenAsync(
PlanningPhase parentPhase, params TaskStatus[] childStatuses)
{
using var ctx = _db.CreateContext();
if (!ctx.Lists.Any())
ctx.Lists.Add(new ListEntity { Id = "l1", Name = "L", CreatedAt = DateTime.UtcNow });
ctx.Tasks.Add(new TaskEntity { Id = "p1", ListId = "l1", Title = "Parent", Number = ++_numberSeed,
Status = TaskStatus.Running, PlanningPhase = parentPhase, CreatedAt = DateTime.UtcNow });
int i = 0;
foreach (var cs in childStatuses)
ctx.Tasks.Add(new TaskEntity { Id = $"c{i++}", ListId = "l1", Title = "Child", Number = ++_numberSeed,
Status = cs, ParentTaskId = "p1", CreatedAt = DateTime.UtcNow });
await ctx.SaveChangesAsync();
return "p1";
}
[Fact]
public async Task SubmitForChildren_NormalParent_ChainsIdleChildren()
{
var id = await SeedRunningParentWithChildrenAsync(
PlanningPhase.None, TaskStatus.Idle, TaskStatus.Idle, TaskStatus.Idle);
var result = await _built.State.SubmitForChildrenAsync(id, DateTime.UtcNow, "ran ok", default);
Assert.True(result.Ok);
using var ctx = _db.CreateContext();
var repo = new TaskRepository(ctx);
var c0 = await repo.GetByIdAsync("c0");
var c1 = await repo.GetByIdAsync("c1");
var c2 = await repo.GetByIdAsync("c2");
Assert.Equal(TaskStatus.Queued, c0!.Status);
Assert.Null(c0.BlockedByTaskId);
Assert.Equal(TaskStatus.Queued, c1!.Status);
Assert.Equal("c0", c1.BlockedByTaskId);
Assert.Equal(TaskStatus.Queued, c2!.Status);
Assert.Equal("c1", c2.BlockedByTaskId);
}
[Fact]
public async Task SubmitForChildren_PlanningParent_DoesNotChainChildren()
{
var id = await SeedRunningParentWithChildrenAsync(PlanningPhase.Active, TaskStatus.Idle, TaskStatus.Idle);
var result = await _built.State.SubmitForChildrenAsync(id, DateTime.UtcNow, "ran ok", default);
Assert.True(result.Ok);
using var ctx = _db.CreateContext();
var repo = new TaskRepository(ctx);
var c0 = await repo.GetByIdAsync("c0");
var c1 = await repo.GetByIdAsync("c1");
Assert.Equal(TaskStatus.Idle, c0!.Status);
Assert.Null(c0.BlockedByTaskId);
Assert.Equal(TaskStatus.Idle, c1!.Status);
Assert.Null(c1.BlockedByTaskId);
}
[Fact]
public async Task SubmitForChildren_RunningChild_DoesNotFailParentTransition()
{
var id = await SeedRunningParentWithChildrenAsync(PlanningPhase.None, TaskStatus.Running, TaskStatus.Idle);
var result = await _built.State.SubmitForChildrenAsync(id, DateTime.UtcNow, "ran ok", default);
Assert.True(result.Ok);
using var ctx = _db.CreateContext();
var repo = new TaskRepository(ctx);
Assert.Equal(TaskStatus.WaitingForChildren, (await repo.GetByIdAsync(id))!.Status);
// Reshape aborted (a running child cannot be reshaped) — the untouched sibling stays Idle.
Assert.Equal(TaskStatus.Idle, (await repo.GetByIdAsync("c1"))!.Status);
}
private async Task SeedParentWithChildrenAsync(string parentStatus, params TaskStatus[] childStatuses) private async Task SeedParentWithChildrenAsync(string parentStatus, params TaskStatus[] childStatuses)
{ {
using var ctx = _db.CreateContext(); using var ctx = _db.CreateContext();