Compare commits

...
33 Commits
Author SHA1 Message Date
mika kuns db447f36da Merge remote-tracking branch 'origin/main' 2026-07-29 09:29:32 +02:00
mika kuns df2fcd8def Merge claudedo/9a0fd00eaf164f3883e4a9e7e28ac99f 2026-07-29 09:15:06 +02:00
mika kuns 17ef99bc9b Merge claudedo/c90fe66442cb4b72b0476777c2225c65 2026-07-29 09:13:53 +02:00
mika kuns c4425d6499 Merge claudedo/5af6ac9acb4447929e3e550c3c233a25 2026-07-29 09:13:06 +02:00
mika kuns be6ccb2c17 Merge claudedo/f6226a2ec97d41fe8906f1f86ed33e41 2026-07-29 09:12:51 +02:00
mika kuns 57d433276e Merge claudedo/c0630785eb8e47c8bf1da4108028a3c4 2026-07-29 09:12:36 +02:00
mika kuns 785ebe55e4 Merge claudedo/85e48360c061409a9ffd9c772450cf73 2026-07-29 09:12:24 +02:00
mika kuns e1807fd53b fix(review): propagate HubException from ApproveReviewAsync so blocked merges surface errors
TryInvokeAsync swallowed all exceptions including HubException, so a
blocked merge (uncommitted changes in target, mid-merge state, inactive
worktree) returned null silently — the task stayed WaitingForReview with
no feedback shown.  Switch to a direct _hub.InvokeAsync so both VM
catch blocks (TasksIsland ErrorReported, DetailsIsland ShowErrorAsync)
actually fire.

Add regression tests for both call sites verifying that a throwing
worker causes the error to be reported.
2026-07-29 09:08:19 +02:00
mika kuns 149e2adadb feat(ui): accent color presets in Settings → General
Adds Moss / Peat / Sea preset swatches to the General settings tab.
Selecting a preset mutates the live SolidColorBrush objects in the
Application resource dictionary so all StaticResource consumers update
instantly; the choice is persisted to ui.config.json and re-applied
at startup. Missing or unknown saved value falls back to Moss.
2026-07-29 09:08:13 +02:00
mika kuns d569313598 feat(mcp): allow Done via update_task_status with worktree guard
External tasks finished outside a ClaudeDo run had no way to close out
their tracking task; update_task_status now permits Done alongside
Idle/Queued/Cancelled, refusing it when the task has an active
worktree so review_task stays the only path that merges.
2026-07-29 08:58:34 +02:00
mika kuns 0a3c25840f feat(claude-do): Neu angelegter Task soll direkt ausgewählt werden (Detailpan
## Problem
Nach "Neuen Task hinzufügen" (Eingabefeld im Tasks-Header, `Ctrl+N`) wird der Task angelegt und in die
Liste eingefügt, aber **nicht ausgewählt**. Um Beschreibung/Config zu ergänzen, muss man ihn erst
manuell anklicken.

## Ist-Zustand
`src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs`, `AddAsync()` (ab Zeile ~424):
legt die `TaskEntity` an, speichert über `TaskRepository.AddA

ClaudeDo-Task: 5af6ac9acb4447929e3e550c3c233a25
2026-07-29 08:57:55 +02:00
mika kuns 7d6cb2bd3e feat(ui): add Let Claude handle it broom button to tasks header 2026-07-29 08:55:26 +02:00
mika kuns 7c8a9dd61b refactor(claude-do): Task-Kontextmenü: "Als manuell markieren" in das "Markieren
## Problem
Im Task-Kontextmenü gibt es bereits ein Untermenü "Markieren als …" (`tasks.ctxMarkAs`) mit
"Erledigt" und "Abgebrochen". Die beiden Manuell-Einträge hängen dagegen ganz unten flach im
Hauptmenü — inkonsistent, obwohl sie semantisch dasselbe "markieren als"-Verb sind.

## Ist-Zustand
`src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`:
- Zeile ~47-50: `<MenuItem Header="{loc:Tr tasks.ctxM

ClaudeDo-Task: c0630785eb8e47c8bf1da4108028a3c4
2026-07-29 08:53:05 +02:00
ClaudeDo CI 3fbbd7ab93 docs(changelog): update for v2.4.0 2026-07-27 13:07:19 +00:00
Mika Kuns 24f999facd docs: record the manual-task, effort-preset and chip/spinner changes
Changelog / changelog (push) Successful in 2s
Release / release (push) Successful in 38s
2026-07-27 15:02:51 +02:00
Mika Kuns 3a648b7d77 feat(tasks): mark tasks and lists as manual
Reminders written down as todos had no home: every task looked like Claude work.
A manual task now shows a MANUAL badge and hides send-to-queue, refine and the
planning session; the queue picker, daily prep and the list handler all skip it,
with a TaskStateService guard so the MCP surface and hub cannot start one either.
Opening a hand-driven ConPTY session stays available on purpose.

A list can be marked manual in its settings, which makes tasks created there
(UI and MCP add_task) start out manual. Toggle per task from its context menu.
2026-07-27 15:02:51 +02:00
Mika Kuns fde9615b34 feat(settings): per-model effort and turn presets
ClaudeDo never passed --effort, so every session inherited whatever effortLevel
the user's Claude Code config happened to carry. Settings -> General now holds one
row per model alias (haiku medium/20, sonnet high/30, opus high/40, fable high/25)
supplying the global effort and turn defaults; list- and task-level max-turns
overrides still win, and the agent editor's inherited badge follows the model.

--effort is applied to autonomous runs and to every ConPTY spec (task session,
planning start/resume, ad-hoc, list handler). The model itself is deliberately not
forced on interactive sessions. The single global 'Max turns' field is replaced by
the table, and 'fable' joins ModelRegistry.Aliases.

The migration also adds the is_manual columns used by the next commit.
2026-07-27 15:02:51 +02:00
Mika Kuns c93a20f20c feat(ui): Interactive chip for tasks with an open ConPTY session
A task driven by hand in Mission Control is Idle with an Active worktree, so its
lifecycle chip read "Parked" — indistinguishable from a task genuinely set aside.
The shell now mirrors Mission Control's open panes onto the rows, which show an
accent "Interactive" chip instead; tapping it surfaces Mission Control and
focuses that pane (the open command already dedupes by task id).
2026-07-27 15:02:51 +02:00
Mika Kuns 1466d0fbab feat(ui): spinners for ConPTY session start and task refine
Both actions previously gave no feedback: opening a ConPTY session only created
the tile after the launch-spec roundtrip (which may build a worktree), and the
refine button just disappeared while the run was in flight.

Add a shared Ellipse.spinner style, and let ConPtyPaneViewModel resolve its own
launch spec so the tile shows up immediately with a starting overlay. A failed
launch now keeps the tile with its inline error banner instead of never
appearing — Start() is separated from the ctor so the host can subscribe to
ErrorReported before the launch begins.
2026-07-27 15:02:51 +02:00
Mika Kuns 6c8de4aef3 style(ui): pad the diff-viewer file-tree stats off the right edge 2026-07-27 15:02:50 +02:00
Mika Kuns 7f9f0ca128 fix(ui): bind search focus to Ctrl+K instead of OemQuestion
OemQuestion is the '#' key on a German layout, so the window-level binding both
swallowed '#' app-wide and left Ctrl+K dead — even though the search box already
advertises 'Ctrl K' as its shortcut.
2026-07-27 15:02:50 +02:00
Mika Kuns edd2774d86 fix(ui): persist title edits from the detail pane
EditableTitle had no save handler at all (only EditableDescription did), so
retitling a task in the details island was silently discarded on rebind. Mirror
the debounced description save, capturing the row so a task switch mid-debounce
cannot retitle the wrong task.
2026-07-27 15:02:50 +02:00
Mika Kuns 62fc5aaa5a docs(ui): document the list-handler selection modal 2026-07-27 15:02:50 +02:00
Mika Kuns 023e136c53 docs: describe the list-scoped five-phase handler 2026-07-27 15:02:50 +02:00
Mika Kuns 4877802bcd refactor(worker): make the list-handler launch spec single-list and single-repo 2026-07-27 15:02:50 +02:00
Mika Kuns 40eb979924 refactor(ui): scope "Let Claude handle it" to a single list 2026-07-27 15:02:50 +02:00
Mika Kuns e3bacc3143 feat(data): five-phase list-handler prompt with dedupe and enhance 2026-07-27 15:02:50 +02:00
Mika Kuns cc823ec4f6 feat(worker): allow update_task_status to set Cancelled 2026-07-27 15:02:50 +02:00
Mika Kuns ec10b06848 docs(plans): implementation plan for the per-list task handler 2026-07-27 15:02:50 +02:00
Mika Kuns 81cf94187f docs(specs): per-list task handler with read/dedupe/enhance/run/merge 2026-07-27 15:02:50 +02:00
ClaudeDo CI 538b4ede09 docs(changelog): update for v2.3.1 2026-07-24 12:49:31 +00:00
mika kuns 95918414a0 fix(installer): retry stashing app/worker through transient file locks on update
Changelog / changelog (push) Successful in 1s
Release / release (push) Successful in 38s
The update pipeline moved app/ and worker/ to .bak before extraction with no
retry, so a just-killed worker whose file handles had not been released yet
(WaitForExit returns before the OS flushes them) caused Directory.Move to throw
ERROR_SHARING_VIOLATION, stopping the pipeline on a cryptic error screen.

- DownloadAndExtractStep: stash/rollback moves+deletes now retry through
  transient IO/access errors (~5s); a persistent lock returns an actionable
  message instead of the raw error.
- StopWorkerStep: reading MainModule no longer skips Kill on failure, and a
  short settle follows the kill so handles are released before extraction.
2026-07-24 14:49:15 +02:00
ClaudeDo CI 9349386675 docs(changelog): update for v2.3.0 2026-07-24 12:21:49 +00:00
81 changed files with 3708 additions and 401 deletions
+59
View File
@@ -1,5 +1,64 @@
# Changelog # Changelog
## v2.4.0 — 2026-07-27
### Features
- mark tasks and lists as manual (3a648b7)
- per-model effort and turn presets (fde9615)
- Interactive chip for tasks with an open ConPTY session (c93a20f)
- spinners for ConPTY session start and task refine (1466d0f)
- five-phase list-handler prompt with dedupe and enhance (e3bacc3)
- allow update_task_status to set Cancelled (cc823ec)
### Fixes
- bind search focus to Ctrl+K instead of OemQuestion (7f9f0ca)
- persist title edits from the detail pane (edd2774)
### Refactoring
- make the list-handler launch spec single-list and single-repo (4877802)
- scope "Let Claude handle it" to a single list (40eb979)
### Documentation
- record the manual-task, effort-preset and chip/spinner changes (24f999f)
- document the list-handler selection modal (62fc5aa)
- describe the list-scoped five-phase handler (023e136)
- implementation plan for the per-list task handler (ec10b06)
- per-list task handler with read/dedupe/enhance/run/merge (81cf941)
- update for v2.3.1 (538b4ed)
## v2.3.1 — 2026-07-24
### Fixes
- retry stashing app/worker through transient file locks on update (9591841)
### Documentation
- update for v2.3.0 (9349386)
## v2.3.0 — 2026-07-24
### Features
- open merge-helper ConPTY tile from selection (083e1f3)
- add "Let Claude handle it" entry points (ecbc734)
- add merge-helper task selection dialog (327ae2b)
- expose merge-helper launch spec over hub + client (5ba0c09)
- build merge-helper interactive launch spec (78d4e1a)
- add merge-helper prompt templates (c7d64e9)
- add continue_merge and abort_merge MCP tools (7517f2a)
- let review_task/merge_task leave conflicts in tree via MCP (f4f7c81)
### Documentation
- helper handles all merges; manual conflict fallback (2a3ab55)
- spec + implementation plan (962f68c)
- update for v2.2.0 (d12a888)
## v2.2.0 — 2026-07-24 ## v2.2.0 — 2026-07-24
### Features ### Features
+27
View File
@@ -34,12 +34,39 @@ Stand: 2026-07-24. Diese Datei listet die **aktiv verifizierten Findings** aus d
- **Conflict-Resolver: farbliches Hervorheben eingefügter Zeilen im Result-Pane** (grüner „flow" der übernommenen Zeilen). §4. - **Conflict-Resolver: farbliches Hervorheben eingefügter Zeilen im Result-Pane** (grüner „flow" der übernommenen Zeilen). §4.
- Als ClaudeDo-Tasks in Liste „Claude do" erfasst: **Approve erzwingt Diff/Review vor Merge** (koppelt den blocked-Merge-Silent-Fail-Fix) und **interaktive Planning-Session über embedded ConPTY statt externem wt-Fenster** (koppelt den Planning-Permission-Prompt). - Als ClaudeDo-Tasks in Liste „Claude do" erfasst: **Approve erzwingt Diff/Review vor Merge** (koppelt den blocked-Merge-Silent-Fail-Fix) und **interaktive Planning-Session über embedded ConPTY statt externem wt-Fenster** (koppelt den Planning-Permission-Prompt).
## Sichtprüfung offen (2026-07-27, Batch „Claude do"-Liste)
Alles gebaut + unit-getestet, aber **nicht visuell verifiziert** — Mika prüft:
- **Ctrl+K fokussiert die Suche, `#` ist wieder tippbar** (Binding war `OemQuestion` = `#` auf DE-Layout).
- **Titel-Edit in der Detail-Insel persistiert** (400 ms debounced, wie die Beschreibung); Row-Titel + Merge-Kontext ziehen mit.
- **Spinner beim ConPTY-Start:** Tile erscheint sofort mit „Sitzung wird gestartet…" und deckt Launch-Spec-Roundtrip + Spawn ab. **Nicht** abgedeckt: die Sekunden, die die claude-TUI danach zum ersten Frame braucht (dafür bräuchte es einen Hook auf `TerminalControl.DataReceived`). Bei Launch-Fehler bleibt das Tile jetzt mit Inline-Banner stehen statt zu verschwinden.
- **Spinner beim Refine** ersetzt den Refine-Button in der Row, solange der Run läuft.
- **Interactive-Chip** statt „Parked" auf Tasks mit offener ConPTY-Session; Klick öffnet Mission Control und fokussiert die Pane. Accent-Tint (bewusst dieselbe „live"-Familie wie Running, klar unterschieden vom slate-blauen Parked).
- **Diff-Viewer:** rechter Abstand der `+n n`-Zahlen im File-Tree.
- **Manual-Tasks:** MANUAL-Badge, Kontextmenü-Toggle, Listen-Checkbox „Manuelle Liste"; Queue/Refine/Planning ausgeblendet, ConPTY bleibt.
- **Settings → Allgemein:** Tabelle „Vorgaben pro Modell" (Effort + Max. Durchläufe je haiku/sonnet/opus/fable). Ersetzt das einzelne globale „Max. Durchläufe"-Feld.
Offene Entscheidungen dazu:
- Interaktive ConPTY-Sessions bekommen `--effort`, aber **kein** `--model` — die Session läuft weiter unter dem Modell aus Mikas Claude-Config, der Effort kommt aus dem Preset des Modells, das ClaudeDo für die Task auflösen würde. Falls ClaudeDo auch interaktiv das Modell erzwingen soll, ist das ein Folge-Task.
- `AppSettings.DefaultMaxTurns` ist nur noch Fallback für ein Modell ohne Preset-Zeile und hat keinen Editor mehr. Spalte könnte später entfallen.
## Beobachtung (offen — Entscheidung Mika) ## Beobachtung (offen — Entscheidung Mika)
- **`--permission-mode auto` + Modell `haiku` → Writes werden denied:** Kontrolliert verifiziert (CLI 2.1.207): unter dem Default-Mode `auto` bekommt **sonnet** Writes auto-approved (`permission_denials:[]`), **haiku** wird `denied` (`permission_denials:[Write]`, keine Datei) — eine haiku-Task macht unter `auto` still nichts und landet ohne Änderung in `WaitingForReview`. Normalbetrieb (Default = sonnet) nicht betroffen. KEINE CLI-Regression, sondern modellabhängiges `auto`-Verhalten. Optionen falls es nervt: haiku aus der Auswahl nehmen, ODER Runner auf `acceptEdits`/`bypassPermissions` (modell-unabhängig). Mika: erstmal beobachten. Siehe Memory `auto_permission_haiku_footgun`. - **`--permission-mode auto` + Modell `haiku` → Writes werden denied:** Kontrolliert verifiziert (CLI 2.1.207): unter dem Default-Mode `auto` bekommt **sonnet** Writes auto-approved (`permission_denials:[]`), **haiku** wird `denied` (`permission_denials:[Write]`, keine Datei) — eine haiku-Task macht unter `auto` still nichts und landet ohne Änderung in `WaitingForReview`. Normalbetrieb (Default = sonnet) nicht betroffen. KEINE CLI-Regression, sondern modellabhängiges `auto`-Verhalten. Optionen falls es nervt: haiku aus der Auswahl nehmen, ODER Runner auf `acceptEdits`/`bypassPermissions` (modell-unabhängig). Mika: erstmal beobachten. Siehe Memory `auto_permission_haiku_footgun`.
--- ---
## Offene Verifikation (2026-07-27)
- **List handler (2026-07-27)** — visual pass: the Broom button is gone from the lists footer,
the context-menu item appears only on lists with a working dir, and the selection dialog has no
LIST column. Plus a real-Claude smoke run of the five phases (dedupe questions, enhancements
landing in task descriptions, queued execution, merges).
---
## Bewusst verworfen (nicht erneut vorschlagen) ## Bewusst verworfen (nicht erneut vorschlagen)
- **CI-Build/Test-Pipeline** — push-to-main + release-on-push deckt das ab; Tests laufen am Ende jeder Session. - **CI-Build/Test-Pipeline** — push-to-main + release-on-push deckt das ab; Tests laufen am Ende jeder Session.
@@ -0,0 +1,938 @@
# Per-List Task Handler Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Make "Let Claude handle it" list-scoped only, and turn its prompt into a five-phase run — read all tasks, dedupe, enhance, queue, review+merge.
**Architecture:** Four independent commits. Two touch only leaf code (the MCP status tool, the prompt templates). One strips the global UI entry point. The last is an atomic sweep that makes `listId` non-nullable end to end and collapses the launch spec to a single repo — atomic because a half-flipped signature chain leaves nullable warnings scattered across a commit boundary.
**Tech Stack:** .NET 8, xUnit, Avalonia 12, EF Core + SQLite, CommunityToolkit.Mvvm.
**Spec:** `docs/superpowers/specs/2026-07-27-list-handler-design.md`
**Build note:** `dotnet build ClaudeDo.slnx` needs .NET 9 — build individual csproj with `-c Release` (a running Worker locks `Debug` output).
**Staging note:** the checkout is shared with parallel sessions. Always `git add -- <exact paths>` and `git commit -- <exact paths>`. Never `git add -A`, never a bare `git commit`.
---
### Task 1: `update_task_status` accepts `Cancelled`
Dedupe needs to retire an **Idle** duplicate. Today nothing can: `UpdateTaskStatus` allows only
`Idle`/`Queued`, `cancel_task` only cancels a *running* task, and `review_task(decision="cancel")`
requires WaitingForReview/Running/Queued. `TaskStateService.CancelAsync` already owns the
transition and its side effects.
`BatchMcpTools.BatchUpdateTaskStatus` delegates to this same method, so batch cancel comes free.
**Files:**
- Modify: `src/ClaudeDo.Worker/External/ExternalMcpService.cs:264-300`
- Test: `tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs`
- [ ] **Step 1: Write the failing tests**
Append inside the `ExternalMcpServiceTests` class. `SeedTaskAsync` does not exist in this class —
seed inline the way the existing tests do, via `_lists` / `_tasks`.
```csharp
private async Task<TaskEntity> SeedPlainTaskAsync(TaskStatus status)
{
var listId = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
var task = new TaskEntity
{
Id = Guid.NewGuid().ToString(), ListId = listId, Title = "t",
Status = status, CreatedAt = DateTime.UtcNow, CommitType = "chore",
};
await _tasks.AddAsync(task);
return task;
}
[Fact]
public async Task UpdateTaskStatus_Cancelled_CancelsAnIdleTask()
{
var task = await SeedPlainTaskAsync(TaskStatus.Idle);
var queue = CreateQueue();
var sut = BuildSut(queue);
var dto = await sut.UpdateTaskStatus(task.Id, "Cancelled", CancellationToken.None);
Assert.Equal("Cancelled", dto.Status);
var loaded = await _tasks.GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.Cancelled, loaded!.Status);
}
[Fact]
public async Task UpdateTaskStatus_Done_StillRejected()
{
var task = await SeedPlainTaskAsync(TaskStatus.Idle);
var queue = CreateQueue();
var sut = BuildSut(queue);
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => sut.UpdateTaskStatus(task.Id, "Done", CancellationToken.None));
Assert.Contains("not settable externally", ex.Message);
}
```
- [ ] **Step 2: Run the tests to verify they fail**
```bash
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release \
--filter "FullyQualifiedName~ExternalMcpServiceTests.UpdateTaskStatus"
```
Expected: `UpdateTaskStatus_Cancelled_CancelsAnIdleTask` FAILS with
`Status 'Cancelled' is not settable externally.`; `UpdateTaskStatus_Done_StillRejected` passes.
- [ ] **Step 3: Add the `Cancelled` branch**
In `ExternalMcpService.UpdateTaskStatus`, insert between the `Queued` case and `default`:
```csharp
case TaskStatus.Cancelled:
var cancelResult = await _state.CancelAsync(taskId, DateTime.UtcNow, cancellationToken);
if (!cancelResult.Ok)
throw new InvalidOperationException(cancelResult.Reason ?? "Cannot cancel task.");
break;
```
Then update the `[McpServerTool, Description(...)]` text directly above the method — it currently
claims only Idle and Queued are permitted. Replace the whole attribute with:
```csharp
[McpServerTool, Description(
"Update a task's status. Only 'Idle', 'Queued' and 'Cancelled' are permitted externally — " +
"use run_task_now for execution control, and review_task to act on a WaitingForReview task. " +
"Settable: Idle (reset to editable), Queued (enqueue for execution), " +
"Cancelled (retire the task without deleting it; it can be reset to Idle later). " +
"Full lifecycle: Idle → Queued → Running → WaitingForReview → Done | Failed | Cancelled.")]
```
Also fix the `default` branch message, which still points at `cancel_task`:
```csharp
default:
throw new InvalidOperationException(
$"Status '{target}' is not settable externally. Use run_task_now or review_task.");
```
- [ ] **Step 4: Run the tests to verify they pass**
```bash
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release \
--filter "FullyQualifiedName~ExternalMcpServiceTests"
```
Expected: all pass.
- [ ] **Step 5: Run the MCP schema test**
`ExternalMcpToolSchemaTests` asserts over tool descriptions and may pin the old text.
```bash
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release \
--filter "FullyQualifiedName~ExternalMcpToolSchemaTests"
```
Expected: PASS. If it fails on the changed description, update the assertion to match the new
text — do not revert the description.
- [ ] **Step 6: Commit**
```bash
git add -- src/ClaudeDo.Worker/External/ExternalMcpService.cs tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs
git commit -m "feat(worker): allow update_task_status to set Cancelled" -- src/ClaudeDo.Worker/External/ExternalMcpService.cs tests/ClaudeDo.Worker.Tests/External/ExternalMcpServiceTests.cs
```
(If Step 5 required a schema-test edit, add that path to both commands too.)
---
### Task 2: Five-phase helper prompt
**Files:**
- Modify: `src/ClaudeDo.Data/PromptFiles.cs:231-276` (`MergeHelperDefault`, `MergeHelperInitialDefault`)
- Test: `tests/ClaudeDo.Data.Tests/PromptFilesTests.cs:54-80`
- [ ] **Step 1: Write the failing tests**
Replace the existing `DefaultFor_merge_helper_is_non_empty_and_mentions_the_merge_tools` test with
the two below, and keep the other merge-helper tests as they are.
```csharp
[Fact]
public void DefaultFor_merge_helper_covers_all_five_phases()
{
var d = PromptFiles.DefaultFor(PromptKind.MergeHelper);
Assert.False(string.IsNullOrWhiteSpace(d));
Assert.Contains("Phase 0", d);
Assert.Contains("Phase 1", d);
Assert.Contains("Phase 2", d);
Assert.Contains("Phase 3", d);
Assert.Contains("Phase 4", d);
Assert.Contains("Phase 5", d);
}
[Fact]
public void DefaultFor_merge_helper_names_the_tools_each_phase_needs()
{
var d = PromptFiles.DefaultFor(PromptKind.MergeHelper);
Assert.Contains("batch_get_tasks", d); // phase 0
Assert.Contains("update_task", d); // phase 1 + 2
Assert.Contains("get_app_settings", d); // phase 3
Assert.Contains("update_task_status", d); // phase 3
Assert.Contains("review_task", d); // phase 4
Assert.Contains("continue_merge", d); // phase 4
Assert.DoesNotContain("run_task_now(", d); // single override slot — must not batch-start
}
```
- [ ] **Step 2: Run the tests to verify they fail**
```bash
dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release \
--filter "FullyQualifiedName~PromptFilesTests"
```
Expected: both new tests FAIL (no "Phase 0", no `batch_get_tasks`).
- [ ] **Step 3: Replace `MergeHelperDefault`**
Replace the whole `private const string MergeHelperDefault = """ … """;` block with:
```csharp
private const string MergeHelperDefault = """
You are the ClaudeDo list handler, running as an interactive session with the user watching. Ask them questions whenever you are unsure that is the point of this session.
Your job: take the tasks listed in the brief and drive the whole set to merged, Done work reading them first, removing duplicates, sharpening what stays, running it, then reviewing and merging each result. You act through the mcp__claudedo__* tools. Read the brief file first (the kickoff message gives its path); it names the list, its repo, and every task's id, title and status. All tasks belong to that one list and one repo.
Work the five phases in order. Do not start a phase before the previous one is finished.
## Phase 0 Read everything
Call batch_get_tasks with every id from the brief and read each task's title, description, status and parent/child links. Do not act on any single task before you have read them all Phase 1 needs the whole set in view.
## Phase 1 Dedupe
Compare the tasks pairwise for overlap: same goal stated twice, one task fully contained in another, two tasks that would edit the same thing for the same reason.
Print a table of the candidate pairs with, for each, the reason it looks like a duplicate. Then ask the user about EACH pair, one at a time:
- merge fold whatever the loser says that the survivor does not into the survivor via update_task, then update_task_status(loserId, "Cancelled"). Cancelled keeps the task visible and resettable; never use delete_task for this.
- keep both note why and move on.
Cancel nothing without an explicit answer. If there are no duplicates, say so and go on.
## Phase 2 Enhance for execution
Each surviving task is about to be run by an autonomous agent with no further input. Sharpen it so that run can succeed. For each task, rewrite title and description to carry:
- concrete acceptance criteria what must be true when it is done,
- the files and areas actually involved, found with Read/Grep/Glob in the repo. Do not guess paths; look them up.
- what is explicitly out of scope.
Write it back with update_task (title, description and commitType are the settable fields).
Rules: do not change what the user asked for, and do not invent requirements. You are making the existing intent precise, not adding to it. If a task is too vague to sharpen without guessing, ASK instead of guessing. Report a short before/after per task.
## Phase 3 Run
Do NOT use run_task_now for a batch there is a single override slot and the second call fails with "override slot busy".
Read get_app_settings and tell the user how many parallel execution slots are configured (maxParallelExecutions). If it is 1, say plainly that the tasks will execute one after another and that the value is changeable in ClaudeDo's settings.
Then, for each surviving task:
- Idle or Failed update_task_status(id, "Queued"). For a Failed task ask first whether to reset_failed_task and re-queue it, or skip it.
- Queued leave it; it is already waiting for a slot.
- Running or WaitingForChildren leave it; only poll.
- WaitingForReview leave it; it goes straight to Phase 4.
Poll get_task until every task has left Queued and Running WaitingForReview on success, Failed on error. Report progress as tasks land; do not poll silently for minutes.
## Phase 4 Review and merge
One task at a time, in the order the brief lists them.
1. Inspect the change with get_task_diff (stat first, then the full diff if it is non-trivial) and sanity-check it against the task's title and description.
2. If the change looks wrong, incomplete, or risky, STOP and ask the user before merging offer reject_rerun (with feedback) or skip.
3. Otherwise merge with review_task(taskId, decision="approve", leaveConflictsInTree=true).
- Clean merge the task is Done; move on.
- Conflict (markers left in the working tree, repoPath returned) resolve it.
Every branch in this run forked from the same base, so conflicts between them are the NORMAL case, not a failure. Resolve them and keep going; do not abandon the run because a merge conflicted.
Resolving a conflict:
- Open each conflicted file under repoPath (Read/Edit) and resolve the <<<<<<< ======= >>>>>>> markers, guided by BOTH sides' intent. Then call continue_merge(taskId). If markers remain it tells you fix and call again. Use abort_merge(taskId) to cancel a merge you cannot safely resolve.
- For a task WITH children (a unit merge), pass the PARENT task id to continue_merge / abort_merge.
- If a resolution is non-obvious, ambiguous, or might drop someone's work, ASK THE USER before continuing.
- Prefer the MCP tools whenever they apply. Only if the MCP tools cannot reach an in-progress merge may you finish it by hand: resolve the markers, then `git add -- <the resolved paths>` and `git commit` NEVER `git add -A` or a bare commit, because the checkout is shared with other sessions.
Rules for the whole session:
- Never use raw `git merge`, `git reset`, or `git checkout` to force a merge. Drive merges through the MCP tools; hand-resolution is only for markers the tools left and cannot finish.
- Ask the user for anything ambiguous, risky, or destructive.
## Phase 5 Summary
Print one line per task from the original brief:
title dedupe action (kept / merged into X / cancelled as duplicate of X) enhanced (yes/no) final status merge commit (if any) conflicts resolved (if any).
Then list anything you skipped or left for the user and why, and any follow-ups worth turning into new tasks.
""";
```
- [ ] **Step 4: Replace `MergeHelperInitialDefault`**
The scope is now always one list with one repo, so the header states it once and the task lines
drop the constant `list:` / `repo:` fields.
```csharp
private const string MergeHelperInitialDefault = """
# List handler brief
Scope: {scope}
Repo: {repo}
Handle the following tasks. Work Phases 05 as your instructions describe, asking me whenever you are unsure.
{tasks}
When every task is handled, print the summary.
""";
```
- [ ] **Step 5: Add the `{repo}` token test**
`{repo}` is a new token — Task 4 will pass it. Add to `PromptFilesTests`:
```csharp
[Fact]
public void DefaultFor_merge_helper_initial_has_repo_token()
{
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperInitial);
Assert.Contains("{repo}", d);
}
```
The existing `RenderTemplate_merge_helper_initial_substitutes_scope_and_tasks` test passes only
`scope` and `tasks`. `RenderTemplate` leaves unknown tokens alone, so its two `Assert.Contains`
still hold and its `Assert.DoesNotContain("{scope}", outp)` still holds. Leave it unchanged.
- [ ] **Step 6: Run the tests to verify they pass**
```bash
dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release \
--filter "FullyQualifiedName~PromptFilesTests"
```
Expected: all pass.
- [ ] **Step 7: Commit**
```bash
git add -- src/ClaudeDo.Data/PromptFiles.cs tests/ClaudeDo.Data.Tests/PromptFilesTests.cs
git commit -m "feat(data): five-phase list-handler prompt with dedupe and enhance" -- src/ClaudeDo.Data/PromptFiles.cs tests/ClaudeDo.Data.Tests/PromptFilesTests.cs
```
---
### Task 3: Drop the global entry point and the LIST column
This removes every caller that passes a null `listId`, clearing the way for Task 4's signature
sweep. Types stay nullable here; only callers and UI go.
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs:103-113`
- Modify: `src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml:184,206-210`
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/MergeHelperSelectionModalViewModel.cs:14-53`
- Modify: `src/ClaudeDo.Ui/Views/Modals/MergeHelperSelectionModal.axaml:41-72`
- Modify: `src/ClaudeDo.Localization/locales/en.json`, `src/ClaudeDo.Localization/locales/de.json`
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/MergeHelperSelectionModalViewModelTests.cs`
- [ ] **Step 1: Update the dialog tests to the list-only API**
`Configure` becomes `Configure(string listId, string listName)` and `IsGlobal` and `ListName` are
gone. Rewrite the affected tests. `Load_ExcludesTerminalStatuses_AndTicksActionableByDefault`,
`CanConfirm_FollowsRowSelection` and `Confirm_ReturnsSelectedIds_InRowOrder` all used
`Configure(null, null)` to see every seeded task — point them at `"L1"` instead, which holds all
eight seeded statuses (`t-other-list` lives in `L2` and drops out).
Replace the four tests below; leave `Load_NoCandidates_HasTasksFalse_CannotConfirm` untouched.
```csharp
[Fact]
public async Task Load_ExcludesTerminalStatuses_AndTicksActionableByDefault()
{
await SeedAllStatusesAsync();
var vm = BuildVm();
vm.Configure("L1", "Work");
await vm.LoadAsync();
Assert.DoesNotContain(vm.Tasks, t => t.Id is "t-done" or "t-cancelled");
Assert.DoesNotContain(vm.Tasks, t => t.Id == "t-other-list");
Assert.Equal(6, vm.Tasks.Count);
Assert.True(vm.Tasks.Single(t => t.Id == "t-idle").IsSelected);
Assert.True(vm.Tasks.Single(t => t.Id == "t-queued").IsSelected);
Assert.True(vm.Tasks.Single(t => t.Id == "t-review").IsSelected);
Assert.True(vm.Tasks.Single(t => t.Id == "t-failed").IsSelected);
Assert.False(vm.Tasks.Single(t => t.Id == "t-running").IsSelected);
Assert.False(vm.Tasks.Single(t => t.Id == "t-children").IsSelected);
}
[Fact]
public async Task Load_PerListScope_FiltersToThatList()
{
await SeedAllStatusesAsync();
var vm = BuildVm();
vm.Configure("L2", "Home");
await vm.LoadAsync();
Assert.Single(vm.Tasks);
Assert.Equal("t-other-list", vm.Tasks[0].Id);
Assert.Contains("Home", vm.ScopeLabel);
}
[Fact]
public async Task CanConfirm_FollowsRowSelection()
{
await SeedAllStatusesAsync();
var vm = BuildVm();
vm.Configure("L1", "Work");
await vm.LoadAsync();
Assert.True(vm.CanConfirm);
vm.SelectNoneCommand.Execute(null);
Assert.False(vm.CanConfirm);
Assert.All(vm.Tasks, t => Assert.False(t.IsSelected));
vm.Tasks[0].IsSelected = true; // single row re-enables via PropertyChanged hook
Assert.True(vm.CanConfirm);
vm.SelectAllCommand.Execute(null);
Assert.All(vm.Tasks, t => Assert.True(t.IsSelected));
}
[Fact]
public async Task Confirm_ReturnsSelectedIds_InRowOrder()
{
await SeedAllStatusesAsync();
var vm = BuildVm();
vm.Configure("L1", "Work");
await vm.LoadAsync();
vm.SelectNoneCommand.Execute(null);
vm.Tasks.Single(t => t.Id == "t-review").IsSelected = true;
vm.Tasks.Single(t => t.Id == "t-idle").IsSelected = true;
var closed = false;
vm.CloseAction = () => closed = true;
vm.ConfirmCommand.Execute(null);
var result = await vm.Result.Task;
Assert.NotNull(result);
// Row order (SortOrder): t-idle was seeded before t-review.
Assert.Equal(new[] { "t-idle", "t-review" }, result);
Assert.True(closed);
}
```
Also change `Cancel_ReturnsNull`'s `vm.Configure(null, null);` to `vm.Configure("L1", "Work");`.
- [ ] **Step 2: Run the tests to verify they fail**
```bash
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release \
--filter "FullyQualifiedName~MergeHelperSelectionModalViewModelTests"
```
Expected: FAIL — the project does not compile, because `Configure(string, string)` does not exist
yet and `IsGlobal` was removed from an assertion that still compiles against it. Compilation
failure is the expected "red" here.
- [ ] **Step 3: Make the dialog VM list-only**
In `MergeHelperSelectionModalViewModel.cs`:
Remove the `ListName` property from `MergeHelperTaskRowViewModel`:
```csharp
public sealed partial class MergeHelperTaskRowViewModel : ViewModelBase
{
public required string Id { get; init; }
public required string Title { get; init; }
public required string StatusText { get; init; }
[ObservableProperty] private bool _isSelected;
}
```
Change the field to non-nullable, drop `IsGlobal`, and make `Configure` list-only:
```csharp
private string _listId = "";
```
```csharp
[ObservableProperty] private string _scopeLabel = "";
public bool HasTasks => Tasks.Count > 0;
```
```csharp
public void Configure(string listId, string listName)
{
_listId = listId;
ScopeLabel = Loc.T("modals.mergeHelper.scopeList", listName);
}
```
In `LoadAsync`, the list filter is now unconditional and `ListName` is no longer selected:
```csharp
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var candidates = await ctx.Tasks.AsNoTracking()
.Where(t => t.Status != TaskStatus.Done && t.Status != TaskStatus.Cancelled)
.Where(t => t.ListId == _listId)
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
.Select(t => new { t.Id, t.Title, t.Status })
.ToListAsync(ct);
foreach (var c in candidates)
{
var row = new MergeHelperTaskRowViewModel
{
Id = c.Id,
Title = c.Title,
StatusText = c.Status.ToString(),
IsSelected = IsTickedByDefault(c.Status),
};
row.PropertyChanged += OnRowChanged;
Tasks.Add(row);
}
```
- [ ] **Step 4: Drop the LIST column from the dialog view**
In `MergeHelperSelectionModal.axaml`, change both `ColumnDefinitions="32,*,120,120"` (lines 41 and
57) to `ColumnDefinitions="32,*,120"`, and delete the two `Grid.Column="3"` elements — the header
`TextBlock` bound to `modals.mergeHelper.columnList` (lines 45-46) and the row `TextBlock` bound to
`ListName` (lines 68-71).
- [ ] **Step 5: Remove the global command and the Broom button**
In `ListsIslandViewModel.cs`, delete the whole `LetClaudeHandleAllAsync` method including its
`[RelayCommand]` attribute (lines 103-113). Leave `LetClaudeHandleListAsync` and the
`MergeHelperRequest` record as they are — Task 4 changes those.
In `ListsIslandView.axaml`, revert the button row to two columns:
```xml
<!-- New list + import row -->
<Grid ColumnDefinitions="*,Auto" Margin="0,4,0,0">
```
and delete the whole `<Button Grid.Column="2" … LetClaudeHandleAllCommand … />` element
(lines 206-210) including its `<PathIcon>` child.
- [ ] **Step 6: Remove the three dead localization keys**
Delete from **both** `src/ClaudeDo.Localization/locales/en.json` and
`src/ClaudeDo.Localization/locales/de.json`:
- `lists.letClaudeAllTip` (and the trailing comma on the preceding key, so the object stays valid JSON)
- `modals.mergeHelper.scopeAll`
- `modals.mergeHelper.columnList` (and the trailing comma on the preceding key)
Keep `lists.contextLetClaude` and `modals.mergeHelper.scopeList`.
- [ ] **Step 7: Build and run the tests**
```bash
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
```
Expected: build succeeds, all tests pass. The localization parity test is the one that catches a
key removed from only one of the two JSON files.
- [ ] **Step 8: Commit**
```bash
git add -- src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml src/ClaudeDo.Ui/ViewModels/Modals/MergeHelperSelectionModalViewModel.cs src/ClaudeDo.Ui/Views/Modals/MergeHelperSelectionModal.axaml src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json tests/ClaudeDo.Ui.Tests/ViewModels/MergeHelperSelectionModalViewModelTests.cs
git commit -m "refactor(ui): scope \"Let Claude handle it\" to a single list" -- src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs src/ClaudeDo.Ui/Views/Islands/ListsIslandView.axaml src/ClaudeDo.Ui/ViewModels/Modals/MergeHelperSelectionModalViewModel.cs src/ClaudeDo.Ui/Views/Modals/MergeHelperSelectionModal.axaml src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json tests/ClaudeDo.Ui.Tests/ViewModels/MergeHelperSelectionModalViewModelTests.cs
```
---
### Task 4: Non-nullable `listId` and a single-repo launch spec
One atomic commit across Worker and Ui. Splitting it would leave one side passing `string?` into a
`string` parameter — nullable warnings strewn across a commit boundary, and a launch spec that
still carries a dead multi-repo path.
**Files:**
- Modify: `src/ClaudeDo.Worker/Runner/Interfaces/IInteractiveLaunchSpecService.cs:36`
- Modify: `src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs:166-253`
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs:682-687`
- Modify: `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs:90`
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs:525-526`
- Modify: `src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs:325-357`
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs:20`
- Modify: `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs:103`
- Modify: `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs:78`
- Test: `tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs:363-472`
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs:409-463`
- [ ] **Step 1: Rewrite the launch-spec tests**
In `InteractiveLaunchSpecServiceTests.cs`, replace the four merge-helper `[Fact]`s (from
`BuildForMergeHelperAsync_EmptyTaskIds_ThrowsInvalidOperation` through
`BuildForMergeHelperAsync_WithListId_UsesListWorkingDirAsCwdAndListScope`) with these five. Keep
the `_mergeHelperSessionDirs` field and `TrackSessionDir` helper above them exactly as they are.
```csharp
[Fact]
public async Task BuildForMergeHelperAsync_EmptyTaskIds_ThrowsInvalidOperation()
{
var listId = await SeedListAsync(workingDir: _tempDir);
var svc = BuildService();
await Assert.ThrowsAsync<InvalidOperationException>(
() => svc.BuildForMergeHelperAsync(Array.Empty<string>(), listId, CancellationToken.None));
}
[Fact]
public async Task BuildForMergeHelperAsync_ListWithoutExistingWorkingDir_ThrowsInvalidOperation()
{
var listId = await SeedListAsync(workingDir: Path.Combine(_tempDir, "gone"));
var taskId = Guid.NewGuid().ToString();
await SeedTaskAsync(taskId, listId, TaskStatus.WaitingForReview);
var svc = BuildService();
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => svc.BuildForMergeHelperAsync(new[] { taskId }, listId, CancellationToken.None));
Assert.Contains("working directory", ex.Message);
}
[Fact]
public async Task BuildForMergeHelperAsync_UnknownList_Throws()
{
var listId = await SeedListAsync(workingDir: _tempDir);
var taskId = Guid.NewGuid().ToString();
await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
var svc = BuildService();
await Assert.ThrowsAsync<KeyNotFoundException>(
() => svc.BuildForMergeHelperAsync(new[] { taskId }, "no-such-list", CancellationToken.None));
}
[Fact]
public async Task BuildForMergeHelperAsync_BuildsListScopedSpecWithSingleRepo()
{
var repo = Path.Combine(_tempDir, "repoOnly");
Directory.CreateDirectory(repo);
var listId = await SeedListAsync(workingDir: repo, name: "Alpha");
var t1 = Guid.NewGuid().ToString();
var t2 = Guid.NewGuid().ToString();
await SeedTaskAsync(t1, listId, TaskStatus.WaitingForReview, title: "First task");
await SeedTaskAsync(t2, listId, TaskStatus.Idle, title: "Second task");
var svc = BuildService();
var spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2 }, listId, CancellationToken.None);
var sessionDir = TrackSessionDir(spec);
Assert.Equal(repo, spec.Cwd);
Assert.Equal(_claudeStubPath, spec.Exe);
var args = spec.Args.ToList();
var pmIdx = args.IndexOf("--permission-mode");
Assert.True(pmIdx >= 0);
Assert.Equal("default", args[pmIdx + 1]);
var atIdx = args.IndexOf("--allowedTools");
Assert.Equal("mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill", args[atIdx + 1]);
// --add-dir: session dir + the list's single repo dir
var addIdx = args.IndexOf("--add-dir");
var appendIdx = args.IndexOf("--append-system-prompt-file");
var addDirs = args.GetRange(addIdx + 1, appendIdx - addIdx - 1);
Assert.Equal(new[] { sessionDir, repo }, addDirs);
var systemPromptPath = args[appendIdx + 1];
Assert.Equal(Path.Combine(sessionDir, "system-prompt.md"), systemPromptPath);
Assert.True(File.Exists(systemPromptPath));
// kickoff is the LAST arg (positional), single line, points at brief.md
var kickoff = args[^1];
var briefPath = Path.Combine(sessionDir, "brief.md");
Assert.Contains(briefPath, kickoff);
Assert.DoesNotContain('\n', kickoff);
Assert.Equal("200000", spec.Env["MCP_TOOL_TIMEOUT"]);
}
[Fact]
public async Task BuildForMergeHelperAsync_BriefNamesListRepoAndEveryTask()
{
var repo = Path.Combine(_tempDir, "repoBrief");
Directory.CreateDirectory(repo);
var listId = await SeedListAsync(workingDir: repo, name: "Alpha");
var t1 = Guid.NewGuid().ToString();
var t2 = Guid.NewGuid().ToString();
await SeedTaskAsync(t1, listId, TaskStatus.WaitingForReview, title: "First task");
await SeedTaskAsync(t2, listId, TaskStatus.Idle, title: "Second task");
var svc = BuildService();
var spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2 }, listId, CancellationToken.None);
var sessionDir = TrackSessionDir(spec);
var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md"));
Assert.Contains("Scope: List: Alpha", brief);
Assert.Contains($"Repo: {repo}", brief);
Assert.Contains("First task", brief);
Assert.Contains("Second task", brief);
Assert.Contains(t1, brief);
Assert.Contains(t2, brief);
}
```
- [ ] **Step 2: Run the launch-spec tests to verify they fail**
```bash
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release \
--filter "FullyQualifiedName~InteractiveLaunchSpecServiceTests.BuildForMergeHelper"
```
Expected: FAIL. `BuildForMergeHelperAsync_UnknownList_Throws` fails because the current code
resolves the repo from the tasks and never validates the list; the brief test fails on the missing
`Repo:` line.
- [ ] **Step 3: Rewrite `BuildForMergeHelperAsync`**
Replace the method body (`InteractiveLaunchSpecService.cs:166-253`) with:
```csharp
public async Task<LaunchSpec> BuildForMergeHelperAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct)
{
if (taskIds.Count == 0)
throw new InvalidOperationException("No tasks selected for the list handler.");
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var taskRepo = new TaskRepository(ctx);
var listRepo = new ListRepository(ctx);
var list = await listRepo.GetByIdAsync(listId, ct)
?? throw new KeyNotFoundException($"List not found: {listId}");
var repoDir = list.WorkingDir;
if (string.IsNullOrEmpty(repoDir) || !Directory.Exists(repoDir))
throw new InvalidOperationException($"list '{list.Name}' has no existing working directory");
var briefLines = new List<string>();
foreach (var id in taskIds)
{
var task = await taskRepo.GetByIdAsync(id, ct)
?? throw new KeyNotFoundException($"Task not found: {id}");
briefLines.Add($"- [{task.Status}] {task.Title} (id: {task.Id})");
}
var sessionDir = Path.Combine(Paths.AppDataRoot(), "merge-helper-sessions", Guid.NewGuid().ToString());
Directory.CreateDirectory(sessionDir);
var systemPromptPath = Path.Combine(sessionDir, "system-prompt.md");
await File.WriteAllTextAsync(systemPromptPath, PromptFiles.ReadOrDefault(PromptKind.MergeHelper), ct);
var briefPath = Path.Combine(sessionDir, "brief.md");
await File.WriteAllTextAsync(briefPath, PromptFiles.Render(PromptKind.MergeHelperInitial,
new Dictionary<string, string>
{
["scope"] = $"List: {list.Name}",
["repo"] = repoDir,
["tasks"] = string.Join("\n", briefLines),
}), ct);
var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath)
?? throw new InvalidOperationException($"claude executable not found: {_claudePath}");
// Mirrors WindowsTerminalLauncher.BuildPlanningStartArgs ordering: variadic flags
// (--allowedTools, --add-dir) first, then a single-value flag, then the single-line
// positional kickoff LAST — a multi-line positional prompt truncates at the first
// newline, so the full multi-line brief travels via the file exposed through --add-dir.
var args = new List<string>
{
"--permission-mode", "default",
"--allowedTools", MergeHelperAllowedTools,
"--add-dir", sessionDir, repoDir,
"--append-system-prompt-file", systemPromptPath,
$"Read the file {briefPath} first. It lists the tasks you must handle and their status. " +
"After reading it, begin the session as your instructions describe.",
};
var env = new Dictionary<string, string>
{
["MCP_TOOL_TIMEOUT"] = "200000",
};
return new LaunchSpec(cwd: repoDir, resolvedClaude, args, env);
}
```
- [ ] **Step 4: Flip the interface and hub signatures**
`src/ClaudeDo.Worker/Runner/Interfaces/IInteractiveLaunchSpecService.cs:36`:
```csharp
Task<LaunchSpec> BuildForMergeHelperAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct);
```
`src/ClaudeDo.Worker/Hub/WorkerHub.cs:682`:
```csharp
public Task<LaunchSpec> GetMergeHelperLaunchSpec(string[] taskIds, string listId) => HubGuard(() =>
```
(leave the method body as it is).
- [ ] **Step 5: Run the Worker tests**
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release \
--filter "FullyQualifiedName~InteractiveLaunchSpecServiceTests"
```
Expected: build succeeds, all pass. `TasksIslandViewModelPlanningTests.cs:78` holds a fake
implementing `IWorkerClient` — its signature is flipped in Step 7; if the Worker.Tests build fails
there, do Step 7 first and re-run.
- [ ] **Step 6: Update the Mission Control tests**
In `MissionControlViewModelTests.cs`, the four `OpenMergeHelperConPtySessionAsync` calls pass
`null` as the list id. Replace `null` with `"L1"` on lines 421, 435, 436 and 461, and change the
`ThrowingMergeHelperLaunchSpecWorker` override signature at line 411 to:
```csharp
public override Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct = default)
```
The list-title lookup in `OpenMergeHelperConPtySessionAsync` is wrapped in a `try/catch` and falls
back to the plain title, so an unseeded `"L1"` is harmless.
- [ ] **Step 7: Flip the UI signatures**
`src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs:90`:
```csharp
Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct = default);
```
`src/ClaudeDo.Ui/Services/WorkerClient.cs:525`:
```csharp
public async Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct = default)
=> await _hub.InvokeAsync<LaunchSpec>("GetMergeHelperLaunchSpec", taskIds, listId, ct);
```
`tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs:103` and
`tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs:78` — same parameter change
(`string? listId``string listId`), bodies unchanged.
`src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs:20`:
```csharp
/// <summary>Confirmed handler run: the scope list and the ordered selected task ids.</summary>
public sealed record MergeHelperRequest(string ListId, IReadOnlyList<string> TaskIds);
```
`src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs:325-341` — the `listId is not null` guard is
now dead:
```csharp
// List-handler session over a hand-picked set of tasks ("Let Claude handle it").
// Ad-hoc style: no owning task, never deduped — every run opens a fresh pane.
public async System.Threading.Tasks.Task OpenMergeHelperConPtySessionAsync(string listId, IReadOnlyList<string> taskIds)
{
if (taskIds is not { Count: > 0 }) return;
var title = Loc.T("missionControl.mergeHelperTitle");
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
var list = await ctx.Lists.AsNoTracking().FirstOrDefaultAsync(l => l.Id == listId);
if (list?.Name is { Length: > 0 } name) title = $"{title} — {name}";
}
catch { /* best-effort title lookup */ }
```
Leave the rest of the method (the `try` block that fetches the spec and adds the pane) unchanged.
- [ ] **Step 8: Build everything and run the full suite**
```bash
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
```
Expected: both builds succeed with no `CS8600`/`CS8604` nullability warnings on the touched files,
and every test passes.
- [ ] **Step 9: Commit**
```bash
git add -- src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs src/ClaudeDo.Worker/Runner/Interfaces/IInteractiveLaunchSpecService.cs src/ClaudeDo.Worker/Hub/WorkerHub.cs src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs src/ClaudeDo.Ui/Services/WorkerClient.cs src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs
git commit -m "refactor(worker): make the list-handler launch spec single-list and single-repo" -- src/ClaudeDo.Worker/Runner/InteractiveLaunchSpecService.cs src/ClaudeDo.Worker/Runner/Interfaces/IInteractiveLaunchSpecService.cs src/ClaudeDo.Worker/Hub/WorkerHub.cs src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs src/ClaudeDo.Ui/Services/WorkerClient.cs src/ClaudeDo.Ui/ViewModels/MissionControlViewModel.cs src/ClaudeDo.Ui/ViewModels/Islands/ListsIslandViewModel.cs tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs tests/ClaudeDo.Ui.Tests/ViewModels/MissionControlViewModelTests.cs tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs tests/ClaudeDo.Worker.Tests/Runner/InteractiveLaunchSpecServiceTests.cs
```
---
### Task 5: Update the project docs
**Files:**
- Modify: `src/ClaudeDo.Ui/CLAUDE.md`
- Modify: `src/ClaudeDo.Worker/CLAUDE.md`
- Modify: `docs/open.md`
- [ ] **Step 1: Check what the CLAUDE.md files claim**
```bash
grep -n "merge.helper\|Let Claude handle\|MergeHelper" src/ClaudeDo.Ui/CLAUDE.md src/ClaudeDo.Worker/CLAUDE.md docs/open.md
```
- [ ] **Step 2: Correct any stale claim**
Where those files describe the merge helper as having a global scope, or describe the prompt as
run-and-merge only, update them to: list-scoped only, single repo, five phases (read, dedupe,
enhance, queue, review+merge). Note in `src/ClaudeDo.Worker/CLAUDE.md` that
`update_task_status` now also accepts `Cancelled`. Do not restructure the files beyond that.
- [ ] **Step 3: Add the open verification items**
Append to the open-items section of `docs/open.md`:
```markdown
- **List handler (2026-07-27)** — visual pass: the Broom button is gone from the lists footer,
the context-menu item appears only on lists with a working dir, and the selection dialog has no
LIST column. Plus a real-Claude smoke run of the five phases (dedupe questions, enhancements
landing in task descriptions, queued execution, merges).
```
- [ ] **Step 4: Commit**
```bash
git add -- src/ClaudeDo.Ui/CLAUDE.md src/ClaudeDo.Worker/CLAUDE.md docs/open.md
git commit -m "docs: describe the list-scoped five-phase handler" -- src/ClaudeDo.Ui/CLAUDE.md src/ClaudeDo.Worker/CLAUDE.md docs/open.md
```
---
## Verification left to the user
None of this can be confirmed from tests alone:
- The lists footer no longer shows the Broom button, and the row context menu still offers
"Let Claude handle it" for lists with a working dir.
- The selection dialog shows TASK and STATUS only, and the scope line reads `List: <name>`.
- A real ConPTY run: Phase 1 asks about duplicates, Phase 2's enhancements are visible in the task
descriptions afterwards, Phase 3 reports the slot count and the tasks execute, Phase 4 merges or
hands off to conflict resolution.
@@ -0,0 +1,191 @@
# "Let Claude handle it" — per-list handler with read / dedupe / enhance / run / merge
Date: 2026-07-27
Supersedes parts of: `2026-07-24-merge-helper-design.md` (global scope, run-then-merge-only prompt)
## 1. Problem
The merge helper shipped in v2.3.0 with two entry points — a per-list context-menu item and a
global footer Broom button — and a prompt that only runs and merges the selected tasks.
Two things are wrong with that:
- **The global scope is unwanted.** A run spanning several lists spans several repos, which
makes `cwd`, the `--add-dir` set and the merge order ambiguous for no benefit. The user
works one list (= one repo) at a time.
- **The helper starts too late.** It takes the task list as given: it never reads the tasks as
a set, so duplicates run twice and produce conflicting worktrees, and vague tasks go into an
autonomous run under-specified and come back wrong.
## 2. Goal
One entry point, on a user list. It opens an interactive ConPTY session that takes the selected
tasks through five phases: read them all, dedupe them, sharpen them for autonomous execution,
run them, then review and merge each worktree.
Non-goals: no change to the autonomous queue path, no change to the ConPTY tile plumbing,
no rename of the `MergeHelper*` identifiers (the user-facing label stays "Let Claude handle it").
## 3. Scope becomes list-only
`listId` becomes non-nullable across the whole chain:
| Layer | Change |
|---|---|
| `ListsIslandViewModel` | `MergeHelperRequest(string ListId, …)`; `LetClaudeHandleAllAsync` deleted |
| `IslandsShellViewModel:241` | unchanged (already forwards `req.ListId`) |
| `MissionControlViewModel:327` | `OpenMergeHelperConPtySessionAsync(string listId, …)` |
| `IWorkerClient:90` / `WorkerClient:525` | `GetMergeHelperLaunchSpecAsync(taskIds, string listId, ct)` |
| `WorkerHub:682` | `GetMergeHelperLaunchSpec(string[] taskIds, string listId)` |
| `IInteractiveLaunchSpecService:36` | `BuildForMergeHelperAsync(taskIds, string listId, ct)` |
Deleted UI surface:
- Broom button `ListsIslandView.axaml:205-210` and `LetClaudeHandleAllCommand`.
- `IsGlobal` on `MergeHelperSelectionModalViewModel` and the LIST column
(`MergeHelperSelectionModal.axaml:71`) — with a single list the column is constant.
- Localization keys `lists.letClaudeAllTip`, `modals.mergeHelper.scopeAll`,
`modals.mergeHelper.columnList` (en + de, parity test enforces both).
`Configure(string listId, string listName)` loses its nullable overload; `ScopeLabel` always
renders `modals.mergeHelper.scopeList`.
### 3.1 Single repo in the launch spec
`BuildForMergeHelperAsync` currently collects a distinct `repoDirs` set across the selected
tasks and picks `cwd` per scope. With a list scope every task shares the list's `WorkingDir`,
so this collapses to:
- Load the list; throw `InvalidOperationException` if it has no existing `WorkingDir`.
- `cwd` = that directory; `--add-dir` = the session dir + that one directory.
- The per-task brief line drops the now-constant `list:` and `repo:` fields.
The context-menu item is already hidden when `WorkingDir` is empty, so the throw is a guard,
not a normal path.
### 3.2 Entry point visibility
The item stays in the list row's context menu, next to "List settings", "Worktrees overview",
"Open in Explorer" and "Open in Terminal". That is the established place for list-scoped
actions; a second always-visible button in the row would break the pattern.
## 4. Worker: `Cancelled` becomes externally settable
`ExternalMcpService.UpdateTaskStatus` accepts only `Idle` and `Queued` and throws
`"Status '{target}' is not settable externally. Use run_task_now or cancel_task."` for the
rest — but neither escape hatch reaches an **Idle** task: `cancel_task` only cancels a
*running* task, and `review_task(decision="cancel")` requires WaitingForReview/Running/Queued.
The dedupe phase needs exactly that: retire an Idle duplicate without destroying it.
Add to the switch:
```csharp
case TaskStatus.Cancelled:
var cancelResult = await _state.CancelAsync(taskId, DateTime.UtcNow, cancellationToken);
if (!cancelResult.Ok)
throw new InvalidOperationException(cancelResult.Reason ?? "Cannot cancel task.");
break;
```
`TaskStateService.CancelAsync` (`State/TaskStateService.cs:244`) already owns the transition
and its worktree/parent side effects. The existing error message in `UpdateTaskStatus` already
lists `Cancelled` as valid, so this also removes a lie. A cancelled task stays visible and can
be reset to Idle — nothing is lost, unlike `delete_task`.
## 5. The five-phase prompt
`PromptFiles.MergeHelperDefault` is rewritten. The session stays interactive and the helper is
told to ask whenever unsure — that is the point of a watched ConPTY session.
### Phase 0 — Read
`batch_get_tasks` over every id in the brief before touching anything: title, description,
status, parent/child links. The helper must hold the whole set in mind before acting on any
single task.
### Phase 1 — Dedupe
Compare the tasks pairwise for overlap. Emit a table of candidate pairs with the reason each
pair looks like a duplicate, then **ask per pair**:
- merge → fold the loser's unique content into the survivor via `update_task`, then
`update_task_status(loserId, "Cancelled")`;
- keep both → note why and move on.
Nothing is cancelled without an explicit answer.
### Phase 2 — Enhance
For each surviving task, sharpen title and description for autonomous execution:
- concrete acceptance criteria,
- the files/areas actually involved — grounded in the repo via Read/Grep/Glob, not guessed,
- explicit out-of-scope.
Write back with `update_task` (title / description / commitType are the settable fields; it
refuses while Running, which cannot happen this early). Rules: do not change intent, do not
invent requirements. A task too vague to sharpen safely gets a question, not a guess.
### Phase 3 — Run
`run_task_now` cannot be used for a batch: `OverrideSlotService.StartInSlot`
(`Queue/OverrideSlotService.cs:65-70`) holds a single slot and throws `"override slot busy"`
on the second concurrent call. The queue picker is the only parallel path.
So: read `get_app_settings`, tell the user how many parallel slots are configured
(`MaxParallelExecutions`, default 1 — `AppSettingsEntity.cs:14`), then
`update_task_status(id, "Queued")` for every surviving task, then poll `get_task` until each
has left Queued/Running — `WaitingForReview` on success, `Failed` on error. Announcing the
slot count up front stops the user wondering why "run them all" executes one at a time.
A task already `Running` or `WaitingForChildren` when the session starts is not re-queued, only
polled. A task already `WaitingForReview` skips straight to Phase 4.
### Phase 4 — Review and merge
Sequential, in list order. A task that came back `Failed` needs human judgement — ask whether
to `reset_failed_task` and re-queue it, or skip it. Otherwise, unchanged from the shipped
prompt: `get_task_diff` (stat first,
full diff when non-trivial), sanity-check against the task's intent, ask before merging
anything that looks wrong, then `review_task(taskId, decision="approve",
leaveConflictsInTree=true)` and the conflict loop (`continue_merge` / `abort_merge`, parent id
for unit merges, hand-resolution only where MCP cannot reach, always
`git commit -- <paths>` and never `git add -A` because the checkout is shared).
New in this phase: the prompt states that because Phase 3 branches all fork from the same
base, **conflicts are the normal case, not an exception** — resolve them rather than bailing
out of the run.
### Phase 5 — Summary
One line per task: title — dedupe action — enhanced? — final status — merge commit —
conflicts resolved. Then anything skipped and why, then follow-ups.
### Brief template
`MergeHelperInitialDefault` names the list and repo once in the header and drops the per-task
`list:`/`repo:` fields, leaving `- [{status}] {title} (id: {id})`. Descriptions stay out of the
brief; Phase 0 fetches them.
## 6. Testing
| Test | Change |
|---|---|
| `MergeHelperSelectionModalViewModelTests` | drop `IsGlobal`, list-scoped `Configure` |
| `InteractiveLaunchSpecServiceTests` | three `null`-listId cases → non-null; drop the two-repo global-scope test; add "list without WorkingDir throws" |
| `MissionControlViewModelTests` | four `OpenMergeHelperConPtySessionAsync(null, …)` calls |
| `StubWorkerClient`, `TasksIslandViewModelPlanningTests` fake | signature |
| `PromptFilesTests` | assert the five phase markers in the default prompt |
| `Localization.Tests` | parity after removing three keys |
| new: `ExternalMcpService` / worker test | `UpdateTaskStatus(id, "Cancelled")` cancels an Idle task; unknown status still throws |
No test spawns the real `claude` CLI — the prompt content is asserted as text, the session
itself is a manual smoke step.
## 7. Verification left to the user
- The list context menu shows "Let Claude handle it" only for lists with a working dir, and
the Broom button is gone from the footer row.
- The selection dialog has no LIST column and reads "List: <name>".
- A real ConPTY run: dedupe questions appear, enhancements land in the task descriptions,
queued tasks execute, merges complete or hand off to conflict resolution.
+3
View File
@@ -3,6 +3,7 @@ using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using ClaudeDo.Ui;
using ClaudeDo.Ui.Services; using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels; using ClaudeDo.Ui.ViewModels;
using ClaudeDo.Ui.Views; using ClaudeDo.Ui.Views;
@@ -22,6 +23,8 @@ public partial class App : Application
public override void Initialize() public override void Initialize()
{ {
AvaloniaXamlLoader.Load(this); AvaloniaXamlLoader.Load(this);
if (_services?.GetService<AppSettings>() is { } settings)
AccentPresetService.Apply(AccentPresets.Find(settings.AccentPreset));
} }
public override void OnFrameworkInitializationCompleted() public override void OnFrameworkInitializationCompleted()
+6 -4
View File
@@ -4,8 +4,8 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
## Models ## Models
- **TaskEntity** — Id, ListId, Title, Description, Status (`Idle|Queued|Running|WaitingForChildren|WaitingForReview|Done|Failed|Cancelled`), PlanningPhase (`None|Active|Finalized` — parent-only), BlockedByTaskId (nullable FK to predecessor in a chain), ScheduledFor, Result, ReviewFeedback (nullable; reviewer's rejection comment, consumed and cleared by the runner on the next re-run), LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath / MaxTurns (nullable overrides), IsStarred, IsMyDay, Notes, ParentTaskId, PlanningSessionId, PlanningSessionToken, PlanningFinalizedAt, CreatedBy. Legacy values `Manual`/`Planning`/`Planned`/`Draft`/`Waiting` were retired; existing rows backfill automatically via the `RetireLegacyTaskStatus` migration. - **TaskEntity** — Id, ListId, Title, Description, Status (`Idle|Queued|Running|WaitingForChildren|WaitingForReview|Done|Failed|Cancelled`), PlanningPhase (`None|Active|Finalized` — parent-only), BlockedByTaskId (nullable FK to predecessor in a chain), ScheduledFor, Result, ReviewFeedback (nullable; reviewer's rejection comment, consumed and cleared by the runner on the next re-run), LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath / MaxTurns (nullable overrides), IsStarred, IsMyDay, IsManual (reminder only the user can do — automation skips it), Notes, ParentTaskId, PlanningSessionId, PlanningSessionToken, PlanningFinalizedAt, CreatedBy. Legacy values `Manual`/`Planning`/`Planned`/`Draft`/`Waiting` were retired; existing rows backfill automatically via the `RetireLegacyTaskStatus` migration.
- **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt - **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt, IsManual (reminder list — tasks created here default to `IsManual`)
- **ListConfigEntity** — ListId (PK, 1:1 with list), Model, SystemPrompt, AgentPath, MaxTurns (all nullable) - **ListConfigEntity** — ListId (PK, 1:1 with list), Model, SystemPrompt, AgentPath, MaxTurns (all nullable)
- **WorktreeEntity** — TaskId (PK, 1:1 with task), Path, BranchName, BaseCommit, HeadCommit, DiffStat, State (Active|Merged|Discarded|Kept) - **WorktreeEntity** — TaskId (PK, 1:1 with task), Path, BranchName, BaseCommit, HeadCommit, DiffStat, State (Active|Merged|Discarded|Kept)
- **TaskRunEntity** — per-run record (session_id, tokens, turns, result, structured output, exit code, log path) - **TaskRunEntity** — per-run record (session_id, tokens, turns, result, structured output, exit code, log path)
@@ -13,7 +13,9 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
- **DailyNoteEntity** — Id, Date (DateOnly), Text, SortOrder, CreatedAt → table `daily_notes` - **DailyNoteEntity** — Id, Date (DateOnly), Text, SortOrder, CreatedAt → table `daily_notes`
- **WeekReportEntity** — Id, StartDate/EndDate (DateOnly), Markdown, GeneratedAt → table `week_reports`, unique index on (start_date, end_date) - **WeekReportEntity** — Id, StartDate/EndDate (DateOnly), Markdown, GeneratedAt → table `week_reports`, unique index on (start_date, end_date)
- **TaskAttachmentEntity** — Id, TaskId (FK to tasks, ON DELETE CASCADE), FileName, ByteSize, CreatedAt → table `task_attachments` - **TaskAttachmentEntity** — Id, TaskId (FK to tasks, ON DELETE CASCADE), FileName, ByteSize, CreatedAt → table `task_attachments`
- **AppSettingsEntity** also carries `ReportExcludedPaths` (string?, JSON array of excluded path prefixes, column `report_excluded_paths`), `StandupWeekday` (int DayOfWeek, default Wednesday, column `standup_weekday`), and `DailyPrepMaxTasks` (int, default 5, column `daily_prep_max_tasks` — hard cap on how many open tasks the daily-prep / "Prime Claude" feature may place in MyDay) - **AppSettingsEntity** also carries `ReportExcludedPaths` (string?, JSON array of excluded path prefixes, column `report_excluded_paths`), `StandupWeekday` (int DayOfWeek, default Wednesday, column `standup_weekday`), and `DailyPrepMaxTasks` (int, default 5, column `daily_prep_max_tasks` — hard cap on how many open tasks the daily-prep / "Prime Claude" feature may place in MyDay) and `ModelPresets` (string?, JSON array of `ModelPreset` rows, column `model_presets`)
- **ModelPresets** / **ModelPreset** — per-model run defaults (`Model`, `Effort`, `MaxTurns`): one row per `ModelRegistry.Aliases` entry, supplying the **global** effort and max-turns defaults. `Parse`/`Serialize` normalize (unknown models dropped, missing aliases filled from `Defaults`, effort validated, turns clamped 1200) and never throw — a malformed settings row must not stop a run. `For(presets, model)` always returns a usable row. Ship defaults: haiku medium/20, sonnet high/30, opus high/40, fable high/25.
- **EffortRegistry** — the `--effort` levels (`low|medium|high|xhigh|max`) + `NormalizeLevel` (blank → null = don't pass the flag)
- **SubtaskEntity**, **AppSettingsEntity**, **AgentInfo** — existing helpers / settings / record for scanned agent files - **SubtaskEntity**, **AppSettingsEntity**, **AgentInfo** — existing helpers / settings / record for scanned agent files
## Repositories ## Repositories
@@ -42,7 +44,7 @@ All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. The atomic `Q
## Schema ## Schema
Tables: `lists`, `tasks`, `worktrees`, `list_config`, `task_runs`, `subtasks`, `app_settings`, `prime_schedules`, `daily_notes`, `week_reports`, `task_attachments`. Managed by EF Core migrations in the `Migrations/` folder. The `tasks` table holds `status`, `planning_phase` (default `none`), and `blocked_by_task_id` (FK to `tasks.id`, `ON DELETE SET NULL`). Migration `WeeklyReport` added `daily_notes`, `week_reports`, and the two new `app_settings` columns. Migration `DailyPrepMaxTasks` added the `daily_prep_max_tasks` column to `app_settings` (no new tables). Migration `AddTaskAttachments` created the `task_attachments` table. `TaskRepository.DeleteAsync` and `ListRepository.DeleteAsync` also delete the on-disk attachment dir(s) via an optional `AttachmentStore` ctor param (defaults to the production store). Tables: `lists`, `tasks`, `worktrees`, `list_config`, `task_runs`, `subtasks`, `app_settings`, `prime_schedules`, `daily_notes`, `week_reports`, `task_attachments`. Managed by EF Core migrations in the `Migrations/` folder. The `tasks` table holds `status`, `planning_phase` (default `none`), and `blocked_by_task_id` (FK to `tasks.id`, `ON DELETE SET NULL`). Migration `WeeklyReport` added `daily_notes`, `week_reports`, and the two new `app_settings` columns. Migration `DailyPrepMaxTasks` added the `daily_prep_max_tasks` column to `app_settings` (no new tables). Migration `AddTaskAttachments` created the `task_attachments` table. Migration `AddModelPresetsAndManualFlag` added `app_settings.model_presets` plus the `is_manual` flags on `tasks` and `lists`. `TaskRepository.DeleteAsync` and `ListRepository.DeleteAsync` also delete the on-disk attachment dir(s) via an optional `AttachmentStore` ctor param (defaults to the production store).
## Conventions ## Conventions
@@ -45,6 +45,7 @@ public class AppSettingsEntityConfiguration : IEntityTypeConfiguration<AppSettin
.HasColumnName("daily_prep_max_tasks").IsRequired().HasDefaultValue(5); .HasColumnName("daily_prep_max_tasks").IsRequired().HasDefaultValue(5);
builder.Property(s => s.SessionSkills).HasColumnName("session_skills"); builder.Property(s => s.SessionSkills).HasColumnName("session_skills");
builder.Property(s => s.ModelPresets).HasColumnName("model_presets");
builder.HasData(new AppSettingsEntity { Id = AppSettingsEntity.SingletonId }); builder.HasData(new AppSettingsEntity { Id = AppSettingsEntity.SingletonId });
} }
@@ -17,6 +17,7 @@ public class ListEntityConfiguration : IEntityTypeConfiguration<ListEntity>
builder.Property(l => l.WorkingDir).HasColumnName("working_dir"); builder.Property(l => l.WorkingDir).HasColumnName("working_dir");
builder.Property(l => l.DefaultCommitType).HasColumnName("default_commit_type").IsRequired().HasDefaultValue("chore"); builder.Property(l => l.DefaultCommitType).HasColumnName("default_commit_type").IsRequired().HasDefaultValue("chore");
builder.Property(l => l.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0); builder.Property(l => l.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0);
builder.Property(l => l.IsManual).HasColumnName("is_manual").IsRequired().HasDefaultValue(false);
builder.HasIndex(l => l.SortOrder).HasDatabaseName("idx_lists_sort"); builder.HasIndex(l => l.SortOrder).HasDatabaseName("idx_lists_sort");
@@ -89,6 +89,7 @@ public class TaskEntityConfiguration : IEntityTypeConfiguration<TaskEntity>
builder.Property(t => t.MaxTurns).HasColumnName("max_turns"); builder.Property(t => t.MaxTurns).HasColumnName("max_turns");
builder.Property(t => t.IsStarred).HasColumnName("is_starred").HasDefaultValue(false); builder.Property(t => t.IsStarred).HasColumnName("is_starred").HasDefaultValue(false);
builder.Property(t => t.IsMyDay).HasColumnName("is_my_day").HasDefaultValue(false); builder.Property(t => t.IsMyDay).HasColumnName("is_my_day").HasDefaultValue(false);
builder.Property(t => t.IsManual).HasColumnName("is_manual").HasDefaultValue(false);
builder.Property(t => t.Notes).HasColumnName("notes"); builder.Property(t => t.Notes).HasColumnName("notes");
builder.Property(t => t.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0); builder.Property(t => t.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0);
builder.Property(t => t.SessionSkills).HasColumnName("session_skills"); builder.Property(t => t.SessionSkills).HasColumnName("session_skills");
@@ -0,0 +1,802 @@
// <auto-generated />
using System;
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260727114206_AddModelPresetsAndManualFlag")]
partial class AddModelPresetsAndManualFlag
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<int>("DailyPrepMaxTasks")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(5)
.HasColumnName("daily_prep_max_tasks");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sonnet")
.HasColumnName("default_model");
b.Property<string>("DefaultPermissionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<int>("MaxParallelExecutions")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1)
.HasColumnName("max_parallel_executions");
b.Property<string>("ModelPresets")
.HasColumnType("TEXT")
.HasColumnName("model_presets");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
b.Property<string>("ReportExcludedPaths")
.HasColumnType("TEXT")
.HasColumnName("report_excluded_paths");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("StandupWeekday")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(3)
.HasColumnName("standup_weekday");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
DailyPrepMaxTasks = 5,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 100,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
MaxParallelExecutions = 1,
StandupWeekday = 3,
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT")
.HasColumnName("note_date");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER")
.HasColumnName("sort_order");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("text");
b.HasKey("Id");
b.HasIndex("Date");
b.ToTable("daily_notes", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.HasIndex("SortOrder")
.HasDatabaseName("idx_lists_sort");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("Days")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(31)
.HasColumnName("days_of_week");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("enabled");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT")
.HasColumnName("last_run_at");
b.Property<string>("PromptOverride")
.HasColumnType("TEXT")
.HasColumnName("prompt_override");
b.Property<TimeSpan>("TimeOfDay")
.HasColumnType("TEXT")
.HasColumnName("time_of_day");
b.HasKey("Id");
b.ToTable("prime_schedules", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SessionSkillEntity", b =>
{
b.Property<string>("Name")
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<DateTimeOffset>("AddedAt")
.HasColumnType("TEXT")
.HasColumnName("added_at");
b.Property<string>("Description")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<string>("PinnedRef")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("pinned_ref");
b.Property<string>("SourceUrl")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("source_url");
b.Property<string>("Subpath")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("subpath");
b.HasKey("Name");
b.ToTable("session_skills", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<long>("ByteSize")
.HasColumnType("INTEGER")
.HasColumnName("byte_size");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("FileName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("file_name");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_attachments_task_id");
b.ToTable("task_attachments", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<string>("ReviewFeedback")
.HasColumnType("TEXT")
.HasColumnName("review_feedback");
b.Property<int>("RoadblockCount")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("roadblock_count");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<string>("SessionSkills")
.HasColumnType("TEXT")
.HasColumnName("session_skills");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.HasIndex("ListId", "SortOrder")
.HasDatabaseName("idx_tasks_list_sort");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateOnly>("EndDate")
.HasColumnType("TEXT")
.HasColumnName("end_date");
b.Property<DateTime>("GeneratedAt")
.HasColumnType("TEXT")
.HasColumnName("generated_at");
b.Property<string>("Markdown")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("markdown");
b.Property<DateOnly>("StartDate")
.HasColumnType("TEXT")
.HasColumnName("start_date");
b.HasKey("Id");
b.HasIndex("StartDate", "EndDate")
.IsUnique();
b.ToTable("week_reports", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskAttachmentEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany()
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List");
b.Navigation("Parent");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Children");
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,57 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class AddModelPresetsAndManualFlag : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "is_manual",
table: "tasks",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<bool>(
name: "is_manual",
table: "lists",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "model_presets",
table: "app_settings",
type: "TEXT",
nullable: true);
migrationBuilder.UpdateData(
table: "app_settings",
keyColumn: "id",
keyValue: 1,
column: "model_presets",
value: null);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "is_manual",
table: "tasks");
migrationBuilder.DropColumn(
name: "is_manual",
table: "lists");
migrationBuilder.DropColumn(
name: "model_presets",
table: "app_settings");
}
}
}
@@ -66,6 +66,10 @@ namespace ClaudeDo.Data.Migrations
.HasDefaultValue(1) .HasDefaultValue(1)
.HasColumnName("max_parallel_executions"); .HasColumnName("max_parallel_executions");
b.Property<string>("ModelPresets")
.HasColumnType("TEXT")
.HasColumnName("model_presets");
b.Property<string>("RepoImportFolders") b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("repo_import_folders"); .HasColumnName("repo_import_folders");
@@ -202,6 +206,12 @@ namespace ClaudeDo.Data.Migrations
.HasDefaultValue("chore") .HasDefaultValue("chore")
.HasColumnName("default_commit_type"); .HasColumnName("default_commit_type");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasColumnType("TEXT") .HasColumnType("TEXT")
@@ -406,6 +416,12 @@ namespace ClaudeDo.Data.Migrations
.HasColumnType("TEXT") .HasColumnType("TEXT")
.HasColumnName("finished_at"); .HasColumnName("finished_at");
b.Property<bool>("IsManual")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_manual");
b.Property<bool>("IsMyDay") b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("INTEGER") .HasColumnType("INTEGER")
@@ -30,4 +30,8 @@ public sealed class AppSettingsEntity
// JSON array of session skill names applied by default to new tasks. // JSON array of session skill names applied by default to new tasks.
public string? SessionSkills { get; set; } public string? SessionSkills { get; set; }
// JSON array of ModelPreset rows (model → effort + max turns). Supplies the global effort and
// turn defaults per model; list-/task-level max-turns overrides still win. Null = ship defaults.
public string? ModelPresets { get; set; }
} }
@@ -0,0 +1,25 @@
namespace ClaudeDo.Data.Models;
/// <summary>
/// The reasoning-effort levels the claude CLI accepts for <c>--effort</c>.
/// </summary>
public static class EffortRegistry
{
public static readonly IReadOnlyList<string> Levels = new[] { "low", "medium", "high", "xhigh", "max" };
public const string DefaultLevel = "high";
/// <summary>
/// Validate an effort level from external input. Null/blank → null (don't pass --effort).
/// Returns the canonical lowercase level; throws on an unknown value.
/// </summary>
public static string? NormalizeLevel(string? effort)
{
var e = effort?.Trim();
if (string.IsNullOrEmpty(e)) return null;
foreach (var level in Levels)
if (string.Equals(level, e, StringComparison.OrdinalIgnoreCase))
return level;
throw new ArgumentException($"Unknown effort '{effort}'. Allowed: {string.Join(", ", Levels)}.");
}
}
+3
View File
@@ -8,6 +8,9 @@ public sealed class ListEntity
public string? WorkingDir { get; set; } public string? WorkingDir { get; set; }
public string DefaultCommitType { get; set; } = CommitTypeRegistry.DefaultType; public string DefaultCommitType { get; set; } = CommitTypeRegistry.DefaultType;
public int SortOrder { get; set; } public int SortOrder { get; set; }
// A manual list holds reminders rather than Claude work: new tasks created here start out
// manual (see TaskEntity.IsManual).
public bool IsManual { get; set; }
// Navigation properties // Navigation properties
public ListConfigEntity? Config { get; set; } public ListConfigEntity? Config { get; set; }
+91
View File
@@ -0,0 +1,91 @@
using System.Text.Json;
namespace ClaudeDo.Data.Models;
/// <summary>Per-model run defaults: which reasoning effort and turn budget a session gets when
/// it runs under <paramref name="Model"/> and nothing more specific overrides it.</summary>
public sealed record ModelPreset(string Model, string Effort, int MaxTurns);
/// <summary>
/// Serialization + lookup for <see cref="AppSettingsEntity.ModelPresets"/> (a JSON array on the
/// singleton settings row). One row per <see cref="ModelRegistry.Aliases"/> entry; the row supplies
/// the *global* effort and max-turns defaults, which list- and task-level overrides still beat.
/// </summary>
public static class ModelPresets
{
private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web);
/// <summary>Shipping defaults — one row per known model alias.</summary>
public static IReadOnlyList<ModelPreset> Defaults { get; } = new[]
{
new ModelPreset("haiku", "medium", 20),
new ModelPreset("sonnet", "high", 30),
new ModelPreset("opus", "high", 40),
new ModelPreset("fable", "high", 25),
};
public static string SerializeDefaults() => Serialize(Defaults);
public static string Serialize(IEnumerable<ModelPreset> presets)
=> JsonSerializer.Serialize(Normalize(presets), Json);
/// <summary>Parses the stored JSON, falling back to <see cref="Defaults"/> for anything absent
/// or malformed — a bad settings row must never stop a run from starting.</summary>
public static IReadOnlyList<ModelPreset> Parse(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return Defaults;
try
{
var parsed = JsonSerializer.Deserialize<List<ModelPreset>>(json, Json);
return parsed is { Count: > 0 } ? Normalize(parsed) : Defaults;
}
catch (JsonException)
{
return Defaults;
}
}
/// <summary>The preset for <paramref name="model"/>, or the default row for it. Never null so
/// callers don't have to special-case an unconfigured or unknown model.</summary>
public static ModelPreset For(IReadOnlyList<ModelPreset> presets, string? model)
{
var alias = (model ?? ModelRegistry.DefaultAlias).Trim();
foreach (var p in presets)
if (string.Equals(p.Model, alias, StringComparison.OrdinalIgnoreCase))
return p;
foreach (var p in Defaults)
if (string.Equals(p.Model, alias, StringComparison.OrdinalIgnoreCase))
return p;
return new ModelPreset(alias, EffortRegistry.DefaultLevel, 30);
}
public static ModelPreset For(string? json, string? model) => For(Parse(json), model);
// One row per known alias, canonical order, clamped values — so an edited or partial list
// still round-trips into something every consumer can rely on.
private static IReadOnlyList<ModelPreset> Normalize(IEnumerable<ModelPreset> presets)
{
var byModel = new Dictionary<string, ModelPreset>(StringComparer.OrdinalIgnoreCase);
foreach (var p in presets)
{
if (p is null || string.IsNullOrWhiteSpace(p.Model)) continue;
byModel[p.Model.Trim()] = p;
}
var result = new List<ModelPreset>();
foreach (var fallback in Defaults)
{
var source = byModel.TryGetValue(fallback.Model, out var found) ? found : fallback;
var effort = TryNormalizeEffort(source.Effort) ?? fallback.Effort;
var turns = source.MaxTurns is >= 1 and <= 200 ? source.MaxTurns : fallback.MaxTurns;
result.Add(new ModelPreset(fallback.Model, effort, turns));
}
return result;
}
private static string? TryNormalizeEffort(string? effort)
{
try { return EffortRegistry.NormalizeLevel(effort); }
catch (ArgumentException) { return null; }
}
}
+1 -1
View File
@@ -2,7 +2,7 @@ namespace ClaudeDo.Data.Models;
public static class ModelRegistry public static class ModelRegistry
{ {
public static readonly IReadOnlyList<string> Aliases = new[] { "sonnet", "opus", "haiku" }; public static readonly IReadOnlyList<string> Aliases = new[] { "sonnet", "opus", "haiku", "fable" };
/// <summary>Model aliases ordered cheapest → most capable. Single source for prompt cost guidance.</summary> /// <summary>Model aliases ordered cheapest → most capable. Single source for prompt cost guidance.</summary>
public static readonly IReadOnlyList<string> ByCostAscending = new[] { "haiku", "sonnet", "opus" }; public static readonly IReadOnlyList<string> ByCostAscending = new[] { "haiku", "sonnet", "opus" };
+4
View File
@@ -43,6 +43,10 @@ public sealed class TaskEntity
public int? MaxTurns { get; set; } public int? MaxTurns { get; set; }
public bool IsStarred { get; set; } public bool IsStarred { get; set; }
public bool IsMyDay { get; set; } public bool IsMyDay { get; set; }
// Manual = a reminder only the user can do. Automation skips it (queue picker, daily prep,
// list handler) and the Claude affordances are hidden; a hand-driven ConPTY session is still
// allowed. New tasks in a manual list default to true.
public bool IsManual { get; set; }
public string? Notes { get; set; } public string? Notes { get; set; }
public int SortOrder { get; set; } public int SortOrder { get; set; }
public string? SessionSkills { get; set; } public string? SessionSkills { get; set; }
+55 -18
View File
@@ -229,22 +229,57 @@ public static class PromptFiles
"""; """;
private const string MergeHelperDefault = """ private const string MergeHelperDefault = """
You are the ClaudeDo Merge Helper, running as an interactive session with the user watching. Ask them questions whenever you are unsure that is the point of this session. You are the ClaudeDo list handler, running as an interactive session with the user watching. Ask them questions whenever you are unsure that is the point of this session.
Your job: take the tasks listed in the brief and drive each one to a merged, Done state, then print a summary of everything that changed. You act through the mcp__claudedo__* tools. Read the brief file first (the kickoff message gives its path); it lists each task's id, title, status, list and repo. Your job: take the tasks listed in the brief and drive the whole set to merged, Done work reading them first, removing duplicates, sharpening what stays, running it, then reviewing and merging each result. You act through the mcp__claudedo__* tools. Read the brief file first (the kickoff message gives its path); it names the list, its repo, and every task's id, title and status. All tasks belong to that one list and one repo.
Handle the tasks in the order listed, one at a time. For each task, act on its current status: Work the five phases in order. Do not start a phase before the previous one is finished.
- Idle or Queued: run it with run_task_now, then poll get_task until it leaves Running (it lands in WaitingForReview on success, or Failed).
- Running or WaitingForChildren: poll get_task until it surfaces for review.
- Failed: this usually needs human judgement ask the user whether to reset_failed_task and re-run, or skip it.
- WaitingForReview: review, then merge it (below).
Reviewing and merging a WaitingForReview task: ## Phase 0 Read everything
1. Inspect the change with get_task_diff (stat first, then the full diff if it is non-trivial) and sanity-check it against the task's title/description. Call batch_get_tasks with every id from the brief and read each task's title, description, status and parent/child links. Do not act on any single task before you have read them all Phase 1 needs the whole set in view.
## Phase 1 Dedupe
Compare the tasks pairwise for overlap: same goal stated twice, one task fully contained in another, two tasks that would edit the same thing for the same reason.
Print a table of the candidate pairs with, for each, the reason it looks like a duplicate. Then ask the user about EACH pair, one at a time:
- merge fold whatever the loser says that the survivor does not into the survivor via update_task, then update_task_status(loserId, "Cancelled"). Cancelled keeps the task visible and resettable; never use delete_task for this.
- keep both note why and move on.
Cancel nothing without an explicit answer. If there are no duplicates, say so and go on.
## Phase 2 Enhance for execution
Each surviving task is about to be run by an autonomous agent with no further input. Sharpen it so that run can succeed. For each task, rewrite title and description to carry:
- concrete acceptance criteria what must be true when it is done,
- the files and areas actually involved, found with Read/Grep/Glob in the repo. Do not guess paths; look them up.
- what is explicitly out of scope.
Write it back with update_task (title, description and commitType are the settable fields).
Rules: do not change what the user asked for, and do not invent requirements. You are making the existing intent precise, not adding to it. If a task is too vague to sharpen without guessing, ASK instead of guessing. Report a short before/after per task.
## Phase 3 Run
Do NOT use run_task_now for a batch there is a single override slot and the second call fails with "override slot busy".
Read get_app_settings and tell the user how many parallel execution slots are configured (maxParallelExecutions). If it is 1, say plainly that the tasks will execute one after another and that the value is changeable in ClaudeDo's settings.
Then, for each surviving task:
- Idle or Failed update_task_status(id, "Queued"). For a Failed task ask first whether to reset_failed_task and re-queue it, or skip it.
- Queued leave it; it is already waiting for a slot.
- Running or WaitingForChildren leave it; only poll.
- WaitingForReview leave it; it goes straight to Phase 4.
Poll get_task until every task has left Queued and Running WaitingForReview on success, Failed on error. Report progress as tasks land; do not poll silently for minutes.
## Phase 4 Review and merge
One task at a time, in the order the brief lists them.
1. Inspect the change with get_task_diff (stat first, then the full diff if it is non-trivial) and sanity-check it against the task's title and description.
2. If the change looks wrong, incomplete, or risky, STOP and ask the user before merging offer reject_rerun (with feedback) or skip. 2. If the change looks wrong, incomplete, or risky, STOP and ask the user before merging offer reject_rerun (with feedback) or skip.
3. Otherwise merge it with review_task(taskId, decision="approve", leaveConflictsInTree=true). 3. Otherwise merge with review_task(taskId, decision="approve", leaveConflictsInTree=true).
- Clean merge the task is Done; move on. - Clean merge the task is Done; move on.
- Conflict (markers left in the working tree, repoPath returned) resolve it (below). - Conflict (markers left in the working tree, repoPath returned) resolve it.
Every branch in this run forked from the same base, so conflicts between them are the NORMAL case, not a failure. Resolve them and keep going; do not abandon the run because a merge conflicted.
Resolving a conflict: Resolving a conflict:
- Open each conflicted file under repoPath (Read/Edit) and resolve the <<<<<<< ======= >>>>>>> markers, guided by BOTH sides' intent. Then call continue_merge(taskId). If markers remain it tells you fix and call again. Use abort_merge(taskId) to cancel a merge you cannot safely resolve. - Open each conflicted file under repoPath (Read/Edit) and resolve the <<<<<<< ======= >>>>>>> markers, guided by BOTH sides' intent. Then call continue_merge(taskId). If markers remain it tells you fix and call again. Use abort_merge(taskId) to cancel a merge you cannot safely resolve.
@@ -252,22 +287,24 @@ public static class PromptFiles
- If a resolution is non-obvious, ambiguous, or might drop someone's work, ASK THE USER before continuing. - If a resolution is non-obvious, ambiguous, or might drop someone's work, ASK THE USER before continuing.
- Prefer the MCP tools whenever they apply. Only if the MCP tools cannot reach an in-progress merge may you finish it by hand: resolve the markers, then `git add -- <the resolved paths>` and `git commit` NEVER `git add -A` or a bare commit, because the checkout is shared with other sessions. - Prefer the MCP tools whenever they apply. Only if the MCP tools cannot reach an in-progress merge may you finish it by hand: resolve the markers, then `git add -- <the resolved paths>` and `git commit` NEVER `git add -A` or a bare commit, because the checkout is shared with other sessions.
Rules: Rules for the whole session:
- Never use raw `git merge`, `git reset`, or `git checkout` to force a merge. Drive merges through the MCP tools; hand-resolution is only for markers the tools left and cannot finish. - Never use raw `git merge`, `git reset`, or `git checkout` to force a merge. Drive merges through the MCP tools; hand-resolution is only for markers the tools left and cannot finish.
- Ask the user for anything ambiguous, risky, or destructive. - Ask the user for anything ambiguous, risky, or destructive.
When every task is handled, print a SUMMARY: ## Phase 5 Summary
- one line per task: title final status merge commit (if any) conflicts resolved (if any) Print one line per task from the original brief:
- anything you skipped or left for the user, and why title dedupe action (kept / merged into X / cancelled as duplicate of X) enhanced (yes/no) final status merge commit (if any) conflicts resolved (if any).
- suggested follow-ups, if any.
Then list anything you skipped or left for the user and why, and any follow-ups worth turning into new tasks.
"""; """;
private const string MergeHelperInitialDefault = """ private const string MergeHelperInitialDefault = """
# Merge Helper brief # List handler brief
Scope: {scope} Scope: {scope}
Repo: {repo}
Handle the following tasks, in order. For each, drive it to a merged/Done state per your instructions, asking me when unsure. Handle the following tasks. Work Phases 05 as your instructions describe, asking me whenever you are unsure.
{tasks} {tasks}
@@ -1,5 +1,6 @@
using System.IO; using System.IO;
using System.IO.Compression; using System.IO.Compression;
using System.Threading;
using ClaudeDo.Installer.Core; using ClaudeDo.Installer.Core;
using ClaudeDo.Releases; using ClaudeDo.Releases;
@@ -77,10 +78,23 @@ public sealed class DownloadAndExtractStep : IInstallStep
var appBak = appDest + ".bak"; var appBak = appDest + ".bak";
var workerBak = workerDest + ".bak"; var workerBak = workerDest + ".bak";
if (Directory.Exists(appBak)) Directory.Delete(appBak, recursive: true); try
if (Directory.Exists(workerBak)) Directory.Delete(workerBak, recursive: true); {
if (Directory.Exists(appDest)) Directory.Move(appDest, appBak); if (Directory.Exists(appBak)) DeleteWithRetry(appBak);
if (Directory.Exists(workerDest)) Directory.Move(workerDest, workerBak); if (Directory.Exists(workerBak)) DeleteWithRetry(workerBak);
if (Directory.Exists(appDest)) MoveWithRetry(appDest, appBak);
if (Directory.Exists(workerDest)) MoveWithRetry(workerDest, workerBak);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// A just-stopped app/worker (or an Explorer/terminal window sitting in
// the install dir) still held a handle. Surface an actionable message
// instead of the raw "process cannot access the file" error.
return StepResult.Fail(
"Could not replace the existing app/worker files — they are still in use. " +
"Make sure ClaudeDo is fully closed (app and worker) and no Explorer or " +
$"terminal window is open inside the install folder, then run the update again. Details: {ex.Message}");
}
progress.Report("Extracting..."); progress.Report("Extracting...");
Directory.CreateDirectory(ctx.InstallDirectory); Directory.CreateDirectory(ctx.InstallDirectory);
@@ -91,17 +105,17 @@ public sealed class DownloadAndExtractStep : IInstallStep
catch (Exception ex) catch (Exception ex)
{ {
// Roll back to previous binaries. // Roll back to previous binaries.
if (Directory.Exists(appDest)) Directory.Delete(appDest, recursive: true); if (Directory.Exists(appDest)) DeleteWithRetry(appDest);
if (Directory.Exists(workerDest)) Directory.Delete(workerDest, recursive: true); if (Directory.Exists(workerDest)) DeleteWithRetry(workerDest);
if (Directory.Exists(appBak)) Directory.Move(appBak, appDest); if (Directory.Exists(appBak)) MoveWithRetry(appBak, appDest);
if (Directory.Exists(workerBak)) Directory.Move(workerBak, workerDest); if (Directory.Exists(workerBak)) MoveWithRetry(workerBak, workerDest);
return StepResult.Fail( return StepResult.Fail(
$"Extraction failed; previous binaries have been restored: {ex.Message}."); $"Extraction failed; previous binaries have been restored: {ex.Message}.");
} }
// Success — drop stash. // Success — drop stash.
if (Directory.Exists(appBak)) Directory.Delete(appBak, recursive: true); if (Directory.Exists(appBak)) DeleteWithRetry(appBak);
if (Directory.Exists(workerBak)) Directory.Delete(workerBak, recursive: true); if (Directory.Exists(workerBak)) DeleteWithRetry(workerBak);
ctx.InstalledVersion = release.TagName.TrimStart('v', 'V'); ctx.InstalledVersion = release.TagName.TrimStart('v', 'V');
return StepResult.Ok(); return StepResult.Ok();
@@ -111,4 +125,26 @@ public sealed class DownloadAndExtractStep : IInstallStep
try { Directory.Delete(scratchDir, recursive: true); } catch { /* best effort */ } try { Directory.Delete(scratchDir, recursive: true); } catch { /* best effort */ }
} }
} }
private static void MoveWithRetry(string source, string dest)
=> RetryIo(() => Directory.Move(source, dest));
private static void DeleteWithRetry(string dir)
=> RetryIo(() => Directory.Delete(dir, recursive: true));
// WaitForExit returns before Windows releases a just-killed process's file
// handles, so the stash Move/Delete can briefly hit a sharing violation.
// Retry through transient IO/access errors (~5s) before letting it surface.
private static void RetryIo(Action action)
{
const int attempts = 10;
for (var i = 0; ; i++)
{
try { action(); return; }
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException && i < attempts - 1)
{
Thread.Sleep(500);
}
}
}
} }
+13 -2
View File
@@ -19,22 +19,33 @@ public sealed class StopWorkerStep : IInstallStep
{ {
progress.Report("Stopping ClaudeDo processes (if running)..."); progress.Report("Stopping ClaudeDo processes (if running)...");
var installDir = ctx.InstallDirectory; var installDir = ctx.InstallDirectory;
var killedAny = false;
foreach (var name in ProcessNames) foreach (var name in ProcessNames)
{ {
foreach (var p in Process.GetProcessesByName(name)) foreach (var p in Process.GetProcessesByName(name))
{ {
try try
{ {
var path = p.MainModule?.FileName; // Scope to THIS install when the module path is readable; if it
// can't be read (access race / exiting process), fall through and
// kill anyway — a survivor would lock the install dir during
// extraction. Reading MainModule must not skip the Kill.
string? path = null;
try { path = p.MainModule?.FileName; } catch { /* unreadable — kill anyway */ }
if (path is not null && !IsUnder(path, installDir)) continue; if (path is not null && !IsUnder(path, installDir)) continue;
p.Kill(entireProcessTree: true); p.Kill(entireProcessTree: true);
p.WaitForExit(10000); p.WaitForExit(10000);
killedAny = true;
} }
catch { /* process may have exited or be inaccessible */ } catch { /* process may have exited or be inaccessible */ }
finally { p.Dispose(); } finally { p.Dispose(); }
} }
} }
await Task.CompletedTask;
// WaitForExit returns before the OS releases the process's file handles.
// Give it a moment so DownloadAndExtractStep's Directory.Move doesn't race
// a still-open handle. (That step also retries, this just avoids the churn.)
if (killedAny) await Task.Delay(1500, ct);
return StepResult.Ok(); return StepResult.Ok();
} }
+22 -8
View File
@@ -15,6 +15,9 @@
"defaultInstructionsPlaceholder": "Basis-Anweisungen, die auf jede Aufgabe angewendet werden", "defaultInstructionsPlaceholder": "Basis-Anweisungen, die auf jede Aufgabe angewendet werden",
"model": "Modell", "model": "Modell",
"maxTurns": "Max. Durchläufe", "maxTurns": "Max. Durchläufe",
"effort": "Effort",
"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.",
"permission": "Berechtigung", "permission": "Berechtigung",
"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.",
@@ -28,7 +31,11 @@
"weekdayFriday": "Freitag", "weekdayFriday": "Freitag",
"weekdaySaturday": "Samstag", "weekdaySaturday": "Samstag",
"sessionSkills": "Session-Skills", "sessionSkills": "Session-Skills",
"sessionSkillsHint": "Gilt für jede Aufgabe. Kombiniert sich mit Listen- und Aufgaben-Auswahl." "sessionSkillsHint": "Gilt für jede Aufgabe. Kombiniert sich mit Listen- und Aufgaben-Auswahl.",
"accentPreset": "Akzentfarbe",
"accentPresetMoss": "Moos",
"accentPresetPeat": "Torf",
"accentPresetSea": "Meer"
}, },
"worktrees": { "worktrees": {
"strategy": "Strategie", "strategy": "Strategie",
@@ -114,6 +121,7 @@
}, },
"tasks": { "tasks": {
"showCompletedTip": "Abgeschlossene anzeigen", "showCompletedTip": "Abgeschlossene anzeigen",
"letClaudeTip": "Claude machen lassen",
"listSettingsTip": "Listeneinstellungen", "listSettingsTip": "Listeneinstellungen",
"addPlaceholder": "Aufgabe hinzufügen…", "addPlaceholder": "Aufgabe hinzufügen…",
"enterKey": "ENTER", "enterKey": "ENTER",
@@ -141,6 +149,10 @@
"ctxAddToMyDay": "Zu Mein Tag hinzufügen", "ctxAddToMyDay": "Zu Mein Tag hinzufügen",
"badgeDraft": "ENTWURF", "badgeDraft": "ENTWURF",
"badgePlanned": "GEPLANT", "badgePlanned": "GEPLANT",
"badgeManual": "MANUELL",
"manualTip": "Manuelle Erinnerung — Claude nimmt sie nie auf",
"ctxMarkManual": "Als manuell markieren",
"ctxMarkClaudeTask": "Als Claude-Aufgabe markieren",
"approve": "Genehmigen", "approve": "Genehmigen",
"approveTip": "Genehmigen — als Erledigt markieren", "approveTip": "Genehmigen — als Erledigt markieren",
"reject": "Ablehnen", "reject": "Ablehnen",
@@ -152,6 +164,7 @@
"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",
"scheduleTitle": "Aufgabe planen", "scheduleTitle": "Aufgabe planen",
"scheduleWhen": "WANN", "scheduleWhen": "WANN",
"scheduleConfirm": "Planen", "scheduleConfirm": "Planen",
@@ -160,7 +173,8 @@
"feedbackLabel": "FEEDBACK FÜR DEN AGENTEN", "feedbackLabel": "FEEDBACK FÜR DEN AGENTEN",
"feedbackPlaceholder": "Was soll der Agent korrigieren?", "feedbackPlaceholder": "Was soll der Agent korrigieren?",
"rerun": "Erneut ausführen", "rerun": "Erneut ausführen",
"refineTip": "Aufgabe mit Claude verfeinern" "refineTip": "Aufgabe mit Claude verfeinern",
"refiningTip": "Claude verfeinert diese Aufgabe…"
}, },
"lists": { "lists": {
"heading": "Listen", "heading": "Listen",
@@ -175,8 +189,7 @@
"contextOpenTerminal": "Im Terminal öffnen", "contextOpenTerminal": "Im Terminal öffnen",
"contextLetClaude": "Claude machen lassen", "contextLetClaude": "Claude machen lassen",
"newList": "Neue Liste", "newList": "Neue Liste",
"addReposTip": "Repos als Listen hinzufügen", "addReposTip": "Repos als Listen hinzufügen"
"letClaudeAllTip": "Claude machen lassen (alle Listen)"
}, },
"details": { "details": {
"pickUpInTerminalTip": "Diese Sitzung im Terminal fortsetzen", "pickUpInTerminalTip": "Diese Sitzung im Terminal fortsetzen",
@@ -268,6 +281,7 @@
"overviewMode": "Übersicht", "overviewMode": "Übersicht",
"closeSession": "Sitzung schließen", "closeSession": "Sitzung schließen",
"conptyLaunchFailed": "ConPTY-Sitzung konnte nicht geöffnet werden: {0}", "conptyLaunchFailed": "ConPTY-Sitzung konnte nicht geöffnet werden: {0}",
"conptyStarting": "Sitzung wird gestartet…",
"mergeHelperTitle": "Merge-Helfer", "mergeHelperTitle": "Merge-Helfer",
"submitForReviewFailed": "Einreichen zum Review fehlgeschlagen: {0}", "submitForReviewFailed": "Einreichen zum Review fehlgeschlagen: {0}",
"submitForReview": "Zum Review einreichen", "submitForReview": "Zum Review einreichen",
@@ -313,6 +327,8 @@
"workingDirectoryPlaceholder": "(keines)", "workingDirectoryPlaceholder": "(keines)",
"browse": "Durchsuchen...", "browse": "Durchsuchen...",
"defaultCommitType": "Standard-Commit-Typ", "defaultCommitType": "Standard-Commit-Typ",
"manualList": "Manuelle Liste (Erinnerungen)",
"manualListHint": "Neue Aufgaben in dieser Liste sind zunächst manuell: kein Einreihen, Ausführen oder Verfeinern, und die Automatik überspringt sie. Eine handgesteuerte Sitzung kannst du weiterhin öffnen.",
"sectionAgent": "AGENT", "sectionAgent": "AGENT",
"resetAgentSettings": "Agent-Einstellungen zurücksetzen" "resetAgentSettings": "Agent-Einstellungen zurücksetzen"
}, },
@@ -371,7 +387,6 @@
"mergeHelper": { "mergeHelper": {
"title": "CLAUDE MACHEN LASSEN", "title": "CLAUDE MACHEN LASSEN",
"windowTitle": "Claude machen lassen", "windowTitle": "Claude machen lassen",
"scopeAll": "Alle Listen",
"scopeList": "Liste: {0}", "scopeList": "Liste: {0}",
"confirm": "Claude machen lassen", "confirm": "Claude machen lassen",
"cancel": "Abbrechen", "cancel": "Abbrechen",
@@ -379,8 +394,7 @@
"selectNone": "Keine auswählen", "selectNone": "Keine auswählen",
"empty": "Keine Aufgaben zu übergeben — alles erledigt oder abgebrochen.", "empty": "Keine Aufgaben zu übergeben — alles erledigt oder abgebrochen.",
"columnTask": "AUFGABE", "columnTask": "AUFGABE",
"columnStatus": "STATUS", "columnStatus": "STATUS"
"columnList": "LISTE"
}, },
"unfinishedPlanning": { "unfinishedPlanning": {
"title": "UNVOLLENDETE PLANUNGSSITZUNG", "title": "UNVOLLENDETE PLANUNGSSITZUNG",
@@ -524,7 +538,7 @@
"connection": { "online": "Online", "connecting": "Verbinden…", "offline": "Offline" }, "connection": { "online": "Online", "connecting": "Verbinden…", "offline": "Offline" },
"shell": { "restartingWorker": "Worker wird neu gestartet…" }, "shell": { "restartingWorker": "Worker wird neu gestartet…" },
"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" }, "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" },
"planningBadge": { "active": "PLANUNG", "finalized": "GEPLANT" }, "planningBadge": { "active": "PLANUNG", "finalized": "GEPLANT" },
"taskRow": { "createdPrefix": "Erstellt {0}", "stepsText": "{0}/{1} Schritte" }, "taskRow": { "createdPrefix": "Erstellt {0}", "stepsText": "{0}/{1} Schritte" },
"tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "planningOpenFailed": "Planungssitzung konnte nicht geöffnet werden: {0}", "planningResumeFailed": "Planungssitzung konnte nicht fortgesetzt werden: {0}", "pickUpInTerminalFailed": "Im Terminal fortsetzen fehlgeschlagen: {0}", "approveFailed": "Genehmigen & Mergen fehlgeschlagen: {0}" }, "tasksIsland": { "completedHeader": "ABGESCHLOSSEN", "completedHeaderCount": "ABGESCHLOSSEN · {0}", "planningOpenFailed": "Planungssitzung konnte nicht geöffnet werden: {0}", "planningResumeFailed": "Planungssitzung konnte nicht fortgesetzt werden: {0}", "pickUpInTerminalFailed": "Im Terminal fortsetzen fehlgeschlagen: {0}", "approveFailed": "Genehmigen & Mergen fehlgeschlagen: {0}" },
+22 -8
View File
@@ -15,6 +15,9 @@
"defaultInstructionsPlaceholder": "Baseline instructions applied to every task", "defaultInstructionsPlaceholder": "Baseline instructions applied to every task",
"model": "Model", "model": "Model",
"maxTurns": "Max turns", "maxTurns": "Max turns",
"effort": "Effort",
"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.",
"permission": "Permission", "permission": "Permission",
"maxParallelExecutions": "Max parallel executions", "maxParallelExecutions": "Max parallel executions",
"maxParallelExecutionsHint": "How many queued tasks the worker runs at once.", "maxParallelExecutionsHint": "How many queued tasks the worker runs at once.",
@@ -28,7 +31,11 @@
"weekdayFriday": "Friday", "weekdayFriday": "Friday",
"weekdaySaturday": "Saturday", "weekdaySaturday": "Saturday",
"sessionSkills": "Session skills", "sessionSkills": "Session skills",
"sessionSkillsHint": "Applied to every task. Combines with list- and task-level selections." "sessionSkillsHint": "Applied to every task. Combines with list- and task-level selections.",
"accentPreset": "Accent color",
"accentPresetMoss": "Moss",
"accentPresetPeat": "Peat",
"accentPresetSea": "Sea"
}, },
"worktrees": { "worktrees": {
"strategy": "Strategy", "strategy": "Strategy",
@@ -114,6 +121,7 @@
}, },
"tasks": { "tasks": {
"showCompletedTip": "Show completed", "showCompletedTip": "Show completed",
"letClaudeTip": "Let Claude handle it",
"listSettingsTip": "List settings", "listSettingsTip": "List settings",
"addPlaceholder": "Add a task…", "addPlaceholder": "Add a task…",
"enterKey": "ENTER", "enterKey": "ENTER",
@@ -141,6 +149,10 @@
"ctxAddToMyDay": "Add to My Day", "ctxAddToMyDay": "Add to My Day",
"badgeDraft": "DRAFT", "badgeDraft": "DRAFT",
"badgePlanned": "PLANNED", "badgePlanned": "PLANNED",
"badgeManual": "MANUAL",
"manualTip": "Manual reminder — Claude never picks this up",
"ctxMarkManual": "Mark as manual",
"ctxMarkClaudeTask": "Mark as Claude task",
"approve": "Approve", "approve": "Approve",
"approveTip": "Approve — mark Done", "approveTip": "Approve — mark Done",
"reject": "Reject", "reject": "Reject",
@@ -152,6 +164,7 @@
"removeFromQueueTip": "Remove from queue", "removeFromQueueTip": "Remove from queue",
"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",
"scheduleTitle": "Schedule task", "scheduleTitle": "Schedule task",
"scheduleWhen": "WHEN", "scheduleWhen": "WHEN",
"scheduleConfirm": "Schedule", "scheduleConfirm": "Schedule",
@@ -160,7 +173,8 @@
"feedbackLabel": "FEEDBACK FOR THE AGENT", "feedbackLabel": "FEEDBACK FOR THE AGENT",
"feedbackPlaceholder": "What should the agent fix?", "feedbackPlaceholder": "What should the agent fix?",
"rerun": "Re-run", "rerun": "Re-run",
"refineTip": "Refine this task with Claude" "refineTip": "Refine this task with Claude",
"refiningTip": "Claude is refining this task…"
}, },
"lists": { "lists": {
"heading": "Lists", "heading": "Lists",
@@ -175,8 +189,7 @@
"contextOpenTerminal": "Open in Terminal", "contextOpenTerminal": "Open in Terminal",
"contextLetClaude": "Let Claude handle it", "contextLetClaude": "Let Claude handle it",
"newList": "New list", "newList": "New list",
"addReposTip": "Add repos as lists", "addReposTip": "Add repos as lists"
"letClaudeAllTip": "Let Claude handle it (all lists)"
}, },
"details": { "details": {
"pickUpInTerminalTip": "Pick up this session in a terminal", "pickUpInTerminalTip": "Pick up this session in a terminal",
@@ -268,6 +281,7 @@
"overviewMode": "Overview", "overviewMode": "Overview",
"closeSession": "Close session", "closeSession": "Close session",
"conptyLaunchFailed": "Couldn't open ConPTY session: {0}", "conptyLaunchFailed": "Couldn't open ConPTY session: {0}",
"conptyStarting": "Starting session…",
"mergeHelperTitle": "Merge Helper", "mergeHelperTitle": "Merge Helper",
"submitForReviewFailed": "Couldn't submit for review: {0}", "submitForReviewFailed": "Couldn't submit for review: {0}",
"submitForReview": "Submit for review", "submitForReview": "Submit for review",
@@ -313,6 +327,8 @@
"workingDirectoryPlaceholder": "(none)", "workingDirectoryPlaceholder": "(none)",
"browse": "Browse...", "browse": "Browse...",
"defaultCommitType": "Default commit type", "defaultCommitType": "Default commit type",
"manualList": "Manual list (reminders)",
"manualListHint": "New tasks in this list start out manual: no queueing, running or refining, and automation skips them. You can still open a hand-driven session.",
"sectionAgent": "AGENT", "sectionAgent": "AGENT",
"resetAgentSettings": "Reset agent settings" "resetAgentSettings": "Reset agent settings"
}, },
@@ -371,7 +387,6 @@
"mergeHelper": { "mergeHelper": {
"title": "LET CLAUDE HANDLE IT", "title": "LET CLAUDE HANDLE IT",
"windowTitle": "Let Claude handle it", "windowTitle": "Let Claude handle it",
"scopeAll": "All lists",
"scopeList": "List: {0}", "scopeList": "List: {0}",
"confirm": "Let Claude handle it", "confirm": "Let Claude handle it",
"cancel": "Cancel", "cancel": "Cancel",
@@ -379,8 +394,7 @@
"selectNone": "Select none", "selectNone": "Select none",
"empty": "No tasks to hand over — everything is done or cancelled.", "empty": "No tasks to hand over — everything is done or cancelled.",
"columnTask": "TASK", "columnTask": "TASK",
"columnStatus": "STATUS", "columnStatus": "STATUS"
"columnList": "LIST"
}, },
"unfinishedPlanning": { "unfinishedPlanning": {
"title": "UNFINISHED PLANNING SESSION", "title": "UNFINISHED PLANNING SESSION",
@@ -524,7 +538,7 @@
"connection": { "online": "Online", "connecting": "Connecting…", "offline": "Offline" }, "connection": { "online": "Online", "connecting": "Connecting…", "offline": "Offline" },
"shell": { "restartingWorker": "Restarting worker…" }, "shell": { "restartingWorker": "Restarting worker…" },
"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" }, "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" },
"planningBadge": { "active": "PLANNING", "finalized": "PLANNED" }, "planningBadge": { "active": "PLANNING", "finalized": "PLANNED" },
"taskRow": { "createdPrefix": "Created {0}", "stepsText": "{0}/{1} steps" }, "taskRow": { "createdPrefix": "Created {0}", "stepsText": "{0}/{1} steps" },
"tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "planningOpenFailed": "Couldn't open planning session: {0}", "planningResumeFailed": "Couldn't resume planning session: {0}", "pickUpInTerminalFailed": "Pick up in terminal failed: {0}", "approveFailed": "Approve & merge failed: {0}" }, "tasksIsland": { "completedHeader": "COMPLETED", "completedHeaderCount": "COMPLETED · {0}", "planningOpenFailed": "Couldn't open planning session: {0}", "planningResumeFailed": "Couldn't resume planning session: {0}", "pickUpInTerminalFailed": "Pick up in terminal failed: {0}", "approveFailed": "Approve & merge failed: {0}" },
+43
View File
@@ -0,0 +1,43 @@
using Avalonia.Media;
using ClaudeDo.Ui.Localization;
using ClaudeDo.Ui.ViewModels;
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClaudeDo.Ui;
public sealed record AccentPreset(string Name, string Accent, string Dim, string Soft, string Glow);
public static class AccentPresets
{
// Hue 88 — moss/sage green (original)
public static readonly AccentPreset Moss = new("moss", "#FF7C9166", "#FF64785A", "#FF3E4B39", "#387C9166");
// Hue ~40 — warm earthy brown/terra
public static readonly AccentPreset Peat = new("peat", "#FF9A7B5C", "#FF7F6449", "#FF4D3C2C", "#389A7B5C");
// Hue ~180 — cool teal/sea-green
public static readonly AccentPreset Sea = new("sea", "#FF5B8F8C", "#FF4A7573", "#FF263D3C", "#385B8F8C");
public static readonly IReadOnlyList<AccentPreset> All = [Moss, Peat, Sea];
public static AccentPreset Default => Moss;
public static AccentPreset Find(string? name) =>
All.FirstOrDefault(p => p.Name == name) ?? Default;
}
public sealed partial class AccentPresetSwatchViewModel : ViewModelBase
{
public AccentPreset Preset { get; }
[ObservableProperty] private bool _isSelected;
public Color DisplayColor { get; }
public string DisplayName =>
Loc.T($"settings.general.accentPreset{char.ToUpperInvariant(Preset.Name[0])}{Preset.Name.Substring(1)}");
public AccentPresetSwatchViewModel(AccentPreset preset, bool selected)
{
Preset = preset;
_isSelected = selected;
DisplayColor = Color.Parse(preset.Accent);
}
}
+1
View File
@@ -8,6 +8,7 @@ public sealed class AppSettings
public string DbPath { get; set; } = "~/.todo-app/todo.db"; public string DbPath { get; set; } = "~/.todo-app/todo.db";
public string SignalRUrl { get; set; } = "http://127.0.0.1:47821/hub"; public string SignalRUrl { get; set; } = "http://127.0.0.1:47821/hub";
public string Language { get; set; } = ""; public string Language { get; set; } = "";
public string AccentPreset { get; set; } = "";
private static readonly string ConfigPath = Paths.Expand("~/.todo-app/ui.config.json"); private static readonly string ConfigPath = Paths.Expand("~/.todo-app/ui.config.json");
+8 -5
View File
@@ -20,8 +20,8 @@ ViewModels/
Islands/ — ListsIsland, TasksIsland, DetailsIsland, TaskRow, ListNavItem, Islands/ — ListsIsland, TasksIsland, DetailsIsland, TaskRow, ListNavItem,
NotesEditor, MergePreviewPresenter NotesEditor, MergePreviewPresenter
Agent/ — AgentConfigEditorViewModel (scope-parameterized: List | Task) Agent/ — AgentConfigEditorViewModel (scope-parameterized: List | Task)
Modals/ — About, DiffViewer (+ DiffModels), ListSettings, Merge, RepoImport, Modals/ — About, DiffViewer (+ DiffModels), ListSettings, Merge, MergeHelperSelection,
Settings (+ Settings/ tab VMs), UnfinishedPlanning, WeeklyReport, RepoImport, Settings (+ Settings/ tab VMs), UnfinishedPlanning, WeeklyReport,
WorkerConnection, WorktreesOverview, UnifiedDiffParser WorkerConnection, WorktreesOverview, UnifiedDiffParser
Conflicts/ — ConflictResolverViewModel + ConflictModels (MergeFile/MergeFileSegment/MergeConflictBlock) Conflicts/ — ConflictResolverViewModel + ConflictModels (MergeFile/MergeFileSegment/MergeConflictBlock)
Views/ — mirrors the VM layout; Islands/Detail/ holds TaskHeaderBar, Views/ — mirrors the VM layout; Islands/Detail/ holds TaskHeaderBar,
@@ -35,11 +35,11 @@ Design/ — Tokens.axaml (design tokens; merged before styles) + IslandStyle
- **IslandsShellViewModel** — root coordinator; owns the three island VMs and the `WorkerClient`, wires cross-island events (selection, notes/prep mode, conflict resolution), owns connection state, the update banner, the inline worker-log strip (clickable → Log Visualizer overlay via `OpenLogVisualizerCommand`; `FlashFooterError` surfaces UI-action failures + the worker's Serilog Warn/Error there), responsive-layout flags (`ShowLists`/`ShowDetails` by window width), `PrimeStatus` flash, and the modal openers (About, RepoImport, WeeklyReport, WorktreesOverview, WorkerConnection help, LogVisualizer) plus `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), owns connection state, the update banner, the inline worker-log strip (clickable → Log Visualizer overlay via `OpenLogVisualizerCommand`; `FlashFooterError` surfaces UI-action failures + the worker's Serilog Warn/Error there), responsive-layout flags (`ShowLists`/`ShowDetails` by window width), `PrimeStatus` flash, and the modal openers (About, RepoImport, WeeklyReport, WorktreesOverview, WorkerConnection help, LogVisualizer) plus `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`.
- **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 (approve, reject-rerun, reject-park, cancel); planning session lifecycle (open/resume/discard/finalize, `QueuePlanningSubtasksAsync`); `RefineTask`, `OpenConPtySessionRequested` (embedded ConPTY terminal), `PickUpInTerminalAsync`; MyDay extras (`IsMyDayList`, `ClearDayCommand`, `ShowPrepLogCommand`) and the pinned Notes pseudo-row (`ShowNotesRow`, `OpenNotesCommand`). Raises `NotesRequested`/`PrepRequested` events consumed by 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 (approve, reject-rerun, reject-park, cancel); planning session lifecycle (open/resume/discard/finalize, `QueuePlanningSubtasksAsync`); `RefineTask`, `OpenConPtySessionRequested` (embedded ConPTY terminal), `PickUpInTerminalAsync`, `ToggleManual` (per-task manual flag) and `SyncInteractiveSessions` (mirrors Mission Control's open ConPTY panes onto the rows); MyDay extras (`IsMyDayList`, `ClearDayCommand`, `ShowPrepLogCommand`) and the pinned Notes pseudo-row (`ShowNotesRow`, `OpenNotesCommand`). Raises `NotesRequested`/`PrepRequested` events consumed by the shell.
- **DetailsIslandViewModel** — the detail pane for a bound `TaskRowViewModel`. Owns live-log streaming (`Log` via `StreamLineFormatter`), debounced title/description editing, subtasks, session-outcome/roadblock split (splits `Result` at the roadblock marker into two cards), the three-tab work console (`output`/`git`/`session`), child surfacing (`ChildOutcomes` rows plus `ChildrenNeedingAttention`/`HasChildrenNeedingAttention` — children that failed, were cancelled, await review, or reported roadblocks — drive an attention band on the Session tab, which is only visible when `HasChildOutcomes`), and the modes: `IsNotesMode` (hosts `NotesEditorViewModel`), `IsPrepMode`, computed `IsTaskDetailVisible = !IsNotesMode && !IsPrepMode`. Three concerns are extracted into section VMs exposed as properties: **AgentConfigEditorViewModel** (scope=Task; per-task Model/MaxTurns/AgentPath overrides with `InheritedBadge` + `InheritanceResolver`, additive SystemPrompt, debounced auto-save; exposed as `AgentSettings`), **MergeSectionViewModel** (merge-target selection, mergeability indicator via `MergePreviewPresenter` over `PreviewMergeAsync`, `OpenDiffAsync` and `ReviewCombinedDiffCommand` — both build a `DiffViewerViewModel`, call `ShowDiffViewer`, and fire the `DiffViewed` callback; `HasReviewableDiff` reports whether anything is inspectable, feeding the review gate), **PrepPanelViewModel** (daily-prep panel: `PrepLog`, `PlanDayCommand``RunDailyPrepNowAsync`, persisted last run via `GetLastPrepLogAsync`). Attachments: `Attachments` (`ObservableCollection<AttachmentRowViewModel>`), `IsDragOver`, `DropStatus`, `CanAcceptDrop`, `AddFilesAsync`, `RemoveAttachmentCommand`; loads on task change; `ComposedPreview` includes attachment paths. Writes directly via `new AttachmentStore()` + `new TaskAttachmentRepository(ctx)`. Helper rows (`ChildOutcomeRowViewModel`, `SubtaskRowViewModel`, `LogLineViewModel`, `AttachmentRowViewModel`) live in the same file. - **DetailsIslandViewModel** — the detail pane for a bound `TaskRowViewModel`. Owns live-log streaming (`Log` via `StreamLineFormatter`), debounced title/description editing, subtasks, session-outcome/roadblock split (splits `Result` at the roadblock marker into two cards), the three-tab work console (`output`/`git`/`session`), child surfacing (`ChildOutcomes` rows plus `ChildrenNeedingAttention`/`HasChildrenNeedingAttention` — children that failed, were cancelled, await review, or reported roadblocks — drive an attention band on the Session tab, which is only visible when `HasChildOutcomes`), and the modes: `IsNotesMode` (hosts `NotesEditorViewModel`), `IsPrepMode`, computed `IsTaskDetailVisible = !IsNotesMode && !IsPrepMode`. Three concerns are extracted into section VMs exposed as properties: **AgentConfigEditorViewModel** (scope=Task; per-task Model/MaxTurns/AgentPath overrides with `InheritedBadge` + `InheritanceResolver`, additive SystemPrompt, debounced auto-save; exposed as `AgentSettings`), **MergeSectionViewModel** (merge-target selection, mergeability indicator via `MergePreviewPresenter` over `PreviewMergeAsync`, `OpenDiffAsync` and `ReviewCombinedDiffCommand` — both build a `DiffViewerViewModel`, call `ShowDiffViewer`, and fire the `DiffViewed` callback; `HasReviewableDiff` reports whether anything is inspectable, feeding the review gate), **PrepPanelViewModel** (daily-prep panel: `PrepLog`, `PlanDayCommand``RunDailyPrepNowAsync`, persisted last run via `GetLastPrepLogAsync`). Attachments: `Attachments` (`ObservableCollection<AttachmentRowViewModel>`), `IsDragOver`, `DropStatus`, `CanAcceptDrop`, `AddFilesAsync`, `RemoveAttachmentCommand`; loads on task change; `ComposedPreview` includes attachment paths. Writes directly via `new AttachmentStore()` + `new TaskAttachmentRepository(ctx)`. Helper rows (`ChildOutcomeRowViewModel`, `SubtaskRowViewModel`, `LogLineViewModel`, `AttachmentRowViewModel`) live in the same file.
- **TaskRowViewModel** / **ListNavItemViewModel** — lightweight display VMs (task row: status, planning phase, parent/blocked links, roadblock count, computed `IsDraft`/`IsPlanned`/`IsChild`/`IsPlanningParent`/`CanRefine`; list row: kind Smart/Virtual/User, count, icon/dot keys, drop hints). - **TaskRowViewModel** / **ListNavItemViewModel** — lightweight display VMs (task row: status, planning phase, parent/blocked links, roadblock count, computed `IsDraft`/`IsPlanned`/`IsChild`/`IsPlanningParent`/`CanRefine`; plus `IsManual` (→ MANUAL badge; suppresses `CanSendToQueue`/`CanRefine`/`CanOpenPlanningSession`) and `HasInteractiveSession` (→ accent "Interactive" chip instead of "Parked"; tapping it jumps to that Mission Control pane); list row: kind Smart/Virtual/User, count, icon/dot keys, drop hints, `IsManual`).
- **NotesEditorViewModel** — day navigator + bullet CRUD for daily notes via `INotesApi`. - **NotesEditorViewModel** — day navigator + bullet CRUD for daily notes via `INotesApi`.
- **Modal VMs** — `SettingsModalViewModel` (four tabs: General, Worktrees, Files prompt-paths, Prime Claude incl. `DailyPrepMaxTasks` + prime-schedule rows), `ListSettingsModalViewModel` (name, working dir, commit type, delete list; hosts shared `AgentConfigEditorViewModel` as `Agent` property (scope=List) — save delegates to `Agent.SaveAsync()`), `RepoImportModalViewModel` (bulk-create lists from git repos found under chosen parents; already-wired repos disabled), `WeeklyReportModalViewModel` (range pickers default "since last standup weekday → today", cached per range, markdown via MarkdownView), `MergeModalViewModel` (single-task merge form, called from the diff modal), `WorktreesOverviewModalViewModel` (global/per-list worktree rows, batch merge + state ops), `UnfinishedPlanningModalViewModel` (Resume/FinalizeNow/Discard for a draft planning session), `WorkerConnectionModalViewModel` (offline help), `AboutModalViewModel`, `LogVisualizerViewModel` (worker logs, last 30 min, all levels + a warn/error-only filter; loads via `GetRecentLogsAsync`). - **Modal VMs** — `SettingsModalViewModel` (four tabs: General, Worktrees, Files prompt-paths, Prime Claude incl. `DailyPrepMaxTasks` + prime-schedule rows). General hosts the per-model preset table (`ModelPresets``ModelPresetRowViewModel`: effort + max turns per alias) which **replaced** the single global "Max turns" field, `ListSettingsModalViewModel` (name, working dir, commit type, "manual list" flag, delete list; hosts shared `AgentConfigEditorViewModel` as `Agent` property (scope=List) — save delegates to `Agent.SaveAsync()`), `RepoImportModalViewModel` (bulk-create lists from git repos found under chosen parents; already-wired repos disabled), `WeeklyReportModalViewModel` (range pickers default "since last standup weekday → today", cached per range, markdown via MarkdownView), `MergeModalViewModel` (single-task merge form, called from the diff modal), `WorktreesOverviewModalViewModel` (global/per-list worktree rows, batch merge + state ops), `UnfinishedPlanningModalViewModel` (Resume/FinalizeNow/Discard for a draft planning session), `MergeHelperSelectionModalViewModel` ("Let Claude handle it": checkbox picker over one list's non-terminal, non-manual tasks, pre-ticks the actionable ones; list-scoped only — `Configure(listId, listName)`, no global scope. Opened from the list row's context menu, which is hidden when the list has no working dir; on confirm `ListsIslandViewModel` raises `LetClaudeHandleRequested` → shell → `MissionControlViewModel.OpenMergeHelperConPtySessionAsync` opens an ad-hoc ConPTY tile running the five-phase handler prompt), `WorkerConnectionModalViewModel` (offline help), `AboutModalViewModel`, `LogVisualizerViewModel` (worker logs, last 30 min, all levels + a warn/error-only filter; loads via `GetRecentLogsAsync`).
- **Diff stack** — `UnifiedDiffParser` (static; parses `git diff` output into `DiffFileViewModel`s, detecting added/deleted/renamed/binary files and per-line numbers; `Flatten` injects file-header rows for a combined single-pane view). `DiffModels.cs` holds shared types: `DiffLineViewModel`, `DiffFileViewModel`, `DiffLineKind`, `DiffFileStatus`, `SubtaskDiffRow`, `DiffTreeNodeViewModel`, `DiffTree`. `DiffViewerViewModel` is a single unified read-only diff viewer with two modes: **Files** (dirty worktree / branch-vs-base / commit-range — loads via GitService, shows a folder file-tree on the left + per-file diff pane on the right, Merge button for live branch source) and **Planning** (per-subtask diffs via `GetPlanningAggregateAsync`, subtask list left + flat diff right, combined integration-branch toggle). The Merge button opens the merge form, which routes to `ConflictResolverViewModel` on conflict. `DiffLinesView` renders per-file diff content with binary/empty placeholders. - **Diff stack** — `UnifiedDiffParser` (static; parses `git diff` output into `DiffFileViewModel`s, detecting added/deleted/renamed/binary files and per-line numbers; `Flatten` injects file-header rows for a combined single-pane view). `DiffModels.cs` holds shared types: `DiffLineViewModel`, `DiffFileViewModel`, `DiffLineKind`, `DiffFileStatus`, `SubtaskDiffRow`, `DiffTreeNodeViewModel`, `DiffTree`. `DiffViewerViewModel` is a single unified read-only diff viewer with two modes: **Files** (dirty worktree / branch-vs-base / commit-range — loads via GitService, shows a folder file-tree on the left + per-file diff pane on the right, Merge button for live branch source) and **Planning** (per-subtask diffs via `GetPlanningAggregateAsync`, subtask list left + flat diff right, combined integration-branch toggle). The Merge button opens the merge form, which routes to `ConflictResolverViewModel` on conflict. `DiffLinesView` renders per-file diff content with binary/empty placeholders.
- **Conflicts** — `ConflictResolverViewModel` (in-app **Rider-style 3-pane merge editor** for both single-task and planning unit-merge conflicts: single-task starts the conflict merge, parses each conflicted file into stable/conflict `MergeFileSegment`s via the worker's `GetMergeConflictDocuments`; exposes the active file's three reconstructed documents — `ActiveOursText` / `ActiveResultText` / `ActiveTheirsText` (from `MergeFile.OursText/ResultText/TheirsText`; Result seeds unresolved conflicts with Ours) — plus `ActiveFile`/`SelectFileCommand` (multi-file switcher), `Current`/`Next`/`Previous` (focused-conflict nav), a per-active-file `PositionText` readout, per-block `AcceptOurs/Theirs/Both/Base` + `MergeFile.Compose`, and `CanContinue` gated on every file resolved + no binary; writes each file via `WriteConflictResolution`, continue/abort; **planning mode** via `OpenForPlanningAsync(parentId, subtaskId)` loads the current subtask's mid-merge conflicts without re-starting the merge and routes continue/abort to `ContinuePlanningMerge`/`AbortPlanningMerge`, so a unit-merge conflict re-opens the editor per subtask via the `PlanningMergeConflict` broadcast). The view (`Views/Conflicts/ConflictResolverView`) shows the whole file in three **AvaloniaEdit** panes — MAIN/ours (read-only) | editable Result | INCOMING/theirs (read-only) — with TextMate highlighting by extension (theme `StyleInclude` in `App.axaml`); a code-behind `IBackgroundRenderer` tints each conflict block (unresolved/resolved) across panes, an `IReadOnlySectionProvider` + `TextAnchor` regions keep only conflict spans editable in Result (edits flow back to the block); each unresolved conflict starts EMPTY (a thin marker bar); the between-pane gutter controls **toggle** each side in/out of the result — ``/`` add MAIN/INCOMING in click order (first pick on top), clicking again removes that side — so a conflict can take main, incoming, both, or neither; a `FilesSummary` readout shows how many files still have conflicts, and the three panes share a proportional synced vertical scroll. A conflict overview ruler right of the Result pane (`ConflictMap`) maps every conflict in the file proportionally (click a tick to jump) — handy for long files. Conflict block tints live in `Tokens.axaml` (`Merge*TintBrush`). The editor is reached from review **Approve** on conflict and from the **Merge** button in the Diff window (a conflicting `MergeTask` hands off to the resolver via `RequestConflictResolution`). - **Conflicts** — `ConflictResolverViewModel` (in-app **Rider-style 3-pane merge editor** for both single-task and planning unit-merge conflicts: single-task starts the conflict merge, parses each conflicted file into stable/conflict `MergeFileSegment`s via the worker's `GetMergeConflictDocuments`; exposes the active file's three reconstructed documents — `ActiveOursText` / `ActiveResultText` / `ActiveTheirsText` (from `MergeFile.OursText/ResultText/TheirsText`; Result seeds unresolved conflicts with Ours) — plus `ActiveFile`/`SelectFileCommand` (multi-file switcher), `Current`/`Next`/`Previous` (focused-conflict nav), a per-active-file `PositionText` readout, per-block `AcceptOurs/Theirs/Both/Base` + `MergeFile.Compose`, and `CanContinue` gated on every file resolved + no binary; writes each file via `WriteConflictResolution`, continue/abort; **planning mode** via `OpenForPlanningAsync(parentId, subtaskId)` loads the current subtask's mid-merge conflicts without re-starting the merge and routes continue/abort to `ContinuePlanningMerge`/`AbortPlanningMerge`, so a unit-merge conflict re-opens the editor per subtask via the `PlanningMergeConflict` broadcast). The view (`Views/Conflicts/ConflictResolverView`) shows the whole file in three **AvaloniaEdit** panes — MAIN/ours (read-only) | editable Result | INCOMING/theirs (read-only) — with TextMate highlighting by extension (theme `StyleInclude` in `App.axaml`); a code-behind `IBackgroundRenderer` tints each conflict block (unresolved/resolved) across panes, an `IReadOnlySectionProvider` + `TextAnchor` regions keep only conflict spans editable in Result (edits flow back to the block); each unresolved conflict starts EMPTY (a thin marker bar); the between-pane gutter controls **toggle** each side in/out of the result — ``/`` add MAIN/INCOMING in click order (first pick on top), clicking again removes that side — so a conflict can take main, incoming, both, or neither; a `FilesSummary` readout shows how many files still have conflicts, and the three panes share a proportional synced vertical scroll. A conflict overview ruler right of the Result pane (`ConflictMap`) maps every conflict in the file proportionally (click a tick to jump) — handy for long files. Conflict block tints live in `Tokens.axaml` (`Merge*TintBrush`). The editor is reached from review **Approve** on conflict and from the **Merge** button in the Diff window (a conflicting `MergeTask` hands off to the resolver via `RequestConflictResolution`).
@@ -65,5 +65,8 @@ Modals use `TaskCompletionSource` results behind the reusable `ModalShell` contr
- Context menus exist on both list rows and task rows; right-click selects before opening the menu - Context menus exist on both list rows and task rows; right-click selects before opening the menu
- "Run Now" CanExecute re-evaluates when worker connection state changes - "Run Now" CanExecute re-evaluates when worker connection state changes
- Icon gotcha: `PathIcon` fills geometry. Line-art/stroke icons must be defined as filled geometry or rendered as a stroked `Path` (e.g. `Icon.PlanDay` via the `Path.plan-icon` style); a pure stroke path used with `PathIcon` is invisible. - Icon gotcha: `PathIcon` fills geometry. Line-art/stroke icons must be defined as filled geometry or rendered as a stroked `Path` (e.g. `Icon.PlanDay` via the `Path.plan-icon` style); a pure stroke path used with `PathIcon` is invisible.
- Window key bindings live on `MainWindow`: `Ctrl+K` focuses search, `Ctrl+N` the add-task box. Do **not** bind bare punctuation gestures — `OemQuestion` used to hold search focus and silently swallowed `#` app-wide on a German layout.
- `Ellipse.spinner` (IslandStyles) is the shared indeterminate spinner: used for a starting ConPTY pane (`InteractiveTerminalViewModel.IsStarting`) and in place of the refine button while `TaskRowViewModel.IsRefining`.
- `ConPtyPaneViewModel` resolves its own launch spec (ctor takes a descriptor **factory**; the host wires handlers and then calls `Start()`), so the Mission Control tile appears immediately with its spinner while the worker is still preparing the worktree. A failed launch keeps the tile with its inline error banner instead of never appearing.
- `SessionTerminalView` is the reusable log terminal (StyledProperties `Entries`, `Label`, `IsRunning`, `IsDone`, `IsFailed`) used for both the task `Log` and the prep `PrepLog`. - `SessionTerminalView` is the reusable log terminal (StyledProperties `Entries`, `Label`, `IsRunning`, `IsDone`, `IsFailed`) used for both the task `Log` and the prep `PrepLog`.
- `DetailsIslandView` is a pane-wide drag-and-drop file target (`DragDrop.AllowDrop`, Avalonia 12 `DataFormat.File`) with a "Drop to attach" hover overlay. `DescriptionStepsCard` shows an Attachments list (file name, size, remove button), an "Add file…" picker, and an explicit `DropStatus` confirmation line. Keys use the `details.attachments.*` localization namespace (en + de). - `DetailsIslandView` is a pane-wide drag-and-drop file target (`DragDrop.AllowDrop`, Avalonia 12 `DataFormat.File`) with a "Drop to attach" hover overlay. `DescriptionStepsCard` shows an Attachments list (file name, size, remove button), an "Add file…" picker, and an explicit `DropStatus` confirmation line. Keys use the `details.attachments.*` localization namespace (en + de).
+40
View File
@@ -116,6 +116,7 @@
<SolidColorBrush x:Key="DraftBadgeBrush" Color="{StaticResource TextMuteColor}"/> <SolidColorBrush x:Key="DraftBadgeBrush" Color="{StaticResource TextMuteColor}"/>
<SolidColorBrush x:Key="PlanningBadgeBrush" Color="{StaticResource PeatColor}"/> <SolidColorBrush x:Key="PlanningBadgeBrush" Color="{StaticResource PeatColor}"/>
<SolidColorBrush x:Key="PlannedBadgeBrush" Color="{StaticResource SageColor}"/> <SolidColorBrush x:Key="PlannedBadgeBrush" Color="{StaticResource SageColor}"/>
<SolidColorBrush x:Key="ManualBadgeBrush" Color="{StaticResource TextFaintColor}"/>
</Styles.Resources> </Styles.Resources>
@@ -247,6 +248,21 @@
<Setter Property="Foreground" Value="#8FB9D6" /> <Setter Property="Foreground" Value="#8FB9D6" />
</Style> </Style>
<!-- interactive → accent, the "live" family shared with running: the task has a ConPTY session
open in Mission Control. Clickable (jumps to that pane), hence the hand cursor + hover lift.
Deliberately NOT the slate-blue parked treatment — telling the two apart is the whole point. -->
<Style Selector="Border.chip.interactive">
<Setter Property="Background" Value="{StaticResource RunningTintBrush}" />
<Setter Property="BorderBrush" Value="{StaticResource AccentBrush}" />
<Setter Property="Cursor" Value="Hand" />
</Style>
<Style Selector="Border.chip.interactive > TextBlock">
<Setter Property="Foreground" Value="{StaticResource AccentBrush}" />
</Style>
<Style Selector="Border.chip.interactive:pointerover">
<Setter Property="Background" Value="{StaticResource AccentSoftBrush}" />
</Style>
<!-- Worktree-state chips (worktrees overview) --> <!-- Worktree-state chips (worktrees overview) -->
<!-- active → slate-blue (same hue as parked: a live worktree) --> <!-- active → slate-blue (same hue as parked: a live worktree) -->
<Style Selector="Border.chip.wt-active"> <Style Selector="Border.chip.wt-active">
@@ -454,6 +470,25 @@
</Style.Animations> </Style.Animations>
</Style> </Style>
<!-- Indeterminate ring spinner (dashed ring, rotated forever) -->
<Style Selector="Ellipse.spinner">
<Setter Property="Width" Value="14" />
<Setter Property="Height" Value="14" />
<Setter Property="Stroke" Value="{StaticResource AccentBrush}" />
<Setter Property="StrokeThickness" Value="2" />
<Setter Property="StrokeDashArray" Value="3,2" />
<Setter Property="RenderTransform" Value="rotate(0deg)" />
<Style.Animations>
<Animation Duration="0:0:0.9" IterationCount="INFINITE" Easing="LinearEasing">
<KeyFrame Cue="0%"> <Setter Property="RotateTransform.Angle" Value="0" /> </KeyFrame>
<KeyFrame Cue="100%"><Setter Property="RotateTransform.Angle" Value="360" /></KeyFrame>
</Animation>
</Style.Animations>
</Style>
<Style Selector="Ellipse.spinner.dim">
<Setter Property="Stroke" Value="{StaticResource TextDimBrush}" />
</Style>
<!-- ============================================================ --> <!-- ============================================================ -->
<!-- AGENT STRIP --> <!-- AGENT STRIP -->
<!-- ============================================================ --> <!-- ============================================================ -->
@@ -997,6 +1032,11 @@
<Setter Property="Background" Value="{DynamicResource PlannedBadgeBrush}"/> <Setter Property="Background" Value="{DynamicResource PlannedBadgeBrush}"/>
</Style> </Style>
<!-- manual → faint gray: a reminder Claude never touches -->
<Style Selector="Border.badge.manual">
<Setter Property="Background" Value="{DynamicResource ManualBadgeBrush}"/>
</Style>
<!-- ============================================================ --> <!-- ============================================================ -->
<!-- SHARED MODAL STYLES (promoted from per-modal Window.Styles) --> <!-- SHARED MODAL STYLES (promoted from per-modal Window.Styles) -->
<!-- ============================================================ --> <!-- ============================================================ -->
@@ -0,0 +1,24 @@
using Avalonia;
using Avalonia.Media;
namespace ClaudeDo.Ui.Services;
public static class AccentPresetService
{
public static void Apply(AccentPreset preset)
{
if (Application.Current is not { } app) return;
SetBrushColor(app, "AccentBrush", preset.Accent);
SetBrushColor(app, "AccentDimBrush", preset.Dim);
SetBrushColor(app, "AccentSoftBrush", preset.Soft);
SetBrushColor(app, "AccentGlowBrush", preset.Glow);
SetBrushColor(app, "MossBrush", preset.Accent);
}
private static void SetBrushColor(Application app, string key, string hex)
{
if (app.TryGetResource(key, null, out var value) && value is SolidColorBrush brush)
brush.Color = Color.Parse(hex);
}
}
@@ -86,8 +86,8 @@ public interface IWorkerClient : INotifyPropertyChanged
/// no task, no worktree.</summary> /// no task, no worktree.</summary>
Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default); Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default);
/// <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 to that list; null = all lists.</summary> /// tasks to a merged/Done state. listId scopes the session (and cwd) to that list.</summary>
Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string? listId, CancellationToken ct = default); Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct = default);
/// <summary>Starts a planning session and returns the launch spec for an embedded ConPTY /// <summary>Starts a planning session and returns the launch spec for an embedded ConPTY
/// planning terminal (replaces StartPlanningSessionAsync's external wt window).</summary> /// planning terminal (replaces StartPlanningSessionAsync's external wt window).</summary>
Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default); Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default);
+9 -5
View File
@@ -450,10 +450,10 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
await _hub.InvokeAsync("SetTaskStatus", taskId, status.ToString()); await _hub.InvokeAsync("SetTaskStatus", taskId, status.ToString());
} }
public Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) public async Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch)
{ {
LastApproveTarget = targetBranch; LastApproveTarget = targetBranch;
return TryInvokeAsync<MergeResultDto>("ApproveReview", taskId, targetBranch); return await _hub.InvokeAsync<MergeResultDto>("ApproveReview", taskId, targetBranch);
} }
public Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch) public Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch)
@@ -522,7 +522,7 @@ 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 _hub.InvokeAsync<LaunchSpec>("GetAdHocLaunchSpec", directory, ct); => await _hub.InvokeAsync<LaunchSpec>("GetAdHocLaunchSpec", directory, ct);
public async Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string? listId, CancellationToken ct = default) public async Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct = default)
=> await _hub.InvokeAsync<LaunchSpec>("GetMergeHelperLaunchSpec", taskIds, listId, ct); => await _hub.InvokeAsync<LaunchSpec>("GetMergeHelperLaunchSpec", taskIds, listId, ct);
public async Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default) public async Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default)
@@ -607,7 +607,11 @@ public sealed record AppSettingsDto(
string? ReportExcludedPaths, string? ReportExcludedPaths,
int StandupWeekday, int StandupWeekday,
int DailyPrepMaxTasks, int DailyPrepMaxTasks,
List<string>? SessionSkills = null); List<string>? SessionSkills = null,
List<ModelPresetDto>? ModelPresets = null);
// Per-model run defaults (effort + turn budget) edited in Settings → General.
public sealed record ModelPresetDto(string Model, string Effort, int MaxTurns);
public sealed record SessionSkillDto( public sealed record SessionSkillDto(
string Name, string Name,
@@ -624,7 +628,7 @@ public record MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalB
public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDocumentDto> Files); public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDocumentDto> Files);
public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList<MergeSegmentDto> Segments); public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList<MergeSegmentDto> Segments);
public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs); public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs);
public sealed record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType); public sealed record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType, bool IsManual = false);
public sealed record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null); public sealed record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
public sealed record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null); public sealed record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
public sealed record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null); public sealed record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
@@ -47,7 +47,11 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
[ObservableProperty] private string _effectiveSystemPromptHint = ""; [ObservableProperty] private string _effectiveSystemPromptHint = "";
private string _globalModel = ModelRegistry.DefaultAlias; private string _globalModel = ModelRegistry.DefaultAlias;
private int _globalMaxTurns = 100; // The global max-turns default is per-model (Settings -> General), so it moves with whichever
// model actually ends up in effect here.
private IReadOnlyList<ModelPreset> _presets = ModelPresets.Defaults;
private string EffectiveModel => Model ?? _listModel ?? _globalModel;
private int GlobalMaxTurns => ModelPresets.For(_presets, EffectiveModel).MaxTurns;
private string? _listModel; // Task scope only private string? _listModel; // Task scope only
private int? _listMaxTurns; // Task scope only private int? _listMaxTurns; // Task scope only
private string? _listAgentName; // Task scope only private string? _listAgentName; // Task scope only
@@ -56,7 +60,7 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
private CancellationTokenSource? _saveCts; private CancellationTokenSource? _saveCts;
public int EffectiveMaxTurns => public int EffectiveMaxTurns =>
MaxTurns is decimal t ? (int)t : (_listMaxTurns ?? _globalMaxTurns); MaxTurns is decimal t ? (int)t : (_listMaxTurns ?? GlobalMaxTurns);
public ObservableCollection<string> ModelOptions { get; } = new(ModelRegistry.Aliases); public ObservableCollection<string> ModelOptions { get; } = new(ModelRegistry.Aliases);
public ObservableCollection<AgentInfo> Agents { get; } = new(); public ObservableCollection<AgentInfo> Agents { get; } = new();
@@ -97,7 +101,14 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
foreach (var s in SessionSkills) s.PropertyChanged -= OnSkillItemPropertyChanged; foreach (var s in SessionSkills) s.PropertyChanged -= OnSkillItemPropertyChanged;
} }
partial void OnModelChanged(string? value) { RecomputeModelBadge(); QueueSave(); } partial void OnModelChanged(string? value)
{
RecomputeModelBadge();
// A different model means a different global turn default.
RecomputeTurnsBadge();
OnPropertyChanged(nameof(EffectiveMaxTurns));
QueueSave();
}
partial void OnMaxTurnsChanged(decimal? value) partial void OnMaxTurnsChanged(decimal? value)
{ {
@@ -130,8 +141,8 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
{ {
var own = MaxTurns?.ToString(); var own = MaxTurns?.ToString();
var (value, source) = _scope == AgentConfigScope.Task var (value, source) = _scope == AgentConfigScope.Task
? InheritanceResolver.Resolve(own, _listMaxTurns?.ToString(), _globalMaxTurns.ToString()) ? InheritanceResolver.Resolve(own, _listMaxTurns?.ToString(), GlobalMaxTurns.ToString())
: InheritanceResolver.ResolveList(own, _globalMaxTurns.ToString()); : InheritanceResolver.ResolveList(own, GlobalMaxTurns.ToString());
TurnsInheritedHint = value; TurnsInheritedHint = value;
TurnsBadge = BadgeFor(source, MaxTurns is not null); TurnsBadge = BadgeFor(source, MaxTurns is not null);
} }
@@ -284,7 +295,9 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
{ {
var app = await _worker.GetAppSettingsAsync(); var app = await _worker.GetAppSettingsAsync();
_globalModel = app?.DefaultModel ?? ModelRegistry.DefaultAlias; _globalModel = app?.DefaultModel ?? ModelRegistry.DefaultAlias;
_globalMaxTurns = app?.DefaultMaxTurns ?? 100; _presets = app?.ModelPresets is { Count: > 0 } rows
? rows.Select(r => new ModelPreset(r.Model, r.Effort, r.MaxTurns)).ToList()
: ModelPresets.Defaults;
} }
private void ApplyConfig(string? model, int? maxTurns, string? systemPrompt, string? agentPath) private void ApplyConfig(string? model, int? maxTurns, string? systemPrompt, string? agentPath)
@@ -22,6 +22,15 @@ public sealed partial class InteractiveTerminalViewModel : ViewModelBase, IDispo
[ObservableProperty] private int? _exitCode; [ObservableProperty] private int? _exitCode;
[ObservableProperty] private string? _startError; [ObservableProperty] private string? _startError;
/// <summary>True from construction until the child process is actually launched (or the launch
/// failed) — covers both the caller's launch-spec roundtrip and the ConPTY spawn, so the host
/// can show a spinner instead of an empty black pane.</summary>
public bool IsStarting => !IsRunning && !HasExited && StartError is null;
partial void OnIsRunningChanged(bool value) => OnPropertyChanged(nameof(IsStarting));
partial void OnHasExitedChanged(bool value) => OnPropertyChanged(nameof(IsStarting));
partial void OnStartErrorChanged(string? value) => OnPropertyChanged(nameof(IsStarting));
public InteractiveTerminalViewModel() public InteractiveTerminalViewModel()
{ {
_session.ProcessExited += OnSessionProcessExited; _session.ProcessExited += OnSessionProcessExited;
@@ -74,6 +83,15 @@ public sealed partial class InteractiveTerminalViewModel : ViewModelBase, IDispo
ExitCode = exitCode; ExitCode = exitCode;
} }
/// <summary>Reports a failure that happened before <see cref="Start"/> could be called (e.g. the
/// launch-spec roundtrip threw), so it surfaces through the same banner as a spawn failure.</summary>
public void ReportStartFailure(string message)
{
IsRunning = false;
HasExited = true;
StartError = message;
}
public void Kill() => _session.Kill(); public void Kill() => _session.Kill();
public void Dispose() public void Dispose()
@@ -258,6 +258,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
private bool _suppressDescSave; private bool _suppressDescSave;
private CancellationTokenSource? _descSaveCts; private CancellationTokenSource? _descSaveCts;
private bool _suppressTitleSave;
private CancellationTokenSource? _titleSaveCts;
// Set by shell so CloseDetailCommand can clear SelectedTask // Set by shell so CloseDetailCommand can clear SelectedTask
public Action? CloseDetail { get; set; } public Action? CloseDetail { get; set; }
@@ -430,6 +433,39 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
} }
} }
partial void OnEditableTitleChanged(string value)
{
if (_suppressTitleSave || Task is null) return;
_titleSaveCts?.Cancel();
_titleSaveCts = new CancellationTokenSource();
// Capture the row so a task switch mid-debounce cannot retitle the new task.
_ = SaveTitleAsync(Task, value, _titleSaveCts.Token);
}
private async System.Threading.Tasks.Task SaveTitleAsync(
TaskRowViewModel row, string value, CancellationToken ct)
{
try
{
await System.Threading.Tasks.Task.Delay(400, ct);
var title = value?.Trim();
// An empty title would make the row unidentifiable — keep the stored one.
if (string.IsNullOrEmpty(title)) return;
await using var ctx = _dbFactory.CreateDbContext();
var repo = new TaskRepository(ctx);
var entity = await repo.GetByIdAsync(row.Id);
if (entity is null || entity.Title == title) return;
entity.Title = title;
await repo.UpdateAsync(entity);
ct.ThrowIfCancellationRequested();
row.Title = title;
if (ReferenceEquals(Task, row))
Merge.SyncTaskContext(row.Id, title, row.IsPlanningParent);
}
catch (OperationCanceledException) { }
catch { }
}
partial void OnEditableDescriptionChanged(string value) partial void OnEditableDescriptionChanged(string value)
{ {
if (_suppressDescSave || Task is null) return; if (_suppressDescSave || Task is null) return;
@@ -525,7 +561,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
if (entity == null) return; if (entity == null) return;
EditableTitle = entity.Title; _suppressTitleSave = true;
try { EditableTitle = entity.Title; }
finally { _suppressTitleSave = false; }
_suppressDescSave = true; _suppressDescSave = true;
try { EditableDescription = entity.Description ?? ""; } try { EditableDescription = entity.Description ?? ""; }
finally { _suppressDescSave = false; } finally { _suppressDescSave = false; }
@@ -12,6 +12,8 @@ public sealed partial class ListNavItemViewModel : ViewModelBase
[ObservableProperty] private bool _isActive; [ObservableProperty] private bool _isActive;
[ObservableProperty] private string? _workingDir; [ObservableProperty] private string? _workingDir;
[ObservableProperty] private string _defaultCommitType = CommitTypeRegistry.DefaultType; [ObservableProperty] private string _defaultCommitType = CommitTypeRegistry.DefaultType;
// Reminder list: tasks created here default to manual.
[ObservableProperty] private bool _isManual;
[ObservableProperty] private bool _dropHintAbove; [ObservableProperty] private bool _dropHintAbove;
[ObservableProperty] private bool _dropHintBelow; [ObservableProperty] private bool _dropHintBelow;
public string? IconKey { get; init; } public string? IconKey { get; init; }
@@ -16,8 +16,8 @@ namespace ClaudeDo.Ui.ViewModels.Islands;
public enum ListKind { Smart, Virtual, User } public enum ListKind { Smart, Virtual, User }
/// <summary>Confirmed merge-helper run: the scope list (null = all lists) and the ordered selected task ids.</summary> /// <summary>Confirmed handler run: the scope list and the ordered selected task ids.</summary>
public sealed record MergeHelperRequest(string? ListId, IReadOnlyList<string> TaskIds); public sealed record MergeHelperRequest(string ListId, IReadOnlyList<string> TaskIds);
public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
{ {
@@ -47,7 +47,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
if (row is null || Dialogs is null || _services is null) return; if (row is null || Dialogs is null || _services is null) return;
var rawId = row.Id.StartsWith("user:", StringComparison.Ordinal) ? row.Id["user:".Length..] : row.Id; var rawId = row.Id.StartsWith("user:", StringComparison.Ordinal) ? row.Id["user:".Length..] : row.Id;
var vm = _services.GetRequiredService<ListSettingsModalViewModel>(); var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
await vm.LoadAsync(rawId, row.Name, row.WorkingDir, row.DefaultCommitType); await vm.LoadAsync(rawId, row.Name, row.WorkingDir, row.DefaultCommitType, row.IsManual);
await Dialogs.ShowListSettingsAsync(vm); await Dialogs.ShowListSettingsAsync(vm);
if (vm.Deleted) await LoadAsync(); if (vm.Deleted) await LoadAsync();
else await RefreshRowAsync(row.Id); else await RefreshRowAsync(row.Id);
@@ -100,18 +100,6 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
LetClaudeHandleRequested?.Invoke(new MergeHelperRequest(rawId, ids)); LetClaudeHandleRequested?.Invoke(new MergeHelperRequest(rawId, ids));
} }
[RelayCommand]
private async Task LetClaudeHandleAllAsync()
{
if (Dialogs is null || _services is null) return;
var vm = _services.GetRequiredService<MergeHelperSelectionModalViewModel>();
vm.Configure(null, null);
await vm.LoadAsync();
var ids = await Dialogs.ShowMergeHelperSelectionAsync(vm);
if (ids is { Count: > 0 })
LetClaudeHandleRequested?.Invoke(new MergeHelperRequest(null, ids));
}
[RelayCommand] [RelayCommand]
private void OpenInExplorer(ListNavItemViewModel? row) private void OpenInExplorer(ListNavItemViewModel? row)
{ {
@@ -255,6 +243,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
DotColorKey = dotColors[idx % dotColors.Length], DotColorKey = dotColors[idx % dotColors.Length],
WorkingDir = l.WorkingDir, WorkingDir = l.WorkingDir,
DefaultCommitType = l.DefaultCommitType, DefaultCommitType = l.DefaultCommitType,
IsManual = l.IsManual,
}; };
Items.Add(item); Items.Add(item);
UserLists.Add(item); UserLists.Add(item);
@@ -329,7 +318,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
if (Dialogs is not null && _services is not null) if (Dialogs is not null && _services is not null)
{ {
var vm = _services.GetRequiredService<ListSettingsModalViewModel>(); var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
await vm.LoadAsync(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType); await vm.LoadAsync(entity.Id, entity.Name, entity.WorkingDir, entity.DefaultCommitType, entity.IsManual);
await Dialogs.ShowListSettingsAsync(vm); await Dialogs.ShowListSettingsAsync(vm);
if (vm.Deleted) await LoadAsync(); if (vm.Deleted) await LoadAsync();
else await RefreshRowAsync(item.Id); else await RefreshRowAsync(item.Id);
@@ -409,6 +398,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
row.Name = entity.Name; row.Name = entity.Name;
row.WorkingDir = entity.WorkingDir; row.WorkingDir = entity.WorkingDir;
row.DefaultCommitType = entity.DefaultCommitType; row.DefaultCommitType = entity.DefaultCommitType;
row.IsManual = entity.IsManual;
} }
catch { /* best-effort refresh */ } catch { /* best-effort refresh */ }
} }
@@ -35,10 +35,20 @@ public sealed partial class TaskRowViewModel : ViewModelBase
[ObservableProperty] private bool _parentInView = true; [ObservableProperty] private bool _parentInView = true;
[ObservableProperty] private int _roadblockCount; [ObservableProperty] private int _roadblockCount;
[ObservableProperty] private bool _isRefining; [ObservableProperty] private bool _isRefining;
// Manual = a reminder only the user can do. Every "hand this to Claude" affordance is hidden
// and automation skips it; opening a hand-driven ConPTY session stays allowed.
[ObservableProperty] private bool _isManual;
// Set by the shell from Mission Control's open ConPTY panes: this task has a live hand-driven
// session, which outranks the persisted status on the lifecycle chip (an interactive task is
// typically Idle+Active-worktree, i.e. would otherwise read "Parked").
[ObservableProperty] private bool _hasInteractiveSession;
// Set by the custom drag while this row is being dragged — drives the "grabbed" row style. // Set by the custom drag while this row is being dragged — drives the "grabbed" row style.
[ObservableProperty] private bool _isDragging; [ObservableProperty] private bool _isDragging;
public bool CanRefine => Status == TaskStatus.Idle && PlanningPhase == PlanningPhase.None && !IsRefining; public bool CanRefine => Status == TaskStatus.Idle && PlanningPhase == PlanningPhase.None
&& !IsRefining && !IsManual;
public string? ManualBadge => IsManual ? Loc.T("tasks.badgeManual") : null;
public DateTime CreatedAt { get; init; } public DateTime CreatedAt { get; init; }
public string CreatedAtFormatted => CreatedAt == default ? "—" : Loc.T("vm.taskRow.createdPrefix", CreatedAt.ToString("MMM d")); public string CreatedAtFormatted => CreatedAt == default ? "—" : Loc.T("vm.taskRow.createdPrefix", CreatedAt.ToString("MMM d"));
@@ -60,7 +70,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase
public bool CanOpenPlanningSession => Status == TaskStatus.Idle public bool CanOpenPlanningSession => Status == TaskStatus.Idle
&& PlanningPhase == PlanningPhase.None && PlanningPhase == PlanningPhase.None
&& !IsChild; && !IsChild
&& !IsManual;
public bool CanResumeOrDiscardPlanning => PlanningPhase == PlanningPhase.Active; public bool CanResumeOrDiscardPlanning => PlanningPhase == PlanningPhase.Active;
// Pick up in a terminal only where a session + worktree reliably still exist: a task // Pick up in a terminal only where a session + worktree reliably still exist: a task
@@ -84,8 +95,10 @@ public sealed partial class TaskRowViewModel : ViewModelBase
public bool IsOverdue => ScheduledFor is { } d && d.Date < DateTime.Today && !Done; public bool IsOverdue => ScheduledFor is { } d && d.Date < DateTime.Today && !Done;
public bool IsRunning => Status == TaskStatus.Running; public bool IsRunning => Status == TaskStatus.Running;
public bool IsWaitingForReview => Status == TaskStatus.WaitingForReview; public bool IsWaitingForReview => Status == TaskStatus.WaitingForReview;
// Parked = set aside from review: Idle but still holding its Active worktree (vs a plain Idle task). // Parked = set aside from review: Idle but still holding its Active worktree (vs a plain Idle
public bool IsParked => Status == TaskStatus.Idle && WorktreeState == ClaudeDo.Data.Models.WorktreeState.Active; // 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
&& !HasInteractiveSession;
public bool IsQueued => Status == TaskStatus.Queued && string.IsNullOrEmpty(BlockedByTaskId); public bool IsQueued => Status == TaskStatus.Queued && string.IsNullOrEmpty(BlockedByTaskId);
public bool IsWaiting => Status == TaskStatus.Queued && !string.IsNullOrEmpty(BlockedByTaskId); public bool IsWaiting => Status == TaskStatus.Queued && !string.IsNullOrEmpty(BlockedByTaskId);
public bool CanRemoveFromQueue => IsQueued || HasQueuedSubtasks; public bool CanRemoveFromQueue => IsQueued || HasQueuedSubtasks;
@@ -94,7 +107,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase
// it must be finalized first. // it must be finalized first.
public bool CanSendToQueue => !IsRunning && !IsQueued && !IsWaitingForReview && !HasQueuedSubtasks public bool CanSendToQueue => !IsRunning && !IsQueued && !IsWaitingForReview && !HasQueuedSubtasks
&& (!IsChild || ParentFinalized) && (!IsChild || ParentFinalized)
&& PlanningPhase != PlanningPhase.Active; && PlanningPhase != PlanningPhase.Active
&& !IsManual;
// Parent-level "send plan to queue" — only once the plan is finalized (children Planned). // Parent-level "send plan to queue" — only once the plan is finalized (children Planned).
// Drives the routing inside SendToQueue, not a separate menu entry. // Drives the routing inside SendToQueue, not a separate menu entry.
public bool CanQueuePlan => !IsChild && HasPlanningChildren public bool CanQueuePlan => !IsChild && HasPlanningChildren
@@ -115,7 +129,14 @@ public sealed partial class TaskRowViewModel : ViewModelBase
public string DiffDeletionsText => $"{DiffDeletions}"; public string DiffDeletionsText => $"{DiffDeletions}";
public string StepsText => Loc.T("vm.taskRow.stepsText", StepsCompleted, StepsCount); public string StepsText => Loc.T("vm.taskRow.stepsText", StepsCompleted, StepsCount);
public string StatusLabel => IsParked ? Loc.T("vm.taskStatus.parked") : Status switch // Null on a plain status chip so no tooltip shows there — the chip is only actionable
// while a session is open.
public string? InteractiveChipTooltip
=> HasInteractiveSession ? Loc.T("tasks.interactiveChipTip") : null;
public string StatusLabel
=> HasInteractiveSession ? Loc.T("vm.taskStatus.interactive")
: IsParked ? Loc.T("vm.taskStatus.parked") : Status switch
{ {
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"),
@@ -130,10 +151,14 @@ public sealed partial class TaskRowViewModel : ViewModelBase
// A planning parent that is still Idle (Active planning, or Finalized-but-not-yet-queued) // A planning parent that is still Idle (Active planning, or Finalized-but-not-yet-queued)
// already shows its state via the PLANNING/PLANNED badge next to the title. The lifecycle // already shows its state via the PLANNING/PLANNED badge next to the title. The lifecycle
// chip would read "Idle" (or "Parked"), which is misleading, so hide it in that case. // chip would read "Idle" (or "Parked"), which is misleading, so hide it in that case
public bool ShowStatusChip => !(PlanningPhase != PlanningPhase.None && Status == TaskStatus.Idle); // unless there's a live session, where "Interactive" is exactly what the user needs to see.
public bool ShowStatusChip => HasInteractiveSession
|| !(PlanningPhase != PlanningPhase.None && Status == TaskStatus.Idle);
public string StatusChipClass => (Status, IsBlocked: !string.IsNullOrEmpty(BlockedByTaskId)) switch public string StatusChipClass => HasInteractiveSession
? "interactive"
: (Status, IsBlocked: !string.IsNullOrEmpty(BlockedByTaskId)) switch
{ {
(TaskStatus.Running, _) => "running", (TaskStatus.Running, _) => "running",
(TaskStatus.WaitingForReview, _) => "review", (TaskStatus.WaitingForReview, _) => "review",
@@ -208,6 +233,23 @@ public sealed partial class TaskRowViewModel : ViewModelBase
partial void OnIsRefiningChanged(bool value) => OnPropertyChanged(nameof(CanRefine)); partial void OnIsRefiningChanged(bool value) => OnPropertyChanged(nameof(CanRefine));
partial void OnIsManualChanged(bool value)
{
OnPropertyChanged(nameof(ManualBadge));
OnPropertyChanged(nameof(CanRefine));
OnPropertyChanged(nameof(CanSendToQueue));
OnPropertyChanged(nameof(CanOpenPlanningSession));
}
partial void OnHasInteractiveSessionChanged(bool value)
{
OnPropertyChanged(nameof(IsParked));
OnPropertyChanged(nameof(StatusChipClass));
OnPropertyChanged(nameof(StatusLabel));
OnPropertyChanged(nameof(ShowStatusChip));
OnPropertyChanged(nameof(InteractiveChipTooltip));
}
partial void OnHasQueuedSubtasksChanged(bool value) partial void OnHasQueuedSubtasksChanged(bool value)
{ {
OnPropertyChanged(nameof(CanRemoveFromQueue)); OnPropertyChanged(nameof(CanRemoveFromQueue));
@@ -272,6 +314,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
Done = t.Status == TaskStatus.Done; Done = t.Status == TaskStatus.Done;
IsStarred = t.IsStarred; IsStarred = t.IsStarred;
IsMyDay = t.IsMyDay; IsMyDay = t.IsMyDay;
IsManual = t.IsManual;
Status = t.Status; Status = t.Status;
PlanningPhase = t.PlanningPhase; PlanningPhase = t.PlanningPhase;
Branch = t.Worktree?.BranchName; Branch = t.Worktree?.BranchName;
@@ -21,6 +21,9 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
private readonly Dictionary<string, bool> _expandedState = new(); private readonly Dictionary<string, bool> _expandedState = new();
private ListNavItemViewModel? _currentList; private ListNavItemViewModel? _currentList;
private CancellationTokenSource? _loadCts; private CancellationTokenSource? _loadCts;
// Task ids with a live ConPTY pane in Mission Control; kept here so rows loaded later still
// pick the flag up (see SyncInteractiveSessions).
private readonly HashSet<string> _interactiveSessionIds = new();
private static readonly TaskListFilterRegistry _filters = new(); private static readonly TaskListFilterRegistry _filters = new();
public event EventHandler? SelectionChanged; public event EventHandler? SelectionChanged;
@@ -69,6 +72,12 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
[ObservableProperty] private string _completedHeader = ""; [ObservableProperty] private string _completedHeader = "";
[ObservableProperty] private bool _showNotesRow; [ObservableProperty] private bool _showNotesRow;
[ObservableProperty] private bool _isMyDayList; [ObservableProperty] private bool _isMyDayList;
[ObservableProperty] private bool _isLetClaudeVisible;
public event EventHandler? LetClaudeHandleRequested;
[RelayCommand]
private void LetClaudeHandle() => LetClaudeHandleRequested?.Invoke(this, EventArgs.Empty);
internal Task? LoadTask { get; private set; } internal Task? LoadTask { get; private set; }
@@ -208,8 +217,11 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
private void OnCurrentListPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) private void OnCurrentListPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{ {
if (e.PropertyName == nameof(ListNavItemViewModel.Name) && sender is ListNavItemViewModel vm) if (sender is not ListNavItemViewModel vm) return;
if (e.PropertyName == nameof(ListNavItemViewModel.Name))
HeaderTitle = vm.Name; HeaderTitle = vm.Name;
else if (e.PropertyName == nameof(ListNavItemViewModel.WorkingDir))
IsLetClaudeVisible = vm.Kind == ListKind.User && !string.IsNullOrWhiteSpace(vm.WorkingDir);
} }
public void LoadForList(ListNavItemViewModel? list) public void LoadForList(ListNavItemViewModel? list)
@@ -234,12 +246,13 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
HasCompleted = false; HasCompleted = false;
ShowOpenLabel = false; ShowOpenLabel = false;
ShowNotesRow = false; ShowNotesRow = false;
if (list is null) { LoadTask = Task.CompletedTask; return; } if (list is null) { IsLetClaudeVisible = false; LoadTask = Task.CompletedTask; return; }
HeaderTitle = list.Name; HeaderTitle = list.Name;
HeaderEyebrow = DateTime.Now.ToString("dddd · MMM dd", CultureInfo.InvariantCulture).ToUpperInvariant(); HeaderEyebrow = DateTime.Now.ToString("dddd · MMM dd", CultureInfo.InvariantCulture).ToUpperInvariant();
ShowNotesRow = list.Id == "smart:my-day"; ShowNotesRow = list.Id == "smart:my-day";
IsMyDayList = list.Id == "smart:my-day"; IsMyDayList = list.Id == "smart:my-day";
IsLetClaudeVisible = list.Kind == ListKind.User && !string.IsNullOrWhiteSpace(list.WorkingDir);
LoadTask = LoadForListAsync(list, ct); LoadTask = LoadForListAsync(list, ct);
} }
@@ -274,6 +287,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
{ {
var row = TaskRowViewModel.FromEntity(t); var row = TaskRowViewModel.FromEntity(t);
row.ShowListChip = showListChip; row.ShowListChip = showListChip;
row.HasInteractiveSession = _interactiveSessionIds.Contains(row.Id);
Items.Add(row); Items.Add(row);
} }
@@ -423,6 +437,8 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
if (string.IsNullOrWhiteSpace(NewTaskTitle) || _currentList?.Kind != ListKind.User) return; if (string.IsNullOrWhiteSpace(NewTaskTitle) || _currentList?.Kind != ListKind.User) return;
var listId = _currentList.Id["user:".Length..]; var listId = _currentList.Id["user:".Length..];
await using var db = await _dbFactory.CreateDbContextAsync(); await using var db = await _dbFactory.CreateDbContextAsync();
// A manual list holds reminders, so tasks created in it start out manual.
var listIsManual = await db.Lists.Where(l => l.Id == listId).Select(l => l.IsManual).FirstOrDefaultAsync();
var entity = new TaskEntity var entity = new TaskEntity
{ {
Id = Guid.NewGuid().ToString("N"), Id = Guid.NewGuid().ToString("N"),
@@ -430,17 +446,30 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
Title = NewTaskTitle.Trim(), Title = NewTaskTitle.Trim(),
Status = TaskStatus.Idle, Status = TaskStatus.Idle,
CreatedAt = DateTime.UtcNow, CreatedAt = DateTime.UtcNow,
IsManual = listIsManual,
}; };
await new TaskRepository(db).AddAsync(entity); await new TaskRepository(db).AddAsync(entity);
var row = TaskRowViewModel.FromEntity(entity); var row = TaskRowViewModel.FromEntity(entity);
row.ShowListChip = _currentList?.Kind == ListKind.Virtual; row.ShowListChip = _currentList?.Kind == ListKind.Virtual;
Items.Add(row); Items.Add(row);
Regroup(); Regroup();
SelectedTask = row;
NewTaskTitle = ""; NewTaskTitle = "";
UpdateSubtitle(); UpdateSubtitle();
TasksChanged?.Invoke(this, EventArgs.Empty); TasksChanged?.Invoke(this, EventArgs.Empty);
} }
/// <summary>Replaces the set of tasks that currently have an open interactive (ConPTY) session,
/// so their lifecycle chip reads "Interactive" instead of "Parked".</summary>
public void SyncInteractiveSessions(IEnumerable<string> taskIds)
{
_interactiveSessionIds.Clear();
foreach (var id in taskIds)
_interactiveSessionIds.Add(id);
foreach (var r in Items)
r.HasInteractiveSession = _interactiveSessionIds.Contains(r.Id);
}
public bool CanReorder => _currentList?.Kind == ListKind.User; public bool CanReorder => _currentList?.Kind == ListKind.User;
public void ClearDropHints() public void ClearDropHints()
@@ -607,6 +636,23 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
TasksChanged?.Invoke(this, EventArgs.Empty); TasksChanged?.Invoke(this, EventArgs.Empty);
} }
/// <summary>Flips a task between "Claude can run this" and "manual reminder". A manual task
/// hides every hand-off affordance and is skipped by the queue picker and daily prep.</summary>
[RelayCommand]
private async Task ToggleManualAsync(TaskRowViewModel? row)
{
if (row is null) return;
row.IsManual = !row.IsManual;
await using var db = await _dbFactory.CreateDbContextAsync();
var entity = await db.Tasks.FirstOrDefaultAsync(t => t.Id == row.Id);
if (entity != null)
{
entity.IsManual = row.IsManual;
await db.SaveChangesAsync();
}
TasksChanged?.Invoke(this, EventArgs.Empty);
}
[RelayCommand] [RelayCommand]
private async Task AddToMyDayAsync(TaskRowViewModel? row) private async Task AddToMyDayAsync(TaskRowViewModel? row)
{ {
@@ -215,6 +215,8 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
MissionControl.ShowDetached = (monitor, reDock) => Dialogs?.ShowDetachedMonitor(monitor, reDock); MissionControl.ShowDetached = (monitor, reDock) => Dialogs?.ShowDetachedMonitor(monitor, reDock);
MissionControl.OpenSettingsRequested = () => Lists.OpenSettingsCommand.Execute(null); MissionControl.OpenSettingsRequested = () => Lists.OpenSettingsCommand.Execute(null);
MissionControl.ErrorReported += FlashFooterError; MissionControl.ErrorReported += FlashFooterError;
// Keep the task rows' "Interactive" chip in step with Mission Control's open ConPTY panes.
MissionControl.ConPtySessions.CollectionChanged += (_, _) => SyncInteractiveSessionChips();
_updateCheck = updateCheck; _updateCheck = updateCheck;
_installerLocator = installerLocator; _installerLocator = installerLocator;
_workerLocator = workerLocator; _workerLocator = workerLocator;
@@ -249,6 +251,11 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
if (Lists.SelectedList is { } row) if (Lists.SelectedList is { } row)
Lists.OpenListSettingsCommand.Execute(row); Lists.OpenListSettingsCommand.Execute(row);
}; };
Tasks.LetClaudeHandleRequested += (_, _) =>
{
if (Lists.SelectedList is { } row)
Lists.LetClaudeHandleListCommand.Execute(row);
};
Details.CloseDetail = () => Tasks.SelectedTask = null; Details.CloseDetail = () => Tasks.SelectedTask = null;
Details.DeleteFromList = row => Details.DeleteFromList = row =>
{ {
@@ -330,6 +337,16 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
Dialogs.ShowMissionControl(MissionControl); Dialogs.ShowMissionControl(MissionControl);
} }
private void SyncInteractiveSessionChips()
{
if (MissionControl is null) return;
Tasks.SyncInteractiveSessions(
MissionControl.ConPtySessions
.Select(s => s.TaskId)
.Where(id => !string.IsNullOrEmpty(id))
.Select(id => id!));
}
[RelayCommand] [RelayCommand]
private async Task OpenAbout() private async Task OpenAbout()
{ {
@@ -35,18 +35,44 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IMissionControl
public event Action<string>? SubmitForReviewRequested; public event Action<string>? SubmitForReviewRequested;
/// <summary>Task-based pane — dedup'd by <see cref="TaskId"/>. Pass null for an ad-hoc pane /// <summary>Task-based pane — dedup'd by <see cref="TaskId"/>. Pass null for an ad-hoc pane
/// (no task, never deduped); prefer <see cref="CreateAdHoc"/> at ad-hoc call sites.</summary> /// (no task, never deduped); prefer <see cref="CreateAdHoc"/> at ad-hoc call sites.
public ConPtyPaneViewModel(string? taskId, string displayTitle, TerminalLaunchDescriptor descriptor) /// <paramref name="descriptorFactory"/> is resolved by the pane itself (in <see cref="Start"/>)
/// so the tile — and its starting spinner — is visible while the launch spec is still being
/// fetched. The host must wire its handlers and then call <see cref="Start"/>.</summary>
public ConPtyPaneViewModel(
string? taskId,
string displayTitle,
Func<System.Threading.Tasks.Task<TerminalLaunchDescriptor>> descriptorFactory)
{ {
TaskId = taskId; TaskId = taskId;
_displayTitle = displayTitle; _displayTitle = displayTitle;
_descriptorFactory = descriptorFactory;
Terminal.PropertyChanged += OnTerminalPropertyChanged; Terminal.PropertyChanged += OnTerminalPropertyChanged;
Terminal.Start(descriptor); }
private readonly Func<System.Threading.Tasks.Task<TerminalLaunchDescriptor>> _descriptorFactory;
/// <summary>Resolves the launch spec and spawns the session. Call after wiring
/// <see cref="ErrorReported"/> so a failed launch is not swallowed.</summary>
public void Start() => _ = StartAsync();
private async System.Threading.Tasks.Task StartAsync()
{
try
{
Terminal.Start(await _descriptorFactory());
}
catch (Exception ex)
{
Terminal.ReportStartFailure(ex.Message);
}
} }
/// <summary>Ad-hoc pane — no task, no dedup.</summary> /// <summary>Ad-hoc pane — no task, no dedup.</summary>
public static ConPtyPaneViewModel CreateAdHoc(string displayTitle, TerminalLaunchDescriptor descriptor) public static ConPtyPaneViewModel CreateAdHoc(
=> new(null, displayTitle, descriptor); string displayTitle,
Func<System.Threading.Tasks.Task<TerminalLaunchDescriptor>> descriptorFactory)
=> new(null, displayTitle, descriptorFactory);
private void OnTerminalPropertyChanged(object? sender, PropertyChangedEventArgs e) private void OnTerminalPropertyChanged(object? sender, PropertyChangedEventArgs e)
{ {
@@ -241,20 +241,8 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
} }
catch { /* best-effort title lookup */ } catch { /* best-effort title lookup */ }
try AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
{ () => DescribeAsync(() => _worker.GetInteractiveLaunchSpecAsync(taskId))));
var spec = await _worker.GetInteractiveLaunchSpecAsync(taskId);
var descriptor = new TerminalLaunchDescriptor(spec.Cwd, spec.Exe, spec.Args, spec.Env);
var pane = new ConPtyPaneViewModel(taskId, title, descriptor);
pane.ErrorReported += OnConPtyPaneError;
pane.CloseRequested += CloseConPtySession;
pane.SubmitForReviewRequested += OnPaneSubmitForReview;
ConPtySessions.Add(pane);
}
catch (Exception ex)
{
ErrorReported?.Invoke(Loc.T("missionControl.conptyLaunchFailed", ex.Message));
}
} }
// Starts (or resumes) a planning session and hosts it as an embedded ConPTY Command Center // Starts (or resumes) a planning session and hosts it as an embedded ConPTY Command Center
@@ -279,84 +267,64 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
catch { /* best-effort title lookup */ } catch { /* best-effort title lookup */ }
title += Loc.T("missionControl.planningTitleSuffix"); title += Loc.T("missionControl.planningTitleSuffix");
try AddConPtyPane(new ConPtyPaneViewModel(taskId, title, () => DescribeAsync(() => resume
{ ? _worker.GetPlanningResumeLaunchSpecAsync(taskId)
var spec = resume : _worker.GetPlanningStartLaunchSpecAsync(taskId))));
? await _worker.GetPlanningResumeLaunchSpecAsync(taskId)
: await _worker.GetPlanningStartLaunchSpecAsync(taskId);
var descriptor = new TerminalLaunchDescriptor(spec.Cwd, spec.Exe, spec.Args, spec.Env);
var pane = new ConPtyPaneViewModel(taskId, title, descriptor);
pane.ErrorReported += OnConPtyPaneError;
pane.CloseRequested += CloseConPtySession;
pane.SubmitForReviewRequested += OnPaneSubmitForReview;
ConPtySessions.Add(pane);
}
catch (Exception ex)
{
ErrorReported?.Invoke(Loc.T("missionControl.conptyLaunchFailed", ex.Message));
}
} }
// Ad-hoc (task-less) ConPTY session in a user-chosen directory. Never deduped — every call // Ad-hoc (task-less) ConPTY session in a user-chosen directory. Never deduped — every call
// opens a fresh pane, unlike the task-based OpenConPtySessionAsync above. // opens a fresh pane, unlike the task-based OpenConPtySessionAsync above.
public async System.Threading.Tasks.Task OpenAdHocConPtySessionAsync(string directory) public System.Threading.Tasks.Task OpenAdHocConPtySessionAsync(string directory)
{ {
if (string.IsNullOrEmpty(directory)) return; if (string.IsNullOrEmpty(directory)) return System.Threading.Tasks.Task.CompletedTask;
var title = Path.GetFileName(directory.TrimEnd('\\', '/')); var title = Path.GetFileName(directory.TrimEnd('\\', '/'));
if (string.IsNullOrEmpty(title)) title = directory; if (string.IsNullOrEmpty(title)) title = directory;
try AddConPtyPane(ConPtyPaneViewModel.CreateAdHoc(title,
{ () => DescribeAsync(() => _worker.GetAdHocLaunchSpecAsync(directory))));
var spec = await _worker.GetAdHocLaunchSpecAsync(directory); return System.Threading.Tasks.Task.CompletedTask;
var descriptor = new TerminalLaunchDescriptor(spec.Cwd, spec.Exe, spec.Args, spec.Env);
var pane = ConPtyPaneViewModel.CreateAdHoc(title, descriptor);
pane.ErrorReported += OnConPtyPaneError;
pane.CloseRequested += CloseConPtySession;
pane.SubmitForReviewRequested += OnPaneSubmitForReview;
ConPtySessions.Add(pane);
}
catch (Exception ex)
{
ErrorReported?.Invoke(Loc.T("missionControl.conptyLaunchFailed", ex.Message));
}
} }
// Merge-helper session over a hand-picked set of tasks ("Let Claude handle it"). // List-handler session over a hand-picked set of tasks ("Let Claude handle it").
// Ad-hoc style: no owning task, never deduped — every run opens a fresh pane. // Ad-hoc style: no owning task, never deduped — every run opens a fresh pane.
public async System.Threading.Tasks.Task OpenMergeHelperConPtySessionAsync(string? listId, IReadOnlyList<string> taskIds) public async System.Threading.Tasks.Task OpenMergeHelperConPtySessionAsync(string listId, IReadOnlyList<string> taskIds)
{ {
if (taskIds is not { Count: > 0 }) return; if (taskIds is not { Count: > 0 }) return;
var title = Loc.T("missionControl.mergeHelperTitle"); var title = Loc.T("missionControl.mergeHelperTitle");
if (listId is not null)
{
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
var list = await ctx.Lists.AsNoTracking().FirstOrDefaultAsync(l => l.Id == listId);
if (list?.Name is { Length: > 0 } name) title = $"{title} — {name}";
}
catch { /* best-effort title lookup */ }
}
try try
{ {
var spec = await _worker.GetMergeHelperLaunchSpecAsync(taskIds, listId); await using var ctx = await _dbFactory.CreateDbContextAsync();
var descriptor = new TerminalLaunchDescriptor(spec.Cwd, spec.Exe, spec.Args, spec.Env); var list = await ctx.Lists.AsNoTracking().FirstOrDefaultAsync(l => l.Id == listId);
var pane = ConPtyPaneViewModel.CreateAdHoc(title, descriptor); if (list?.Name is { Length: > 0 } name) title = $"{title} — {name}";
pane.ErrorReported += OnConPtyPaneError;
pane.CloseRequested += CloseConPtySession;
pane.SubmitForReviewRequested += OnPaneSubmitForReview;
ConPtySessions.Add(pane);
}
catch (Exception ex)
{
ErrorReported?.Invoke(Loc.T("missionControl.conptyLaunchFailed", ex.Message));
} }
catch { /* best-effort title lookup */ }
AddConPtyPane(ConPtyPaneViewModel.CreateAdHoc(title,
() => DescribeAsync(() => _worker.GetMergeHelperLaunchSpecAsync(taskIds, listId))));
} }
private void OnConPtyPaneError(string message) => ErrorReported?.Invoke(message); // Wires a freshly built pane and shows it immediately — the pane resolves its own launch spec,
// so the tile is on screen (spinner running) while the worker is still preparing the worktree.
private void AddConPtyPane(ConPtyPaneViewModel pane)
{
pane.ErrorReported += OnConPtyPaneError;
pane.CloseRequested += CloseConPtySession;
pane.SubmitForReviewRequested += OnPaneSubmitForReview;
ConPtySessions.Add(pane);
pane.Start();
}
private static async System.Threading.Tasks.Task<TerminalLaunchDescriptor> DescribeAsync(
Func<System.Threading.Tasks.Task<LaunchSpec>> fetch)
{
var spec = await fetch();
return new TerminalLaunchDescriptor(spec.Cwd, spec.Exe, spec.Args, spec.Env);
}
private void OnConPtyPaneError(string message)
=> ErrorReported?.Invoke(Loc.T("missionControl.conptyLaunchFailed", message));
// Submit a task's hand-driven ConPTY work for review, then close the pane (the interactive // Submit a task's hand-driven ConPTY work for review, then close the pane (the interactive
// session is finished). The worker commits the worktree and moves the task to WaitingForReview. // session is finished). The worker commits the worktree and moves the task to WaitingForReview.
@@ -28,6 +28,8 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
[ObservableProperty] private string _name = ""; [ObservableProperty] private string _name = "";
[ObservableProperty] private string _workingDir = ""; [ObservableProperty] private string _workingDir = "";
[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).
[ObservableProperty] private bool _isManual;
public ObservableCollection<string> CommitTypeOptions { get; } = new(CommitTypeRegistry.Types); public ObservableCollection<string> CommitTypeOptions { get; } = new(CommitTypeRegistry.Types);
@@ -49,10 +51,12 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
string name, string name,
string? workingDir, string? workingDir,
string defaultCommitType, string defaultCommitType,
bool isManual = false,
CancellationToken ct = default) CancellationToken ct = default)
{ {
ListId = listId; ListId = listId;
Name = name; Name = name;
IsManual = isManual;
WorkingDir = workingDir ?? ""; WorkingDir = workingDir ?? "";
DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? CommitTypeRegistry.DefaultType : defaultCommitType; DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? CommitTypeRegistry.DefaultType : defaultCommitType;
@@ -66,7 +70,8 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
ListId, ListId,
string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name, string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name,
string.IsNullOrWhiteSpace(WorkingDir) ? null : WorkingDir, string.IsNullOrWhiteSpace(WorkingDir) ? null : WorkingDir,
DefaultCommitType)); DefaultCommitType,
IsManual));
await Agent.SaveAsync(); await Agent.SaveAsync();
@@ -14,7 +14,6 @@ public sealed partial class MergeHelperTaskRowViewModel : ViewModelBase
public required string Id { get; init; } public required string Id { get; init; }
public required string Title { get; init; } public required string Title { get; init; }
public required string StatusText { get; init; } public required string StatusText { get; init; }
public required string ListName { get; init; }
[ObservableProperty] private bool _isSelected; [ObservableProperty] private bool _isSelected;
} }
@@ -27,12 +26,11 @@ public sealed partial class MergeHelperTaskRowViewModel : ViewModelBase
public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
{ {
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory; private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private string? _listId; private string _listId = "";
public ObservableCollection<MergeHelperTaskRowViewModel> Tasks { get; } = new(); public ObservableCollection<MergeHelperTaskRowViewModel> Tasks { get; } = new();
[ObservableProperty] private string _scopeLabel = ""; [ObservableProperty] private string _scopeLabel = "";
[ObservableProperty] private bool _isGlobal;
public bool HasTasks => Tasks.Count > 0; public bool HasTasks => Tasks.Count > 0;
public bool CanConfirm => Tasks.Any(t => t.IsSelected); public bool CanConfirm => Tasks.Any(t => t.IsSelected);
@@ -43,13 +41,10 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
public MergeHelperSelectionModalViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory) public MergeHelperSelectionModalViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory)
=> _dbFactory = dbFactory; => _dbFactory = dbFactory;
public void Configure(string? listId, string? listName) public void Configure(string listId, string listName)
{ {
_listId = listId; _listId = listId;
IsGlobal = listId is null; ScopeLabel = Loc.T("modals.mergeHelper.scopeList", listName);
ScopeLabel = listId is null
? Loc.T("modals.mergeHelper.scopeAll")
: Loc.T("modals.mergeHelper.scopeList", listName ?? "");
} }
public async Task LoadAsync(CancellationToken ct = default) public async Task LoadAsync(CancellationToken ct = default)
@@ -58,14 +53,13 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
Tasks.Clear(); Tasks.Clear();
await using var ctx = await _dbFactory.CreateDbContextAsync(ct); await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var query = ctx.Tasks.AsNoTracking() var candidates = await ctx.Tasks.AsNoTracking()
.Where(t => t.Status != TaskStatus.Done && t.Status != TaskStatus.Cancelled); .Where(t => t.Status != TaskStatus.Done && t.Status != TaskStatus.Cancelled)
if (_listId is not null) .Where(t => t.ListId == _listId)
query = query.Where(t => t.ListId == _listId); // Manual tasks are reminders the user owns — never offer them to the handler.
.Where(t => !t.IsManual)
var candidates = await query
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt) .OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
.Select(t => new { t.Id, t.Title, t.Status, ListName = t.List.Name }) .Select(t => new { t.Id, t.Title, t.Status })
.ToListAsync(ct); .ToListAsync(ct);
foreach (var c in candidates) foreach (var c in candidates)
@@ -75,7 +69,6 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
Id = c.Id, Id = c.Id,
Title = c.Title, Title = c.Title,
StatusText = c.Status.ToString(), StatusText = c.Status.ToString(),
ListName = c.ListName,
IsSelected = IsTickedByDefault(c.Status), IsSelected = IsTickedByDefault(c.Status),
}; };
row.PropertyChanged += OnRowChanged; row.PropertyChanged += OnRowChanged;
@@ -4,6 +4,7 @@ using ClaudeDo.Localization;
using ClaudeDo.Ui.Services; using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Agent; using ClaudeDo.Ui.ViewModels.Agent;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClaudeDo.Ui.ViewModels.Modals.Settings; namespace ClaudeDo.Ui.ViewModels.Modals.Settings;
@@ -27,6 +28,44 @@ public sealed partial class GeneralSettingsTabViewModel : ViewModelBase
public ObservableCollection<SelectableSkillViewModel> SessionSkills { get; } = new(); public ObservableCollection<SelectableSkillViewModel> SessionSkills { get; } = new();
public ObservableCollection<AccentPresetSwatchViewModel> AccentPresetSwatches { get; } = new();
private Action<string>? _persistAccent;
public void InitAccentPresets(string saved, Action<string> persist)
{
_persistAccent = persist;
var current = AccentPresets.Find(saved);
AccentPresetSwatches.Clear();
foreach (var p in AccentPresets.All)
AccentPresetSwatches.Add(new AccentPresetSwatchViewModel(p, p.Name == current.Name));
}
[RelayCommand]
private void SelectAccentPreset(AccentPreset preset)
{
foreach (var s in AccentPresetSwatches)
s.IsSelected = s.Preset.Name == preset.Name;
AccentPresetService.Apply(preset);
_persistAccent?.Invoke(preset.Name);
}
/// <summary>One editable row per model alias: the effort and turn budget a run gets under that
/// model. Supplies the global defaults; list- and task-level max-turns overrides still win.</summary>
public ObservableCollection<ModelPresetRowViewModel> ModelPresets { get; } = new();
public void LoadModelPresets(IReadOnlyCollection<ModelPresetDto>? presets)
{
ModelPresets.Clear();
var source = presets is { Count: > 0 }
? presets.Select(p => new ModelPreset(p.Model, p.Effort, p.MaxTurns)).ToList()
: Data.Models.ModelPresets.Defaults.ToList();
foreach (var p in Data.Models.ModelPresets.Parse(Data.Models.ModelPresets.Serialize(source)))
ModelPresets.Add(new ModelPresetRowViewModel(p));
}
public List<ModelPresetDto> ModelPresetDtos()
=> ModelPresets.Select(r => new ModelPresetDto(r.Model, r.Effort, (int)r.MaxTurns)).ToList();
public GeneralSettingsTabViewModel() { } public GeneralSettingsTabViewModel() { }
public GeneralSettingsTabViewModel(ILocalizer localizer, Action<string> persist) public GeneralSettingsTabViewModel(ILocalizer localizer, Action<string> persist)
@@ -50,10 +89,11 @@ public sealed partial class GeneralSettingsTabViewModel : ViewModelBase
public string? Validate() public string? Validate()
{ {
if (DefaultMaxTurns < 1 || DefaultMaxTurns > 200)
return "Max turns must be between 1 and 200.";
if (MaxParallelExecutions < 1 || MaxParallelExecutions > 20) if (MaxParallelExecutions < 1 || MaxParallelExecutions > 20)
return "Max parallel executions must be between 1 and 20."; return "Max parallel executions must be between 1 and 20.";
foreach (var row in ModelPresets)
if (row.MaxTurns is < 1 or > 200)
return $"Max turns for {row.Model} must be between 1 and 200.";
return null; return null;
} }
@@ -73,3 +113,21 @@ public sealed partial class GeneralSettingsTabViewModel : ViewModelBase
return names.Count == 0 ? null : names; return names.Count == 0 ? null : names;
} }
} }
/// <summary>One row of the per-model preset table. Single consumer, so it lives here.</summary>
public sealed partial class ModelPresetRowViewModel : ViewModelBase
{
public string Model { get; }
public IReadOnlyList<string> EffortLevels { get; } = EffortRegistry.Levels;
[ObservableProperty] private string _effort;
// decimal so it binds straight to a NumericUpDown, like the other numeric settings.
[ObservableProperty] private decimal _maxTurns;
public ModelPresetRowViewModel(ModelPreset preset)
{
Model = preset.Model;
_effort = preset.Effort;
_maxTurns = preset.MaxTurns;
}
}
@@ -36,6 +36,11 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
appSettings.Language = code; appSettings.Language = code;
appSettings.Save(); appSettings.Save();
}); });
General.InitAccentPresets(appSettings.AccentPreset, preset =>
{
appSettings.AccentPreset = preset;
appSettings.Save();
});
Worktrees = new WorktreesSettingsTabViewModel(worker); Worktrees = new WorktreesSettingsTabViewModel(worker);
Files = new FilesSettingsTabViewModel(worker); Files = new FilesSettingsTabViewModel(worker);
Prime = prime; Prime = prime;
@@ -73,6 +78,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
await OnlineInbox.LoadAsync(); await OnlineInbox.LoadAsync();
await SessionSkills.LoadAsync(); await SessionSkills.LoadAsync();
await General.LoadSessionSkillsAsync(_worker, dto?.SessionSkills); await General.LoadSessionSkillsAsync(_worker, dto?.SessionSkills);
General.LoadModelPresets(dto?.ModelPresets);
} }
finally { IsBusy = false; } finally { IsBusy = false; }
} }
@@ -102,7 +108,8 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
.Split('\n').Select(l => l.Trim().TrimEnd('\r')).Where(l => l.Length > 0).ToList()), .Split('\n').Select(l => l.Trim().TrimEnd('\r')).Where(l => l.Length > 0).ToList()),
General.StandupWeekday, General.StandupWeekday,
Prime.DailyPrepMaxTasks, Prime.DailyPrepMaxTasks,
General.SelectedSessionSkillNames()); General.SelectedSessionSkillNames(),
General.ModelPresetDtos());
await _worker.UpdateAppSettingsAsync(dto); await _worker.UpdateAppSettingsAsync(dto);
await Prime.SaveAsync(); await Prime.SaveAsync();
await OnlineInbox.SaveAsync(); await OnlineInbox.SaveAsync();
@@ -181,8 +181,8 @@
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
<!-- New list + import + merge-helper row --> <!-- New list + import row -->
<Grid ColumnDefinitions="*,Auto,Auto" Margin="0,4,0,0"> <Grid ColumnDefinitions="*,Auto" Margin="0,4,0,0">
<Button Grid.Column="0" Classes="new-list-btn" <Button Grid.Column="0" Classes="new-list-btn"
Command="{Binding CreateListCommand}"> Command="{Binding CreateListCommand}">
<StackPanel Orientation="Horizontal" Spacing="6"> <StackPanel Orientation="Horizontal" Spacing="6">
@@ -203,13 +203,6 @@
Width="14" Height="14" Width="14" Height="14"
Foreground="{DynamicResource TextMuteBrush}"/> Foreground="{DynamicResource TextMuteBrush}"/>
</Button> </Button>
<Button Grid.Column="2" Classes="icon-btn" Margin="6,0,0,0"
Command="{Binding LetClaudeHandleAllCommand}"
ToolTip.Tip="{loc:Tr lists.letClaudeAllTip}">
<PathIcon Data="{StaticResource Icon.Broom}"
Width="14" Height="14"
Foreground="{DynamicResource TextMuteBrush}"/>
</Button>
</Grid> </Grid>
</StackPanel> </StackPanel>
+33 -12
View File
@@ -47,6 +47,13 @@
<MenuItem Header="{loc:Tr tasks.ctxMarkAs}"> <MenuItem Header="{loc:Tr tasks.ctxMarkAs}">
<MenuItem Header="{loc:Tr tasks.ctxMarkDone}" Tag="Done" Click="OnSetStatusClick"/> <MenuItem Header="{loc:Tr tasks.ctxMarkDone}" Tag="Done" Click="OnSetStatusClick"/>
<MenuItem Header="{loc:Tr tasks.ctxMarkCancelled}" Tag="Cancelled" Click="OnSetStatusClick"/> <MenuItem Header="{loc:Tr tasks.ctxMarkCancelled}" Tag="Cancelled" Click="OnSetStatusClick"/>
<Separator/>
<MenuItem Header="{loc:Tr tasks.ctxMarkManual}"
IsVisible="{Binding !IsManual}"
Click="OnToggleManualClick"/>
<MenuItem Header="{loc:Tr tasks.ctxMarkClaudeTask}"
IsVisible="{Binding IsManual}"
Click="OnToggleManualClick"/>
</MenuItem> </MenuItem>
<Separator/> <Separator/>
<MenuItem Header="{loc:Tr tasks.ctxOpenConPtySession}" <MenuItem Header="{loc:Tr tasks.ctxOpenConPtySession}"
@@ -134,6 +141,10 @@
IsVisible="{Binding IsPlanningParent}"> IsVisible="{Binding IsPlanningParent}">
<TextBlock Text="{Binding PlanningBadge}"/> <TextBlock Text="{Binding PlanningBadge}"/>
</Border> </Border>
<Border Classes="badge manual" IsVisible="{Binding IsManual}"
ToolTip.Tip="{loc:Tr tasks.manualTip}">
<TextBlock Text="{Binding ManualBadge}"/>
</Border>
</StackPanel> </StackPanel>
</Grid> </Grid>
@@ -154,9 +165,13 @@
IsVisible="{Binding IsAgentSuggested}" IsVisible="{Binding IsAgentSuggested}"
ToolTip.Tip="{loc:Tr tasks.agentSuggestedTip}"/> ToolTip.Tip="{loc:Tr tasks.agentSuggestedTip}"/>
<!-- Status chip --> <!-- Status chip. When a ConPTY session is open it reads "Interactive" and is tappable —
it jumps to that pane in Mission Control. -->
<Border Classes="chip" <Border Classes="chip"
IsVisible="{Binding ShowStatusChip}" IsVisible="{Binding ShowStatusChip}"
Tapped="OnStatusChipTapped"
ToolTip.Tip="{Binding InteractiveChipTooltip}"
Classes.interactive="{Binding HasInteractiveSession}"
Classes.parked="{Binding IsParked}" Classes.parked="{Binding IsParked}"
Classes.running="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Running}" Classes.running="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Running}"
Classes.review="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=WaitingForReview}" Classes.review="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=WaitingForReview}"
@@ -207,17 +222,23 @@
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
<!-- Refine button --> <!-- Refine button, replaced by a spinner while the refine run is in flight -->
<Button Grid.Column="5" Classes="icon-btn refine-btn" <Panel Grid.Column="5">
IsVisible="{Binding CanRefine}" <Button Classes="icon-btn refine-btn"
VerticalAlignment="Top" Margin="0,2,0,0" IsVisible="{Binding CanRefine}"
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).RefineTaskCommand}" VerticalAlignment="Top" Margin="0,2,0,0"
CommandParameter="{Binding}" Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).RefineTaskCommand}"
ToolTip.Tip="{loc:Tr tasks.refineTip}"> CommandParameter="{Binding}"
<Viewbox Width="16" Height="16"> ToolTip.Tip="{loc:Tr tasks.refineTip}">
<Path Classes="plan-icon" Data="{StaticResource Icon.Refine}"/> <Viewbox Width="16" Height="16">
</Viewbox> <Path Classes="plan-icon" Data="{StaticResource Icon.Refine}"/>
</Button> </Viewbox>
</Button>
<Ellipse Classes="spinner"
IsVisible="{Binding IsRefining}"
VerticalAlignment="Top" Margin="0,4,2,0"
ToolTip.Tip="{loc:Tr tasks.refiningTip}"/>
</Panel>
<!-- Star toggle --> <!-- Star toggle -->
<Button Grid.Column="6" Classes="icon-btn star-btn" <Button Grid.Column="6" Classes="icon-btn star-btn"
@@ -43,6 +43,12 @@ public partial class TaskRowView : UserControl
await vm.ClearScheduleCommand.ExecuteAsync(row); await vm.ClearScheduleCommand.ExecuteAsync(row);
} }
private async void OnToggleManualClick(object? sender, RoutedEventArgs e)
{
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
await vm.ToggleManualCommand.ExecuteAsync(row);
}
private async void OnAddToMyDayClick(object? sender, RoutedEventArgs e) private async void OnAddToMyDayClick(object? sender, RoutedEventArgs e)
{ {
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm) if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
@@ -67,6 +73,15 @@ public partial class TaskRowView : UserControl
vm.OpenConPtySessionCommand.Execute(row); vm.OpenConPtySessionCommand.Execute(row);
} }
// The status chip is only actionable while a ConPTY session is open; the open command dedupes
// by task id, so it surfaces Mission Control and focuses the existing pane.
private void OnStatusChipTapped(object? sender, RoutedEventArgs e)
{
if (DataContext is not TaskRowViewModel { HasInteractiveSession: true } row) return;
if (FindTasksVm() is { } vm)
vm.OpenConPtySessionCommand.Execute(row);
}
private async void OnPickUpInTerminalClick(object? sender, RoutedEventArgs e) private async void OnPickUpInTerminalClick(object? sender, RoutedEventArgs e)
{ {
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm) if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
@@ -33,6 +33,10 @@
ToolTip.Tip="{loc:Tr tasks.showCompletedTip}"> ToolTip.Tip="{loc:Tr tasks.showCompletedTip}">
<PathIcon Width="15" Height="15" Data="{StaticResource Icon.Eye}"/> <PathIcon Width="15" Height="15" Data="{StaticResource Icon.Eye}"/>
</Button> </Button>
<Button Classes="icon-btn" IsVisible="{Binding IsLetClaudeVisible}"
Command="{Binding LetClaudeHandleCommand}" ToolTip.Tip="{loc:Tr tasks.letClaudeTip}">
<PathIcon Width="15" Height="15" Data="{StaticResource Icon.Broom}"/>
</Button>
<Button Classes="icon-btn" IsVisible="{Binding IsMyDayList}" <Button Classes="icon-btn" IsVisible="{Binding IsMyDayList}"
Command="{Binding ClearDayCommand}" ToolTip.Tip="{loc:Tr tasks.clearDayTip}"> Command="{Binding ClearDayCommand}" ToolTip.Tip="{loc:Tr tasks.clearDayTip}">
<PathIcon Width="15" Height="15" Data="{StaticResource Icon.Broom}"/> <PathIcon Width="15" Height="15" Data="{StaticResource Icon.Broom}"/>
+3 -2
View File
@@ -18,8 +18,9 @@
<converters:WorkerLogLevelToBrushConverter x:Key="WorkerLogLevelToBrush"/> <converters:WorkerLogLevelToBrushConverter x:Key="WorkerLogLevelToBrush"/>
</Window.Resources> </Window.Resources>
<Window.KeyBindings> <Window.KeyBindings>
<KeyBinding Gesture="OemQuestion" Command="{Binding FocusSearchCommand}"/> <!-- OemQuestion is the '#' key on a German layout: binding it here swallowed the
<KeyBinding Gesture="Shift+OemQuestion" Command="{Binding FocusSearchCommand}"/> character app-wide. Ctrl+K is what the search box advertises anyway. -->
<KeyBinding Gesture="Ctrl+K" Command="{Binding FocusSearchCommand}"/>
<KeyBinding Gesture="Ctrl+N" Command="{Binding FocusAddTaskCommand}"/> <KeyBinding Gesture="Ctrl+N" Command="{Binding FocusAddTaskCommand}"/>
</Window.KeyBindings> </Window.KeyBindings>
<Grid x:Name="RootGrid" RowDefinitions="36,*,22"> <Grid x:Name="RootGrid" RowDefinitions="36,*,22">
@@ -44,8 +44,20 @@
TextWrapping="Wrap" /> TextWrapping="Wrap" />
</Border> </Border>
<!-- Embedded ConPTY terminal --> <!-- Embedded ConPTY terminal, with a starting overlay until the session is spawned -->
<views:InteractiveTerminalView DataContext="{Binding Terminal}" /> <Panel>
<views:InteractiveTerminalView DataContext="{Binding Terminal}" />
<Border IsVisible="{Binding Terminal.IsStarting}"
Background="{DynamicResource VoidBrush}">
<StackPanel Orientation="Horizontal" Spacing="10"
HorizontalAlignment="Center" VerticalAlignment="Center">
<Ellipse Classes="spinner"/>
<TextBlock Classes="meta" Text="{loc:Tr missionControl.conptyStarting}"
Foreground="{DynamicResource TextDimBrush}"
VerticalAlignment="Center"/>
</StackPanel>
</Border>
</Panel>
</DockPanel> </DockPanel>
</Border> </Border>
@@ -79,6 +79,7 @@
<TextBlock Grid.Column="1" Classes="meta" Text="{Binding Name}" <TextBlock Grid.Column="1" Classes="meta" Text="{Binding Name}"
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"/> VerticalAlignment="Center" TextTrimming="CharacterEllipsis"/>
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4" VerticalAlignment="Center" <StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4" VerticalAlignment="Center"
Margin="0,0,8,0"
IsVisible="{Binding ShowStats}"> IsVisible="{Binding ShowStats}">
<TextBlock Foreground="{DynamicResource MossBrightBrush}" FontSize="{StaticResource FontSizeEyebrow}" <TextBlock Foreground="{DynamicResource MossBrightBrush}" FontSize="{StaticResource FontSizeEyebrow}"
Text="{Binding Additions, StringFormat='+{0}'}"/> Text="{Binding Additions, StringFormat='+{0}'}"/>
@@ -60,6 +60,13 @@
SelectedItem="{Binding DefaultCommitType, Mode=TwoWay}" SelectedItem="{Binding DefaultCommitType, Mode=TwoWay}"
HorizontalAlignment="Left" MinWidth="160" /> HorizontalAlignment="Left" MinWidth="160" />
</StackPanel> </StackPanel>
<StackPanel Spacing="4">
<CheckBox IsChecked="{Binding IsManual, Mode=TwoWay}"
Content="{loc:Tr modals.listSettings.manualList}"/>
<TextBlock Text="{loc:Tr modals.listSettings.manualListHint}"
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
</StackPanel>
</StackPanel> </StackPanel>
</Border> </Border>
</StackPanel> </StackPanel>
@@ -38,12 +38,10 @@
</Grid> </Grid>
<!-- Column headers --> <!-- Column headers -->
<Grid DockPanel.Dock="Top" ColumnDefinitions="32,*,120,120" Margin="20,0,20,4" <Grid DockPanel.Dock="Top" ColumnDefinitions="32,*,120" Margin="20,0,20,4"
IsVisible="{Binding HasTasks}"> IsVisible="{Binding HasTasks}">
<TextBlock Grid.Column="1" Classes="eyebrow" Text="{loc:Tr modals.mergeHelper.columnTask}"/> <TextBlock Grid.Column="1" Classes="eyebrow" Text="{loc:Tr modals.mergeHelper.columnTask}"/>
<TextBlock Grid.Column="2" Classes="eyebrow" Text="{loc:Tr modals.mergeHelper.columnStatus}"/> <TextBlock Grid.Column="2" Classes="eyebrow" Text="{loc:Tr modals.mergeHelper.columnStatus}"/>
<TextBlock Grid.Column="3" Classes="eyebrow" Text="{loc:Tr modals.mergeHelper.columnList}"
IsVisible="{Binding IsGlobal}"/>
</Grid> </Grid>
<ScrollViewer Padding="20,2,20,8"> <ScrollViewer Padding="20,2,20,8">
@@ -54,7 +52,7 @@
<ItemsControl ItemsSource="{Binding Tasks}"> <ItemsControl ItemsSource="{Binding Tasks}">
<ItemsControl.ItemTemplate> <ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:MergeHelperTaskRowViewModel"> <DataTemplate DataType="vm:MergeHelperTaskRowViewModel">
<Grid ColumnDefinitions="32,*,120,120" Margin="0,1"> <Grid ColumnDefinitions="32,*,120" Margin="0,1">
<CheckBox Grid.Column="0" MinWidth="0" <CheckBox Grid.Column="0" MinWidth="0"
IsChecked="{Binding IsSelected, Mode=TwoWay}" IsChecked="{Binding IsSelected, Mode=TwoWay}"
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
@@ -65,10 +63,6 @@
HorizontalAlignment="Left" VerticalAlignment="Center"> HorizontalAlignment="Left" VerticalAlignment="Center">
<TextBlock Text="{Binding StatusText}"/> <TextBlock Text="{Binding StatusText}"/>
</Border> </Border>
<TextBlock Classes="meta" Grid.Column="3" Text="{Binding ListName}"
VerticalAlignment="Center" Margin="8,0,0,0"
TextTrimming="CharacterEllipsis"
IsVisible="{Binding $parent[Window].((vm:MergeHelperSelectionModalViewModel)DataContext).IsGlobal}"/>
</Grid> </Grid>
</DataTemplate> </DataTemplate>
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
@@ -2,6 +2,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals" xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
xmlns:settings="using:ClaudeDo.Ui.ViewModels.Modals.Settings" xmlns:settings="using:ClaudeDo.Ui.ViewModels.Modals.Settings"
xmlns:ui="using:ClaudeDo.Ui"
xmlns:agent="using:ClaudeDo.Ui.ViewModels.Agent" xmlns:agent="using:ClaudeDo.Ui.ViewModels.Agent"
xmlns:services="using:ClaudeDo.Ui.Services" xmlns:services="using:ClaudeDo.Ui.Services"
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls" xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
@@ -61,13 +62,46 @@
</ComboBox.ItemTemplate> </ComboBox.ItemTemplate>
</ComboBox> </ComboBox>
</StackPanel> </StackPanel>
<StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="{loc:Tr settings.general.accentPreset}"/>
<ItemsControl ItemsSource="{Binding General.AccentPresetSwatches}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" Spacing="8"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="ui:AccentPresetSwatchViewModel">
<Button Padding="6,4"
Command="{Binding $parent[ItemsControl].((vm:SettingsModalViewModel)DataContext).General.SelectAccentPresetCommand}"
CommandParameter="{Binding Preset}">
<StackPanel Spacing="4">
<Grid Width="32" Height="32">
<Border Width="32" Height="32" CornerRadius="16"
BorderBrush="{DynamicResource AccentBrush}" BorderThickness="2"
IsVisible="{Binding IsSelected}"/>
<Ellipse Width="22" Height="22"
HorizontalAlignment="Center" VerticalAlignment="Center">
<Ellipse.Fill>
<SolidColorBrush Color="{Binding DisplayColor}"/>
</Ellipse.Fill>
</Ellipse>
</Grid>
<TextBlock Text="{Binding DisplayName}"
HorizontalAlignment="Center" FontSize="11"/>
</StackPanel>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<StackPanel Spacing="4"> <StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="{loc:Tr settings.general.defaultInstructions}"/> <TextBlock Classes="field-label" Text="{loc:Tr settings.general.defaultInstructions}"/>
<TextBox AcceptsReturn="True" TextWrapping="Wrap" Height="110" <TextBox AcceptsReturn="True" TextWrapping="Wrap" Height="110"
PlaceholderText="{loc:Tr settings.general.defaultInstructionsPlaceholder}" PlaceholderText="{loc:Tr settings.general.defaultInstructionsPlaceholder}"
Text="{Binding General.DefaultClaudeInstructions, Mode=TwoWay}"/> Text="{Binding General.DefaultClaudeInstructions, Mode=TwoWay}"/>
</StackPanel> </StackPanel>
<Grid ColumnDefinitions="*,12,*,12,*"> <Grid ColumnDefinitions="*,12,*">
<StackPanel Grid.Column="0" Spacing="4"> <StackPanel Grid.Column="0" Spacing="4">
<TextBlock Classes="field-label" Text="{loc:Tr settings.general.model}"/> <TextBlock Classes="field-label" Text="{loc:Tr settings.general.model}"/>
<ComboBox ItemsSource="{Binding General.Models}" <ComboBox ItemsSource="{Binding General.Models}"
@@ -75,17 +109,38 @@
HorizontalAlignment="Stretch"/> HorizontalAlignment="Stretch"/>
</StackPanel> </StackPanel>
<StackPanel Grid.Column="2" Spacing="4"> <StackPanel Grid.Column="2" Spacing="4">
<TextBlock Classes="field-label" Text="{loc:Tr settings.general.maxTurns}"/>
<NumericUpDown Value="{Binding General.DefaultMaxTurns, Mode=TwoWay}"
Minimum="1" Maximum="200" Increment="1" FormatString="0"/>
</StackPanel>
<StackPanel Grid.Column="4" Spacing="4">
<TextBlock Classes="field-label" Text="{loc:Tr settings.general.permission}"/> <TextBlock Classes="field-label" Text="{loc:Tr settings.general.permission}"/>
<ComboBox ItemsSource="{Binding General.PermissionModes}" <ComboBox ItemsSource="{Binding General.PermissionModes}"
SelectedItem="{Binding General.DefaultPermissionMode, Mode=TwoWay}" SelectedItem="{Binding General.DefaultPermissionMode, Mode=TwoWay}"
HorizontalAlignment="Stretch"/> HorizontalAlignment="Stretch"/>
</StackPanel> </StackPanel>
</Grid> </Grid>
<!-- Per-model run defaults: replaces the single global "max turns" field. -->
<StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="{loc:Tr settings.general.modelPresets}"/>
<TextBlock Text="{loc:Tr settings.general.modelPresetsHint}"
Opacity="0.6" FontSize="12" TextWrapping="Wrap"/>
<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="2" Classes="eyebrow" Text="{loc:Tr settings.general.effort}"/>
<TextBlock Grid.Column="4" Classes="eyebrow" Text="{loc:Tr settings.general.maxTurns}"/>
</Grid>
<ItemsControl ItemsSource="{Binding General.ModelPresets}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="settings:ModelPresetRowViewModel">
<Grid ColumnDefinitions="90,12,*,12,110" Margin="0,0,0,6">
<TextBlock Grid.Column="0" Text="{Binding Model}" VerticalAlignment="Center"/>
<ComboBox Grid.Column="2" ItemsSource="{Binding EffortLevels}"
SelectedItem="{Binding Effort, Mode=TwoWay}"
HorizontalAlignment="Stretch"/>
<NumericUpDown Grid.Column="4" Value="{Binding MaxTurns, Mode=TwoWay}"
Minimum="1" Maximum="200" Increment="1" FormatString="0"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<StackPanel Spacing="4"> <StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="{loc:Tr settings.general.maxParallelExecutions}"/> <TextBlock Classes="field-label" Text="{loc:Tr settings.general.maxParallelExecutions}"/>
<NumericUpDown Value="{Binding General.MaxParallelExecutions, Mode=TwoWay}" <NumericUpDown Value="{Binding General.MaxParallelExecutions, Mode=TwoWay}"
+7 -6
View File
@@ -29,11 +29,11 @@ Interfaces (e.g. `IQueueWaker`, `IPrimeClock`, `ITaskStateService`) live in an `
- **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** — only component that writes `Status`, `PlanningPhase`, `BlockedByTaskId`. All transitions return a `TransitionResult` (no exceptions on invalid moves). Wakes the queue and broadcasts `TaskUpdated` automatically; advances the planning chain on child terminal transitions. - **TaskStateService** — only component that writes `Status`, `PlanningPhase`, `BlockedByTaskId`. All transitions return a `TransitionResult` (no exceptions on invalid moves). Wakes the queue and broadcasts `TaskUpdated` automatically; advances the planning chain on child terminal transitions.
- **IQueueWaker / IQueuePicker / QueueService** — waker is a singleton `SemaphoreSlim`; picker performs the atomic `Queued → Running` claim filtered by `BlockedByTaskId IS NULL` and schedule; QueueService is a thin `BackgroundService` that loops on the waker and dispatches via `TaskRunner`. - **IQueueWaker / IQueuePicker / QueueService** — waker is a singleton `SemaphoreSlim`; picker performs the atomic `Queued → Running` claim filtered by `BlockedByTaskId IS NULL`, `is_manual = 0` and schedule; QueueService is a thin `BackgroundService` that loops on the waker and dispatches via `TaskRunner`.
- **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).
- **StaleTaskRecovery** — startup-only service; calls `TaskStateService.RecoverStaleRunningAsync` to flip orphaned `Running` rows to `Failed`. - **StaleTaskRecovery** — startup-only service; calls `TaskStateService.RecoverStaleRunningAsync` to flip orphaned `Running` rows to `Failed`.
- **External/*** — always-on MCP tools for general Claude sessions, scoped to *starting* and *observing* sessions (no worktree/merge, multi-turn, planning, or app-settings writes). Auth via optional `X-ClaudeDo-Key` header. Registered explicitly in `Program.cs`'s external app via `.WithTools<T>()`. Every optional/filter parameter across these tools must carry a C# default value (e.g. `string? status = null`) — the MCP schema only marks a parameter optional when it has one; nullability alone doesn't do it (`ExternalMcpToolSchemaTests` guards this by reflection). `ExternalMcpExceptionFilter.Wrap` is registered as a call-tool filter so `InvalidOperationException`/`ArgumentException` messages survive as `McpException` — otherwise the SDK's own catch-all replaces any non-`McpException` with a generic "An error occurred invoking 'X'." Organized by concern: - **External/*** — always-on MCP tools for general Claude sessions, scoped to *starting* and *observing* sessions (no worktree/merge, multi-turn, planning, or app-settings writes). Auth via optional `X-ClaudeDo-Key` header. Registered explicitly in `Program.cs`'s external app via `.WithTools<T>()`. Every optional/filter parameter across these tools must carry a C# default value (e.g. `string? status = null`) — the MCP schema only marks a parameter optional when it has one; nullability alone doesn't do it (`ExternalMcpToolSchemaTests` guards this by reflection). `ExternalMcpExceptionFilter.Wrap` is registered as a call-tool filter so `InvalidOperationException`/`ArgumentException` messages survive as `McpException` — otherwise the SDK's own catch-all replaces any non-`McpException` with a generic "An error occurred invoking 'X'." Organized by concern:
- `ExternalMcpService` — task CRUD + execution: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `AddSubtask`, `UpdateTask`, `UpdateTaskStatus` (`Idle` / `Queued`), `GetTaskStatusValues`, `ReviewTask` (`approve` / `reject_rerun` / `reject_park` / `cancel` for a WaitingForReview task; approve is review+merge exactly like the hub's `ApproveReview` — unit merge for parents, worktree merge into optional `targetBranch` for childless tasks, conflicts reported in the `ReviewTaskResult`), `RunTaskNow`, `ContinueTask`, `CancelTask`, `DeleteTask`; worktree/git: `GetTaskWorktree`, `GetTaskDiff`, `MergeTask`, `ListWorktrees`, `CleanupTaskWorktree` - `ExternalMcpService` — task CRUD + execution: `ListTaskLists`, `ListTasks`, `GetTask`, `AddTask`, `AddSubtask`, `UpdateTask`, `UpdateTaskStatus` (`Idle` / `Queued` / `Cancelled` / `Done``Cancelled` goes through `TaskStateService.CancelAsync(..., allowFromIdle: true)`, the only caller that opts into cancelling from `Idle`; `PlanningChainCoordinator` relies on `Idle` staying a no-op there by default, since a child parked back to `Idle` mid-chain is a manual opt-out signal; `Done` goes through `TaskStateService.ForceSetStatusAsync` — same unconditional write the UI's "set status freely" affordance uses — but is refused here with an active-worktree task, since that would skip `review_task`'s merge), `GetTaskStatusValues`, `ReviewTask` (`approve` / `reject_rerun` / `reject_park` / `cancel` for a WaitingForReview task; approve is review+merge exactly like the hub's `ApproveReview` — unit merge for parents, worktree merge into optional `targetBranch` for childless tasks, conflicts reported in the `ReviewTaskResult`), `RunTaskNow`, `ContinueTask`, `CancelTask`, `DeleteTask`; worktree/git: `GetTaskWorktree`, `GetTaskDiff`, `MergeTask`, `ListWorktrees`, `CleanupTaskWorktree`
- `BatchMcpTools` — best-effort batch variants that loop the `ExternalMcpService` single-entity methods (sequential — the scoped DbContext is not thread-safe; merge/review stay single-task): `BatchGetTasks`, `BatchAddTasks`, `BatchUpdateTaskStatus`, `BatchCancelTasks`, `BatchDeleteTasks`, `BatchSetMyDay`, `BatchCleanupTaskWorktrees`. Every tool returns a per-item result array ({ id/index, ok, error?, … }) — a failing item never aborts the rest — and rejects batches over 100 items. - `BatchMcpTools` — best-effort batch variants that loop the `ExternalMcpService` single-entity methods (sequential — the scoped DbContext is not thread-safe; merge/review stay single-task): `BatchGetTasks`, `BatchAddTasks`, `BatchUpdateTaskStatus`, `BatchCancelTasks`, `BatchDeleteTasks`, `BatchSetMyDay`, `BatchCleanupTaskWorktrees`. Every tool returns a per-item result array ({ id/index, ok, error?, … }) — a failing item never aborts the rest — and rejects batches over 100 items.
- `ListMcpTools``CreateList`, `UpdateList`, `DeleteList` - `ListMcpTools``CreateList`, `UpdateList`, `DeleteList`
- `ConfigMcpTools``GetListConfig`, `SetListConfig`, `GetTaskConfig`, `SetTaskConfig` - `ConfigMcpTools``GetListConfig`, `SetListConfig`, `GetTaskConfig`, `SetTaskConfig`
@@ -67,12 +67,13 @@ Interfaces (e.g. `IQueueWaker`, `IPrimeClock`, `ITaskStateService`) live in an `
| `Status` | `Idle`, `Queued`, `Running`, `WaitingForChildren`, `WaitingForReview`, `Done`, `Failed`, `Cancelled` | Lifecycle only. `WaitingForChildren` = parent's own work is done, waiting on its children. | | `Status` | `Idle`, `Queued`, `Running`, `WaitingForChildren`, `WaitingForReview`, `Done`, `Failed`, `Cancelled` | Lifecycle only. `WaitingForChildren` = parent's own work is done, waiting on its children. |
| `PlanningPhase` | `None`, `Active`, `Finalized` | Parent-only marker. `Active` ≈ legacy `Planning`; `Finalized` ≈ legacy `Planned`. | | `PlanningPhase` | `None`, `Active`, `Finalized` | Parent-only marker. `Active` ≈ legacy `Planning`; `Finalized` ≈ legacy `Planned`. |
| `BlockedByTaskId` | nullable FK | Replaces legacy `Waiting`. A queued row with `BlockedByTaskId != NULL` is skipped by the picker. | | `BlockedByTaskId` | nullable FK | Replaces legacy `Waiting`. A queued row with `BlockedByTaskId != NULL` is skipped by the picker. |
| `IsManual` | bool | Reminder only the user can do. `TaskStateService.EnqueueAsync`/`StartRunningAsync` refuse it, the queue picker skips it, and `GetDailyPrepCandidates` never offers it. An interactive ConPTY session is still allowed. |
| `ReviewFeedback` | nullable string | Reviewer's rejection comment. Set by `RejectToQueueAsync`; consumed and cleared by `QueueService` on the next re-run (resumes the Claude session with it as the next-turn prompt). | | `ReviewFeedback` | nullable string | Reviewer's rejection comment. Set by `RejectToQueueAsync`; consumed and cleared by `QueueService` on the next re-run (resumes the Claude session with it as the next-turn prompt). |
Allowed transitions (enforced by `TaskStateService`): Allowed transitions (enforced by `TaskStateService`):
``` ```
Idle → Queued | Running (RunNow) Idle → Queued | Running (RunNow) | Cancelled (external update_task_status only, allowFromIdle: true)
Queued → Running | Cancelled | Idle | Failed (OverrideSlotService preflight gap: RunAsync can fail before StartRunningAsync is called) Queued → Running | Cancelled | Idle | Failed (OverrideSlotService preflight gap: RunAsync can fail before StartRunningAsync is called)
Running → WaitingForReview (standalone success, no children) Running → WaitingForReview (standalone success, no children)
| WaitingForChildren (parent with pending children) | WaitingForChildren (parent with pending children)
@@ -121,7 +122,7 @@ A child that hits a roadblock (fails, or reports `CLAUDEDO_BLOCKED` roadblocks)
1. Load task + list metadata from DB; resolve config from `list_config` + task-level overrides (model, system_prompt, agent_path) 1. Load task + list metadata from DB; resolve config from `list_config` + task-level overrides (model, system_prompt, agent_path)
2. Create worktree (if `WorkingDir` set) or sandbox directory 2. Create worktree (if `WorkingDir` set) or sandbox directory
3. Mark task "running", broadcast `TaskStarted` 3. Mark task "running", broadcast `TaskStarted`
4. Build CLI args via `ClaudeArgsBuilder`; inject attachment absolute paths via `TaskPromptComposer.Compose` (appends a read-only "## Reference files" section); invoke `ClaudeProcess` with task prompt 4. Resolve the effective model (task → list → `AppSettings.DefaultModel`), then take its `ModelPresets` row: the preset supplies `--effort` and the **global** max-turns default (task/list `MaxTurns` overrides still win). Build CLI args via `ClaudeArgsBuilder`; inject attachment absolute paths via `TaskPromptComposer.Compose` (appends a read-only "## Reference files" section); invoke `ClaudeProcess` with task prompt
5. Stream NDJSON output through `StreamAnalyzer`; lines forwarded to log file and SignalR (`TaskMessage`) 5. Stream NDJSON output through `StreamAnalyzer`; lines forwarded to log file and SignalR (`TaskMessage`)
6. On success: auto-commit changes (worktree only), store run record, mark "done" 6. On success: auto-commit changes (worktree only), store run record, mark "done"
7. On failure: retry once if session ID available (`--resume`), then mark "failed" 7. On failure: retry once if session ID available (`--resume`), then mark "failed"
@@ -129,7 +130,7 @@ A child that hits a roadblock (fails, or reports `CLAUDEDO_BLOCKED` roadblocks)
## Key Components ## Key Components
- **ClaudeProcess** — spawns `claude -p --output-format stream-json --verbose --permission-mode auto` (or whatever permission mode the app settings specify). Writes prompt to stdin, reads NDJSON from stdout. Supports CancellationToken (kills process tree). - **ClaudeProcess** — spawns `claude -p --output-format stream-json --verbose --permission-mode auto` (or whatever permission mode the app settings specify). Writes prompt to stdin, reads NDJSON from stdout. Supports CancellationToken (kills process tree).
- **ClaudeArgsBuilder** — dynamically constructs CLI args; supports `--model`, `--append-system-prompt`, `--agents`, `--json-schema`, `--resume` - **ClaudeArgsBuilder** — dynamically constructs CLI args; supports `--model`, `--effort`, `--max-turns`, `--append-system-prompt`, `--agents`, `--json-schema`, `--resume`
- **StreamAnalyzer** — parses rich NDJSON output; extracts session_id, token counts, turn counts, result text, structured output. Replaces MessageParser. - **StreamAnalyzer** — parses rich NDJSON output; extracts session_id, token counts, turn counts, result text, structured output. Replaces MessageParser.
- **TaskResetService** — discards a failed task's worktree and resets the task row to Idle; preserves run history. - **TaskResetService** — discards a failed task's worktree and resets the task row to Idle; preserves run history.
- **WorktreeManager** — creates worktrees at `claudedo/{taskId[:8]}` branches, commits changes with semantic messages, updates DB with head commit and diff stats - **WorktreeManager** — creates worktrees at `claudedo/{taskId[:8]}` branches, commits changes with semantic messages, updates DB with head commit and diff stats
@@ -155,7 +156,7 @@ Each CLI invocation is recorded in the `task_runs` table via `TaskRunRepository`
- Review/merge: `ApproveReview(taskId, targetBranch) -> MergeResultDto` (childless task: merges its worktree then Done, conflict stays WaitingForReview; task with children: drives `PlanningMergeOrchestrator` to merge the whole unit), `ContinuePlanningMerge` / `AbortPlanningMerge` (resolve a unit-merge conflict), `PreviewMerge(taskId, targetBranch) -> MergePreviewDto` (non-destructive mergeability check), `RejectReviewToQueue`, `RejectReviewToIdle`, `CancelReview`, `MergeTask`, `GetMergeTargets` - Review/merge: `ApproveReview(taskId, targetBranch) -> MergeResultDto` (childless task: merges its worktree then Done, conflict stays WaitingForReview; task with children: drives `PlanningMergeOrchestrator` to merge the whole unit), `ContinuePlanningMerge` / `AbortPlanningMerge` (resolve a unit-merge conflict), `PreviewMerge(taskId, targetBranch) -> MergePreviewDto` (non-destructive mergeability check), `RejectReviewToQueue`, `RejectReviewToIdle`, `CancelReview`, `MergeTask`, `GetMergeTargets`
- Single-task conflict resolver (Layer C): `StartConflictMerge`, `GetMergeConflictDocuments` (segments), `WriteConflictResolution`, `ContinueConflictMerge`, `AbortConflictMerge` (service-level `TaskMergeService.ContinueMergeAsync`/`AbortMergeAsync` keep their names) - Single-task conflict resolver (Layer C): `StartConflictMerge`, `GetMergeConflictDocuments` (segments), `WriteConflictResolution`, `ContinueConflictMerge`, `AbortConflictMerge` (service-level `TaskMergeService.ContinueMergeAsync`/`AbortMergeAsync` keep their names)
- Planning sessions: `StartPlanningSession`, `ResumePlanningSession`, `DiscardPlanningSession`, `FinalizePlanningSession`, `QueuePlanningSubtasks`, `GetPendingDraftCount`, `GetPlanningAggregate` (per-subtask diffs), `BuildPlanningIntegrationBranch` (combined diff) - Planning sessions: `StartPlanningSession`, `ResumePlanningSession`, `DiscardPlanningSession`, `FinalizePlanningSession`, `QueuePlanningSubtasks`, `GetPendingDraftCount`, `GetPlanningAggregate` (per-subtask diffs), `BuildPlanningIntegrationBranch` (combined diff)
- Interactive sessions (embedded ConPTY, UI process): `ResumeTaskInTerminal` (pick-up-in-terminal), `GetInteractiveLaunchSpec`, `GetAdHocLaunchSpec` - Interactive sessions (embedded ConPTY, UI process): `ResumeTaskInTerminal` (pick-up-in-terminal), `GetInteractiveLaunchSpec`, `GetAdHocLaunchSpec`. **Every** ConPTY spec that `InteractiveLaunchSpecService` builds leads with `--effort <level>` from the relevant model's preset (task/list model for a task session, `PlanningAlias` for planning, list config for the list handler, global default for ad-hoc). `--model` is deliberately NOT forced on an interactive session — the user can still switch models in the TUI.
- Worktrees: `CleanupFinishedWorktrees`, `ResetAllWorktrees`, `GetWorktreesOverview`, `SetWorktreeState`, `ForceRemoveWorktree` - Worktrees: `CleanupFinishedWorktrees`, `ResetAllWorktrees`, `GetWorktreesOverview`, `SetWorktreeState`, `ForceRemoveWorktree`
- Agents/settings/lists: `GetAgents`, `RefreshAgents`, `RestoreDefaultAgents`, `GetAppSettings`, `UpdateAppSettings`, `UpdateList`, `UpdateListConfig`, `GetListConfig`, `UpdateTaskAgentSettings` - Agents/settings/lists: `GetAgents`, `RefreshAgents`, `RestoreDefaultAgents`, `GetAppSettings`, `UpdateAppSettings`, `UpdateList`, `UpdateListConfig`, `GetListConfig`, `UpdateTaskAgentSettings`
- Reports/notes/prep: `GetWeekReport`, `GenerateWeekReport`, `GetDailyNotes`, `AddDailyNote`, `UpdateDailyNote`, `DeleteDailyNote`, `RunDailyPrepNow`, `ClearMyDay`, `GetLastPrepLog`, `ListPrimeSchedules`, `UpsertPrimeSchedule`, `DeletePrimeSchedule` - Reports/notes/prep: `GetWeekReport`, `GenerateWeekReport`, `GetDailyNotes`, `AddDailyNote`, `UpdateDailyNote`, `DeleteDailyNote`, `RunDailyPrepNow`, `ClearMyDay`, `GetLastPrepLog`, `ListPrimeSchedules`, `UpsertPrimeSchedule`, `DeletePrimeSchedule`
+2 -2
View File
@@ -93,8 +93,8 @@ public sealed class BatchMcpTools
} }
[McpServerTool, Description( [McpServerTool, Description(
"Set the status of many tasks at once. status is 'Idle' (reset to editable) or " + "Set the status of many tasks at once. status is 'Idle', 'Queued', 'Cancelled' or 'Done' only — " +
"'Queued' (enqueue for execution) only — same rule as update_task_status. " + "same rule as update_task_status ('Done' is refused per-item for a task with an active worktree). " +
"Returns one result per id: { taskId, ok, error }. Max 100 ids.")] "Returns one result per id: { taskId, ok, error }. Max 100 ids.")]
public async Task<IReadOnlyList<BatchTaskResult>> BatchUpdateTaskStatus( public async Task<IReadOnlyList<BatchTaskResult>> BatchUpdateTaskStatus(
string[] taskIds, string status, CancellationToken cancellationToken) string[] taskIds, string status, CancellationToken cancellationToken)
+31 -4
View File
@@ -185,6 +185,8 @@ public sealed class ExternalMcpService
CommitType = list.DefaultCommitType, CommitType = list.DefaultCommitType,
CreatedBy = createdBy.NullIfBlank() ?? "mcp", CreatedBy = createdBy.NullIfBlank() ?? "mcp",
Model = ModelRegistry.NormalizeAlias(model), Model = ModelRegistry.NormalizeAlias(model),
// A manual list holds reminders, so anything filed there starts out manual.
IsManual = list.IsManual,
}; };
await _tasks.AddAsync(entity, cancellationToken); await _tasks.AddAsync(entity, cancellationToken);
@@ -262,9 +264,12 @@ public sealed class ExternalMcpService
} }
[McpServerTool, Description( [McpServerTool, Description(
"Update a task's status. Only 'Idle' and 'Queued' are permitted externally — " + "Update a task's status. Only 'Idle', 'Queued', 'Cancelled' and 'Done' are permitted externally — " +
"use run_task_now or cancel_task for execution control, and review_task to act on a WaitingForReview task. " + "use run_task_now for execution control, and review_task to act on a WaitingForReview task. " +
"Settable: Idle (reset to editable), Queued (enqueue for execution). " + "Settable: Idle (reset to editable), Queued (enqueue for execution), " +
"Cancelled (retire the task without deleting it; it can be reset to Idle later), " +
"Done (mark complete; refused if the task has an active worktree — use review_task to approve " +
"and merge that worktree instead). " +
"Full lifecycle: Idle → Queued → Running → WaitingForReview → Done | Failed | Cancelled.")] "Full lifecycle: Idle → Queued → Running → WaitingForReview → Done | Failed | Cancelled.")]
public async Task<TaskDto> UpdateTaskStatus( public async Task<TaskDto> UpdateTaskStatus(
string taskId, string taskId,
@@ -291,9 +296,29 @@ public sealed class ExternalMcpService
throw new InvalidOperationException(enqueueResult.Reason ?? "Cannot enqueue task."); throw new InvalidOperationException(enqueueResult.Reason ?? "Cannot enqueue task.");
break; break;
case TaskStatus.Cancelled:
var cancelResult = await _state.CancelAsync(taskId, DateTime.UtcNow, cancellationToken, allowFromIdle: true);
if (!cancelResult.Ok)
throw new InvalidOperationException(cancelResult.Reason ?? "Cannot cancel task.");
break;
case TaskStatus.Done:
await using (var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken))
{
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(taskId, cancellationToken);
if (wt is not null && wt.State == WorktreeState.Active)
throw new InvalidOperationException(
"Task has an active worktree — use review_task to approve and merge instead.");
}
var doneResult = await _state.ForceSetStatusAsync(taskId, TaskStatus.Done, cancellationToken);
if (!doneResult.Ok)
throw new InvalidOperationException(doneResult.Reason ?? "Cannot set task to Done.");
break;
default: default:
throw new InvalidOperationException( throw new InvalidOperationException(
$"Status '{target}' is not settable externally. Use run_task_now or cancel_task."); $"Status '{target}' is not settable externally. Use run_task_now or review_task.");
} }
var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!; var reload = (await _tasks.GetByIdAsync(taskId, cancellationToken))!;
@@ -781,6 +806,8 @@ public sealed class ExternalMcpService
var candidates = idle var candidates = idle
.Where(t => !t.IsMyDay .Where(t => !t.IsMyDay
&& t.BlockedByTaskId == null && t.BlockedByTaskId == null
// A manual task is a reminder only the user can do — never a Claude candidate.
&& !t.IsManual
&& DailyPrepFilter.IsIncludedRepo(t.List?.WorkingDir, excludes)) && DailyPrepFilter.IsIncludedRepo(t.List?.WorkingDir, excludes))
.OrderBy(t => t.CreatedAt) .OrderBy(t => t.CreatedAt)
.Select(ToCandidate) .Select(ToCandidate)
+17 -5
View File
@@ -40,7 +40,11 @@ public record AppSettingsDto(
string? ReportExcludedPaths, string? ReportExcludedPaths,
int StandupWeekday, int StandupWeekday,
int DailyPrepMaxTasks, int DailyPrepMaxTasks,
List<string>? SessionSkills = null); List<string>? SessionSkills = null,
List<ModelPresetDto>? ModelPresets = null);
// Per-model run defaults (effort + turn budget) edited in Settings -> General.
public record ModelPresetDto(string Model, string Effort, int MaxTurns);
public record SessionSkillDto( public record SessionSkillDto(
string Name, string Name,
@@ -74,7 +78,7 @@ public record MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalB
public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDocumentDto> Files); public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDocumentDto> Files);
public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList<MergeSegmentDto> Segments); public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList<MergeSegmentDto> Segments);
public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs); public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs);
public record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType); public record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType, bool IsManual = false);
public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null); public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null); public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null); public record ListConfigDto(string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null, List<string>? SessionSkills = null);
@@ -318,7 +322,9 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
row.ReportExcludedPaths, row.ReportExcludedPaths,
row.StandupWeekday, row.StandupWeekday,
row.DailyPrepMaxTasks, row.DailyPrepMaxTasks,
SkillsFromJson(row.SessionSkills)); SkillsFromJson(row.SessionSkills),
Data.Models.ModelPresets.Parse(row.ModelPresets)
.Select(p => new ModelPresetDto(p.Model, p.Effort, p.MaxTurns)).ToList());
} }
public async Task UpdateAppSettings(AppSettingsDto dto) public async Task UpdateAppSettings(AppSettingsDto dto)
@@ -341,6 +347,11 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
StandupWeekday = dto.StandupWeekday is >= 0 and <= 6 ? dto.StandupWeekday : (int)DayOfWeek.Wednesday, StandupWeekday = dto.StandupWeekday is >= 0 and <= 6 ? dto.StandupWeekday : (int)DayOfWeek.Wednesday,
DailyPrepMaxTasks = dto.DailyPrepMaxTasks, DailyPrepMaxTasks = dto.DailyPrepMaxTasks,
SessionSkills = SkillsToJson(dto.SessionSkills), SessionSkills = SkillsToJson(dto.SessionSkills),
// Normalized on the way in (unknown models dropped, effort validated, turns clamped).
ModelPresets = dto.ModelPresets is { Count: > 0 }
? Data.Models.ModelPresets.Serialize(
dto.ModelPresets.Select(p => new ModelPreset(p.Model, p.Effort, p.MaxTurns)))
: Data.Models.ModelPresets.SerializeDefaults(),
}); });
} }
@@ -492,6 +503,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
entity.Name = dto.Name; entity.Name = dto.Name;
entity.WorkingDir = string.IsNullOrWhiteSpace(dto.WorkingDir) ? null : dto.WorkingDir; entity.WorkingDir = string.IsNullOrWhiteSpace(dto.WorkingDir) ? null : dto.WorkingDir;
entity.DefaultCommitType = string.IsNullOrWhiteSpace(dto.DefaultCommitType) ? CommitTypeRegistry.DefaultType : dto.DefaultCommitType; entity.DefaultCommitType = string.IsNullOrWhiteSpace(dto.DefaultCommitType) ? CommitTypeRegistry.DefaultType : dto.DefaultCommitType;
entity.IsManual = dto.IsManual;
await repo.UpdateAsync(entity); await repo.UpdateAsync(entity);
await _broadcaster.ListUpdated(dto.Id); await _broadcaster.ListUpdated(dto.Id);
@@ -678,8 +690,8 @@ 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; null means all lists. // and cwd to that list.
public Task<LaunchSpec> GetMergeHelperLaunchSpec(string[] taskIds, string? listId) => HubGuard(() => public Task<LaunchSpec> GetMergeHelperLaunchSpec(string[] taskIds, string listId) => 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.");
+2 -1
View File
@@ -15,7 +15,7 @@ public sealed class QueuePicker : IQueuePicker
{ {
// Atomic queue claim: UPDATE + RETURNING in a single statement prevents TOCTOU races. // Atomic queue claim: UPDATE + RETURNING in a single statement prevents TOCTOU races.
// Raw SQL because EF cannot express UPDATE...RETURNING. // Raw SQL because EF cannot express UPDATE...RETURNING.
// Eligible task must be Queued, unblocked, and due (or unscheduled). // Eligible task must be Queued, unblocked, not manual, and due (or unscheduled).
// EF SQLite stores DateTime as "yyyy-MM-dd HH:mm:ss.fffffff" — same format used here for comparison. // EF SQLite stores DateTime as "yyyy-MM-dd HH:mm:ss.fffffff" — same format used here for comparison.
await using var ctx = await _dbFactory.CreateDbContextAsync(ct); await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var nowStr = now.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss.fffffff"); var nowStr = now.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss.fffffff");
@@ -27,6 +27,7 @@ public sealed class QueuePicker : IQueuePicker
SELECT t.id FROM tasks t SELECT t.id FROM tasks t
WHERE t.status = 'queued' WHERE t.status = 'queued'
AND t.blocked_by_task_id IS NULL AND t.blocked_by_task_id IS NULL
AND t.is_manual = 0
AND (t.scheduled_for IS NULL OR t.scheduled_for <= {0}) AND (t.scheduled_for IS NULL OR t.scheduled_for <= {0})
ORDER BY t.sort_order ASC, t.created_at ASC ORDER BY t.sort_order ASC, t.created_at ASC
LIMIT 1 LIMIT 1
@@ -11,7 +11,8 @@ public sealed record ClaudeRunConfig(
string? PermissionMode = null, string? PermissionMode = null,
string? McpConfigPath = null, string? McpConfigPath = null,
string? AllowedTools = null, string? AllowedTools = null,
IReadOnlyList<string>? SkillNames = null IReadOnlyList<string>? SkillNames = null,
string? Effort = null
) )
{ {
public IReadOnlyList<string> SkillNames { get; init; } = SkillNames ?? Array.Empty<string>(); public IReadOnlyList<string> SkillNames { get; init; } = SkillNames ?? Array.Empty<string>();
@@ -53,6 +54,12 @@ public sealed class ClaudeArgsBuilder
args.Add(config.Model); args.Add(config.Model);
} }
if (!string.IsNullOrWhiteSpace(config.Effort))
{
args.Add("--effort");
args.Add(config.Effort);
}
if (config.MaxTurns is int turns && turns > 0) if (config.MaxTurns is int turns && turns > 0)
{ {
args.Add("--max-turns"); args.Add("--max-turns");
@@ -96,6 +96,11 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
? WindowsTerminalLauncher.BuildResumeArgs(sessionId) ? WindowsTerminalLauncher.BuildResumeArgs(sessionId)
: BuildFreshPromptArgs(task); : BuildFreshPromptArgs(task);
// Start the hand-driven session at the effort configured for the model this task would run
// under, instead of inheriting whatever the user's global Claude Code config happens to be.
// The model itself is deliberately NOT forced here — the user can still switch it in the TUI.
args = WithEffort(args, EffortFor(globalSettings, task.Model ?? listConfig?.Model));
// Same run environment variable ClaudeProcess sets for every headless run: the // Same run environment variable ClaudeProcess sets for every headless run: the
// AskUser MCP tool call caps at 60s unless raised, and lifting it is harmless for // AskUser MCP tool call caps at 60s unless raised, and lifting it is harmless for
// every other tool. // every other tool.
@@ -123,7 +128,10 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
}; };
return new LaunchSpec( return new LaunchSpec(
ctx.WorkingDir, resolvedClaude, WindowsTerminalLauncher.BuildPlanningStartArgs(ctx), env); ctx.WorkingDir, resolvedClaude,
WithEffort(WindowsTerminalLauncher.BuildPlanningStartArgs(ctx),
EffortFor(ReadSettings(), ModelRegistry.PlanningAlias)),
env);
} }
public LaunchSpec BuildPlanningResume(PlanningSessionResumeContext ctx) public LaunchSpec BuildPlanningResume(PlanningSessionResumeContext ctx)
@@ -138,10 +146,13 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
}; };
return new LaunchSpec( return new LaunchSpec(
ctx.WorkingDir, resolvedClaude, WindowsTerminalLauncher.BuildPlanningResumeArgs(ctx.ClaudeSessionId), env); ctx.WorkingDir, resolvedClaude,
WithEffort(WindowsTerminalLauncher.BuildPlanningResumeArgs(ctx.ClaudeSessionId),
EffortFor(ReadSettings(), ModelRegistry.PlanningAlias)),
env);
} }
public Task<LaunchSpec> BuildForDirectoryAsync(string directory, CancellationToken ct) public async Task<LaunchSpec> BuildForDirectoryAsync(string directory, CancellationToken ct)
{ {
if (!Directory.Exists(directory)) if (!Directory.Exists(directory))
throw new InvalidOperationException($"Directory does not exist: {directory}"); throw new InvalidOperationException($"Directory does not exist: {directory}");
@@ -149,12 +160,18 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath) var resolvedClaude = WindowsTerminalLauncher.Resolve(_claudePath)
?? throw new InvalidOperationException($"claude executable not found: {_claudePath}"); ?? throw new InvalidOperationException($"claude executable not found: {_claudePath}");
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var settings = await new AppSettingsRepository(ctx).GetAsync(ct);
var env = new Dictionary<string, string> var env = new Dictionary<string, string>
{ {
["MCP_TOOL_TIMEOUT"] = "200000", ["MCP_TOOL_TIMEOUT"] = "200000",
}; };
return Task.FromResult(new LaunchSpec(directory, resolvedClaude, Array.Empty<string>(), env)); // No task and no list here — the global default model's preset decides the effort.
return new LaunchSpec(
directory, resolvedClaude,
WithEffort(Array.Empty<string>(), EffortFor(settings, settings.DefaultModel)), env);
} }
// Tools the merge helper may use without prompting: the claudedo MCP surface (run, poll, // Tools the merge helper may use without prompting: the claudedo MCP surface (run, poll,
@@ -163,51 +180,28 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
private const string MergeHelperAllowedTools = private const string MergeHelperAllowedTools =
"mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill"; "mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill";
public async Task<LaunchSpec> BuildForMergeHelperAsync(IReadOnlyList<string> taskIds, string? listId, CancellationToken ct) public async Task<LaunchSpec> BuildForMergeHelperAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct)
{ {
if (taskIds.Count == 0) if (taskIds.Count == 0)
throw new InvalidOperationException("No tasks selected for the merge helper."); throw new InvalidOperationException("No tasks selected for the list handler.");
await using var ctx = await _dbFactory.CreateDbContextAsync(ct); await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var taskRepo = new TaskRepository(ctx); var taskRepo = new TaskRepository(ctx);
var listRepo = new ListRepository(ctx); var listRepo = new ListRepository(ctx);
var listsById = new Dictionary<string, ListEntity?>(); var list = await listRepo.GetByIdAsync(listId, ct)
var briefLines = new List<string>(); ?? throw new KeyNotFoundException($"List not found: {listId}");
var repoDirs = new List<string>(); // distinct, existing, in first-seen order
var repoDir = list.WorkingDir;
if (string.IsNullOrEmpty(repoDir) || !Directory.Exists(repoDir))
throw new InvalidOperationException($"list '{list.Name}' has no existing working directory");
var briefLines = new List<string>();
foreach (var id in taskIds) foreach (var id in taskIds)
{ {
var task = await taskRepo.GetByIdAsync(id, ct) var task = await taskRepo.GetByIdAsync(id, ct)
?? throw new KeyNotFoundException($"Task not found: {id}"); ?? throw new KeyNotFoundException($"Task not found: {id}");
if (!listsById.TryGetValue(task.ListId, out var list)) briefLines.Add($"- [{task.Status}] {task.Title} (id: {task.Id})");
listsById[task.ListId] = list = await listRepo.GetByIdAsync(task.ListId, ct);
var workingDir = list?.WorkingDir;
if (!string.IsNullOrEmpty(workingDir) && Directory.Exists(workingDir) && !repoDirs.Contains(workingDir))
repoDirs.Add(workingDir);
briefLines.Add(
$"- [{task.Status}] {task.Title} (id: {task.Id}, list: {list?.Name ?? ""}, " +
$"repo: {(string.IsNullOrEmpty(workingDir) ? "" : workingDir)})");
}
if (repoDirs.Count == 0)
throw new InvalidOperationException("none of the selected tasks are in a working directory");
string scopeLabel;
string cwd;
if (listId is not null)
{
var scopeList = await listRepo.GetByIdAsync(listId, ct)
?? throw new KeyNotFoundException($"List not found: {listId}");
scopeLabel = $"List: {scopeList.Name}";
cwd = scopeList.WorkingDir is { Length: > 0 } wd && Directory.Exists(wd) ? wd : repoDirs[0];
}
else
{
scopeLabel = "All lists";
cwd = repoDirs[0];
} }
var sessionDir = Path.Combine(Paths.AppDataRoot(), "merge-helper-sessions", Guid.NewGuid().ToString()); var sessionDir = Path.Combine(Paths.AppDataRoot(), "merge-helper-sessions", Guid.NewGuid().ToString());
@@ -220,7 +214,8 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
await File.WriteAllTextAsync(briefPath, PromptFiles.Render(PromptKind.MergeHelperInitial, await File.WriteAllTextAsync(briefPath, PromptFiles.Render(PromptKind.MergeHelperInitial,
new Dictionary<string, string> new Dictionary<string, string>
{ {
["scope"] = scopeLabel, ["scope"] = $"List: {list.Name}",
["repo"] = repoDir,
["tasks"] = string.Join("\n", briefLines), ["tasks"] = string.Join("\n", briefLines),
}), ct); }), ct);
@@ -231,25 +226,46 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
// (--allowedTools, --add-dir) first, then a single-value flag, then the single-line // (--allowedTools, --add-dir) first, then a single-value flag, then the single-line
// positional kickoff LAST — a multi-line positional prompt truncates at the first // positional kickoff LAST — a multi-line positional prompt truncates at the first
// newline, so the full multi-line brief travels via the file exposed through --add-dir. // newline, so the full multi-line brief travels via the file exposed through --add-dir.
var listConfig = await listRepo.GetConfigAsync(listId, ct);
var settings = await new AppSettingsRepository(ctx).GetAsync(ct);
var args = new List<string> var args = new List<string>
{ {
"--effort", EffortFor(settings, listConfig?.Model),
"--permission-mode", "default", "--permission-mode", "default",
"--allowedTools", MergeHelperAllowedTools, "--allowedTools", MergeHelperAllowedTools,
"--add-dir", sessionDir, "--add-dir", sessionDir, repoDir,
"--append-system-prompt-file", systemPromptPath,
$"Read the file {briefPath} first. It lists the tasks you must handle and their status. " +
"After reading it, begin the session as your instructions describe.",
}; };
args.AddRange(repoDirs);
args.Add("--append-system-prompt-file");
args.Add(systemPromptPath);
args.Add(
$"Read the file {briefPath} first. It lists the tasks you must merge and their status. " +
"After reading it, begin the merge-helper session as your instructions describe.");
var env = new Dictionary<string, string> var env = new Dictionary<string, string>
{ {
["MCP_TOOL_TIMEOUT"] = "200000", ["MCP_TOOL_TIMEOUT"] = "200000",
}; };
return new LaunchSpec(cwd, resolvedClaude, args, env); return new LaunchSpec(repoDir, resolvedClaude, args, env);
}
// The reasoning effort configured for a model in Settings → General. Falls back to the shipped
// preset for that model, so a missing/malformed settings row can never block a launch.
private static string EffortFor(AppSettingsEntity settings, string? model)
=> ModelPresets.For(settings.ModelPresets, model ?? settings.DefaultModel).Effort;
// Prepends `--effort <level>`. It has to lead: a positional kickoff prompt must stay last, and
// it may only follow a single-value flag — a variadic flag would swallow it.
private static IReadOnlyList<string> WithEffort(IReadOnlyList<string> args, string effort)
{
var result = new List<string>(args.Count + 2) { "--effort", effort };
result.AddRange(args);
return result;
}
private AppSettingsEntity ReadSettings()
{
using var ctx = _dbFactory.CreateDbContext();
return new AppSettingsRepository(ctx).GetAsync().GetAwaiter().GetResult();
} }
// The positional prompt claude opens the interactive session on. Empty (no positional arg) // The positional prompt claude opens the interactive session on. Empty (no positional arg)
@@ -30,8 +30,8 @@ public interface IInteractiveLaunchSpecService
/// <summary>Builds a LaunchSpec for an embedded ConPTY "merge helper" session that drives the /// <summary>Builds a LaunchSpec for an embedded ConPTY "merge helper" session that drives the
/// given tasks to a merged/Done state via the mcp__claudedo__* tools. Writes a per-session /// given tasks to a merged/Done state via the mcp__claudedo__* tools. Writes a per-session
/// system prompt + task brief under ~/.todo-app/merge-helper-sessions/&lt;guid&gt; and exposes /// system prompt + task brief under ~/.todo-app/merge-helper-sessions/&lt;guid&gt; and exposes
/// that dir plus every distinct existing repo dir via --add-dir. listId scopes the brief label /// that dir plus the list's repo dir via --add-dir. cwd is the list's working directory.
/// and the cwd to that list; null means all lists (cwd = first existing repo dir). Throws /// Throws KeyNotFoundException if the list doesn't exist; InvalidOperationException if
/// InvalidOperationException if taskIds is empty or no task has an existing working directory.</summary> /// taskIds is empty or the list has no existing working directory.</summary>
Task<LaunchSpec> BuildForMergeHelperAsync(IReadOnlyList<string> taskIds, string? listId, CancellationToken ct); Task<LaunchSpec> BuildForMergeHelperAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct);
} }
+9 -3
View File
@@ -516,14 +516,20 @@ public sealed class TaskRunner
var requestedSkills = UnionSkillNames(task.SessionSkills, listConfig?.SessionSkills, global.SessionSkills); var requestedSkills = UnionSkillNames(task.SessionSkills, listConfig?.SessionSkills, global.SessionSkills);
var skillNames = await FilterToInstalledSkillsAsync(task.Id, requestedSkills, ct); var skillNames = await FilterToInstalledSkillsAsync(task.Id, requestedSkills, ct);
// The model decides the global effort/turn defaults: one preset row per model alias
// (Settings → General). List- and task-level max-turns overrides still win.
var model = task.Model ?? listConfig?.Model ?? global.DefaultModel;
var preset = Data.Models.ModelPresets.For(global.ModelPresets, model);
return new ClaudeRunConfig( return new ClaudeRunConfig(
Model: task.Model ?? listConfig?.Model ?? global.DefaultModel, Model: model,
SystemPrompt: string.IsNullOrWhiteSpace(instructions) ? null : instructions, SystemPrompt: string.IsNullOrWhiteSpace(instructions) ? null : instructions,
AgentPath: task.AgentPath ?? listConfig?.AgentPath, AgentPath: task.AgentPath ?? listConfig?.AgentPath,
ResumeSessionId: resumeSessionId, ResumeSessionId: resumeSessionId,
MaxTurns: ResolveMaxTurns(task.MaxTurns, listConfig?.MaxTurns, global.DefaultMaxTurns), MaxTurns: ResolveMaxTurns(task.MaxTurns, listConfig?.MaxTurns, preset.MaxTurns),
PermissionMode: global.DefaultPermissionMode, PermissionMode: global.DefaultPermissionMode,
SkillNames: skillNames); SkillNames: skillNames,
Effort: preset.Effort);
} }
private async Task<IReadOnlyList<string>> FilterToInstalledSkillsAsync( private async Task<IReadOnlyList<string>> FilterToInstalledSkillsAsync(
@@ -9,7 +9,7 @@ public interface ITaskStateService
Task<TransitionResult> SubmitInteractiveForReviewAsync(string taskId, DateTime finishedAt, CancellationToken ct); Task<TransitionResult> SubmitInteractiveForReviewAsync(string taskId, DateTime finishedAt, CancellationToken ct);
Task<TransitionResult> SubmitForChildrenAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct); Task<TransitionResult> SubmitForChildrenAsync(string taskId, DateTime finishedAt, string? result, CancellationToken ct);
Task<TransitionResult> FailAsync(string taskId, DateTime finishedAt, string? error, CancellationToken ct); Task<TransitionResult> FailAsync(string taskId, DateTime finishedAt, string? error, CancellationToken ct);
Task<TransitionResult> CancelAsync(string taskId, DateTime finishedAt, CancellationToken ct); Task<TransitionResult> CancelAsync(string taskId, DateTime finishedAt, CancellationToken ct, bool allowFromIdle = false);
Task<TransitionResult> ResetToIdleAsync(string taskId, CancellationToken ct); Task<TransitionResult> ResetToIdleAsync(string taskId, CancellationToken ct);
Task<TransitionResult> ApproveReviewAsync(string taskId, CancellationToken ct); Task<TransitionResult> ApproveReviewAsync(string taskId, CancellationToken ct);
+29 -5
View File
@@ -41,6 +41,9 @@ public sealed class TaskStateService : ITaskStateService
if (await IsDraftChildAsync(ctx, taskId, ct)) if (await IsDraftChildAsync(ctx, taskId, ct))
return new TransitionResult(false, "Draft subtask: finalize the plan before queuing it."); return new TransitionResult(false, "Draft subtask: finalize the plan before queuing it.");
if (await IsManualAsync(ctx, taskId, ct))
return new TransitionResult(false, "Manual task: mark it as a Claude task before queuing it.");
var affected = await ctx.Tasks var affected = await ctx.Tasks
.Where(t => t.Id == taskId && t.Status != TaskStatus.Running) .Where(t => t.Id == taskId && t.Status != TaskStatus.Running)
.ExecuteUpdateAsync(s => s.SetProperty(t => t.Status, TaskStatus.Queued), ct); .ExecuteUpdateAsync(s => s.SetProperty(t => t.Status, TaskStatus.Queued), ct);
@@ -60,6 +63,9 @@ public sealed class TaskStateService : ITaskStateService
if (await IsDraftChildAsync(ctx, taskId, ct)) if (await IsDraftChildAsync(ctx, taskId, ct))
return new TransitionResult(false, "Draft subtask: finalize the plan before running it."); return new TransitionResult(false, "Draft subtask: finalize the plan before running it.");
if (await IsManualAsync(ctx, taskId, ct))
return new TransitionResult(false, "Manual task: mark it as a Claude task before running it.");
var affected = await ctx.Tasks var affected = await ctx.Tasks
.Where(t => t.Id == taskId && t.Status != TaskStatus.Running) .Where(t => t.Id == taskId && t.Status != TaskStatus.Running)
.ExecuteUpdateAsync(s => s .ExecuteUpdateAsync(s => s
@@ -241,7 +247,13 @@ public sealed class TaskStateService : ITaskStateService
return new TransitionResult(true, null); return new TransitionResult(true, null);
} }
public async Task<TransitionResult> CancelAsync(string taskId, DateTime finishedAt, CancellationToken ct) // allowFromIdle: only the external update_task_status(Cancelled) path sets this — it lets
// an Idle task be retired without deleting it. Every other caller (hub CancelReview,
// PlanningChainCoordinator's chain-walk, batch cancel) relies on Idle staying a no-op here;
// PlanningChainCoordinator specifically uses "parked back to Idle" as a deliberate opt-out
// signal, so do not flip this default.
public async Task<TransitionResult> CancelAsync(
string taskId, DateTime finishedAt, CancellationToken ct, bool allowFromIdle = false)
{ {
List<string> cancelledChildIds; List<string> cancelledChildIds;
await using (var ctx = await _dbFactory.CreateDbContextAsync(ct)) await using (var ctx = await _dbFactory.CreateDbContextAsync(ct))
@@ -250,7 +262,8 @@ public sealed class TaskStateService : ITaskStateService
.Where(t => t.Id == taskId && .Where(t => t.Id == taskId &&
(t.Status == TaskStatus.Running || t.Status == TaskStatus.Queued (t.Status == TaskStatus.Running || t.Status == TaskStatus.Queued
|| t.Status == TaskStatus.WaitingForReview || t.Status == TaskStatus.WaitingForReview
|| t.Status == TaskStatus.WaitingForChildren)) || t.Status == TaskStatus.WaitingForChildren
|| (allowFromIdle && t.Status == TaskStatus.Idle)))
.ExecuteUpdateAsync(s => s .ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, TaskStatus.Cancelled) .SetProperty(t => t.Status, TaskStatus.Cancelled)
.SetProperty(t => t.FinishedAt, finishedAt), ct); .SetProperty(t => t.FinishedAt, finishedAt), ct);
@@ -316,9 +329,15 @@ public sealed class TaskStateService : ITaskStateService
public async Task<TransitionResult> ForceSetStatusAsync(string taskId, TaskStatus status, CancellationToken ct) public async Task<TransitionResult> ForceSetStatusAsync(string taskId, TaskStatus status, CancellationToken ct)
{ {
await using var ctx = await _dbFactory.CreateDbContextAsync(ct); await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var affected = await ctx.Tasks var affected = status == TaskStatus.Done
.Where(t => t.Id == taskId) ? await ctx.Tasks
.ExecuteUpdateAsync(s => s.SetProperty(t => t.Status, status), ct); .Where(t => t.Id == taskId)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, status)
.SetProperty(t => t.FinishedAt, DateTime.UtcNow), ct)
: await ctx.Tasks
.Where(t => t.Id == taskId)
.ExecuteUpdateAsync(s => s.SetProperty(t => t.Status, status), ct);
if (affected == 0) if (affected == 0)
return new TransitionResult(false, "Task not found."); return new TransitionResult(false, "Task not found.");
@@ -411,6 +430,11 @@ public sealed class TaskStateService : ITaskStateService
// A subtask is "draft" only while its planning parent has an open (Active) session. // A subtask is "draft" only while its planning parent has an open (Active) session.
// Improvement children whose parent has PlanningPhase.None are not drafts and may be // Improvement children whose parent has PlanningPhase.None are not drafts and may be
// queued freely. Standalone tasks (no parent) are never draft. // queued freely. Standalone tasks (no parent) are never draft.
// Server-side backstop for the manual flag: the UI hides the hand-off affordances, but the
// MCP surface and hub can still be driven directly.
private static Task<bool> IsManualAsync(ClaudeDoDbContext ctx, string taskId, CancellationToken ct)
=> ctx.Tasks.AsNoTracking().AnyAsync(t => t.Id == taskId && t.IsManual, ct);
private static async Task<bool> IsDraftChildAsync(ClaudeDoDbContext ctx, string taskId, CancellationToken ct) private static async Task<bool> IsDraftChildAsync(ClaudeDoDbContext ctx, string taskId, CancellationToken ct)
{ {
var parentId = await ctx.Tasks.AsNoTracking() var parentId = await ctx.Tasks.AsNoTracking()
@@ -0,0 +1,91 @@
using ClaudeDo.Data.Models;
namespace ClaudeDo.Data.Tests;
public class ModelPresetsTests
{
[Fact]
public void Defaults_cover_every_known_alias()
{
Assert.Equal(
ModelRegistry.Aliases.OrderBy(a => a),
ModelPresets.Defaults.Select(p => p.Model).OrderBy(m => m));
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("not json at all")]
public void Parse_falls_back_to_defaults_when_unusable(string? json)
{
Assert.Equal(ModelPresets.Defaults, ModelPresets.Parse(json));
}
[Fact]
public void Serialize_roundtrips()
{
var edited = new[] { new ModelPreset("opus", "max", 55) };
var parsed = ModelPresets.Parse(ModelPresets.Serialize(edited));
Assert.Equal("max", ModelPresets.For(parsed, "opus").Effort);
Assert.Equal(55, ModelPresets.For(parsed, "opus").MaxTurns);
}
[Fact]
public void Serialize_fills_missing_aliases_from_defaults()
{
var parsed = ModelPresets.Parse(ModelPresets.Serialize(new[] { new ModelPreset("opus", "max", 55) }));
Assert.Equal(ModelRegistry.Aliases.Count, parsed.Count);
Assert.Equal(ModelPresets.For(ModelPresets.Defaults, "haiku"), ModelPresets.For(parsed, "haiku"));
}
[Theory]
[InlineData("nonsense", 30)] // unknown effort → the default row's effort
[InlineData("high", 0)] // turns out of range → the default row's turns
[InlineData("high", 999)]
public void Serialize_rejects_out_of_range_values(string effort, int turns)
{
var fallback = ModelPresets.For(ModelPresets.Defaults, "sonnet");
var parsed = ModelPresets.Parse(ModelPresets.Serialize(new[] { new ModelPreset("sonnet", effort, turns) }));
var row = ModelPresets.For(parsed, "sonnet");
if (effort == "nonsense") Assert.Equal(fallback.Effort, row.Effort);
if (turns is 0 or 999) Assert.Equal(fallback.MaxTurns, row.MaxTurns);
}
[Fact]
public void For_unknown_model_yields_a_usable_row()
{
var row = ModelPresets.For(ModelPresets.Defaults, "some-future-model");
Assert.Equal("some-future-model", row.Model);
Assert.Contains(row.Effort, EffortRegistry.Levels);
Assert.True(row.MaxTurns > 0);
}
[Fact]
public void For_null_model_uses_the_default_alias()
{
Assert.Equal(
ModelPresets.For(ModelPresets.Defaults, ModelRegistry.DefaultAlias),
ModelPresets.For(ModelPresets.Defaults, null));
}
[Theory]
[InlineData("low", "low")]
[InlineData("XHIGH", "xhigh")]
[InlineData(" max ", "max")]
public void NormalizeLevel_canonicalizes(string input, string expected)
{
Assert.Equal(expected, EffortRegistry.NormalizeLevel(input));
}
[Fact]
public void NormalizeLevel_blank_means_unset_and_unknown_throws()
{
Assert.Null(EffortRegistry.NormalizeLevel(null));
Assert.Throws<ArgumentException>(() => EffortRegistry.NormalizeLevel("gigantic"));
}
}
+27 -3
View File
@@ -52,12 +52,36 @@ public class PromptFilesTests
} }
[Fact] [Fact]
public void DefaultFor_merge_helper_is_non_empty_and_mentions_the_merge_tools() public void DefaultFor_merge_helper_covers_all_five_phases()
{ {
var d = PromptFiles.DefaultFor(PromptKind.MergeHelper); var d = PromptFiles.DefaultFor(PromptKind.MergeHelper);
Assert.False(string.IsNullOrWhiteSpace(d)); Assert.False(string.IsNullOrWhiteSpace(d));
Assert.Contains("review_task", d); Assert.Contains("Phase 0", d);
Assert.Contains("continue_merge", d); Assert.Contains("Phase 1", d);
Assert.Contains("Phase 2", d);
Assert.Contains("Phase 3", d);
Assert.Contains("Phase 4", d);
Assert.Contains("Phase 5", d);
}
[Fact]
public void DefaultFor_merge_helper_names_the_tools_each_phase_needs()
{
var d = PromptFiles.DefaultFor(PromptKind.MergeHelper);
Assert.Contains("batch_get_tasks", d); // phase 0
Assert.Contains("update_task", d); // phase 1 + 2
Assert.Contains("get_app_settings", d); // phase 3
Assert.Contains("update_task_status", d); // phase 3
Assert.Contains("review_task", d); // phase 4
Assert.Contains("continue_merge", d); // phase 4
Assert.DoesNotContain("run_task_now(", d); // single override slot — must not batch-start
}
[Fact]
public void DefaultFor_merge_helper_initial_has_repo_token()
{
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperInitial);
Assert.Contains("{repo}", d);
} }
[Fact] [Fact]
+1 -1
View File
@@ -100,7 +100,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, CancellationToken ct = default) public virtual Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, 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<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default) public virtual Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, 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>()));
@@ -26,11 +26,15 @@ public class AgentConfigEditorViewModelTests
public override Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) { SavedTaskSettings = dto; return Task.CompletedTask; } public override Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) { SavedTaskSettings = dto; return Task.CompletedTask; }
} }
// The global turn default is per-model now, so the preset row for `model` is what carries
// `turns` — DefaultMaxTurns is no longer consulted.
private static AppSettingsDto AppWith(string model, int turns) => private static AppSettingsDto AppWith(string model, int turns) =>
new(DefaultClaudeInstructions: "", DefaultModel: model, DefaultMaxTurns: turns, new(DefaultClaudeInstructions: "", DefaultModel: model, DefaultMaxTurns: turns,
DefaultPermissionMode: "auto", MaxParallelExecutions: 1, WorktreeStrategy: "sibling", DefaultPermissionMode: "auto", MaxParallelExecutions: 1, WorktreeStrategy: "sibling",
CentralWorktreeRoot: null, WorktreeAutoCleanupEnabled: false, WorktreeAutoCleanupDays: 30, CentralWorktreeRoot: null, WorktreeAutoCleanupEnabled: false, WorktreeAutoCleanupDays: 30,
ReportExcludedPaths: null, StandupWeekday: 3, DailyPrepMaxTasks: 5); ReportExcludedPaths: null, StandupWeekday: 3, DailyPrepMaxTasks: 5,
SessionSkills: null,
ModelPresets: new List<ModelPresetDto> { new(model, "high", turns) });
private static TaskEntity TaskWith(string? model, int? turns, string? sp, string? agentPath) => private static TaskEntity TaskWith(string? model, int? turns, string? sp, string? agentPath) =>
new() { Id = "t1", ListId = "l1", Title = "t", CreatedAt = DateTime.UtcNow, new() { Id = "t1", ListId = "l1", Title = "t", CreatedAt = DateTime.UtcNow,
@@ -137,4 +137,28 @@ public class DetailsIslandReviewActionsTests : IDisposable
Assert.True(vm.ShowReviewDiffHint); Assert.True(vm.ShowReviewDiffHint);
Assert.False(vm.ApproveReviewCommand.CanExecute(null)); Assert.False(vm.ApproveReviewCommand.CanExecute(null));
} }
private sealed class ThrowingWorkerClient : StubWorkerClient
{
public override bool IsConnected => true;
public string ExceptionMessage { get; init; } = "blocked: target working tree has uncommitted changes";
public override Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) =>
throw new Exception(ExceptionMessage);
}
[Fact]
public async Task ApproveReview_WhenWorkerThrows_CallsShowErrorAsync()
{
var worker = new ThrowingWorkerClient();
var vm = BuildVm(worker);
vm.Bind(new TaskRowViewModel { Id = "task-err-1", Status = TaskStatus.WaitingForReview });
vm.Monitor.ApplyState(TaskStatus.WaitingForReview);
string? reportedError = null;
vm.ShowErrorAsync = msg => { reportedError = msg; return Task.CompletedTask; };
await vm.ApproveReviewCommand.ExecuteAsync(null);
Assert.Equal(worker.ExceptionMessage, reportedError);
}
} }
@@ -74,11 +74,12 @@ public class MergeHelperSelectionModalViewModelTests : IDisposable
{ {
await SeedAllStatusesAsync(); await SeedAllStatusesAsync();
var vm = BuildVm(); var vm = BuildVm();
vm.Configure(null, null); vm.Configure("L1", "Work");
await vm.LoadAsync(); await vm.LoadAsync();
Assert.DoesNotContain(vm.Tasks, t => t.Id is "t-done" or "t-cancelled"); Assert.DoesNotContain(vm.Tasks, t => t.Id is "t-done" or "t-cancelled");
Assert.Equal(7, vm.Tasks.Count); Assert.DoesNotContain(vm.Tasks, t => t.Id == "t-other-list");
Assert.Equal(6, vm.Tasks.Count);
Assert.True(vm.Tasks.Single(t => t.Id == "t-idle").IsSelected); Assert.True(vm.Tasks.Single(t => t.Id == "t-idle").IsSelected);
Assert.True(vm.Tasks.Single(t => t.Id == "t-queued").IsSelected); Assert.True(vm.Tasks.Single(t => t.Id == "t-queued").IsSelected);
@@ -98,8 +99,6 @@ public class MergeHelperSelectionModalViewModelTests : IDisposable
Assert.Single(vm.Tasks); Assert.Single(vm.Tasks);
Assert.Equal("t-other-list", vm.Tasks[0].Id); Assert.Equal("t-other-list", vm.Tasks[0].Id);
Assert.Equal("Home", vm.Tasks[0].ListName);
Assert.False(vm.IsGlobal);
Assert.Contains("Home", vm.ScopeLabel); Assert.Contains("Home", vm.ScopeLabel);
} }
@@ -108,7 +107,7 @@ public class MergeHelperSelectionModalViewModelTests : IDisposable
{ {
await SeedAllStatusesAsync(); await SeedAllStatusesAsync();
var vm = BuildVm(); var vm = BuildVm();
vm.Configure(null, null); vm.Configure("L1", "Work");
await vm.LoadAsync(); await vm.LoadAsync();
Assert.True(vm.CanConfirm); Assert.True(vm.CanConfirm);
@@ -129,7 +128,7 @@ public class MergeHelperSelectionModalViewModelTests : IDisposable
{ {
await SeedAllStatusesAsync(); await SeedAllStatusesAsync();
var vm = BuildVm(); var vm = BuildVm();
vm.Configure(null, null); vm.Configure("L1", "Work");
await vm.LoadAsync(); await vm.LoadAsync();
vm.SelectNoneCommand.Execute(null); vm.SelectNoneCommand.Execute(null);
@@ -152,7 +151,7 @@ public class MergeHelperSelectionModalViewModelTests : IDisposable
{ {
await SeedAllStatusesAsync(); await SeedAllStatusesAsync();
var vm = BuildVm(); var vm = BuildVm();
vm.Configure(null, null); vm.Configure("L1", "Work");
await vm.LoadAsync(); await vm.LoadAsync();
var closed = false; var closed = false;
@@ -298,7 +298,7 @@ public class MissionControlViewModelTests : IDisposable
} }
[Fact] [Fact]
public async Task OpenConPtySessionAsync_WorkerThrows_RaisesErrorReported_NoPaneAdded() public async Task OpenConPtySessionAsync_WorkerThrows_RaisesErrorReported_PaneShowsFailure()
{ {
var worker = new ThrowingLaunchSpecWorker(); var worker = new ThrowingLaunchSpecWorker();
using var vm = BuildVm(worker); using var vm = BuildVm(worker);
@@ -307,8 +307,10 @@ public class MissionControlViewModelTests : IDisposable
await vm.OpenConPtySessionAsync("t1"); await vm.OpenConPtySessionAsync("t1");
Assert.Empty(vm.ConPtySessions); // The tile is shown up-front (spinner) and keeps the failure inline rather than vanishing.
Assert.Empty(vm.Panes); var pane = Assert.Single(vm.ConPtySessions);
Assert.NotNull(pane.Terminal.StartError);
Assert.False(pane.Terminal.IsStarting);
Assert.NotNull(error); Assert.NotNull(error);
} }
@@ -378,7 +380,7 @@ public class MissionControlViewModelTests : IDisposable
} }
[Fact] [Fact]
public async Task OpenAdHocConPtySessionAsync_WorkerThrows_RaisesErrorReported_NoPaneAdded() public async Task OpenAdHocConPtySessionAsync_WorkerThrows_RaisesErrorReported_PaneShowsFailure()
{ {
var worker = new ThrowingAdHocLaunchSpecWorker(); var worker = new ThrowingAdHocLaunchSpecWorker();
using var vm = BuildVm(worker); using var vm = BuildVm(worker);
@@ -387,8 +389,9 @@ public class MissionControlViewModelTests : IDisposable
await vm.OpenAdHocConPtySessionAsync(Path.Combine("C:", "Some", "Path")); await vm.OpenAdHocConPtySessionAsync(Path.Combine("C:", "Some", "Path"));
Assert.Empty(vm.ConPtySessions); var pane = Assert.Single(vm.ConPtySessions);
Assert.Empty(vm.Panes); Assert.NotNull(pane.Terminal.StartError);
Assert.False(pane.Terminal.IsStarting);
Assert.NotNull(error); Assert.NotNull(error);
} }
@@ -408,7 +411,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, CancellationToken ct = default) public override Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct = default)
=> throw new InvalidOperationException("spec failed"); => throw new InvalidOperationException("spec failed");
} }
@@ -418,7 +421,7 @@ public class MissionControlViewModelTests : IDisposable
var worker = new FakeWorker(); var worker = new FakeWorker();
using var vm = BuildVm(worker); using var vm = BuildVm(worker);
await vm.OpenMergeHelperConPtySessionAsync(null, new[] { "t1", "t2" }); await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1", "t2" });
Assert.Single(vm.ConPtySessions); Assert.Single(vm.ConPtySessions);
Assert.Null(vm.ConPtySessions[0].TaskId); Assert.Null(vm.ConPtySessions[0].TaskId);
@@ -432,8 +435,8 @@ public class MissionControlViewModelTests : IDisposable
var worker = new FakeWorker(); var worker = new FakeWorker();
using var vm = BuildVm(worker); using var vm = BuildVm(worker);
await vm.OpenMergeHelperConPtySessionAsync(null, new[] { "t1" }); await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" });
await vm.OpenMergeHelperConPtySessionAsync(null, new[] { "t1" }); await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" });
Assert.Equal(2, vm.ConPtySessions.Count); Assert.Equal(2, vm.ConPtySessions.Count);
} }
@@ -444,24 +447,25 @@ public class MissionControlViewModelTests : IDisposable
var worker = new FakeWorker(); var worker = new FakeWorker();
using var vm = BuildVm(worker); using var vm = BuildVm(worker);
await vm.OpenMergeHelperConPtySessionAsync(null, Array.Empty<string>()); await vm.OpenMergeHelperConPtySessionAsync("L1", Array.Empty<string>());
Assert.Empty(vm.ConPtySessions); Assert.Empty(vm.ConPtySessions);
Assert.Empty(vm.Panes); Assert.Empty(vm.Panes);
} }
[Fact] [Fact]
public async Task OpenMergeHelperConPtySessionAsync_WorkerThrows_RaisesErrorReported_NoPaneAdded() public async Task OpenMergeHelperConPtySessionAsync_WorkerThrows_RaisesErrorReported_PaneShowsFailure()
{ {
var worker = new ThrowingMergeHelperLaunchSpecWorker(); var worker = new ThrowingMergeHelperLaunchSpecWorker();
using var vm = BuildVm(worker); using var vm = BuildVm(worker);
string? error = null; string? error = null;
vm.ErrorReported += msg => error = msg; vm.ErrorReported += msg => error = msg;
await vm.OpenMergeHelperConPtySessionAsync(null, new[] { "t1" }); await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" });
Assert.Empty(vm.ConPtySessions); var pane = Assert.Single(vm.ConPtySessions);
Assert.Empty(vm.Panes); Assert.NotNull(pane.Terminal.StartError);
Assert.False(pane.Terminal.IsStarting);
Assert.NotNull(error); Assert.NotNull(error);
} }
@@ -0,0 +1,76 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.ViewModels.Islands;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Ui.Tests.ViewModels;
public class TasksIslandAddAndSelectTests : IDisposable
{
private readonly string _dbPath;
public TasksIslandAddAndSelectTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_ui_addselect_{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 sealed class TestDbFactory : IDbContextFactory<ClaudeDoDbContext>
{
private readonly Func<ClaudeDoDbContext> _create;
public TestDbFactory(Func<ClaudeDoDbContext> create) => _create = create;
public ClaudeDoDbContext CreateDbContext() => _create();
}
private async Task SeedListAsync()
{
await using var db = NewContext();
db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow });
await db.SaveChangesAsync();
}
[Fact]
public async Task AddAsync_SelectsNewTask()
{
await SeedListAsync();
var vm = new TasksIslandViewModel(new TestDbFactory(NewContext), worker: null);
vm.LoadForList(new ListNavItemViewModel { Id = "user:L1", Name = "Work", Kind = ListKind.User });
await vm.LoadTask!;
vm.NewTaskTitle = "My new task";
await vm.AddCommand.ExecuteAsync(null);
Assert.NotNull(vm.SelectedTask);
Assert.Equal("My new task", vm.SelectedTask!.Title);
}
[Fact]
public async Task AddAsync_EmptyTitle_DoesNotChangeSelection()
{
await SeedListAsync();
var vm = new TasksIslandViewModel(new TestDbFactory(NewContext), worker: null);
vm.LoadForList(new ListNavItemViewModel { Id = "user:L1", Name = "Work", Kind = ListKind.User });
await vm.LoadTask!;
vm.NewTaskTitle = "";
await vm.AddCommand.ExecuteAsync(null);
Assert.Null(vm.SelectedTask);
}
}
@@ -0,0 +1,66 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Islands;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Ui.Tests.ViewModels;
public class TasksIslandApproveReviewTests : IDisposable
{
private readonly string _dbPath;
public TasksIslandApproveReviewTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_approve_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 sealed class TestDbFactory : IDbContextFactory<ClaudeDoDbContext>
{
private readonly Func<ClaudeDoDbContext> _create;
public TestDbFactory(Func<ClaudeDoDbContext> create) => _create = create;
public ClaudeDoDbContext CreateDbContext() => _create();
}
private sealed class ThrowingWorkerClient : StubWorkerClient
{
public string ExceptionMessage { get; init; } = "blocked: target working tree has uncommitted changes";
public override Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) =>
throw new Exception(ExceptionMessage);
}
[Fact]
public async Task ApproveReview_WhenWorkerThrows_RaisesErrorReported()
{
var worker = new ThrowingWorkerClient();
var factory = new TestDbFactory(NewContext);
var vm = new TasksIslandViewModel(factory, worker);
string? reportedError = null;
vm.ErrorReported += msg => reportedError = msg;
var row = new TaskRowViewModel { Id = "task-err-2", Status = TaskStatus.WaitingForReview };
await vm.ApproveReviewCommand.ExecuteAsync(row);
Assert.NotNull(reportedError);
Assert.Contains(worker.ExceptionMessage, reportedError);
}
}
@@ -189,6 +189,22 @@ public sealed class BatchMcpToolsTests : IDisposable
Assert.Equal(TaskStatus.Queued, (await _tasks.GetByIdAsync(t2.Id))!.Status); Assert.Equal(TaskStatus.Queued, (await _tasks.GetByIdAsync(t2.Id))!.Status);
} }
[Fact]
public async Task BatchUpdateTaskStatus_Done_MixedWorktreeState_ReportsPerItemAndDoesNotAbort()
{
var listId = await SeedListAsync();
var noWorktree = await SeedTaskAsync(listId, "no-wt", TaskStatus.Idle);
var missing = "does-not-exist";
var sut = BuildSut();
var results = await sut.BatchUpdateTaskStatus(
new[] { noWorktree.Id, missing }, "Done", CancellationToken.None);
Assert.True(results.Single(r => r.TaskId == noWorktree.Id).Ok);
Assert.False(results.Single(r => r.TaskId == missing).Ok);
Assert.Equal(TaskStatus.Done, (await _tasks.GetByIdAsync(noWorktree.Id))!.Status);
}
[Fact] [Fact]
public async Task BatchTools_RejectEmptyBatch() public async Task BatchTools_RejectEmptyBatch()
{ {
@@ -380,6 +380,62 @@ public sealed class ExternalMcpServiceTests : IDisposable
Assert.Equal(TaskStatus.WaitingForReview, reloadedParent!.Status); Assert.Equal(TaskStatus.WaitingForReview, reloadedParent!.Status);
} }
[Fact]
public async Task UpdateTaskStatus_Cancelled_CancelsAnIdleTask()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId, status: TaskStatus.Idle);
var sut = BuildSut(CreateQueue());
var dto = await sut.UpdateTaskStatus(task.Id, "Cancelled", CancellationToken.None);
Assert.Equal("Cancelled", dto.Status);
var loaded = await _tasks.GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.Cancelled, loaded!.Status);
}
[Fact]
public async Task UpdateTaskStatus_WaitingForReview_StillRejected()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId, status: TaskStatus.Idle);
var sut = BuildSut(CreateQueue());
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => sut.UpdateTaskStatus(task.Id, "WaitingForReview", CancellationToken.None));
Assert.Contains("not settable externally", ex.Message);
}
[Fact]
public async Task UpdateTaskStatus_Done_NoWorktree_SetsDoneAndFinishedAt()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId, status: TaskStatus.Idle);
var sut = BuildSut(CreateQueue());
var dto = await sut.UpdateTaskStatus(task.Id, "Done", CancellationToken.None);
Assert.Equal("Done", dto.Status);
var loaded = await _tasks.GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.Done, loaded!.Status);
Assert.NotNull(loaded.FinishedAt);
}
[Fact]
public async Task UpdateTaskStatus_Done_WithActiveWorktree_Throws()
{
if (!GitAvailable) return;
var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
var sut = BuildSut(CreateQueue());
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => sut.UpdateTaskStatus(task.Id, "Done", CancellationToken.None));
Assert.Contains("active worktree", ex.Message, StringComparison.OrdinalIgnoreCase);
var loaded = await _tasks.GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.WaitingForReview, loaded!.Status);
}
private ExternalMcpService NewService() => BuildSut(CreateQueue()); private ExternalMcpService NewService() => BuildSut(CreateQueue());
private async Task<string> SeedIdleTask(string title = "t") private async Task<string> SeedIdleTask(string title = "t")
@@ -48,7 +48,8 @@ public sealed class QueuePickerTests : IDisposable
DateTime? scheduledFor = null, DateTime? scheduledFor = null,
string? blockedBy = null, string? blockedBy = null,
bool taskAgentTag = false, bool taskAgentTag = false,
int? sortOrder = null) int? sortOrder = null,
bool isManual = false)
{ {
var task = new TaskEntity var task = new TaskEntity
{ {
@@ -60,6 +61,7 @@ public sealed class QueuePickerTests : IDisposable
ScheduledFor = scheduledFor, ScheduledFor = scheduledFor,
BlockedByTaskId = blockedBy, BlockedByTaskId = blockedBy,
CommitType = "feat", CommitType = "feat",
IsManual = isManual,
}; };
await _tasks.AddAsync(task); await _tasks.AddAsync(task);
if (sortOrder is not null) if (sortOrder is not null)
@@ -70,6 +72,28 @@ public sealed class QueuePickerTests : IDisposable
return task; return task;
} }
[Fact]
public async Task ClaimNextAsync_Skips_ManualTasks()
{
var listId = await CreateListAsync();
await SeedAsync(listId, isManual: true);
Assert.Null(await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None));
}
[Fact]
public async Task ClaimNextAsync_Skips_ManualTask_ButClaimsTheNextOne()
{
var listId = await CreateListAsync();
await SeedAsync(listId, createdAt: DateTime.UtcNow.AddMinutes(-5), isManual: true);
var claudeTask = await SeedAsync(listId, createdAt: DateTime.UtcNow);
var picked = await _picker.ClaimNextAsync(DateTime.UtcNow, CancellationToken.None);
Assert.NotNull(picked);
Assert.Equal(claudeTask.Id, picked!.Id);
}
[Fact] [Fact]
public async Task ClaimNextAsync_Skips_TasksWithBlockedByTaskId() public async Task ClaimNextAsync_Skips_TasksWithBlockedByTaskId()
{ {
@@ -22,6 +22,25 @@ public sealed class ClaudeArgsBuilderTests
Assert.DoesNotContain("--append-system-prompt", args); Assert.DoesNotContain("--append-system-prompt", args);
Assert.DoesNotContain("--agents", args); Assert.DoesNotContain("--agents", args);
Assert.DoesNotContain("--resume", args); Assert.DoesNotContain("--resume", args);
Assert.DoesNotContain("--effort", args);
}
[Fact]
public void Effort_Adds_Effort_Flag()
{
var args = _builder.Build(new ClaudeRunConfig(null, null, null, null, Effort: "xhigh"));
Assert.Contains("--effort", args);
Assert.Equal("xhigh", args[args.ToList().IndexOf("--effort") + 1]);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
public void Blank_Effort_Omits_The_Flag(string? effort)
{
var args = _builder.Build(new ClaudeRunConfig(null, null, null, null, Effort: effort));
Assert.DoesNotContain("--effort", args);
} }
[Fact] [Fact]
@@ -184,7 +184,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
Assert.True(Directory.Exists(wtRow.Path)); Assert.True(Directory.Exists(wtRow.Path));
Assert.Equal(wtRow.Path, spec.Cwd); Assert.Equal(wtRow.Path, spec.Cwd);
Assert.Equal(new[] { "T" }, spec.Args); // fresh session seeds the task title as the prompt Assert.Equal(new[] { "T" }, ArgsAfterEffort(spec)); // fresh session seeds the task title as the prompt
Assert.Equal(_claudeStubPath, spec.Exe); Assert.Equal(_claudeStubPath, spec.Exe);
} }
@@ -230,7 +230,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
Assert.Equal(_worktreeDir, spec.Cwd); Assert.Equal(_worktreeDir, spec.Cwd);
Assert.Equal(_claudeStubPath, spec.Exe); Assert.Equal(_claudeStubPath, spec.Exe);
Assert.Equal(new[] { "--resume", "sess-123" }, spec.Args); Assert.Equal(new[] { "--resume", "sess-123" }, ArgsAfterEffort(spec));
Assert.Equal("200000", spec.Env["MCP_TOOL_TIMEOUT"]); Assert.Equal("200000", spec.Env["MCP_TOOL_TIMEOUT"]);
} }
@@ -246,7 +246,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
var svc = BuildService(); var svc = BuildService();
var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None); var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
Assert.Equal(new[] { "--resume", "sess-kept" }, spec.Args); Assert.Equal(new[] { "--resume", "sess-kept" }, ArgsAfterEffort(spec));
} }
[Fact] [Fact]
@@ -261,7 +261,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
var svc = BuildService(); var svc = BuildService();
var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None); var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
Assert.Equal(new[] { "T" }, spec.Args); // fresh: seeds the task title as the prompt Assert.Equal(new[] { "T" }, ArgsAfterEffort(spec)); // fresh: seeds the task title as the prompt
Assert.Equal(_worktreeDir, spec.Cwd); Assert.Equal(_worktreeDir, spec.Cwd);
} }
@@ -277,7 +277,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
var svc = BuildService(); var svc = BuildService();
var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None); var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
Assert.Equal(new[] { "T" }, spec.Args); // fresh: seeds the task title as the prompt Assert.Equal(new[] { "T" }, ArgsAfterEffort(spec)); // fresh: seeds the task title as the prompt
} }
[Fact] [Fact]
@@ -297,7 +297,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
var svc = BuildService(); var svc = BuildService();
var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None); var spec = await svc.BuildForTaskAsync(taskId, CancellationToken.None);
Assert.Equal(new[] { "T\n\nDo the thing" }, spec.Args); Assert.Equal(new[] { "T\n\nDo the thing" }, ArgsAfterEffort(spec));
} }
[Fact] [Fact]
@@ -333,7 +333,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
Assert.Equal(_tempDir, spec.Cwd); Assert.Equal(_tempDir, spec.Cwd);
Assert.Equal(_claudeStubPath, spec.Exe); Assert.Equal(_claudeStubPath, spec.Exe);
Assert.Empty(spec.Args); Assert.Empty(ArgsAfterEffort(spec));
Assert.Equal("200000", spec.Env["MCP_TOOL_TIMEOUT"]); Assert.Equal("200000", spec.Env["MCP_TOOL_TIMEOUT"]);
Assert.Empty(_seeder.Calls); Assert.Empty(_seeder.Calls);
} }
@@ -348,6 +348,17 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
// ── Merge helper ── // ── Merge helper ──
/// Every ConPTY spec now leads with `--effort <level>` from the per-model preset (the seeded
/// settings row has no overrides, so the shipped default for the default model applies).
/// Asserts that pair and returns the rest of the args for the test's own comparison.
private static string[] ArgsAfterEffort(LaunchSpec spec)
{
var args = spec.Args.ToList();
Assert.Equal("--effort", args[0]);
Assert.Equal(ModelPresets.For(ModelPresets.Defaults, ModelRegistry.DefaultAlias).Effort, args[1]);
return args.Skip(2).ToArray();
}
private readonly List<string> _mergeHelperSessionDirs = new(); private readonly List<string> _mergeHelperSessionDirs = new();
/// The session dir is the value right after --add-dir; register it for cleanup /// The session dir is the value right after --add-dir; register it for cleanup
@@ -363,13 +374,14 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
[Fact] [Fact]
public async Task BuildForMergeHelperAsync_EmptyTaskIds_ThrowsInvalidOperation() public async Task BuildForMergeHelperAsync_EmptyTaskIds_ThrowsInvalidOperation()
{ {
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>(), null, CancellationToken.None)); () => svc.BuildForMergeHelperAsync(Array.Empty<string>(), listId, CancellationToken.None));
} }
[Fact] [Fact]
public async Task BuildForMergeHelperAsync_NoExistingWorkingDirs_ThrowsInvalidOperation() public async Task BuildForMergeHelperAsync_ListWithoutExistingWorkingDir_ThrowsInvalidOperation()
{ {
var listId = await SeedListAsync(workingDir: Path.Combine(_tempDir, "gone")); var listId = await SeedListAsync(workingDir: Path.Combine(_tempDir, "gone"));
var taskId = Guid.NewGuid().ToString(); var taskId = Guid.NewGuid().ToString();
@@ -377,53 +389,56 @@ 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 }, null, CancellationToken.None)); () => svc.BuildForMergeHelperAsync(new[] { taskId }, listId, CancellationToken.None));
Assert.Contains("working directory", ex.Message); Assert.Contains("working directory", ex.Message);
} }
[Fact] [Fact]
public async Task BuildForMergeHelperAsync_TasksAcrossTwoRepos_BuildsGlobalScopeSpec() public async Task BuildForMergeHelperAsync_UnknownList_Throws()
{ {
var repoA = Path.Combine(_tempDir, "repoA"); var listId = await SeedListAsync(workingDir: _tempDir);
var repoB = Path.Combine(_tempDir, "repoB"); var taskId = Guid.NewGuid().ToString();
Directory.CreateDirectory(repoA); await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
Directory.CreateDirectory(repoB);
var listA = await SeedListAsync(workingDir: repoA, name: "Alpha");
var listB = await SeedListAsync(workingDir: repoB, name: "Beta");
var t1 = Guid.NewGuid().ToString();
var t2 = Guid.NewGuid().ToString();
var t3 = Guid.NewGuid().ToString();
await SeedTaskAsync(t1, listA, TaskStatus.WaitingForReview, title: "First task");
await SeedTaskAsync(t2, listB, TaskStatus.Idle, title: "Second task");
await SeedTaskAsync(t3, listA, TaskStatus.Failed, title: "Third task"); // same repo as t1 -> distinct
var svc = BuildService(); var svc = BuildService();
var spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2, t3 }, null, CancellationToken.None); await Assert.ThrowsAsync<KeyNotFoundException>(
() => svc.BuildForMergeHelperAsync(new[] { taskId }, "no-such-list", CancellationToken.None));
}
[Fact]
public async Task BuildForMergeHelperAsync_BuildsListScopedSpecWithSingleRepo()
{
var repo = Path.Combine(_tempDir, "repoOnly");
Directory.CreateDirectory(repo);
var listId = await SeedListAsync(workingDir: repo, name: "Alpha");
var t1 = Guid.NewGuid().ToString();
var t2 = Guid.NewGuid().ToString();
await SeedTaskAsync(t1, listId, TaskStatus.WaitingForReview, title: "First task");
await SeedTaskAsync(t2, listId, TaskStatus.Idle, title: "Second task");
var svc = BuildService();
var spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2 }, listId, CancellationToken.None);
var sessionDir = TrackSessionDir(spec); var sessionDir = TrackSessionDir(spec);
// cwd: no listId -> first existing repo dir. Assert.Equal(repo, spec.Cwd);
Assert.Equal(repoA, spec.Cwd);
Assert.Equal(_claudeStubPath, spec.Exe); Assert.Equal(_claudeStubPath, spec.Exe);
var args = spec.Args.ToList(); var args = spec.Args.ToList();
// --permission-mode default
var pmIdx = args.IndexOf("--permission-mode"); var pmIdx = args.IndexOf("--permission-mode");
Assert.True(pmIdx >= 0); Assert.True(pmIdx >= 0);
Assert.Equal("default", args[pmIdx + 1]); Assert.Equal("default", args[pmIdx + 1]);
// allowedTools string for the merge helper
var atIdx = args.IndexOf("--allowedTools"); var atIdx = args.IndexOf("--allowedTools");
Assert.Equal("mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill", args[atIdx + 1]); Assert.Equal("mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill", args[atIdx + 1]);
// --add-dir: session dir + BOTH distinct repo dirs (repoA only once) // --add-dir: session dir + the list's single repo dir
var addIdx = args.IndexOf("--add-dir"); var addIdx = args.IndexOf("--add-dir");
var appendIdx = args.IndexOf("--append-system-prompt-file"); var appendIdx = args.IndexOf("--append-system-prompt-file");
var addDirs = args.GetRange(addIdx + 1, appendIdx - addIdx - 1); var addDirs = args.GetRange(addIdx + 1, appendIdx - addIdx - 1);
Assert.Equal(new[] { sessionDir, repoA, repoB }, addDirs); Assert.Equal(new[] { sessionDir, repo }, addDirs);
// system prompt file follows --append-system-prompt-file
var systemPromptPath = args[appendIdx + 1]; var systemPromptPath = args[appendIdx + 1];
Assert.Equal(Path.Combine(sessionDir, "system-prompt.md"), systemPromptPath); Assert.Equal(Path.Combine(sessionDir, "system-prompt.md"), systemPromptPath);
Assert.True(File.Exists(systemPromptPath)); Assert.True(File.Exists(systemPromptPath));
@@ -434,43 +449,32 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
Assert.Contains(briefPath, kickoff); Assert.Contains(briefPath, kickoff);
Assert.DoesNotContain('\n', kickoff); Assert.DoesNotContain('\n', kickoff);
// brief exists, carries the scope label and every task's title
Assert.True(File.Exists(briefPath));
var brief = File.ReadAllText(briefPath);
Assert.Contains("Scope: All lists", brief);
Assert.Contains("First task", brief);
Assert.Contains("Second task", brief);
Assert.Contains("Third task", brief);
Assert.Equal("200000", spec.Env["MCP_TOOL_TIMEOUT"]); Assert.Equal("200000", spec.Env["MCP_TOOL_TIMEOUT"]);
} }
[Fact] [Fact]
public async Task BuildForMergeHelperAsync_WithListId_UsesListWorkingDirAsCwdAndListScope() public async Task BuildForMergeHelperAsync_BriefNamesListRepoAndEveryTask()
{ {
var repoA = Path.Combine(_tempDir, "repoA2"); var repo = Path.Combine(_tempDir, "repoBrief");
var repoB = Path.Combine(_tempDir, "repoB2"); Directory.CreateDirectory(repo);
Directory.CreateDirectory(repoA);
Directory.CreateDirectory(repoB);
var listA = await SeedListAsync(workingDir: repoA, name: "Alpha"); var listId = await SeedListAsync(workingDir: repo, name: "Alpha");
var listB = await SeedListAsync(workingDir: repoB, name: "Beta");
var t1 = Guid.NewGuid().ToString(); var t1 = Guid.NewGuid().ToString();
var t2 = Guid.NewGuid().ToString(); var t2 = Guid.NewGuid().ToString();
await SeedTaskAsync(t1, listA, TaskStatus.WaitingForReview, title: "Task in Alpha"); await SeedTaskAsync(t1, listId, TaskStatus.WaitingForReview, title: "First task");
await SeedTaskAsync(t2, listB, TaskStatus.WaitingForReview, title: "Task in Beta"); await SeedTaskAsync(t2, listId, TaskStatus.Idle, title: "Second task");
var svc = BuildService(); var svc = BuildService();
// Scope on listB even though listA's task comes first: cwd must be listB's repo. var spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2 }, listId, CancellationToken.None);
var spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2 }, listB, CancellationToken.None);
var sessionDir = TrackSessionDir(spec); var sessionDir = TrackSessionDir(spec);
Assert.Equal(repoB, spec.Cwd);
var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md")); var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md"));
Assert.Contains("Scope: List: Beta", brief); Assert.Contains("Scope: List: Alpha", brief);
Assert.Contains("Task in Alpha", brief); Assert.Contains($"Repo: {repo}", brief);
Assert.Contains("Task in Beta", brief); Assert.Contains("First task", brief);
Assert.Contains("Second task", brief);
Assert.Contains(t1, brief);
Assert.Contains(t2, brief);
} }
[Fact] [Fact]
@@ -505,11 +509,12 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
var spec = BuildService().BuildPlanningResume(ctx); var spec = BuildService().BuildPlanningResume(ctx);
Assert.Equal("--permission-mode", spec.Args[0]); var args = ArgsAfterEffort(spec);
Assert.Equal("default", spec.Args[1]); Assert.Equal("--permission-mode", args[0]);
Assert.Equal("--allowedTools", spec.Args[2]); Assert.Equal("default", args[1]);
Assert.Contains("mcp__claudedo__", spec.Args[3]); Assert.Equal("--allowedTools", args[2]);
Assert.Equal(new[] { "--resume", "sess-42" }, spec.Args.Skip(4).ToArray()); Assert.Contains("mcp__claudedo__", args[3]);
Assert.Equal(new[] { "--resume", "sess-42" }, args.Skip(4).ToArray());
Assert.Equal("tok-2", spec.Env["CLAUDEDO_PLANNING_TOKEN"]); Assert.Equal("tok-2", spec.Env["CLAUDEDO_PLANNING_TOKEN"]);
Assert.Equal(_worktreeDir, spec.Cwd); Assert.Equal(_worktreeDir, spec.Cwd);
} }
@@ -41,7 +41,8 @@ public sealed class TaskStateServiceTests : IDisposable
string? parentId = null, string? parentId = null,
int sortOrder = 0, int sortOrder = 0,
string? blockedBy = null, string? blockedBy = null,
PlanningPhase phase = PlanningPhase.None) PlanningPhase phase = PlanningPhase.None,
bool isManual = false)
{ {
var id = Guid.NewGuid().ToString(); var id = Guid.NewGuid().ToString();
await using var ctx = _factory.CreateDbContext(); await using var ctx = _factory.CreateDbContext();
@@ -56,6 +57,7 @@ public sealed class TaskStateServiceTests : IDisposable
ParentTaskId = parentId, ParentTaskId = parentId,
SortOrder = sortOrder, SortOrder = sortOrder,
BlockedByTaskId = blockedBy, BlockedByTaskId = blockedBy,
IsManual = isManual,
}); });
await ctx.SaveChangesAsync(); await ctx.SaveChangesAsync();
return id; return id;
@@ -89,6 +91,30 @@ public sealed class TaskStateServiceTests : IDisposable
Assert.Contains(_built.Hub.Proxy.Calls, c => c.Method == "TaskUpdated"); Assert.Contains(_built.Hub.Proxy.Calls, c => c.Method == "TaskUpdated");
} }
[Fact]
public async Task EnqueueAsync_ManualTask_Rejected_AndStaysIdle()
{
var id = await SeedTaskAsync(TaskStatus.Idle, isManual: true);
var result = await _sut.EnqueueAsync(id, default);
Assert.False(result.Ok);
Assert.Contains("Manual task", result.Reason);
Assert.Equal(TaskStatus.Idle, await GetStatusAsync(id));
}
[Fact]
public async Task StartRunningAsync_ManualTask_Rejected_AndStaysIdle()
{
var id = await SeedTaskAsync(TaskStatus.Idle, isManual: true);
var result = await _sut.StartRunningAsync(id, DateTime.UtcNow, default);
Assert.False(result.Ok);
Assert.Contains("Manual task", result.Reason);
Assert.Equal(TaskStatus.Idle, await GetStatusAsync(id));
}
[Fact] [Fact]
public async Task EnqueueAsync_FromRunning_Rejects_AndDoesNotMutate() public async Task EnqueueAsync_FromRunning_Rejects_AndDoesNotMutate()
{ {
@@ -75,7 +75,7 @@ sealed class FakeWorkerClient : IWorkerClient
public Task SubmitTaskForReviewAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask; public Task SubmitTaskForReviewAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
public Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default) public Task<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, 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 Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string? listId, CancellationToken ct = default) public Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string listId, 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 Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default) public 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>()));