Compare commits
41
Commits
v2.3.1
..
4dc4fe27e3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4dc4fe27e3 | ||
|
|
0ee30bee03 | ||
|
|
b9e0721875 | ||
|
|
92eb654f9b | ||
|
|
724814f770 | ||
|
|
b5464fc533 | ||
|
|
44cdad386c | ||
|
|
58f8b11dbb | ||
|
|
e653677487 | ||
|
|
994e94c2af | ||
|
|
db447f36da | ||
|
|
df2fcd8def | ||
|
|
17ef99bc9b | ||
|
|
c4425d6499 | ||
|
|
be6ccb2c17 | ||
|
|
57d433276e | ||
|
|
785ebe55e4 | ||
|
|
e1807fd53b | ||
|
|
149e2adadb | ||
|
|
d569313598 | ||
|
|
0a3c25840f | ||
|
|
7d6cb2bd3e | ||
|
|
7c8a9dd61b | ||
|
|
3fbbd7ab93 | ||
|
|
24f999facd | ||
|
|
3a648b7d77 | ||
|
|
fde9615b34 | ||
|
|
c93a20f20c | ||
|
|
1466d0fbab | ||
|
|
6c8de4aef3 | ||
|
|
7f9f0ca128 | ||
|
|
edd2774d86 | ||
|
|
62fc5aaa5a | ||
|
|
023e136c53 | ||
|
|
4877802bcd | ||
|
|
40eb979924 | ||
|
|
e3bacc3143 | ||
|
|
cc823ec4f6 | ||
|
|
ec10b06848 | ||
|
|
81cf94187f | ||
|
|
538b4ede09 |
@@ -1,5 +1,45 @@
|
||||
# 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
|
||||
|
||||
@@ -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.
|
||||
- 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)
|
||||
|
||||
- **`--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)
|
||||
|
||||
- **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 0–5 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,6 +3,7 @@ using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using ClaudeDo.Ui;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels;
|
||||
using ClaudeDo.Ui.Views;
|
||||
@@ -22,6 +23,8 @@ public partial class App : Application
|
||||
public override void Initialize()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
if (_services?.GetService<AppSettings>() is { } settings)
|
||||
AccentPresetService.Apply(AccentPresets.Find(settings.AccentPreset));
|
||||
}
|
||||
|
||||
public override void OnFrameworkInitializationCompleted()
|
||||
|
||||
@@ -4,8 +4,8 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
|
||||
|
||||
## 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.
|
||||
- **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt
|
||||
- **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, IsManual (reminder list — tasks created here default to `IsManual`)
|
||||
- **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)
|
||||
- **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`
|
||||
- **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`
|
||||
- **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 1–200) 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
|
||||
|
||||
## Repositories
|
||||
@@ -42,7 +44,7 @@ All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. The atomic `Q
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ public class AppSettingsEntityConfiguration : IEntityTypeConfiguration<AppSettin
|
||||
.HasColumnName("daily_prep_max_tasks").IsRequired().HasDefaultValue(5);
|
||||
|
||||
builder.Property(s => s.SessionSkills).HasColumnName("session_skills");
|
||||
builder.Property(s => s.ModelPresets).HasColumnName("model_presets");
|
||||
|
||||
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.DefaultCommitType).HasColumnName("default_commit_type").IsRequired().HasDefaultValue("chore");
|
||||
builder.Property(l => l.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0);
|
||||
builder.Property(l => l.IsManual).HasColumnName("is_manual").IsRequired().HasDefaultValue(false);
|
||||
|
||||
builder.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.IsStarred).HasColumnName("is_starred").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.SortOrder).HasColumnName("sort_order").IsRequired().HasDefaultValue(0);
|
||||
builder.Property(t => t.SessionSkills).HasColumnName("session_skills");
|
||||
|
||||
+802
@@ -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)
|
||||
.HasColumnName("max_parallel_executions");
|
||||
|
||||
b.Property<string>("ModelPresets")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("model_presets");
|
||||
|
||||
b.Property<string>("RepoImportFolders")
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("repo_import_folders");
|
||||
@@ -202,6 +206,12 @@ namespace ClaudeDo.Data.Migrations
|
||||
.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")
|
||||
@@ -406,6 +416,12 @@ namespace ClaudeDo.Data.Migrations
|
||||
.HasColumnType("TEXT")
|
||||
.HasColumnName("finished_at");
|
||||
|
||||
b.Property<bool>("IsManual")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false)
|
||||
.HasColumnName("is_manual");
|
||||
|
||||
b.Property<bool>("IsMyDay")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
|
||||
@@ -30,4 +30,8 @@ public sealed class AppSettingsEntity
|
||||
|
||||
// JSON array of session skill names applied by default to new tasks.
|
||||
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)}.");
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@ public sealed class ListEntity
|
||||
public string? WorkingDir { get; set; }
|
||||
public string DefaultCommitType { get; set; } = CommitTypeRegistry.DefaultType;
|
||||
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
|
||||
public ListConfigEntity? Config { get; set; }
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ namespace ClaudeDo.Data.Models;
|
||||
|
||||
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>
|
||||
public static readonly IReadOnlyList<string> ByCostAscending = new[] { "haiku", "sonnet", "opus" };
|
||||
|
||||
@@ -43,6 +43,10 @@ public sealed class TaskEntity
|
||||
public int? MaxTurns { get; set; }
|
||||
public bool IsStarred { 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 int SortOrder { get; set; }
|
||||
public string? SessionSkills { get; set; }
|
||||
|
||||
@@ -229,22 +229,57 @@ public static class PromptFiles
|
||||
""";
|
||||
|
||||
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:
|
||||
- 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).
|
||||
Work the five phases in order. Do not start a phase before the previous one is finished.
|
||||
|
||||
Reviewing and merging a WaitingForReview task:
|
||||
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.
|
||||
## 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 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.
|
||||
- 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:
|
||||
- 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.
|
||||
- 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.
|
||||
- Ask the user for anything ambiguous, risky, or destructive.
|
||||
|
||||
When every task is handled, print a SUMMARY:
|
||||
- one line per task: title — final status — merge commit (if any) — conflicts resolved (if any)
|
||||
- anything you skipped or left for the user, and why
|
||||
- suggested follow-ups, if any.
|
||||
## 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.
|
||||
""";
|
||||
|
||||
private const string MergeHelperInitialDefault = """
|
||||
# Merge Helper brief
|
||||
# List handler brief
|
||||
|
||||
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 0–5 as your instructions describe, asking me whenever you are unsure.
|
||||
|
||||
{tasks}
|
||||
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
"defaultInstructionsPlaceholder": "Basis-Anweisungen, die auf jede Aufgabe angewendet werden",
|
||||
"model": "Modell",
|
||||
"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",
|
||||
"maxParallelExecutions": "Max. parallele Ausführungen",
|
||||
"maxParallelExecutionsHint": "Wie viele Aufgaben aus der Warteschlange der Worker gleichzeitig ausführt.",
|
||||
@@ -28,7 +31,11 @@
|
||||
"weekdayFriday": "Freitag",
|
||||
"weekdaySaturday": "Samstag",
|
||||
"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": {
|
||||
"strategy": "Strategie",
|
||||
@@ -114,6 +121,7 @@
|
||||
},
|
||||
"tasks": {
|
||||
"showCompletedTip": "Abgeschlossene anzeigen",
|
||||
"letClaudeTip": "Claude machen lassen",
|
||||
"listSettingsTip": "Listeneinstellungen",
|
||||
"addPlaceholder": "Aufgabe hinzufügen…",
|
||||
"enterKey": "ENTER",
|
||||
@@ -141,6 +149,10 @@
|
||||
"ctxAddToMyDay": "Zu Mein Tag hinzufügen",
|
||||
"badgeDraft": "ENTWURF",
|
||||
"badgePlanned": "GEPLANT",
|
||||
"badgeManual": "MANUELL",
|
||||
"manualTip": "Manuelle Erinnerung — Claude nimmt sie nie auf",
|
||||
"ctxMarkManual": "Als manuell markieren",
|
||||
"ctxMarkClaudeTask": "Als Claude-Aufgabe markieren",
|
||||
"approve": "Genehmigen",
|
||||
"approveTip": "Genehmigen — als Erledigt markieren",
|
||||
"reject": "Ablehnen",
|
||||
@@ -152,6 +164,7 @@
|
||||
"removeFromQueueTip": "Aus Warteschlange entfernen",
|
||||
"toggleSubtasksTip": "Unteraufgaben ein-/ausklappen",
|
||||
"agentSuggestedTip": "Vom Agenten vorgeschlagen",
|
||||
"interactiveChipTip": "Interaktive Sitzung offen — klicken, um im Mission Control dorthin zu springen",
|
||||
"scheduleTitle": "Aufgabe planen",
|
||||
"scheduleWhen": "WANN",
|
||||
"scheduleConfirm": "Planen",
|
||||
@@ -160,7 +173,8 @@
|
||||
"feedbackLabel": "FEEDBACK FÜR DEN AGENTEN",
|
||||
"feedbackPlaceholder": "Was soll der Agent korrigieren?",
|
||||
"rerun": "Erneut ausführen",
|
||||
"refineTip": "Aufgabe mit Claude verfeinern"
|
||||
"refineTip": "Aufgabe mit Claude verfeinern",
|
||||
"refiningTip": "Claude verfeinert diese Aufgabe…"
|
||||
},
|
||||
"lists": {
|
||||
"heading": "Listen",
|
||||
@@ -175,8 +189,7 @@
|
||||
"contextOpenTerminal": "Im Terminal öffnen",
|
||||
"contextLetClaude": "Claude machen lassen",
|
||||
"newList": "Neue Liste",
|
||||
"addReposTip": "Repos als Listen hinzufügen",
|
||||
"letClaudeAllTip": "Claude machen lassen (alle Listen)"
|
||||
"addReposTip": "Repos als Listen hinzufügen"
|
||||
},
|
||||
"details": {
|
||||
"pickUpInTerminalTip": "Diese Sitzung im Terminal fortsetzen",
|
||||
@@ -264,10 +277,12 @@
|
||||
"settings": "Einstellungen",
|
||||
"queue": "Warteschlange",
|
||||
"blocked": "Blockiert",
|
||||
"running": "Läuft",
|
||||
"focusMode": "Fokus",
|
||||
"overviewMode": "Übersicht",
|
||||
"closeSession": "Sitzung schließen",
|
||||
"conptyLaunchFailed": "ConPTY-Sitzung konnte nicht geöffnet werden: {0}",
|
||||
"conptyStarting": "Sitzung wird gestartet…",
|
||||
"mergeHelperTitle": "Merge-Helfer",
|
||||
"submitForReviewFailed": "Einreichen zum Review fehlgeschlagen: {0}",
|
||||
"submitForReview": "Zum Review einreichen",
|
||||
@@ -313,6 +328,8 @@
|
||||
"workingDirectoryPlaceholder": "(keines)",
|
||||
"browse": "Durchsuchen...",
|
||||
"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",
|
||||
"resetAgentSettings": "Agent-Einstellungen zurücksetzen"
|
||||
},
|
||||
@@ -371,7 +388,6 @@
|
||||
"mergeHelper": {
|
||||
"title": "CLAUDE MACHEN LASSEN",
|
||||
"windowTitle": "Claude machen lassen",
|
||||
"scopeAll": "Alle Listen",
|
||||
"scopeList": "Liste: {0}",
|
||||
"confirm": "Claude machen lassen",
|
||||
"cancel": "Abbrechen",
|
||||
@@ -379,8 +395,7 @@
|
||||
"selectNone": "Keine auswählen",
|
||||
"empty": "Keine Aufgaben zu übergeben — alles erledigt oder abgebrochen.",
|
||||
"columnTask": "AUFGABE",
|
||||
"columnStatus": "STATUS",
|
||||
"columnList": "LISTE"
|
||||
"columnStatus": "STATUS"
|
||||
},
|
||||
"unfinishedPlanning": {
|
||||
"title": "UNVOLLENDETE PLANUNGSSITZUNG",
|
||||
@@ -524,12 +539,12 @@
|
||||
"connection": { "online": "Online", "connecting": "Verbinden…", "offline": "Offline" },
|
||||
"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" },
|
||||
"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" },
|
||||
"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}" },
|
||||
"diff": { "loadFailed": "Diff konnte nicht geladen werden: {0}", "noChanges": "Keine Änderungen anzuzeigen.", "unavailable": "Diff nicht mehr verfügbar — Commit-Bereich unvollständig." },
|
||||
"planningDiff": { "hubError": "Kombinierte Vorschau konnte nicht erstellt werden (Hub-Fehler).", "conflict": "Kombinierte Vorschau nicht möglich: Teilaufgabe {0} steht im Konflikt mit einer früheren Teilaufgabe ({1} Dateien)." },
|
||||
"planningDiff": { "hubError": "Kombinierte Vorschau konnte nicht erstellt werden (Hub-Fehler).", "conflict": "Kombinierte Vorschau nicht möglich: Teilaufgabe {0} steht im Konflikt mit einer früheren Teilaufgabe ({1} Dateien).", "buildFailed": "Kombinierte Vorschau konnte nicht erstellt werden: {0}" },
|
||||
"merge": { "commitMessage": "Merge-Aufgabe: {0}", "workerOfflineBranches": "Worker offline — Branches können nicht aufgelistet werden.", "loadBranchesFailed": "Branches konnten nicht geladen werden: {0}", "merged": "Zusammengeführt.", "conflict": "Merge-Konflikt — Ziel-Branch wiederhergestellt. Manuell oder über Fortsetzen lösen, dann erneut versuchen.", "blocked": "Blockiert: {0}", "unknownStatus": "Unbekannter Status: {0}", "mergeFailed": "Merge fehlgeschlagen: {0}" },
|
||||
"conflictResolution": { "vsCodeError": "VS Code konnte nicht gestartet werden: {0}. Die Pfade sind oben aufgeführt — kopiere sie manuell.", "subtaskPrefix": "Konflikte in Teilaufgabe: {0}", "targetPrefix": "Zusammenführen in: {0}" },
|
||||
"settingsModal": { "workerOffline": "Worker offline — Einstellungen schreibgeschützt.", "saveFailed": "Speichern fehlgeschlagen: {0}" },
|
||||
@@ -537,8 +552,8 @@
|
||||
"weeklyReport": { "invalidRange": "Ungültiger Datumsbereich.", "generating": "Bericht wird erstellt…", "error": "Fehler: {0}" },
|
||||
"filesTab": { "workerOffline": "Worker offline.", "noneBundled": "Keine Standard-Agenten mitgeliefert.", "allPresent": "Alle Standard-Agenten bereits vorhanden.", "restored": "{0} Standard-Agent(en) wiederhergestellt.", "restoreFailed": "Wiederherstellung fehlgeschlagen: {0}", "openFailed": "Öffnen fehlgeschlagen: {0}" },
|
||||
"sessionSkillsTab": { "installed": "Installiert: {0}", "installFailed": "Installation fehlgeschlagen: {0}", "updated": "Aktualisiert.", "updateFailed": "Aktualisierung fehlgeschlagen: {0}", "removed": "Entfernt.", "removeFailed": "Entfernen fehlgeschlagen: {0}" },
|
||||
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "{0} Worktree(s) entfernt.", "blocked": "Zwangsentfernung nicht möglich: {0} Aufgabe(n) laufen noch. Brich sie zuerst ab.", "removedFrom": "{0} Worktree(s) von {1} Aufgabe(n) entfernt." },
|
||||
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "Liste", "cleanupFailed": "Aufräumen fehlgeschlagen.", "removed": "{0} Worktree(s) entfernt.", "discardFailed": "Worktree konnte nicht verworfen werden.", "keepFailed": "Worktree konnte nicht behalten werden.", "cannotForceRunning": "Eine laufende Aufgabe kann nicht zwangsweise entfernt werden.", "forceRemoveFailed": "Zwangsentfernung fehlgeschlagen.", "batchProgress": "Merge {0}/{1}…", "batchDone": "{0} gemergt, {1} zu lösen." },
|
||||
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "{0} Worktree(s) entfernt.", "blocked": "Zwangsentfernung nicht möglich: {0} Aufgabe(n) laufen noch. Brich sie zuerst ab.", "removedFrom": "{0} Worktree(s) von {1} Aufgabe(n) entfernt.", "cleanupFailed": "Aufräumen fehlgeschlagen: {0}", "resetFailed": "Zurücksetzen fehlgeschlagen: {0}" },
|
||||
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "Liste", "cleanupFailed": "Aufräumen fehlgeschlagen.", "cleanupFailedDetailed": "Aufräumen fehlgeschlagen: {0}", "removed": "{0} Worktree(s) entfernt.", "discardFailed": "Worktree konnte nicht verworfen werden.", "keepFailed": "Worktree konnte nicht behalten werden.", "cannotForceRunning": "Eine laufende Aufgabe kann nicht zwangsweise entfernt werden.", "forceRemoveFailed": "Zwangsentfernung fehlgeschlagen.", "forceRemoveFailedDetailed": "Zwangsentfernung fehlgeschlagen: {0}", "batchProgress": "Merge {0}/{1}…", "batchDone": "{0} gemergt, {1} zu lösen." },
|
||||
"listSettings": { "untitled": "Unbenannt" },
|
||||
"lists": { "localSuffix": "{0} / lokal", "smartMyDay": "Mein Tag", "smartImportant": "Wichtig", "smartPlanned": "Geplant", "virtualQueue": "Warteschlange", "virtualRunning": "Läuft", "virtualReview": "Prüfung", "newList": "Neue Liste" }
|
||||
}
|
||||
|
||||
@@ -15,6 +15,9 @@
|
||||
"defaultInstructionsPlaceholder": "Baseline instructions applied to every task",
|
||||
"model": "Model",
|
||||
"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",
|
||||
"maxParallelExecutions": "Max parallel executions",
|
||||
"maxParallelExecutionsHint": "How many queued tasks the worker runs at once.",
|
||||
@@ -28,7 +31,11 @@
|
||||
"weekdayFriday": "Friday",
|
||||
"weekdaySaturday": "Saturday",
|
||||
"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": {
|
||||
"strategy": "Strategy",
|
||||
@@ -114,6 +121,7 @@
|
||||
},
|
||||
"tasks": {
|
||||
"showCompletedTip": "Show completed",
|
||||
"letClaudeTip": "Let Claude handle it",
|
||||
"listSettingsTip": "List settings",
|
||||
"addPlaceholder": "Add a task…",
|
||||
"enterKey": "ENTER",
|
||||
@@ -141,6 +149,10 @@
|
||||
"ctxAddToMyDay": "Add to My Day",
|
||||
"badgeDraft": "DRAFT",
|
||||
"badgePlanned": "PLANNED",
|
||||
"badgeManual": "MANUAL",
|
||||
"manualTip": "Manual reminder — Claude never picks this up",
|
||||
"ctxMarkManual": "Mark as manual",
|
||||
"ctxMarkClaudeTask": "Mark as Claude task",
|
||||
"approve": "Approve",
|
||||
"approveTip": "Approve — mark Done",
|
||||
"reject": "Reject",
|
||||
@@ -152,6 +164,7 @@
|
||||
"removeFromQueueTip": "Remove from queue",
|
||||
"toggleSubtasksTip": "Expand / collapse subtasks",
|
||||
"agentSuggestedTip": "Suggested by the agent",
|
||||
"interactiveChipTip": "Interactive session open — click to jump to it in Mission Control",
|
||||
"scheduleTitle": "Schedule task",
|
||||
"scheduleWhen": "WHEN",
|
||||
"scheduleConfirm": "Schedule",
|
||||
@@ -160,7 +173,8 @@
|
||||
"feedbackLabel": "FEEDBACK FOR THE AGENT",
|
||||
"feedbackPlaceholder": "What should the agent fix?",
|
||||
"rerun": "Re-run",
|
||||
"refineTip": "Refine this task with Claude"
|
||||
"refineTip": "Refine this task with Claude",
|
||||
"refiningTip": "Claude is refining this task…"
|
||||
},
|
||||
"lists": {
|
||||
"heading": "Lists",
|
||||
@@ -175,8 +189,7 @@
|
||||
"contextOpenTerminal": "Open in Terminal",
|
||||
"contextLetClaude": "Let Claude handle it",
|
||||
"newList": "New list",
|
||||
"addReposTip": "Add repos as lists",
|
||||
"letClaudeAllTip": "Let Claude handle it (all lists)"
|
||||
"addReposTip": "Add repos as lists"
|
||||
},
|
||||
"details": {
|
||||
"pickUpInTerminalTip": "Pick up this session in a terminal",
|
||||
@@ -264,10 +277,12 @@
|
||||
"settings": "Settings",
|
||||
"queue": "Queue",
|
||||
"blocked": "Blocked",
|
||||
"running": "Running",
|
||||
"focusMode": "Focus",
|
||||
"overviewMode": "Overview",
|
||||
"closeSession": "Close session",
|
||||
"conptyLaunchFailed": "Couldn't open ConPTY session: {0}",
|
||||
"conptyStarting": "Starting session…",
|
||||
"mergeHelperTitle": "Merge Helper",
|
||||
"submitForReviewFailed": "Couldn't submit for review: {0}",
|
||||
"submitForReview": "Submit for review",
|
||||
@@ -313,6 +328,8 @@
|
||||
"workingDirectoryPlaceholder": "(none)",
|
||||
"browse": "Browse...",
|
||||
"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",
|
||||
"resetAgentSettings": "Reset agent settings"
|
||||
},
|
||||
@@ -371,7 +388,6 @@
|
||||
"mergeHelper": {
|
||||
"title": "LET CLAUDE HANDLE IT",
|
||||
"windowTitle": "Let Claude handle it",
|
||||
"scopeAll": "All lists",
|
||||
"scopeList": "List: {0}",
|
||||
"confirm": "Let Claude handle it",
|
||||
"cancel": "Cancel",
|
||||
@@ -379,8 +395,7 @@
|
||||
"selectNone": "Select none",
|
||||
"empty": "No tasks to hand over — everything is done or cancelled.",
|
||||
"columnTask": "TASK",
|
||||
"columnStatus": "STATUS",
|
||||
"columnList": "LIST"
|
||||
"columnStatus": "STATUS"
|
||||
},
|
||||
"unfinishedPlanning": {
|
||||
"title": "UNFINISHED PLANNING SESSION",
|
||||
@@ -524,12 +539,12 @@
|
||||
"connection": { "online": "Online", "connecting": "Connecting…", "offline": "Offline" },
|
||||
"shell": { "restartingWorker": "Restarting worker…" },
|
||||
"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" },
|
||||
"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}" },
|
||||
"diff": { "loadFailed": "Failed to load diff: {0}", "noChanges": "No changes to show.", "unavailable": "Diff no longer available — commit range incomplete." },
|
||||
"planningDiff": { "hubError": "Could not build combined preview (hub error).", "conflict": "Cannot build combined preview: subtask {0} conflicts with an earlier subtask ({1} files)." },
|
||||
"planningDiff": { "hubError": "Could not build combined preview (hub error).", "conflict": "Cannot build combined preview: subtask {0} conflicts with an earlier subtask ({1} files).", "buildFailed": "Could not build combined preview: {0}" },
|
||||
"merge": { "commitMessage": "Merge task: {0}", "workerOfflineBranches": "Worker offline — cannot list branches.", "loadBranchesFailed": "Failed to load branches: {0}", "merged": "Merged.", "conflict": "Merge conflict — target branch restored. Resolve manually or via Continue, then retry.", "blocked": "Blocked: {0}", "unknownStatus": "Unknown status: {0}", "mergeFailed": "Merge failed: {0}" },
|
||||
"conflictResolution": { "vsCodeError": "Could not launch VS Code: {0}. Paths are listed above — copy them manually.", "subtaskPrefix": "Conflicts in subtask: {0}", "targetPrefix": "Merging into: {0}" },
|
||||
"settingsModal": { "workerOffline": "Worker offline — settings read-only.", "saveFailed": "Save failed: {0}" },
|
||||
@@ -537,8 +552,8 @@
|
||||
"weeklyReport": { "invalidRange": "Invalid date range.", "generating": "Generating report…", "error": "Error: {0}" },
|
||||
"filesTab": { "workerOffline": "Worker offline.", "noneBundled": "No default agents bundled.", "allPresent": "All default agents already present.", "restored": "Restored {0} default agent(s).", "restoreFailed": "Restore failed: {0}", "openFailed": "Open failed: {0}" },
|
||||
"sessionSkillsTab": { "installed": "Installed: {0}", "installFailed": "Install failed: {0}", "updated": "Updated.", "updateFailed": "Update failed: {0}", "removed": "Removed.", "removeFailed": "Remove failed: {0}" },
|
||||
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "Removed {0} worktree(s).", "blocked": "Cannot force-remove: {0} task(s) still running. Cancel them first.", "removedFrom": "Removed {0} worktree(s) from {1} task(s)." },
|
||||
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "list", "cleanupFailed": "Cleanup failed.", "removed": "Removed {0} worktree(s).", "discardFailed": "Failed to discard worktree.", "keepFailed": "Failed to keep worktree.", "cannotForceRunning": "Cannot force-remove a running task.", "forceRemoveFailed": "Force remove failed.", "batchProgress": "Merging {0}/{1}…", "batchDone": "Merged {0}, {1} need resolution." },
|
||||
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "Removed {0} worktree(s).", "blocked": "Cannot force-remove: {0} task(s) still running. Cancel them first.", "removedFrom": "Removed {0} worktree(s) from {1} task(s).", "cleanupFailed": "Cleanup failed: {0}", "resetFailed": "Reset failed: {0}" },
|
||||
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "list", "cleanupFailed": "Cleanup failed.", "cleanupFailedDetailed": "Cleanup failed: {0}", "removed": "Removed {0} worktree(s).", "discardFailed": "Failed to discard worktree.", "keepFailed": "Failed to keep worktree.", "cannotForceRunning": "Cannot force-remove a running task.", "forceRemoveFailed": "Force remove failed.", "forceRemoveFailedDetailed": "Force remove failed: {0}", "batchProgress": "Merging {0}/{1}…", "batchDone": "Merged {0}, {1} need resolution." },
|
||||
"listSettings": { "untitled": "Untitled" },
|
||||
"lists": { "localSuffix": "{0} / local", "smartMyDay": "My Day", "smartImportant": "Important", "smartPlanned": "Planned", "virtualQueue": "Queue", "virtualRunning": "Running", "virtualReview": "Review", "newList": "New list" }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ public sealed class AppSettings
|
||||
public string DbPath { get; set; } = "~/.todo-app/todo.db";
|
||||
public string SignalRUrl { get; set; } = "http://127.0.0.1:47821/hub";
|
||||
public string Language { get; set; } = "";
|
||||
public string AccentPreset { get; set; } = "";
|
||||
|
||||
private static readonly string ConfigPath = Paths.Expand("~/.todo-app/ui.config.json");
|
||||
|
||||
|
||||
@@ -20,8 +20,8 @@ ViewModels/
|
||||
Islands/ — ListsIsland, TasksIsland, DetailsIsland, TaskRow, ListNavItem,
|
||||
NotesEditor, MergePreviewPresenter
|
||||
Agent/ — AgentConfigEditorViewModel (scope-parameterized: List | Task)
|
||||
Modals/ — About, DiffViewer (+ DiffModels), ListSettings, Merge, RepoImport,
|
||||
Settings (+ Settings/ tab VMs), UnfinishedPlanning, WeeklyReport,
|
||||
Modals/ — About, DiffViewer (+ DiffModels), ListSettings, Merge, MergeHelperSelection,
|
||||
RepoImport, Settings (+ Settings/ tab VMs), UnfinishedPlanning, WeeklyReport,
|
||||
WorkerConnection, WorktreesOverview, UnifiedDiffParser
|
||||
Conflicts/ — ConflictResolverViewModel + ConflictModels (MergeFile/MergeFileSegment/MergeConflictBlock)
|
||||
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`.
|
||||
- **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.
|
||||
- **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`.
|
||||
- **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.
|
||||
- **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
|
||||
- "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.
|
||||
- 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`.
|
||||
- `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).
|
||||
|
||||
@@ -116,6 +116,7 @@
|
||||
<SolidColorBrush x:Key="DraftBadgeBrush" Color="{StaticResource TextMuteColor}"/>
|
||||
<SolidColorBrush x:Key="PlanningBadgeBrush" Color="{StaticResource PeatColor}"/>
|
||||
<SolidColorBrush x:Key="PlannedBadgeBrush" Color="{StaticResource SageColor}"/>
|
||||
<SolidColorBrush x:Key="ManualBadgeBrush" Color="{StaticResource TextFaintColor}"/>
|
||||
|
||||
</Styles.Resources>
|
||||
|
||||
@@ -247,6 +248,21 @@
|
||||
<Setter Property="Foreground" Value="#8FB9D6" />
|
||||
</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) -->
|
||||
<!-- active → slate-blue (same hue as parked: a live worktree) -->
|
||||
<Style Selector="Border.chip.wt-active">
|
||||
@@ -454,6 +470,25 @@
|
||||
</Style.Animations>
|
||||
</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 -->
|
||||
<!-- ============================================================ -->
|
||||
@@ -997,6 +1032,11 @@
|
||||
<Setter Property="Background" Value="{DynamicResource PlannedBadgeBrush}"/>
|
||||
</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) -->
|
||||
<!-- ============================================================ -->
|
||||
|
||||
@@ -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>
|
||||
Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default);
|
||||
/// <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>
|
||||
Task<LaunchSpec> GetMergeHelperLaunchSpecAsync(IReadOnlyList<string> taskIds, string? listId, CancellationToken ct = default);
|
||||
/// 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);
|
||||
/// <summary>Starts a planning session and returns the launch spec for an embedded ConPTY
|
||||
/// planning terminal (replaces StartPlanningSessionAsync's external wt window).</summary>
|
||||
Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default);
|
||||
|
||||
@@ -329,8 +329,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
await _hub.InvokeAsync("RefreshAgents");
|
||||
}
|
||||
|
||||
public Task<SeedResultDto?> RestoreDefaultAgentsAsync()
|
||||
=> TryInvokeAsync<SeedResultDto>("RestoreDefaultAgents");
|
||||
public async Task<SeedResultDto?> RestoreDefaultAgentsAsync()
|
||||
=> await _hub.InvokeAsync<SeedResultDto>("RestoreDefaultAgents");
|
||||
|
||||
private async Task SeedActiveTasksAsync()
|
||||
{
|
||||
@@ -372,8 +372,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
public async Task<List<PrimeScheduleDto>> GetPrimeSchedulesAsync()
|
||||
=> await TryInvokeAsync<List<PrimeScheduleDto>>("ListPrimeSchedules") ?? new List<PrimeScheduleDto>();
|
||||
|
||||
public Task<PrimeScheduleDto?> UpsertPrimeScheduleAsync(PrimeScheduleDto dto)
|
||||
=> TryInvokeAsync<PrimeScheduleDto>("UpsertPrimeSchedule", dto);
|
||||
public async Task<PrimeScheduleDto?> UpsertPrimeScheduleAsync(PrimeScheduleDto dto)
|
||||
=> await _hub.InvokeAsync<PrimeScheduleDto>("UpsertPrimeSchedule", dto);
|
||||
|
||||
public async Task DeletePrimeScheduleAsync(Guid id)
|
||||
{
|
||||
@@ -400,8 +400,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
public async Task<List<DailyNoteDto>> GetDailyNotesAsync(DateOnly day)
|
||||
=> await TryInvokeAsync<List<DailyNoteDto>>("GetDailyNotes", IsoDay(day)) ?? new List<DailyNoteDto>();
|
||||
|
||||
public Task<DailyNoteDto?> AddDailyNoteAsync(DateOnly day, string text)
|
||||
=> TryInvokeAsync<DailyNoteDto>("AddDailyNote", IsoDay(day), text);
|
||||
public async Task<DailyNoteDto?> AddDailyNoteAsync(DateOnly day, string text)
|
||||
=> await _hub.InvokeAsync<DailyNoteDto>("AddDailyNote", IsoDay(day), text);
|
||||
|
||||
public async Task UpdateDailyNoteAsync(string id, string text)
|
||||
=> await _hub.InvokeAsync("UpdateDailyNote", id, text);
|
||||
@@ -450,10 +450,10 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
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;
|
||||
return TryInvokeAsync<MergeResultDto>("ApproveReview", taskId, targetBranch);
|
||||
return await _hub.InvokeAsync<MergeResultDto>("ApproveReview", taskId, targetBranch);
|
||||
}
|
||||
|
||||
public Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch)
|
||||
@@ -474,11 +474,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
await _hub.InvokeAsync("CancelReview", taskId);
|
||||
}
|
||||
|
||||
public Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync(string? listId = null)
|
||||
=> TryInvokeAsync<WorktreeCleanupDto>("CleanupFinishedWorktrees", listId);
|
||||
public async Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync(string? listId = null)
|
||||
=> await _hub.InvokeAsync<WorktreeCleanupDto>("CleanupFinishedWorktrees", listId);
|
||||
|
||||
public Task<WorktreeResetDto?> ResetAllWorktreesAsync()
|
||||
=> TryInvokeAsync<WorktreeResetDto>("ResetAllWorktrees");
|
||||
public async Task<WorktreeResetDto?> ResetAllWorktreesAsync()
|
||||
=> await _hub.InvokeAsync<WorktreeResetDto>("ResetAllWorktrees");
|
||||
|
||||
public async Task<List<WorktreeOverviewDto>> GetWorktreesOverviewAsync(string? listId)
|
||||
=> await TryInvokeAsync<List<WorktreeOverviewDto>>("GetWorktreesOverview", listId)
|
||||
@@ -501,8 +501,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
}
|
||||
}
|
||||
|
||||
public Task<ForceRemoveResultDto?> ForceRemoveWorktreeAsync(string taskId)
|
||||
=> TryInvokeAsync<ForceRemoveResultDto>("ForceRemoveWorktree", taskId);
|
||||
public async Task<ForceRemoveResultDto?> ForceRemoveWorktreeAsync(string taskId)
|
||||
=> await _hub.InvokeAsync<ForceRemoveResultDto>("ForceRemoveWorktree", taskId);
|
||||
|
||||
public async Task<PlanningSessionStartInfo> StartPlanningSessionAsync(string taskId, CancellationToken ct = default)
|
||||
=> await _hub.InvokeAsync<PlanningSessionStartInfo>("StartPlanningSessionAsync", taskId, ct);
|
||||
@@ -522,7 +522,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
public async Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
|
||||
=> 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);
|
||||
|
||||
public async Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
||||
@@ -543,8 +543,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
public async Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregateAsync(string planningTaskId)
|
||||
=> await TryInvokeAsync<List<SubtaskDiffDto>>("GetPlanningAggregate", planningTaskId) ?? [];
|
||||
|
||||
public Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch)
|
||||
=> TryInvokeAsync<CombinedDiffResultDto>("BuildPlanningIntegrationBranch", planningTaskId, targetBranch);
|
||||
public async Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch)
|
||||
=> await _hub.InvokeAsync<CombinedDiffResultDto>("BuildPlanningIntegrationBranch", planningTaskId, targetBranch);
|
||||
|
||||
public async Task ContinuePlanningMergeAsync(string planningTaskId)
|
||||
{
|
||||
@@ -607,7 +607,11 @@ public sealed record AppSettingsDto(
|
||||
string? ReportExcludedPaths,
|
||||
int StandupWeekday,
|
||||
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(
|
||||
string Name,
|
||||
@@ -624,7 +628,7 @@ public record MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalB
|
||||
public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDocumentDto> Files);
|
||||
public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList<MergeSegmentDto> Segments);
|
||||
public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs);
|
||||
public sealed record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType);
|
||||
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 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);
|
||||
|
||||
@@ -47,7 +47,11 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
|
||||
[ObservableProperty] private string _effectiveSystemPromptHint = "";
|
||||
|
||||
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 int? _listMaxTurns; // Task scope only
|
||||
private string? _listAgentName; // Task scope only
|
||||
@@ -56,7 +60,7 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
|
||||
private CancellationTokenSource? _saveCts;
|
||||
|
||||
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<AgentInfo> Agents { get; } = new();
|
||||
@@ -97,7 +101,14 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
|
||||
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)
|
||||
{
|
||||
@@ -130,8 +141,8 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
|
||||
{
|
||||
var own = MaxTurns?.ToString();
|
||||
var (value, source) = _scope == AgentConfigScope.Task
|
||||
? InheritanceResolver.Resolve(own, _listMaxTurns?.ToString(), _globalMaxTurns.ToString())
|
||||
: InheritanceResolver.ResolveList(own, _globalMaxTurns.ToString());
|
||||
? InheritanceResolver.Resolve(own, _listMaxTurns?.ToString(), GlobalMaxTurns.ToString())
|
||||
: InheritanceResolver.ResolveList(own, GlobalMaxTurns.ToString());
|
||||
TurnsInheritedHint = value;
|
||||
TurnsBadge = BadgeFor(source, MaxTurns is not null);
|
||||
}
|
||||
@@ -284,7 +295,9 @@ public sealed partial class AgentConfigEditorViewModel : ViewModelBase, IDisposa
|
||||
{
|
||||
var app = await _worker.GetAppSettingsAsync();
|
||||
_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)
|
||||
|
||||
@@ -22,6 +22,15 @@ public sealed partial class InteractiveTerminalViewModel : ViewModelBase, IDispo
|
||||
[ObservableProperty] private int? _exitCode;
|
||||
[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()
|
||||
{
|
||||
_session.ProcessExited += OnSessionProcessExited;
|
||||
@@ -74,6 +83,15 @@ public sealed partial class InteractiveTerminalViewModel : ViewModelBase, IDispo
|
||||
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 Dispose()
|
||||
|
||||
@@ -258,6 +258,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
private bool _suppressDescSave;
|
||||
private CancellationTokenSource? _descSaveCts;
|
||||
|
||||
private bool _suppressTitleSave;
|
||||
private CancellationTokenSource? _titleSaveCts;
|
||||
|
||||
// Set by shell so CloseDetailCommand can clear SelectedTask
|
||||
public Action? CloseDetail { get; set; }
|
||||
|
||||
@@ -308,6 +311,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
Prep = new PrepPanelViewModel(worker);
|
||||
|
||||
Notes = new NotesEditorViewModel(_notesApi);
|
||||
Notes.ErrorReported += msg => { if (ShowErrorAsync is not null) _ = ShowErrorAsync(msg); };
|
||||
Subtasks.CollectionChanged += (_, _) => NotifyStepsChanged();
|
||||
Subtasks.CollectionChanged += (_, _) => Merge.SyncChildOutcomes(HasChildOutcomes, Subtasks.Count);
|
||||
Attachments.CollectionChanged += (_, _) => OnPropertyChanged(nameof(FilesBadge));
|
||||
@@ -430,6 +434,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)
|
||||
{
|
||||
if (_suppressDescSave || Task is null) return;
|
||||
@@ -525,7 +562,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||
ct.ThrowIfCancellationRequested();
|
||||
if (entity == null) return;
|
||||
|
||||
EditableTitle = entity.Title;
|
||||
_suppressTitleSave = true;
|
||||
try { EditableTitle = entity.Title; }
|
||||
finally { _suppressTitleSave = false; }
|
||||
_suppressDescSave = true;
|
||||
try { EditableDescription = entity.Description ?? ""; }
|
||||
finally { _suppressDescSave = false; }
|
||||
|
||||
@@ -12,6 +12,8 @@ public sealed partial class ListNavItemViewModel : ViewModelBase
|
||||
[ObservableProperty] private bool _isActive;
|
||||
[ObservableProperty] private string? _workingDir;
|
||||
[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 _dropHintBelow;
|
||||
public string? IconKey { get; init; }
|
||||
|
||||
@@ -16,8 +16,8 @@ namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||
|
||||
public enum ListKind { Smart, Virtual, User }
|
||||
|
||||
/// <summary>Confirmed merge-helper run: the scope list (null = all lists) and the ordered selected task ids.</summary>
|
||||
public sealed record MergeHelperRequest(string? ListId, IReadOnlyList<string> TaskIds);
|
||||
/// <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 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;
|
||||
var rawId = row.Id.StartsWith("user:", StringComparison.Ordinal) ? row.Id["user:".Length..] : row.Id;
|
||||
var vm = _services.GetRequiredService<ListSettingsModalViewModel>();
|
||||
await vm.LoadAsync(rawId, row.Name, row.WorkingDir, row.DefaultCommitType);
|
||||
await vm.LoadAsync(rawId, row.Name, row.WorkingDir, row.DefaultCommitType, row.IsManual);
|
||||
await Dialogs.ShowListSettingsAsync(vm);
|
||||
if (vm.Deleted) await LoadAsync();
|
||||
else await RefreshRowAsync(row.Id);
|
||||
@@ -100,18 +100,6 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
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]
|
||||
private void OpenInExplorer(ListNavItemViewModel? row)
|
||||
{
|
||||
@@ -255,6 +243,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
DotColorKey = dotColors[idx % dotColors.Length],
|
||||
WorkingDir = l.WorkingDir,
|
||||
DefaultCommitType = l.DefaultCommitType,
|
||||
IsManual = l.IsManual,
|
||||
};
|
||||
Items.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)
|
||||
{
|
||||
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);
|
||||
if (vm.Deleted) await LoadAsync();
|
||||
else await RefreshRowAsync(item.Id);
|
||||
@@ -409,6 +398,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||
row.Name = entity.Name;
|
||||
row.WorkingDir = entity.WorkingDir;
|
||||
row.DefaultCommitType = entity.DefaultCommitType;
|
||||
row.IsManual = entity.IsManual;
|
||||
}
|
||||
catch { /* best-effort refresh */ }
|
||||
}
|
||||
|
||||
@@ -24,6 +24,9 @@ public sealed partial class NotesEditorViewModel : ViewModelBase
|
||||
|
||||
public NotesEditorViewModel(INotesApi api) => _api = api;
|
||||
|
||||
// Raised when a worker call fails so the host VM can surface it (e.g. via ShowErrorAsync).
|
||||
public event Action<string>? ErrorReported;
|
||||
|
||||
public ObservableCollection<NoteBulletViewModel> Bullets { get; } = new();
|
||||
|
||||
[ObservableProperty] private DateOnly _currentDay = DateOnly.FromDateTime(DateTime.Today);
|
||||
@@ -55,9 +58,16 @@ public sealed partial class NotesEditorViewModel : ViewModelBase
|
||||
{
|
||||
var text = NewBulletText.Trim();
|
||||
if (text.Length == 0) return;
|
||||
var dto = await _api.AddAsync(CurrentDay, text);
|
||||
if (dto is not null) Bullets.Add(MakeBullet(dto.Id, dto.Text));
|
||||
NewBulletText = "";
|
||||
try
|
||||
{
|
||||
var dto = await _api.AddAsync(CurrentDay, text);
|
||||
if (dto is not null) Bullets.Add(MakeBullet(dto.Id, dto.Text));
|
||||
NewBulletText = "";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ErrorReported?.Invoke(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand] private Task PrevDay() => LoadDayAsync(CurrentDay.AddDays(-1));
|
||||
|
||||
@@ -35,10 +35,20 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
[ObservableProperty] private bool _parentInView = true;
|
||||
[ObservableProperty] private int _roadblockCount;
|
||||
[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.
|
||||
[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 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
|
||||
&& PlanningPhase == PlanningPhase.None
|
||||
&& !IsChild;
|
||||
&& !IsChild
|
||||
&& !IsManual;
|
||||
public bool CanResumeOrDiscardPlanning => PlanningPhase == PlanningPhase.Active;
|
||||
|
||||
// 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 IsRunning => Status == TaskStatus.Running;
|
||||
public bool IsWaitingForReview => Status == TaskStatus.WaitingForReview;
|
||||
// Parked = set aside from review: Idle but still holding its Active worktree (vs a plain Idle task).
|
||||
public bool IsParked => Status == TaskStatus.Idle && WorktreeState == ClaudeDo.Data.Models.WorktreeState.Active;
|
||||
// Parked = set aside from review: Idle but still holding its Active worktree (vs a plain Idle
|
||||
// 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 IsWaiting => Status == TaskStatus.Queued && !string.IsNullOrEmpty(BlockedByTaskId);
|
||||
public bool CanRemoveFromQueue => IsQueued || HasQueuedSubtasks;
|
||||
@@ -94,7 +107,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
// it must be finalized first.
|
||||
public bool CanSendToQueue => !IsRunning && !IsQueued && !IsWaitingForReview && !HasQueuedSubtasks
|
||||
&& (!IsChild || ParentFinalized)
|
||||
&& PlanningPhase != PlanningPhase.Active;
|
||||
&& PlanningPhase != PlanningPhase.Active
|
||||
&& !IsManual;
|
||||
// Parent-level "send plan to queue" — only once the plan is finalized (children Planned).
|
||||
// Drives the routing inside SendToQueue, not a separate menu entry.
|
||||
public bool CanQueuePlan => !IsChild && HasPlanningChildren
|
||||
@@ -115,7 +129,14 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
public string DiffDeletionsText => $"−{DiffDeletions}";
|
||||
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.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)
|
||||
// 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.
|
||||
public bool ShowStatusChip => !(PlanningPhase != PlanningPhase.None && Status == TaskStatus.Idle);
|
||||
// chip would read "Idle" (or "Parked"), which is misleading, so hide it in that case —
|
||||
// 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.WaitingForReview, _) => "review",
|
||||
@@ -208,6 +233,23 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
|
||||
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)
|
||||
{
|
||||
OnPropertyChanged(nameof(CanRemoveFromQueue));
|
||||
@@ -272,6 +314,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
Done = t.Status == TaskStatus.Done;
|
||||
IsStarred = t.IsStarred;
|
||||
IsMyDay = t.IsMyDay;
|
||||
IsManual = t.IsManual;
|
||||
Status = t.Status;
|
||||
PlanningPhase = t.PlanningPhase;
|
||||
Branch = t.Worktree?.BranchName;
|
||||
|
||||
@@ -21,6 +21,9 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
private readonly Dictionary<string, bool> _expandedState = new();
|
||||
private ListNavItemViewModel? _currentList;
|
||||
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();
|
||||
|
||||
public event EventHandler? SelectionChanged;
|
||||
@@ -69,6 +72,12 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
[ObservableProperty] private string _completedHeader = "";
|
||||
[ObservableProperty] private bool _showNotesRow;
|
||||
[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; }
|
||||
|
||||
@@ -208,8 +217,11 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
|
||||
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;
|
||||
else if (e.PropertyName == nameof(ListNavItemViewModel.WorkingDir))
|
||||
IsLetClaudeVisible = vm.Kind == ListKind.User && !string.IsNullOrWhiteSpace(vm.WorkingDir);
|
||||
}
|
||||
|
||||
public void LoadForList(ListNavItemViewModel? list)
|
||||
@@ -234,12 +246,13 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
HasCompleted = false;
|
||||
ShowOpenLabel = false;
|
||||
ShowNotesRow = false;
|
||||
if (list is null) { LoadTask = Task.CompletedTask; return; }
|
||||
if (list is null) { IsLetClaudeVisible = false; LoadTask = Task.CompletedTask; return; }
|
||||
|
||||
HeaderTitle = list.Name;
|
||||
HeaderEyebrow = DateTime.Now.ToString("dddd · MMM dd", CultureInfo.InvariantCulture).ToUpperInvariant();
|
||||
ShowNotesRow = list.Id == "smart:my-day";
|
||||
IsMyDayList = list.Id == "smart:my-day";
|
||||
IsLetClaudeVisible = list.Kind == ListKind.User && !string.IsNullOrWhiteSpace(list.WorkingDir);
|
||||
|
||||
LoadTask = LoadForListAsync(list, ct);
|
||||
}
|
||||
@@ -274,6 +287,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
var row = TaskRowViewModel.FromEntity(t);
|
||||
row.ShowListChip = showListChip;
|
||||
row.HasInteractiveSession = _interactiveSessionIds.Contains(row.Id);
|
||||
Items.Add(row);
|
||||
}
|
||||
|
||||
@@ -423,6 +437,8 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
if (string.IsNullOrWhiteSpace(NewTaskTitle) || _currentList?.Kind != ListKind.User) return;
|
||||
var listId = _currentList.Id["user:".Length..];
|
||||
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
|
||||
{
|
||||
Id = Guid.NewGuid().ToString("N"),
|
||||
@@ -430,17 +446,30 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
Title = NewTaskTitle.Trim(),
|
||||
Status = TaskStatus.Idle,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
IsManual = listIsManual,
|
||||
};
|
||||
await new TaskRepository(db).AddAsync(entity);
|
||||
var row = TaskRowViewModel.FromEntity(entity);
|
||||
row.ShowListChip = _currentList?.Kind == ListKind.Virtual;
|
||||
Items.Add(row);
|
||||
Regroup();
|
||||
SelectedTask = row;
|
||||
NewTaskTitle = "";
|
||||
UpdateSubtitle();
|
||||
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 void ClearDropHints()
|
||||
@@ -607,6 +636,23 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||
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]
|
||||
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.OpenSettingsRequested = () => Lists.OpenSettingsCommand.Execute(null);
|
||||
MissionControl.ErrorReported += FlashFooterError;
|
||||
// Keep the task rows' "Interactive" chip in step with Mission Control's open ConPTY panes.
|
||||
MissionControl.ConPtySessions.CollectionChanged += (_, _) => SyncInteractiveSessionChips();
|
||||
_updateCheck = updateCheck;
|
||||
_installerLocator = installerLocator;
|
||||
_workerLocator = workerLocator;
|
||||
@@ -249,6 +251,11 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
if (Lists.SelectedList is { } row)
|
||||
Lists.OpenListSettingsCommand.Execute(row);
|
||||
};
|
||||
Tasks.LetClaudeHandleRequested += (_, _) =>
|
||||
{
|
||||
if (Lists.SelectedList is { } row)
|
||||
Lists.LetClaudeHandleListCommand.Execute(row);
|
||||
};
|
||||
Details.CloseDetail = () => Tasks.SelectedTask = null;
|
||||
Details.DeleteFromList = row =>
|
||||
{
|
||||
@@ -330,6 +337,16 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||
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]
|
||||
private async Task OpenAbout()
|
||||
{
|
||||
|
||||
@@ -35,18 +35,44 @@ public sealed partial class ConPtyPaneViewModel : ViewModelBase, IMissionControl
|
||||
public event Action<string>? SubmitForReviewRequested;
|
||||
|
||||
/// <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>
|
||||
public ConPtyPaneViewModel(string? taskId, string displayTitle, TerminalLaunchDescriptor descriptor)
|
||||
/// (no task, never deduped); prefer <see cref="CreateAdHoc"/> at ad-hoc call sites.
|
||||
/// <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;
|
||||
_displayTitle = displayTitle;
|
||||
_descriptorFactory = descriptorFactory;
|
||||
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>
|
||||
public static ConPtyPaneViewModel CreateAdHoc(string displayTitle, TerminalLaunchDescriptor descriptor)
|
||||
=> new(null, displayTitle, descriptor);
|
||||
public static ConPtyPaneViewModel CreateAdHoc(
|
||||
string displayTitle,
|
||||
Func<System.Threading.Tasks.Task<TerminalLaunchDescriptor>> descriptorFactory)
|
||||
=> new(null, displayTitle, descriptorFactory);
|
||||
|
||||
private void OnTerminalPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
|
||||
@@ -81,7 +81,7 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
ConPtySessions.CollectionChanged += OnConPtySessionsChanged;
|
||||
Panes.CollectionChanged += OnPanesChanged;
|
||||
|
||||
_onTaskStarted = (slot, taskId, startedAt) => { EnsureMonitor(taskId); _ = RefreshQueueAsync(); };
|
||||
_onTaskStarted = (slot, taskId, startedAt) => { _ = RefreshQueueAsync(); };
|
||||
_worker.TaskStartedEvent += _onTaskStarted;
|
||||
|
||||
_onTaskFinished = (slot, taskId, status, finishedAt) => _ = RefreshQueueAsync();
|
||||
@@ -90,10 +90,9 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
_onTaskUpdated = taskId => _ = RefreshQueueAsync();
|
||||
_worker.TaskUpdatedEvent += _onTaskUpdated;
|
||||
|
||||
_onConnectionRestored = () => { SeedActive(); _ = RefreshQueueAsync(); };
|
||||
_onConnectionRestored = () => { _ = RefreshQueueAsync(); };
|
||||
_worker.ConnectionRestoredEvent += _onConnectionRestored;
|
||||
|
||||
SeedActive();
|
||||
_ = RefreshQueueAsync();
|
||||
}
|
||||
|
||||
@@ -103,19 +102,26 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||
var rows = await ctx.Tasks.AsNoTracking()
|
||||
.Where(t => t.Status == ClaudeDo.Data.Models.TaskStatus.Queued)
|
||||
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
||||
.Select(t => new { t.Id, t.Title, t.BlockedByTaskId })
|
||||
.Where(t => t.Status == ClaudeDo.Data.Models.TaskStatus.Queued
|
||||
|| t.Status == ClaudeDo.Data.Models.TaskStatus.Running)
|
||||
.OrderBy(t => t.Status == ClaudeDo.Data.Models.TaskStatus.Running ? 0 : 1)
|
||||
.ThenBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
||||
.Select(t => new { t.Id, t.Title, t.BlockedByTaskId, t.Status })
|
||||
.ToListAsync();
|
||||
|
||||
Queued.Clear();
|
||||
foreach (var r in rows)
|
||||
{
|
||||
var id = r.Id;
|
||||
Queued.Add(new QueuedTaskViewModel
|
||||
{
|
||||
Id = r.Id,
|
||||
Id = id,
|
||||
Title = r.Title ?? string.Empty,
|
||||
IsBlocked = r.BlockedByTaskId != null,
|
||||
IsRunning = r.Status == ClaudeDo.Data.Models.TaskStatus.Running,
|
||||
OpenInAppCommand = new RelayCommand(() => _openInApp?.Invoke(id)),
|
||||
});
|
||||
}
|
||||
OnPropertyChanged(nameof(HasQueued));
|
||||
}
|
||||
catch { /* best-effort queue refresh */ }
|
||||
@@ -141,13 +147,15 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
await RefreshQueueAsync();
|
||||
}
|
||||
|
||||
private void SeedActive()
|
||||
// Kept intentionally — unused since auto-seeding was disabled (2026-07-29).
|
||||
internal void SeedActive()
|
||||
{
|
||||
foreach (var a in _worker.GetActiveTasks())
|
||||
EnsureMonitor(a.TaskId);
|
||||
}
|
||||
|
||||
private void EnsureMonitor(string taskId)
|
||||
// Kept intentionally — unused since auto-seeding was disabled (2026-07-29).
|
||||
internal void EnsureMonitor(string taskId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(taskId)) return;
|
||||
if (Monitors.Any(m => m.SubscribedTaskId == taskId)) return;
|
||||
@@ -241,20 +249,8 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
}
|
||||
catch { /* best-effort title lookup */ }
|
||||
|
||||
try
|
||||
{
|
||||
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));
|
||||
}
|
||||
AddConPtyPane(new ConPtyPaneViewModel(taskId, title,
|
||||
() => DescribeAsync(() => _worker.GetInteractiveLaunchSpecAsync(taskId))));
|
||||
}
|
||||
|
||||
// Starts (or resumes) a planning session and hosts it as an embedded ConPTY Command Center
|
||||
@@ -279,84 +275,64 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
catch { /* best-effort title lookup */ }
|
||||
title += Loc.T("missionControl.planningTitleSuffix");
|
||||
|
||||
try
|
||||
{
|
||||
var spec = resume
|
||||
? 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));
|
||||
}
|
||||
AddConPtyPane(new ConPtyPaneViewModel(taskId, title, () => DescribeAsync(() => resume
|
||||
? _worker.GetPlanningResumeLaunchSpecAsync(taskId)
|
||||
: _worker.GetPlanningStartLaunchSpecAsync(taskId))));
|
||||
}
|
||||
|
||||
// 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.
|
||||
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('\\', '/'));
|
||||
if (string.IsNullOrEmpty(title)) title = directory;
|
||||
|
||||
try
|
||||
{
|
||||
var spec = await _worker.GetAdHocLaunchSpecAsync(directory);
|
||||
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));
|
||||
}
|
||||
AddConPtyPane(ConPtyPaneViewModel.CreateAdHoc(title,
|
||||
() => DescribeAsync(() => _worker.GetAdHocLaunchSpecAsync(directory))));
|
||||
return System.Threading.Tasks.Task.CompletedTask;
|
||||
}
|
||||
|
||||
// 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.
|
||||
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;
|
||||
|
||||
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
|
||||
{
|
||||
var spec = await _worker.GetMergeHelperLaunchSpecAsync(taskIds, listId);
|
||||
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));
|
||||
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 */ }
|
||||
|
||||
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
|
||||
// session is finished). The worker commits the worktree and moves the task to WaitingForReview.
|
||||
@@ -460,10 +436,12 @@ public sealed partial class MissionControlViewModel : ViewModelBase, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Read-only display row for a queued task in the Mission Control side strip.</summary>
|
||||
/// <summary>Read-only display row for a queued or running task in the Mission Control side strip.</summary>
|
||||
public sealed class QueuedTaskViewModel
|
||||
{
|
||||
public required string Id { get; init; }
|
||||
public required string Title { get; init; }
|
||||
public bool IsBlocked { get; init; }
|
||||
public bool IsRunning { get; init; }
|
||||
public IRelayCommand? OpenInAppCommand { get; init; }
|
||||
}
|
||||
|
||||
@@ -197,6 +197,11 @@ public sealed partial class DiffViewerViewModel : ViewModelBase
|
||||
DisplayedDiff = "";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DisplayedDiff = "";
|
||||
CombinedWarning = Loc.T("vm.planningDiff.buildFailed", ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsLoadingCombined = false;
|
||||
|
||||
@@ -28,6 +28,8 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
[ObservableProperty] private string _name = "";
|
||||
[ObservableProperty] private string _workingDir = "";
|
||||
[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);
|
||||
|
||||
@@ -49,10 +51,12 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
string name,
|
||||
string? workingDir,
|
||||
string defaultCommitType,
|
||||
bool isManual = false,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
ListId = listId;
|
||||
Name = name;
|
||||
IsManual = isManual;
|
||||
WorkingDir = workingDir ?? "";
|
||||
DefaultCommitType = string.IsNullOrWhiteSpace(defaultCommitType) ? CommitTypeRegistry.DefaultType : defaultCommitType;
|
||||
|
||||
@@ -66,7 +70,8 @@ public sealed partial class ListSettingsModalViewModel : ViewModelBase
|
||||
ListId,
|
||||
string.IsNullOrWhiteSpace(Name) ? Loc.T("vm.listSettings.untitled") : Name,
|
||||
string.IsNullOrWhiteSpace(WorkingDir) ? null : WorkingDir,
|
||||
DefaultCommitType));
|
||||
DefaultCommitType,
|
||||
IsManual));
|
||||
|
||||
await Agent.SaveAsync();
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ public sealed partial class MergeHelperTaskRowViewModel : ViewModelBase
|
||||
public required string Id { get; init; }
|
||||
public required string Title { get; init; }
|
||||
public required string StatusText { get; init; }
|
||||
public required string ListName { get; init; }
|
||||
|
||||
[ObservableProperty] private bool _isSelected;
|
||||
}
|
||||
@@ -27,12 +26,11 @@ public sealed partial class MergeHelperTaskRowViewModel : ViewModelBase
|
||||
public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
|
||||
{
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private string? _listId;
|
||||
private string _listId = "";
|
||||
|
||||
public ObservableCollection<MergeHelperTaskRowViewModel> Tasks { get; } = new();
|
||||
|
||||
[ObservableProperty] private string _scopeLabel = "";
|
||||
[ObservableProperty] private bool _isGlobal;
|
||||
|
||||
public bool HasTasks => Tasks.Count > 0;
|
||||
public bool CanConfirm => Tasks.Any(t => t.IsSelected);
|
||||
@@ -43,13 +41,10 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
|
||||
public MergeHelperSelectionModalViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory)
|
||||
=> _dbFactory = dbFactory;
|
||||
|
||||
public void Configure(string? listId, string? listName)
|
||||
public void Configure(string listId, string listName)
|
||||
{
|
||||
_listId = listId;
|
||||
IsGlobal = listId is null;
|
||||
ScopeLabel = listId is null
|
||||
? Loc.T("modals.mergeHelper.scopeAll")
|
||||
: Loc.T("modals.mergeHelper.scopeList", listName ?? "");
|
||||
ScopeLabel = Loc.T("modals.mergeHelper.scopeList", listName);
|
||||
}
|
||||
|
||||
public async Task LoadAsync(CancellationToken ct = default)
|
||||
@@ -58,14 +53,13 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
|
||||
Tasks.Clear();
|
||||
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
var query = ctx.Tasks.AsNoTracking()
|
||||
.Where(t => t.Status != TaskStatus.Done && t.Status != TaskStatus.Cancelled);
|
||||
if (_listId is not null)
|
||||
query = query.Where(t => t.ListId == _listId);
|
||||
|
||||
var candidates = await query
|
||||
var candidates = await ctx.Tasks.AsNoTracking()
|
||||
.Where(t => t.Status != TaskStatus.Done && t.Status != TaskStatus.Cancelled)
|
||||
.Where(t => t.ListId == _listId)
|
||||
// Manual tasks are reminders the user owns — never offer them to the handler.
|
||||
.Where(t => !t.IsManual)
|
||||
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
||||
.Select(t => new { t.Id, t.Title, t.Status, ListName = t.List.Name })
|
||||
.Select(t => new { t.Id, t.Title, t.Status })
|
||||
.ToListAsync(ct);
|
||||
|
||||
foreach (var c in candidates)
|
||||
@@ -75,7 +69,6 @@ public sealed partial class MergeHelperSelectionModalViewModel : ViewModelBase
|
||||
Id = c.Id,
|
||||
Title = c.Title,
|
||||
StatusText = c.Status.ToString(),
|
||||
ListName = c.ListName,
|
||||
IsSelected = IsTickedByDefault(c.Status),
|
||||
};
|
||||
row.PropertyChanged += OnRowChanged;
|
||||
|
||||
@@ -4,6 +4,7 @@ using ClaudeDo.Localization;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.Agent;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Modals.Settings;
|
||||
|
||||
@@ -27,6 +28,44 @@ public sealed partial class GeneralSettingsTabViewModel : ViewModelBase
|
||||
|
||||
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(ILocalizer localizer, Action<string> persist)
|
||||
@@ -50,10 +89,11 @@ public sealed partial class GeneralSettingsTabViewModel : ViewModelBase
|
||||
|
||||
public string? Validate()
|
||||
{
|
||||
if (DefaultMaxTurns < 1 || DefaultMaxTurns > 200)
|
||||
return "Max turns must be between 1 and 200.";
|
||||
if (MaxParallelExecutions < 1 || MaxParallelExecutions > 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;
|
||||
}
|
||||
|
||||
@@ -73,3 +113,21 @@ public sealed partial class GeneralSettingsTabViewModel : ViewModelBase
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ public sealed partial class WorktreesSettingsTabViewModel : ViewModelBase
|
||||
var r = await _worker.CleanupFinishedWorktreesAsync();
|
||||
StatusMessage = r is null ? Loc.T("vm.worktreesTab.workerOffline") : Loc.T("vm.worktreesTab.removed", r.Removed);
|
||||
}
|
||||
catch (Exception ex) { StatusMessage = Loc.T("vm.worktreesTab.cleanupFailed", ex.Message); }
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
@@ -63,6 +64,7 @@ public sealed partial class WorktreesSettingsTabViewModel : ViewModelBase
|
||||
else if (r.Blocked) StatusMessage = Loc.T("vm.worktreesTab.blocked", r.RunningTasks);
|
||||
else StatusMessage = Loc.T("vm.worktreesTab.removedFrom", r.Removed, r.TasksAffected);
|
||||
}
|
||||
catch (Exception ex) { StatusMessage = Loc.T("vm.worktreesTab.resetFailed", ex.Message); }
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,11 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
||||
appSettings.Language = code;
|
||||
appSettings.Save();
|
||||
});
|
||||
General.InitAccentPresets(appSettings.AccentPreset, preset =>
|
||||
{
|
||||
appSettings.AccentPreset = preset;
|
||||
appSettings.Save();
|
||||
});
|
||||
Worktrees = new WorktreesSettingsTabViewModel(worker);
|
||||
Files = new FilesSettingsTabViewModel(worker);
|
||||
Prime = prime;
|
||||
@@ -73,6 +78,7 @@ public sealed partial class SettingsModalViewModel : ViewModelBase
|
||||
await OnlineInbox.LoadAsync();
|
||||
await SessionSkills.LoadAsync();
|
||||
await General.LoadSessionSkillsAsync(_worker, dto?.SessionSkills);
|
||||
General.LoadModelPresets(dto?.ModelPresets);
|
||||
}
|
||||
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()),
|
||||
General.StandupWeekday,
|
||||
Prime.DailyPrepMaxTasks,
|
||||
General.SelectedSessionSkillNames());
|
||||
General.SelectedSessionSkillNames(),
|
||||
General.ModelPresetDtos());
|
||||
await _worker.UpdateAppSettingsAsync(dto);
|
||||
await Prime.SaveAsync();
|
||||
await OnlineInbox.SaveAsync();
|
||||
|
||||
@@ -174,6 +174,7 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
|
||||
StatusMessage = result is null ? Loc.T("vm.worktreesOverview.cleanupFailed") : Loc.T("vm.worktreesOverview.removed", result.Removed);
|
||||
await LoadAsync();
|
||||
}
|
||||
catch (Exception ex) { StatusMessage = Loc.T("vm.worktreesOverview.cleanupFailedDetailed", ex.Message); }
|
||||
finally { IsBusy = false; }
|
||||
}
|
||||
|
||||
@@ -241,7 +242,16 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
|
||||
if (row.IsRunning) { StatusMessage = Loc.T("vm.worktreesOverview.cannotForceRunning"); return; }
|
||||
if (ConfirmAction is not null && !await ConfirmAction($"Force remove worktree for '{row.TaskTitle}'? This deletes the directory and branch.")) return;
|
||||
|
||||
var result = await _worker.ForceRemoveWorktreeAsync(row.TaskId);
|
||||
ForceRemoveResultDto? result;
|
||||
try
|
||||
{
|
||||
result = await _worker.ForceRemoveWorktreeAsync(row.TaskId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
StatusMessage = Loc.T("vm.worktreesOverview.forceRemoveFailedDetailed", ex.Message);
|
||||
return;
|
||||
}
|
||||
if (result is null || !result.Removed)
|
||||
{
|
||||
StatusMessage = result?.Reason ?? Loc.T("vm.worktreesOverview.forceRemoveFailed");
|
||||
|
||||
@@ -181,8 +181,8 @@
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
<!-- New list + import + merge-helper row -->
|
||||
<Grid ColumnDefinitions="*,Auto,Auto" Margin="0,4,0,0">
|
||||
<!-- New list + import row -->
|
||||
<Grid ColumnDefinitions="*,Auto" Margin="0,4,0,0">
|
||||
<Button Grid.Column="0" Classes="new-list-btn"
|
||||
Command="{Binding CreateListCommand}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
@@ -203,13 +203,6 @@
|
||||
Width="14" Height="14"
|
||||
Foreground="{DynamicResource TextMuteBrush}"/>
|
||||
</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>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
@@ -47,6 +47,13 @@
|
||||
<MenuItem Header="{loc:Tr tasks.ctxMarkAs}">
|
||||
<MenuItem Header="{loc:Tr tasks.ctxMarkDone}" Tag="Done" Click="OnSetStatusClick"/>
|
||||
<MenuItem Header="{loc:Tr tasks.ctxMarkCancelled}" Tag="Cancelled" Click="OnSetStatusClick"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="{loc:Tr tasks.ctxMarkManual}"
|
||||
IsVisible="{Binding !IsManual}"
|
||||
Click="OnToggleManualClick"/>
|
||||
<MenuItem Header="{loc:Tr tasks.ctxMarkClaudeTask}"
|
||||
IsVisible="{Binding IsManual}"
|
||||
Click="OnToggleManualClick"/>
|
||||
</MenuItem>
|
||||
<Separator/>
|
||||
<MenuItem Header="{loc:Tr tasks.ctxOpenConPtySession}"
|
||||
@@ -134,6 +141,10 @@
|
||||
IsVisible="{Binding IsPlanningParent}">
|
||||
<TextBlock Text="{Binding PlanningBadge}"/>
|
||||
</Border>
|
||||
<Border Classes="badge manual" IsVisible="{Binding IsManual}"
|
||||
ToolTip.Tip="{loc:Tr tasks.manualTip}">
|
||||
<TextBlock Text="{Binding ManualBadge}"/>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
@@ -154,9 +165,13 @@
|
||||
IsVisible="{Binding IsAgentSuggested}"
|
||||
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"
|
||||
IsVisible="{Binding ShowStatusChip}"
|
||||
Tapped="OnStatusChipTapped"
|
||||
ToolTip.Tip="{Binding InteractiveChipTooltip}"
|
||||
Classes.interactive="{Binding HasInteractiveSession}"
|
||||
Classes.parked="{Binding IsParked}"
|
||||
Classes.running="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=Running}"
|
||||
Classes.review="{Binding Status, Converter={StaticResource EqStatus}, ConverterParameter=WaitingForReview}"
|
||||
@@ -207,17 +222,23 @@
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Refine button -->
|
||||
<Button Grid.Column="5" Classes="icon-btn refine-btn"
|
||||
IsVisible="{Binding CanRefine}"
|
||||
VerticalAlignment="Top" Margin="0,2,0,0"
|
||||
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).RefineTaskCommand}"
|
||||
CommandParameter="{Binding}"
|
||||
ToolTip.Tip="{loc:Tr tasks.refineTip}">
|
||||
<Viewbox Width="16" Height="16">
|
||||
<Path Classes="plan-icon" Data="{StaticResource Icon.Refine}"/>
|
||||
</Viewbox>
|
||||
</Button>
|
||||
<!-- Refine button, replaced by a spinner while the refine run is in flight -->
|
||||
<Panel Grid.Column="5">
|
||||
<Button Classes="icon-btn refine-btn"
|
||||
IsVisible="{Binding CanRefine}"
|
||||
VerticalAlignment="Top" Margin="0,2,0,0"
|
||||
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).RefineTaskCommand}"
|
||||
CommandParameter="{Binding}"
|
||||
ToolTip.Tip="{loc:Tr tasks.refineTip}">
|
||||
<Viewbox Width="16" Height="16">
|
||||
<Path Classes="plan-icon" Data="{StaticResource Icon.Refine}"/>
|
||||
</Viewbox>
|
||||
</Button>
|
||||
<Ellipse Classes="spinner"
|
||||
IsVisible="{Binding IsRefining}"
|
||||
VerticalAlignment="Top" Margin="0,4,2,0"
|
||||
ToolTip.Tip="{loc:Tr tasks.refiningTip}"/>
|
||||
</Panel>
|
||||
|
||||
<!-- Star toggle -->
|
||||
<Button Grid.Column="6" Classes="icon-btn star-btn"
|
||||
|
||||
@@ -43,6 +43,12 @@ public partial class TaskRowView : UserControl
|
||||
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)
|
||||
{
|
||||
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
|
||||
@@ -67,6 +73,15 @@ public partial class TaskRowView : UserControl
|
||||
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)
|
||||
{
|
||||
if (DataContext is TaskRowViewModel row && FindTasksVm() is { } vm)
|
||||
|
||||
@@ -33,6 +33,10 @@
|
||||
ToolTip.Tip="{loc:Tr tasks.showCompletedTip}">
|
||||
<PathIcon Width="15" Height="15" Data="{StaticResource Icon.Eye}"/>
|
||||
</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}"
|
||||
Command="{Binding ClearDayCommand}" ToolTip.Tip="{loc:Tr tasks.clearDayTip}">
|
||||
<PathIcon Width="15" Height="15" Data="{StaticResource Icon.Broom}"/>
|
||||
|
||||
@@ -18,8 +18,9 @@
|
||||
<converters:WorkerLogLevelToBrushConverter x:Key="WorkerLogLevelToBrush"/>
|
||||
</Window.Resources>
|
||||
<Window.KeyBindings>
|
||||
<KeyBinding Gesture="OemQuestion" Command="{Binding FocusSearchCommand}"/>
|
||||
<KeyBinding Gesture="Shift+OemQuestion" Command="{Binding FocusSearchCommand}"/>
|
||||
<!-- OemQuestion is the '#' key on a German layout: binding it here swallowed the
|
||||
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}"/>
|
||||
</Window.KeyBindings>
|
||||
<Grid x:Name="RootGrid" RowDefinitions="36,*,22">
|
||||
|
||||
@@ -44,8 +44,20 @@
|
||||
TextWrapping="Wrap" />
|
||||
</Border>
|
||||
|
||||
<!-- Embedded ConPTY terminal -->
|
||||
<views:InteractiveTerminalView DataContext="{Binding Terminal}" />
|
||||
<!-- Embedded ConPTY terminal, with a starting overlay until the session is spawned -->
|
||||
<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>
|
||||
</Border>
|
||||
|
||||
@@ -73,21 +73,35 @@
|
||||
<ItemsControl ItemsSource="{Binding Queued}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:QueuedTaskViewModel">
|
||||
<Border Margin="0,0,0,4" Padding="8,6"
|
||||
Background="{DynamicResource SurfaceBrush}"
|
||||
BorderBrush="{DynamicResource LineBrush}"
|
||||
BorderThickness="1" CornerRadius="6">
|
||||
<StackPanel Spacing="2">
|
||||
<TextBlock Text="{Binding Title}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
ToolTip.Tip="{Binding Title}"
|
||||
Foreground="{DynamicResource TextDimBrush}" />
|
||||
<TextBlock Classes="meta"
|
||||
Text="{loc:Tr missionControl.blocked}"
|
||||
IsVisible="{Binding IsBlocked}"
|
||||
Foreground="{DynamicResource AmberBrush}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Button Command="{Binding OpenInAppCommand}"
|
||||
Padding="0" Margin="0,0,0,4"
|
||||
Background="Transparent" BorderThickness="0"
|
||||
HorizontalAlignment="Stretch" HorizontalContentAlignment="Stretch"
|
||||
Cursor="Hand">
|
||||
<Panel>
|
||||
<Border Background="{DynamicResource SurfaceBrush}"
|
||||
BorderBrush="{DynamicResource LineBrush}"
|
||||
BorderThickness="1" CornerRadius="6" />
|
||||
<Border Background="{DynamicResource RunningTintBrush}"
|
||||
BorderBrush="{DynamicResource RunningTintBorderBrush}"
|
||||
BorderThickness="1" CornerRadius="6"
|
||||
IsVisible="{Binding IsRunning}" />
|
||||
<StackPanel Margin="8,6" Spacing="2">
|
||||
<TextBlock Text="{Binding Title}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
ToolTip.Tip="{Binding Title}"
|
||||
Foreground="{DynamicResource TextDimBrush}" />
|
||||
<TextBlock Classes="meta"
|
||||
Text="{loc:Tr missionControl.blocked}"
|
||||
IsVisible="{Binding IsBlocked}"
|
||||
Foreground="{DynamicResource AmberBrush}" />
|
||||
<TextBlock Classes="meta"
|
||||
Text="{loc:Tr missionControl.running}"
|
||||
IsVisible="{Binding IsRunning}"
|
||||
Foreground="{DynamicResource StatusRunningBrush}" />
|
||||
</StackPanel>
|
||||
</Panel>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
<TextBlock Grid.Column="1" Classes="meta" Text="{Binding Name}"
|
||||
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"/>
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="4" VerticalAlignment="Center"
|
||||
Margin="0,0,8,0"
|
||||
IsVisible="{Binding ShowStats}">
|
||||
<TextBlock Foreground="{DynamicResource MossBrightBrush}" FontSize="{StaticResource FontSizeEyebrow}"
|
||||
Text="{Binding Additions, StringFormat='+{0}'}"/>
|
||||
|
||||
@@ -60,6 +60,13 @@
|
||||
SelectedItem="{Binding DefaultCommitType, Mode=TwoWay}"
|
||||
HorizontalAlignment="Left" MinWidth="160" />
|
||||
</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>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
|
||||
@@ -38,12 +38,10 @@
|
||||
</Grid>
|
||||
|
||||
<!-- 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}">
|
||||
<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="3" Classes="eyebrow" Text="{loc:Tr modals.mergeHelper.columnList}"
|
||||
IsVisible="{Binding IsGlobal}"/>
|
||||
</Grid>
|
||||
|
||||
<ScrollViewer Padding="20,2,20,8">
|
||||
@@ -54,7 +52,7 @@
|
||||
<ItemsControl ItemsSource="{Binding Tasks}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<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"
|
||||
IsChecked="{Binding IsSelected, Mode=TwoWay}"
|
||||
VerticalAlignment="Center"/>
|
||||
@@ -65,10 +63,6 @@
|
||||
HorizontalAlignment="Left" VerticalAlignment="Center">
|
||||
<TextBlock Text="{Binding StatusText}"/>
|
||||
</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>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
|
||||
xmlns:settings="using:ClaudeDo.Ui.ViewModels.Modals.Settings"
|
||||
xmlns:ui="using:ClaudeDo.Ui"
|
||||
xmlns:agent="using:ClaudeDo.Ui.ViewModels.Agent"
|
||||
xmlns:services="using:ClaudeDo.Ui.Services"
|
||||
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
|
||||
@@ -61,13 +62,46 @@
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</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">
|
||||
<TextBlock Classes="field-label" Text="{loc:Tr settings.general.defaultInstructions}"/>
|
||||
<TextBox AcceptsReturn="True" TextWrapping="Wrap" Height="110"
|
||||
PlaceholderText="{loc:Tr settings.general.defaultInstructionsPlaceholder}"
|
||||
Text="{Binding General.DefaultClaudeInstructions, Mode=TwoWay}"/>
|
||||
</StackPanel>
|
||||
<Grid ColumnDefinitions="*,12,*,12,*">
|
||||
<Grid ColumnDefinitions="*,12,*">
|
||||
<StackPanel Grid.Column="0" Spacing="4">
|
||||
<TextBlock Classes="field-label" Text="{loc:Tr settings.general.model}"/>
|
||||
<ComboBox ItemsSource="{Binding General.Models}"
|
||||
@@ -75,17 +109,38 @@
|
||||
HorizontalAlignment="Stretch"/>
|
||||
</StackPanel>
|
||||
<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}"/>
|
||||
<ComboBox ItemsSource="{Binding General.PermissionModes}"
|
||||
SelectedItem="{Binding General.DefaultPermissionMode, Mode=TwoWay}"
|
||||
HorizontalAlignment="Stretch"/>
|
||||
</StackPanel>
|
||||
</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">
|
||||
<TextBlock Classes="field-label" Text="{loc:Tr settings.general.maxParallelExecutions}"/>
|
||||
<NumericUpDown Value="{Binding General.MaxParallelExecutions, Mode=TwoWay}"
|
||||
|
||||
@@ -29,18 +29,18 @@ 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`
|
||||
- **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).
|
||||
- **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:
|
||||
- `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.
|
||||
- `ListMcpTools` — `CreateList`, `UpdateList`, `DeleteList`
|
||||
- `ConfigMcpTools` — `GetListConfig`, `SetListConfig`, `GetTaskConfig`, `SetTaskConfig`
|
||||
- `RunHistoryMcpTools` — `ListRuns`, `GetRun`, `GetTaskLog` (latest run's log, tail-capped at 256 KB)
|
||||
- `AgentMcpTools` — `ListAgents`
|
||||
- `LifecycleMcpTools` — `ResetFailedTask`
|
||||
- `AppSettingsMcpTools` — `GetAppSettings` (read-only)
|
||||
- `AppSettingsMcpTools` — `GetAppSettings` (read-only; includes `MaxParallelExecutions`)
|
||||
- `AttachmentMcpTools` — `AddTaskAttachment(taskId, fileName, textContent?|base64Content?)`, `ListTaskAttachments`, `RemoveTaskAttachment`. Re-attaching the same fileName overwrites; add/remove refuse on a Running task.
|
||||
- `ExternalMcpService` also exposes two daily-prep tools:
|
||||
- `GetDailyPrepCandidates` — returns Idle, non-blocked tasks in a git repo NOT excluded by `AppSettings.ReportExcludedPaths` and not already `IsMyDay`, plus the current Idle MyDay tasks and `maxTasks` (= `DailyPrepMaxTasks`). Repo-exclusion logic lives in the `DailyPrepFilter` helper (same file).
|
||||
@@ -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. |
|
||||
| `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. |
|
||||
| `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). |
|
||||
|
||||
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)
|
||||
Running → WaitingForReview (standalone success, no 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)
|
||||
2. Create worktree (if `WorkingDir` set) or sandbox directory
|
||||
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`)
|
||||
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"
|
||||
@@ -129,7 +130,7 @@ A child that hits a roadblock (fails, or reports `CLAUDEDO_BLOCKED` roadblocks)
|
||||
## 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).
|
||||
- **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.
|
||||
- **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
|
||||
@@ -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`
|
||||
- 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)
|
||||
- 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. The list-handler spec (`BuildForMergeHelperAsync`) uses `--permission-mode auto` so it runs unattended; the `--allowedTools` allowlist (`mcp__claudedo__*,Read,Grep,Glob,Edit,Bash,WebFetch,WebSearch,Skill`) remains the security boundary.
|
||||
- Worktrees: `CleanupFinishedWorktrees`, `ResetAllWorktrees`, `GetWorktreesOverview`, `SetWorktreeState`, `ForceRemoveWorktree`
|
||||
- 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`
|
||||
|
||||
+3
-1
@@ -8,6 +8,7 @@ namespace ClaudeDo.Worker.External;
|
||||
|
||||
public sealed record AppSettingsReadDto(
|
||||
string DefaultModel, int DefaultMaxTurns, string DefaultPermissionMode,
|
||||
int MaxParallelExecutions,
|
||||
string WorktreeStrategy, string? CentralWorktreeRoot,
|
||||
bool WorktreeAutoCleanupEnabled, int WorktreeAutoCleanupDays);
|
||||
|
||||
@@ -18,13 +19,14 @@ public sealed class AppSettingsMcpTools
|
||||
|
||||
public AppSettingsMcpTools(IDbContextFactory<ClaudeDoDbContext> dbFactory) => _dbFactory = dbFactory;
|
||||
|
||||
[McpServerTool, Description("Read the worker's app-level defaults (model, max turns, permission mode, worktree strategy). Read-only.")]
|
||||
[McpServerTool, Description("Read the worker's app-level defaults (model, max turns, permission mode, max parallel execution slots, worktree strategy). Read-only.")]
|
||||
public async Task<AppSettingsReadDto> GetAppSettings(CancellationToken cancellationToken)
|
||||
{
|
||||
using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
|
||||
var row = await new AppSettingsRepository(ctx).GetAsync(cancellationToken);
|
||||
return new AppSettingsReadDto(
|
||||
row.DefaultModel, row.DefaultMaxTurns, row.DefaultPermissionMode,
|
||||
row.MaxParallelExecutions,
|
||||
row.WorktreeStrategy, row.CentralWorktreeRoot,
|
||||
row.WorktreeAutoCleanupEnabled, row.WorktreeAutoCleanupDays);
|
||||
}
|
||||
|
||||
+2
-2
@@ -93,8 +93,8 @@ public sealed class BatchMcpTools
|
||||
}
|
||||
|
||||
[McpServerTool, Description(
|
||||
"Set the status of many tasks at once. status is 'Idle' (reset to editable) or " +
|
||||
"'Queued' (enqueue for execution) only — same rule as update_task_status. " +
|
||||
"Set the status of many tasks at once. status is 'Idle', 'Queued', 'Cancelled' or 'Done' only — " +
|
||||
"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.")]
|
||||
public async Task<IReadOnlyList<BatchTaskResult>> BatchUpdateTaskStatus(
|
||||
string[] taskIds, string status, CancellationToken cancellationToken)
|
||||
|
||||
+31
-4
@@ -185,6 +185,8 @@ public sealed class ExternalMcpService
|
||||
CommitType = list.DefaultCommitType,
|
||||
CreatedBy = createdBy.NullIfBlank() ?? "mcp",
|
||||
Model = ModelRegistry.NormalizeAlias(model),
|
||||
// A manual list holds reminders, so anything filed there starts out manual.
|
||||
IsManual = list.IsManual,
|
||||
};
|
||||
await _tasks.AddAsync(entity, cancellationToken);
|
||||
|
||||
@@ -262,9 +264,12 @@ public sealed class ExternalMcpService
|
||||
}
|
||||
|
||||
[McpServerTool, Description(
|
||||
"Update a task's status. Only 'Idle' and 'Queued' are permitted externally — " +
|
||||
"use run_task_now or cancel_task for execution control, and review_task to act on a WaitingForReview task. " +
|
||||
"Settable: Idle (reset to editable), Queued (enqueue for execution). " +
|
||||
"Update a task's status. Only 'Idle', 'Queued', 'Cancelled' and 'Done' 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), " +
|
||||
"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.")]
|
||||
public async Task<TaskDto> UpdateTaskStatus(
|
||||
string taskId,
|
||||
@@ -291,9 +296,29 @@ public sealed class ExternalMcpService
|
||||
throw new InvalidOperationException(enqueueResult.Reason ?? "Cannot enqueue task.");
|
||||
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:
|
||||
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))!;
|
||||
@@ -781,6 +806,8 @@ public sealed class ExternalMcpService
|
||||
var candidates = idle
|
||||
.Where(t => !t.IsMyDay
|
||||
&& 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))
|
||||
.OrderBy(t => t.CreatedAt)
|
||||
.Select(ToCandidate)
|
||||
|
||||
@@ -40,7 +40,11 @@ public record AppSettingsDto(
|
||||
string? ReportExcludedPaths,
|
||||
int StandupWeekday,
|
||||
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(
|
||||
string Name,
|
||||
@@ -74,7 +78,7 @@ public record MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalB
|
||||
public record MergeConflictDocumentsDto(string TaskId, IReadOnlyList<ConflictDocumentDto> Files);
|
||||
public record ConflictDocumentDto(string Path, bool IsBinary, IReadOnlyList<MergeSegmentDto> Segments);
|
||||
public record MergeSegmentDto(bool IsConflict, string Text, string Ours, string? Base, string Theirs);
|
||||
public 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 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);
|
||||
@@ -318,7 +322,9 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
row.ReportExcludedPaths,
|
||||
row.StandupWeekday,
|
||||
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)
|
||||
@@ -341,6 +347,11 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
StandupWeekday = dto.StandupWeekday is >= 0 and <= 6 ? dto.StandupWeekday : (int)DayOfWeek.Wednesday,
|
||||
DailyPrepMaxTasks = dto.DailyPrepMaxTasks,
|
||||
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.WorkingDir = string.IsNullOrWhiteSpace(dto.WorkingDir) ? null : dto.WorkingDir;
|
||||
entity.DefaultCommitType = string.IsNullOrWhiteSpace(dto.DefaultCommitType) ? CommitTypeRegistry.DefaultType : dto.DefaultCommitType;
|
||||
entity.IsManual = dto.IsManual;
|
||||
await repo.UpdateAsync(entity);
|
||||
|
||||
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
|
||||
// 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.
|
||||
public Task<LaunchSpec> GetMergeHelperLaunchSpec(string[] taskIds, string? listId) => HubGuard(() =>
|
||||
// and cwd to that list.
|
||||
public Task<LaunchSpec> GetMergeHelperLaunchSpec(string[] taskIds, string listId) => HubGuard(() =>
|
||||
{
|
||||
if (_interactiveLaunchSpec is null)
|
||||
throw new InvalidOperationException("Interactive launch spec service is not configured.");
|
||||
|
||||
@@ -15,7 +15,7 @@ public sealed class QueuePicker : IQueuePicker
|
||||
{
|
||||
// Atomic queue claim: UPDATE + RETURNING in a single statement prevents TOCTOU races.
|
||||
// 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.
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
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
|
||||
WHERE t.status = 'queued'
|
||||
AND t.blocked_by_task_id IS NULL
|
||||
AND t.is_manual = 0
|
||||
AND (t.scheduled_for IS NULL OR t.scheduled_for <= {0})
|
||||
ORDER BY t.sort_order ASC, t.created_at ASC
|
||||
LIMIT 1
|
||||
|
||||
@@ -11,7 +11,8 @@ public sealed record ClaudeRunConfig(
|
||||
string? PermissionMode = null,
|
||||
string? McpConfigPath = 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>();
|
||||
@@ -53,6 +54,12 @@ public sealed class ClaudeArgsBuilder
|
||||
args.Add(config.Model);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(config.Effort))
|
||||
{
|
||||
args.Add("--effort");
|
||||
args.Add(config.Effort);
|
||||
}
|
||||
|
||||
if (config.MaxTurns is int turns && turns > 0)
|
||||
{
|
||||
args.Add("--max-turns");
|
||||
|
||||
@@ -96,6 +96,11 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
|
||||
? WindowsTerminalLauncher.BuildResumeArgs(sessionId)
|
||||
: 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
|
||||
// AskUser MCP tool call caps at 60s unless raised, and lifting it is harmless for
|
||||
// every other tool.
|
||||
@@ -123,7 +128,10 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
|
||||
};
|
||||
|
||||
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)
|
||||
@@ -138,10 +146,13 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
|
||||
};
|
||||
|
||||
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))
|
||||
throw new InvalidOperationException($"Directory does not exist: {directory}");
|
||||
@@ -149,12 +160,18 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
|
||||
var resolvedClaude = WindowsTerminalLauncher.Resolve(_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>
|
||||
{
|
||||
["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,
|
||||
@@ -163,51 +180,28 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
|
||||
private const string MergeHelperAllowedTools =
|
||||
"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)
|
||||
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);
|
||||
var taskRepo = new TaskRepository(ctx);
|
||||
var listRepo = new ListRepository(ctx);
|
||||
|
||||
var listsById = new Dictionary<string, ListEntity?>();
|
||||
var briefLines = new List<string>();
|
||||
var repoDirs = new List<string>(); // distinct, existing, in first-seen order
|
||||
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}");
|
||||
if (!listsById.TryGetValue(task.ListId, out var list))
|
||||
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];
|
||||
briefLines.Add($"- [{task.Status}] {task.Title} (id: {task.Id})");
|
||||
}
|
||||
|
||||
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,
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
["scope"] = scopeLabel,
|
||||
["scope"] = $"List: {list.Name}",
|
||||
["repo"] = repoDir,
|
||||
["tasks"] = string.Join("\n", briefLines),
|
||||
}), ct);
|
||||
|
||||
@@ -231,25 +226,46 @@ public sealed class InteractiveLaunchSpecService : IInteractiveLaunchSpecService
|
||||
// (--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 listConfig = await listRepo.GetConfigAsync(listId, ct);
|
||||
var settings = await new AppSettingsRepository(ctx).GetAsync(ct);
|
||||
|
||||
var args = new List<string>
|
||||
{
|
||||
"--permission-mode", "default",
|
||||
"--effort", EffortFor(settings, listConfig?.Model),
|
||||
"--permission-mode", "auto",
|
||||
"--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>
|
||||
{
|
||||
["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)
|
||||
|
||||
@@ -30,8 +30,8 @@ public interface IInteractiveLaunchSpecService
|
||||
/// <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
|
||||
/// system prompt + task brief under ~/.todo-app/merge-helper-sessions/<guid> and exposes
|
||||
/// that dir plus every distinct existing repo dir via --add-dir. listId scopes the brief label
|
||||
/// and the cwd to that list; null means all lists (cwd = first existing repo dir). Throws
|
||||
/// InvalidOperationException if taskIds is empty or no task has an existing working directory.</summary>
|
||||
Task<LaunchSpec> BuildForMergeHelperAsync(IReadOnlyList<string> taskIds, string? listId, CancellationToken ct);
|
||||
/// that dir plus the list's repo dir via --add-dir. cwd is the list's working directory.
|
||||
/// Throws KeyNotFoundException if the list doesn't exist; InvalidOperationException if
|
||||
/// taskIds is empty or the list has no existing working directory.</summary>
|
||||
Task<LaunchSpec> BuildForMergeHelperAsync(IReadOnlyList<string> taskIds, string listId, CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -516,14 +516,20 @@ public sealed class TaskRunner
|
||||
var requestedSkills = UnionSkillNames(task.SessionSkills, listConfig?.SessionSkills, global.SessionSkills);
|
||||
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(
|
||||
Model: task.Model ?? listConfig?.Model ?? global.DefaultModel,
|
||||
Model: model,
|
||||
SystemPrompt: string.IsNullOrWhiteSpace(instructions) ? null : instructions,
|
||||
AgentPath: task.AgentPath ?? listConfig?.AgentPath,
|
||||
ResumeSessionId: resumeSessionId,
|
||||
MaxTurns: ResolveMaxTurns(task.MaxTurns, listConfig?.MaxTurns, global.DefaultMaxTurns),
|
||||
MaxTurns: ResolveMaxTurns(task.MaxTurns, listConfig?.MaxTurns, preset.MaxTurns),
|
||||
PermissionMode: global.DefaultPermissionMode,
|
||||
SkillNames: skillNames);
|
||||
SkillNames: skillNames,
|
||||
Effort: preset.Effort);
|
||||
}
|
||||
|
||||
private async Task<IReadOnlyList<string>> FilterToInstalledSkillsAsync(
|
||||
|
||||
@@ -9,7 +9,7 @@ public interface ITaskStateService
|
||||
Task<TransitionResult> SubmitInteractiveForReviewAsync(string taskId, DateTime finishedAt, 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> 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> ApproveReviewAsync(string taskId, CancellationToken ct);
|
||||
|
||||
@@ -41,6 +41,9 @@ public sealed class TaskStateService : ITaskStateService
|
||||
if (await IsDraftChildAsync(ctx, taskId, ct))
|
||||
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
|
||||
.Where(t => t.Id == taskId && t.Status != TaskStatus.Running)
|
||||
.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))
|
||||
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
|
||||
.Where(t => t.Id == taskId && t.Status != TaskStatus.Running)
|
||||
.ExecuteUpdateAsync(s => s
|
||||
@@ -241,7 +247,13 @@ public sealed class TaskStateService : ITaskStateService
|
||||
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;
|
||||
await using (var ctx = await _dbFactory.CreateDbContextAsync(ct))
|
||||
@@ -250,7 +262,8 @@ public sealed class TaskStateService : ITaskStateService
|
||||
.Where(t => t.Id == taskId &&
|
||||
(t.Status == TaskStatus.Running || t.Status == TaskStatus.Queued
|
||||
|| t.Status == TaskStatus.WaitingForReview
|
||||
|| t.Status == TaskStatus.WaitingForChildren))
|
||||
|| t.Status == TaskStatus.WaitingForChildren
|
||||
|| (allowFromIdle && t.Status == TaskStatus.Idle)))
|
||||
.ExecuteUpdateAsync(s => s
|
||||
.SetProperty(t => t.Status, TaskStatus.Cancelled)
|
||||
.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)
|
||||
{
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||
var affected = await ctx.Tasks
|
||||
.Where(t => t.Id == taskId)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(t => t.Status, status), ct);
|
||||
var affected = status == TaskStatus.Done
|
||||
? await ctx.Tasks
|
||||
.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)
|
||||
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.
|
||||
// Improvement children whose parent has PlanningPhase.None are not drafts and may be
|
||||
// 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)
|
||||
{
|
||||
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"));
|
||||
}
|
||||
}
|
||||
@@ -52,12 +52,36 @@ public class PromptFilesTests
|
||||
}
|
||||
|
||||
[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);
|
||||
Assert.False(string.IsNullOrWhiteSpace(d));
|
||||
Assert.Contains("review_task", d);
|
||||
Assert.Contains("continue_merge", 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
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DefaultFor_merge_helper_initial_has_repo_token()
|
||||
{
|
||||
var d = PromptFiles.DefaultFor(PromptKind.MergeHelperInitial);
|
||||
Assert.Contains("{repo}", d);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -100,7 +100,7 @@ public abstract class StubWorkerClient : IWorkerClient
|
||||
=> Task.FromResult(new LaunchSpec(".", "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
||||
public virtual Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
|
||||
=> 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>()));
|
||||
public virtual Task<LaunchSpec> GetPlanningStartLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
||||
=> 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; }
|
||||
}
|
||||
|
||||
// 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) =>
|
||||
new(DefaultClaudeInstructions: "", DefaultModel: model, DefaultMaxTurns: turns,
|
||||
DefaultPermissionMode: "auto", MaxParallelExecutions: 1, WorktreeStrategy: "sibling",
|
||||
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) =>
|
||||
new() { Id = "t1", ListId = "l1", Title = "t", CreatedAt = DateTime.UtcNow,
|
||||
|
||||
@@ -137,4 +137,28 @@ public class DetailsIslandReviewActionsTests : IDisposable
|
||||
Assert.True(vm.ShowReviewDiffHint);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,11 +21,14 @@ public class DiffViewerViewModelTests
|
||||
{
|
||||
public IReadOnlyList<SubtaskDiffDto> AggregateResult { get; set; } = Array.Empty<SubtaskDiffDto>();
|
||||
public CombinedDiffResultDto? CombinedResult { get; set; }
|
||||
public string? CombinedException { get; set; }
|
||||
|
||||
public override Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregateAsync(string planningTaskId) =>
|
||||
Task.FromResult(AggregateResult);
|
||||
public override Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch) =>
|
||||
Task.FromResult(CombinedResult);
|
||||
CombinedException is not null
|
||||
? throw new Exception(CombinedException)
|
||||
: Task.FromResult(CombinedResult);
|
||||
}
|
||||
|
||||
// ── Files mode: commit-range guards (ported from DiffModal) ──
|
||||
@@ -193,4 +196,26 @@ public class DiffViewerViewModelTests
|
||||
Assert.NotNull(vm.CombinedWarning);
|
||||
Assert.NotEmpty(vm.CombinedWarning!);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Planning_ToggleCombined_WhenWorkerThrows_ShowsExceptionMessage()
|
||||
{
|
||||
var fake = new FakePlanningWorker
|
||||
{
|
||||
AggregateResult = new[] { new SubtaskDiffDto("s1", "First", "b1", "base1", "head1", null, "DIFF-A") },
|
||||
CombinedException = "planning task not found",
|
||||
};
|
||||
var vm = new DiffViewerViewModel(null!, fake);
|
||||
vm.ConfigurePlanning("plan-1", "main");
|
||||
await vm.LoadAsync();
|
||||
|
||||
vm.IsCombinedMode = true;
|
||||
|
||||
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||
while (DateTime.UtcNow < deadline && vm.IsLoadingCombined) await Task.Delay(10);
|
||||
|
||||
Assert.NotNull(vm.CombinedWarning);
|
||||
Assert.Contains("planning task not found", vm.CombinedWarning);
|
||||
Assert.Equal("", vm.DisplayedDiff);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
using System.IO;
|
||||
using ClaudeDo.Localization;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.Modals.Settings;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||
|
||||
public class FilesSettingsTabViewModelTests
|
||||
{
|
||||
public FilesSettingsTabViewModelTests()
|
||||
{
|
||||
var dir = AppContext.BaseDirectory;
|
||||
while (dir is not null && !Directory.Exists(Path.Combine(dir, "src", "ClaudeDo.Localization", "locales")))
|
||||
dir = Path.GetDirectoryName(dir);
|
||||
Loc.Current = new Localizer(
|
||||
LocaleStore.Load(Path.Combine(dir!, "src", "ClaudeDo.Localization", "locales")), "en");
|
||||
}
|
||||
|
||||
private sealed class ThrowingWorker : StubWorkerClient
|
||||
{
|
||||
public string ExceptionMessage { get; init; } = "permission denied copying agent files";
|
||||
public override Task<SeedResultDto?> RestoreDefaultAgentsAsync() =>
|
||||
throw new Exception(ExceptionMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RestoreDefaultAgents_WhenWorkerThrows_ShowsExceptionMessage_NotGenericOffline()
|
||||
{
|
||||
var worker = new ThrowingWorker();
|
||||
var vm = new FilesSettingsTabViewModel(worker);
|
||||
|
||||
await vm.RestoreDefaultAgentsCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Contains(worker.ExceptionMessage, vm.StatusMessage);
|
||||
Assert.False(vm.IsBusy);
|
||||
}
|
||||
}
|
||||
@@ -74,11 +74,12 @@ public class MergeHelperSelectionModalViewModelTests : IDisposable
|
||||
{
|
||||
await SeedAllStatusesAsync();
|
||||
var vm = BuildVm();
|
||||
vm.Configure(null, null);
|
||||
vm.Configure("L1", "Work");
|
||||
await vm.LoadAsync();
|
||||
|
||||
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-queued").IsSelected);
|
||||
@@ -98,8 +99,6 @@ public class MergeHelperSelectionModalViewModelTests : IDisposable
|
||||
|
||||
Assert.Single(vm.Tasks);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -108,7 +107,7 @@ public class MergeHelperSelectionModalViewModelTests : IDisposable
|
||||
{
|
||||
await SeedAllStatusesAsync();
|
||||
var vm = BuildVm();
|
||||
vm.Configure(null, null);
|
||||
vm.Configure("L1", "Work");
|
||||
await vm.LoadAsync();
|
||||
|
||||
Assert.True(vm.CanConfirm);
|
||||
@@ -129,7 +128,7 @@ public class MergeHelperSelectionModalViewModelTests : IDisposable
|
||||
{
|
||||
await SeedAllStatusesAsync();
|
||||
var vm = BuildVm();
|
||||
vm.Configure(null, null);
|
||||
vm.Configure("L1", "Work");
|
||||
await vm.LoadAsync();
|
||||
|
||||
vm.SelectNoneCommand.Execute(null);
|
||||
@@ -152,7 +151,7 @@ public class MergeHelperSelectionModalViewModelTests : IDisposable
|
||||
{
|
||||
await SeedAllStatusesAsync();
|
||||
var vm = BuildVm();
|
||||
vm.Configure(null, null);
|
||||
vm.Configure("L1", "Work");
|
||||
await vm.LoadAsync();
|
||||
|
||||
var closed = false;
|
||||
|
||||
@@ -49,8 +49,10 @@ public class MissionControlViewModelTests : IDisposable
|
||||
private MissionControlViewModel BuildVm(StubWorkerClient worker)
|
||||
=> new MissionControlViewModel(new TestDbFactory(NewContext), worker);
|
||||
|
||||
// ── acceptance criterion (a): TaskStarted must NOT add a pane ──────────────
|
||||
|
||||
[Fact]
|
||||
public void TwoStarts_CreateTwoMonitors_ColumnCountTwo()
|
||||
public void TaskStarted_DoesNotAddPane()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
@@ -58,18 +60,140 @@ public class MissionControlViewModelTests : IDisposable
|
||||
worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow);
|
||||
worker.RaiseTaskStarted("slot-2", "t2", DateTime.UtcNow);
|
||||
|
||||
Assert.Empty(vm.Monitors);
|
||||
Assert.Empty(vm.Panes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Construction_DoesNotSeedMonitorsEvenWithActiveTasks()
|
||||
{
|
||||
var worker = new SeededFakeWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
|
||||
Assert.Empty(vm.Monitors);
|
||||
Assert.Empty(vm.Panes);
|
||||
}
|
||||
|
||||
private sealed class SeededFakeWorker : StubWorkerClient
|
||||
{
|
||||
public override IReadOnlyList<ActiveTask> GetActiveTasks()
|
||||
=> new[] { new ActiveTask("slot-1", "seed1", DateTime.UtcNow) };
|
||||
}
|
||||
|
||||
// ── acceptance criterion (b): RefreshQueueAsync returns Running rows first ─
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshQueueAsync_RunningFirst_WithIsRunningSet()
|
||||
{
|
||||
await using (var db = NewContext())
|
||||
{
|
||||
db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow });
|
||||
// Running task has higher SortOrder — must still appear first
|
||||
db.Tasks.Add(new TaskEntity { Id = "r1", ListId = "L1", Title = "running-task", Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow, SortOrder = 10 });
|
||||
db.Tasks.Add(new TaskEntity { Id = "q1", ListId = "L1", Title = "queued-task", Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow, SortOrder = 0 });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var worker = new FakeWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
|
||||
await vm.RefreshQueueAsync();
|
||||
|
||||
Assert.True(vm.HasQueued);
|
||||
Assert.Equal(2, vm.Queued.Count);
|
||||
|
||||
Assert.Equal("r1", vm.Queued[0].Id);
|
||||
Assert.True(vm.Queued[0].IsRunning);
|
||||
|
||||
Assert.Equal("q1", vm.Queued[1].Id);
|
||||
Assert.False(vm.Queued[1].IsRunning);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HasQueued_TrueWhenOnlyRunningTasksExist()
|
||||
{
|
||||
await using (var db = NewContext())
|
||||
{
|
||||
db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow });
|
||||
db.Tasks.Add(new TaskEntity { Id = "r1", ListId = "L1", Title = "running", Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow, SortOrder = 0 });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var worker = new FakeWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
|
||||
await vm.RefreshQueueAsync();
|
||||
|
||||
Assert.True(vm.HasQueued);
|
||||
}
|
||||
|
||||
// ── acceptance criterion (c): row click invokes OpenInApp ──────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Row_Click_InvokesOpenInApp()
|
||||
{
|
||||
await using (var db = NewContext())
|
||||
{
|
||||
db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow });
|
||||
db.Tasks.Add(new TaskEntity { Id = "q1", ListId = "L1", Title = "clickable", Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow, SortOrder = 0 });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var worker = new FakeWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
string? opened = null;
|
||||
vm.OpenInApp = id => opened = id;
|
||||
|
||||
await vm.RefreshQueueAsync();
|
||||
vm.Queued[0].OpenInAppCommand!.Execute(null);
|
||||
|
||||
Assert.Equal("q1", opened);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Row_Click_RunningTask_InvokesOpenInApp()
|
||||
{
|
||||
await using (var db = NewContext())
|
||||
{
|
||||
db.Lists.Add(new ListEntity { Id = "L1", Name = "Work", CreatedAt = DateTime.UtcNow });
|
||||
db.Tasks.Add(new TaskEntity { Id = "r1", ListId = "L1", Title = "running", Status = TaskStatus.Running, CreatedAt = DateTime.UtcNow, SortOrder = 0 });
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var worker = new FakeWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
string? opened = null;
|
||||
vm.OpenInApp = id => opened = id;
|
||||
|
||||
await vm.RefreshQueueAsync();
|
||||
vm.Queued[0].OpenInAppCommand!.Execute(null);
|
||||
|
||||
Assert.Equal("r1", opened);
|
||||
}
|
||||
|
||||
// ── monitor machinery tests (EnsureMonitor still works when called directly) ─
|
||||
|
||||
[Fact]
|
||||
public void EnsureMonitor_TwoCalls_CreateTwoMonitors_ColumnCountTwo()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
|
||||
vm.EnsureMonitor("t1");
|
||||
vm.EnsureMonitor("t2");
|
||||
|
||||
Assert.Equal(2, vm.Monitors.Count);
|
||||
Assert.Equal(2, vm.ColumnCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DuplicateStart_DoesNotAddSecondMonitor()
|
||||
public void EnsureMonitor_DuplicateCall_DoesNotAddSecondMonitor()
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
|
||||
worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow);
|
||||
worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow);
|
||||
vm.EnsureMonitor("t1");
|
||||
vm.EnsureMonitor("t1");
|
||||
|
||||
Assert.Equal(1, vm.Monitors.Count);
|
||||
}
|
||||
@@ -80,7 +204,7 @@ public class MissionControlViewModelTests : IDisposable
|
||||
var worker = new FakeWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
|
||||
worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow);
|
||||
vm.EnsureMonitor("t1");
|
||||
worker.RaiseTaskFinished("slot-1", "t1", "done", DateTime.UtcNow);
|
||||
|
||||
Assert.Equal(1, vm.Monitors.Count);
|
||||
@@ -93,8 +217,8 @@ public class MissionControlViewModelTests : IDisposable
|
||||
var worker = new FakeWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
|
||||
worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow);
|
||||
worker.RaiseTaskStarted("slot-2", "t2", DateTime.UtcNow);
|
||||
vm.EnsureMonitor("t1");
|
||||
vm.EnsureMonitor("t2");
|
||||
worker.RaiseTaskFinished("slot-1", "t1", "done", DateTime.UtcNow);
|
||||
|
||||
vm.ClearFinishedCommand.Execute(null);
|
||||
@@ -104,22 +228,6 @@ public class MissionControlViewModelTests : IDisposable
|
||||
Assert.Equal(1, vm.ColumnCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SeedsFromActiveTasksOnConstruction()
|
||||
{
|
||||
var worker = new SeededFakeWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
|
||||
Assert.Equal(1, vm.Monitors.Count);
|
||||
Assert.Equal("seed1", vm.Monitors[0].SubscribedTaskId);
|
||||
}
|
||||
|
||||
private sealed class SeededFakeWorker : StubWorkerClient
|
||||
{
|
||||
public override IReadOnlyList<ActiveTask> GetActiveTasks()
|
||||
=> new[] { new ActiveTask("slot-1", "seed1", DateTime.UtcNow) };
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void OpenInApp_PropagatesToMonitors_AndCommandInvokesHook()
|
||||
{
|
||||
@@ -129,7 +237,7 @@ public class MissionControlViewModelTests : IDisposable
|
||||
string? revealed = null;
|
||||
vm.OpenInApp = id => revealed = id;
|
||||
|
||||
worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow);
|
||||
vm.EnsureMonitor("t1");
|
||||
|
||||
vm.Monitors[0].OpenInAppCommand.Execute(null);
|
||||
|
||||
@@ -145,7 +253,7 @@ public class MissionControlViewModelTests : IDisposable
|
||||
Action? reDock = null;
|
||||
vm.ShowDetached = (m, rd) => { detached = m; reDock = rd; };
|
||||
|
||||
worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow);
|
||||
vm.EnsureMonitor("t1");
|
||||
var monitor = vm.Monitors[0];
|
||||
|
||||
monitor.DetachCommand.Execute(null);
|
||||
@@ -162,7 +270,7 @@ public class MissionControlViewModelTests : IDisposable
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow);
|
||||
vm.EnsureMonitor("t1");
|
||||
worker.RaiseTaskFinished("slot-1", "t1", "waiting_for_review", DateTime.UtcNow);
|
||||
|
||||
Assert.True(vm.Monitors[0].IsWaitingForReview);
|
||||
@@ -177,7 +285,7 @@ public class MissionControlViewModelTests : IDisposable
|
||||
using var vm = BuildVm(worker);
|
||||
Action? reDock = null;
|
||||
vm.ShowDetached = (m, rd) => reDock = rd;
|
||||
worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow);
|
||||
vm.EnsureMonitor("t1");
|
||||
var monitor = vm.Monitors[0];
|
||||
|
||||
monitor.DetachCommand.Execute(null);
|
||||
@@ -195,7 +303,7 @@ public class MissionControlViewModelTests : IDisposable
|
||||
var worker = new FakeWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
vm.ShowDetached = (m, rd) => { };
|
||||
worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow);
|
||||
vm.EnsureMonitor("t1");
|
||||
var monitor = vm.Monitors[0];
|
||||
|
||||
var closeRequested = false;
|
||||
@@ -210,9 +318,9 @@ public class MissionControlViewModelTests : IDisposable
|
||||
{
|
||||
var worker = new FakeWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
worker.RaiseTaskStarted("s1", "t1", DateTime.UtcNow);
|
||||
worker.RaiseTaskStarted("s2", "t2", DateTime.UtcNow);
|
||||
worker.RaiseTaskStarted("s3", "t3", DateTime.UtcNow);
|
||||
vm.EnsureMonitor("t1");
|
||||
vm.EnsureMonitor("t2");
|
||||
vm.EnsureMonitor("t3");
|
||||
|
||||
vm.MoveMonitor(vm.Monitors[0], vm.Monitors[2]); // move t1 to t3's slot
|
||||
Assert.Equal(new[] { "t2", "t3", "t1" }, vm.Monitors.Select(m => m.SubscribedTaskId).ToArray());
|
||||
@@ -298,7 +406,7 @@ public class MissionControlViewModelTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenConPtySessionAsync_WorkerThrows_RaisesErrorReported_NoPaneAdded()
|
||||
public async Task OpenConPtySessionAsync_WorkerThrows_RaisesErrorReported_PaneShowsFailure()
|
||||
{
|
||||
var worker = new ThrowingLaunchSpecWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
@@ -307,8 +415,10 @@ public class MissionControlViewModelTests : IDisposable
|
||||
|
||||
await vm.OpenConPtySessionAsync("t1");
|
||||
|
||||
Assert.Empty(vm.ConPtySessions);
|
||||
Assert.Empty(vm.Panes);
|
||||
// The tile is shown up-front (spinner) and keeps the failure inline rather than vanishing.
|
||||
var pane = Assert.Single(vm.ConPtySessions);
|
||||
Assert.NotNull(pane.Terminal.StartError);
|
||||
Assert.False(pane.Terminal.IsStarting);
|
||||
Assert.NotNull(error);
|
||||
}
|
||||
|
||||
@@ -318,7 +428,7 @@ public class MissionControlViewModelTests : IDisposable
|
||||
var worker = new FakeWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
|
||||
worker.RaiseTaskStarted("slot-1", "t1", DateTime.UtcNow);
|
||||
vm.EnsureMonitor("t1");
|
||||
await vm.OpenConPtySessionAsync("t2");
|
||||
|
||||
Assert.Equal(2, vm.Panes.Count);
|
||||
@@ -378,7 +488,7 @@ public class MissionControlViewModelTests : IDisposable
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenAdHocConPtySessionAsync_WorkerThrows_RaisesErrorReported_NoPaneAdded()
|
||||
public async Task OpenAdHocConPtySessionAsync_WorkerThrows_RaisesErrorReported_PaneShowsFailure()
|
||||
{
|
||||
var worker = new ThrowingAdHocLaunchSpecWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
@@ -387,8 +497,9 @@ public class MissionControlViewModelTests : IDisposable
|
||||
|
||||
await vm.OpenAdHocConPtySessionAsync(Path.Combine("C:", "Some", "Path"));
|
||||
|
||||
Assert.Empty(vm.ConPtySessions);
|
||||
Assert.Empty(vm.Panes);
|
||||
var pane = Assert.Single(vm.ConPtySessions);
|
||||
Assert.NotNull(pane.Terminal.StartError);
|
||||
Assert.False(pane.Terminal.IsStarting);
|
||||
Assert.NotNull(error);
|
||||
}
|
||||
|
||||
@@ -408,7 +519,7 @@ public class MissionControlViewModelTests : IDisposable
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -418,7 +529,7 @@ public class MissionControlViewModelTests : IDisposable
|
||||
var worker = new FakeWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
|
||||
await vm.OpenMergeHelperConPtySessionAsync(null, new[] { "t1", "t2" });
|
||||
await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1", "t2" });
|
||||
|
||||
Assert.Single(vm.ConPtySessions);
|
||||
Assert.Null(vm.ConPtySessions[0].TaskId);
|
||||
@@ -432,8 +543,8 @@ public class MissionControlViewModelTests : IDisposable
|
||||
var worker = new FakeWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
|
||||
await vm.OpenMergeHelperConPtySessionAsync(null, new[] { "t1" });
|
||||
await vm.OpenMergeHelperConPtySessionAsync(null, new[] { "t1" });
|
||||
await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" });
|
||||
await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" });
|
||||
|
||||
Assert.Equal(2, vm.ConPtySessions.Count);
|
||||
}
|
||||
@@ -444,24 +555,25 @@ public class MissionControlViewModelTests : IDisposable
|
||||
var worker = new FakeWorker();
|
||||
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.Panes);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenMergeHelperConPtySessionAsync_WorkerThrows_RaisesErrorReported_NoPaneAdded()
|
||||
public async Task OpenMergeHelperConPtySessionAsync_WorkerThrows_RaisesErrorReported_PaneShowsFailure()
|
||||
{
|
||||
var worker = new ThrowingMergeHelperLaunchSpecWorker();
|
||||
using var vm = BuildVm(worker);
|
||||
string? error = null;
|
||||
vm.ErrorReported += msg => error = msg;
|
||||
|
||||
await vm.OpenMergeHelperConPtySessionAsync(null, new[] { "t1" });
|
||||
await vm.OpenMergeHelperConPtySessionAsync("L1", new[] { "t1" });
|
||||
|
||||
Assert.Empty(vm.ConPtySessions);
|
||||
Assert.Empty(vm.Panes);
|
||||
var pane = Assert.Single(vm.ConPtySessions);
|
||||
Assert.NotNull(pane.Terminal.StartError);
|
||||
Assert.False(pane.Terminal.IsStarting);
|
||||
Assert.NotNull(error);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,33 @@ public class NotesEditorViewModelTests
|
||||
public Task DeleteAsync(string id) { Store.RemoveAll(n => n.Id == id); return Task.CompletedTask; }
|
||||
}
|
||||
|
||||
private sealed class ThrowingNotes : INotesApi
|
||||
{
|
||||
public string ExceptionMessage { get; init; } = "worker offline";
|
||||
public Task<List<DailyNoteDto>> ListAsync(DateOnly day) => Task.FromResult(new List<DailyNoteDto>());
|
||||
public Task<DailyNoteDto?> AddAsync(DateOnly day, string text) => throw new Exception(ExceptionMessage);
|
||||
public Task UpdateAsync(string id, string text) => Task.CompletedTask;
|
||||
public Task DeleteAsync(string id) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddBullet_WhenApiThrows_RaisesErrorReported_AndKeepsDraftText()
|
||||
{
|
||||
var api = new ThrowingNotes();
|
||||
var vm = new NotesEditorViewModel(api);
|
||||
await vm.LoadDayAsync(new DateOnly(2026, 6, 1));
|
||||
|
||||
string? reportedError = null;
|
||||
vm.ErrorReported += msg => reportedError = msg;
|
||||
|
||||
vm.NewBulletText = "Standup vorbereitet";
|
||||
await vm.AddBulletCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Equal(api.ExceptionMessage, reportedError);
|
||||
Assert.Empty(vm.Bullets);
|
||||
Assert.Equal("Standup vorbereitet", vm.NewBulletText);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddBullet_PersistsAndAppears_ForCurrentDay()
|
||||
{
|
||||
|
||||
@@ -19,6 +19,14 @@ public class PrimeClaudeTabViewModelTests
|
||||
public Task DeleteAsync(Guid id) { Deletes.Add(id); return Task.CompletedTask; }
|
||||
}
|
||||
|
||||
private sealed class ThrowingApi : IPrimeScheduleApi
|
||||
{
|
||||
public string ExceptionMessage { get; init; } = "worker offline";
|
||||
public Task<List<PrimeScheduleDto>> ListAsync() => Task.FromResult(new List<PrimeScheduleDto>());
|
||||
public Task<PrimeScheduleDto?> UpsertAsync(PrimeScheduleDto dto) => throw new Exception(ExceptionMessage);
|
||||
public Task DeleteAsync(Guid id) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
private static PrimeScheduleDto Dto(Guid id, int days, TimeSpan time) =>
|
||||
new(id, days, time, true, null, null);
|
||||
|
||||
@@ -93,4 +101,19 @@ public class PrimeClaudeTabViewModelTests
|
||||
vm.AddScheduleCommand.Execute(null);
|
||||
Assert.Null(vm.Validate());
|
||||
}
|
||||
|
||||
// SettingsModalViewModel.Save() is the only place that catches Prime.SaveAsync's
|
||||
// failures (via a try/catch around the whole settings save) and surfaces them as
|
||||
// "Save failed: {message}". For that to work, SaveAsync must propagate the worker's
|
||||
// exception rather than swallow it.
|
||||
[Fact]
|
||||
public async Task Save_WhenApiThrows_PropagatesException()
|
||||
{
|
||||
var api = new ThrowingApi();
|
||||
var vm = new PrimeClaudeTabViewModel(api);
|
||||
vm.AddScheduleCommand.Execute(null);
|
||||
|
||||
var ex = await Assert.ThrowsAsync<Exception>(() => vm.SaveAsync());
|
||||
Assert.Equal(api.ExceptionMessage, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.IO;
|
||||
using ClaudeDo.Localization;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.Modals;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||
|
||||
public class WorktreesOverviewModalErrorTests
|
||||
{
|
||||
public WorktreesOverviewModalErrorTests()
|
||||
{
|
||||
var dir = AppContext.BaseDirectory;
|
||||
while (dir is not null && !Directory.Exists(Path.Combine(dir, "src", "ClaudeDo.Localization", "locales")))
|
||||
dir = Path.GetDirectoryName(dir);
|
||||
Loc.Current = new Localizer(
|
||||
LocaleStore.Load(Path.Combine(dir!, "src", "ClaudeDo.Localization", "locales")), "en");
|
||||
}
|
||||
|
||||
private sealed class ThrowingWorker : StubWorkerClient
|
||||
{
|
||||
public string ExceptionMessage { get; init; } = "worktree is locked by another process";
|
||||
public override Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync(string? listId = null) =>
|
||||
throw new Exception(ExceptionMessage);
|
||||
public override Task<ForceRemoveResultDto?> ForceRemoveWorktreeAsync(string taskId) =>
|
||||
throw new Exception(ExceptionMessage);
|
||||
}
|
||||
|
||||
private static WorktreesOverviewModalViewModel NewVm(ThrowingWorker worker) =>
|
||||
new(worker, () => null!, new MergeCoordinator());
|
||||
|
||||
[Fact]
|
||||
public async Task CleanupFinished_WhenWorkerThrows_ShowsExceptionMessage()
|
||||
{
|
||||
var worker = new ThrowingWorker();
|
||||
var vm = NewVm(worker);
|
||||
|
||||
await vm.CleanupFinishedCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.NotNull(vm.StatusMessage);
|
||||
Assert.Contains(worker.ExceptionMessage, vm.StatusMessage);
|
||||
Assert.False(vm.IsBusy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ForceRemove_WhenWorkerThrows_ShowsExceptionMessage_AndKeepsRow()
|
||||
{
|
||||
var worker = new ThrowingWorker();
|
||||
var vm = NewVm(worker);
|
||||
var row = new WorktreeOverviewRowViewModel
|
||||
{
|
||||
TaskId = "task-1",
|
||||
TaskTitle = "Task 1",
|
||||
TaskStatus = ClaudeDo.Data.Models.TaskStatus.Idle,
|
||||
State = ClaudeDo.Data.Models.WorktreeState.Active,
|
||||
};
|
||||
vm.AddRowForTest(row);
|
||||
|
||||
await vm.ForceRemoveCommand.ExecuteAsync(row);
|
||||
|
||||
Assert.NotNull(vm.StatusMessage);
|
||||
Assert.Contains(worker.ExceptionMessage, vm.StatusMessage);
|
||||
Assert.Contains(row, vm.Rows);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using System.IO;
|
||||
using ClaudeDo.Localization;
|
||||
using ClaudeDo.Ui.Localization;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.Modals.Settings;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||
|
||||
public class WorktreesSettingsTabViewModelTests
|
||||
{
|
||||
public WorktreesSettingsTabViewModelTests()
|
||||
{
|
||||
var dir = AppContext.BaseDirectory;
|
||||
while (dir is not null && !Directory.Exists(Path.Combine(dir, "src", "ClaudeDo.Localization", "locales")))
|
||||
dir = Path.GetDirectoryName(dir);
|
||||
Loc.Current = new Localizer(
|
||||
LocaleStore.Load(Path.Combine(dir!, "src", "ClaudeDo.Localization", "locales")), "en");
|
||||
}
|
||||
|
||||
private sealed class ThrowingWorker : StubWorkerClient
|
||||
{
|
||||
public string ExceptionMessage { get; init; } = "disk full";
|
||||
public override Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync(string? listId = null) =>
|
||||
throw new Exception(ExceptionMessage);
|
||||
public override Task<WorktreeResetDto?> ResetAllWorktreesAsync() =>
|
||||
throw new Exception(ExceptionMessage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CleanupWorktrees_WhenWorkerThrows_ShowsExceptionMessage()
|
||||
{
|
||||
var worker = new ThrowingWorker();
|
||||
var vm = new WorktreesSettingsTabViewModel(worker);
|
||||
|
||||
await vm.CleanupWorktreesCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Contains(worker.ExceptionMessage, vm.StatusMessage);
|
||||
Assert.False(vm.IsBusy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConfirmResetAll_WhenWorkerThrows_ShowsExceptionMessage()
|
||||
{
|
||||
var worker = new ThrowingWorker();
|
||||
var vm = new WorktreesSettingsTabViewModel(worker);
|
||||
|
||||
await vm.ConfirmResetAllCommand.ExecuteAsync(null);
|
||||
|
||||
Assert.Contains(worker.ExceptionMessage, vm.StatusMessage);
|
||||
Assert.False(vm.IsBusy);
|
||||
Assert.False(vm.ShowResetConfirm);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Worker.External;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.External;
|
||||
|
||||
public sealed class AppSettingsMcpToolsTests : IDisposable
|
||||
{
|
||||
private readonly DbFixture _db = new();
|
||||
|
||||
public void Dispose() => _db.Dispose();
|
||||
|
||||
[Fact]
|
||||
public async Task GetAppSettings_ReturnsMaxParallelExecutionsFromDb()
|
||||
{
|
||||
using (var ctx = _db.CreateContext())
|
||||
{
|
||||
var row = await ctx.AppSettings.FirstOrDefaultAsync(s => s.Id == AppSettingsEntity.SingletonId)
|
||||
?? throw new InvalidOperationException("Expected seeded app_settings row.");
|
||||
row.MaxParallelExecutions = 3;
|
||||
await ctx.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var sut = new AppSettingsMcpTools(_db.CreateFactory());
|
||||
|
||||
var result = await sut.GetAppSettings(CancellationToken.None);
|
||||
|
||||
Assert.Equal(3, result.MaxParallelExecutions);
|
||||
}
|
||||
}
|
||||
@@ -189,6 +189,22 @@ public sealed class BatchMcpToolsTests : IDisposable
|
||||
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]
|
||||
public async Task BatchTools_RejectEmptyBatch()
|
||||
{
|
||||
|
||||
@@ -380,6 +380,62 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
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 async Task<string> SeedIdleTask(string title = "t")
|
||||
|
||||
@@ -98,7 +98,7 @@ public class BroadcastLogSinkTests
|
||||
[Fact]
|
||||
public void Does_not_throw_when_detached()
|
||||
{
|
||||
var buffer = new LogRingBuffer(TimeSpan.FromHours(1));
|
||||
var buffer = new LogRingBuffer(TimeSpan.FromHours(1), utcNow: () => EvtTime.UtcDateTime);
|
||||
var sink = new BroadcastLogSink(buffer);
|
||||
sink.Emit(Evt(LogEventLevel.Error, "no subscriber"));
|
||||
|
||||
|
||||
@@ -48,7 +48,8 @@ public sealed class QueuePickerTests : IDisposable
|
||||
DateTime? scheduledFor = null,
|
||||
string? blockedBy = null,
|
||||
bool taskAgentTag = false,
|
||||
int? sortOrder = null)
|
||||
int? sortOrder = null,
|
||||
bool isManual = false)
|
||||
{
|
||||
var task = new TaskEntity
|
||||
{
|
||||
@@ -60,6 +61,7 @@ public sealed class QueuePickerTests : IDisposable
|
||||
ScheduledFor = scheduledFor,
|
||||
BlockedByTaskId = blockedBy,
|
||||
CommitType = "feat",
|
||||
IsManual = isManual,
|
||||
};
|
||||
await _tasks.AddAsync(task);
|
||||
if (sortOrder is not null)
|
||||
@@ -70,6 +72,28 @@ public sealed class QueuePickerTests : IDisposable
|
||||
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]
|
||||
public async Task ClaimNextAsync_Skips_TasksWithBlockedByTaskId()
|
||||
{
|
||||
|
||||
@@ -22,6 +22,25 @@ public sealed class ClaudeArgsBuilderTests
|
||||
Assert.DoesNotContain("--append-system-prompt", args);
|
||||
Assert.DoesNotContain("--agents", 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]
|
||||
|
||||
@@ -184,7 +184,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
||||
Assert.True(Directory.Exists(wtRow.Path));
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -230,7 +230,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
||||
|
||||
Assert.Equal(_worktreeDir, spec.Cwd);
|
||||
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"]);
|
||||
}
|
||||
|
||||
@@ -246,7 +246,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
||||
var svc = BuildService();
|
||||
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]
|
||||
@@ -261,7 +261,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
||||
var svc = BuildService();
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -277,7 +277,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
||||
var svc = BuildService();
|
||||
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]
|
||||
@@ -297,7 +297,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
||||
var svc = BuildService();
|
||||
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]
|
||||
@@ -333,7 +333,7 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
||||
|
||||
Assert.Equal(_tempDir, spec.Cwd);
|
||||
Assert.Equal(_claudeStubPath, spec.Exe);
|
||||
Assert.Empty(spec.Args);
|
||||
Assert.Empty(ArgsAfterEffort(spec));
|
||||
Assert.Equal("200000", spec.Env["MCP_TOOL_TIMEOUT"]);
|
||||
Assert.Empty(_seeder.Calls);
|
||||
}
|
||||
@@ -348,6 +348,17 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
||||
|
||||
// ── 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();
|
||||
|
||||
/// The session dir is the value right after --add-dir; register it for cleanup
|
||||
@@ -363,13 +374,14 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
||||
[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>(), null, CancellationToken.None));
|
||||
() => svc.BuildForMergeHelperAsync(Array.Empty<string>(), listId, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildForMergeHelperAsync_NoExistingWorkingDirs_ThrowsInvalidOperation()
|
||||
public async Task BuildForMergeHelperAsync_ListWithoutExistingWorkingDir_ThrowsInvalidOperation()
|
||||
{
|
||||
var listId = await SeedListAsync(workingDir: Path.Combine(_tempDir, "gone"));
|
||||
var taskId = Guid.NewGuid().ToString();
|
||||
@@ -377,53 +389,56 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
||||
|
||||
var svc = BuildService();
|
||||
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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildForMergeHelperAsync_TasksAcrossTwoRepos_BuildsGlobalScopeSpec()
|
||||
public async Task BuildForMergeHelperAsync_UnknownList_Throws()
|
||||
{
|
||||
var repoA = Path.Combine(_tempDir, "repoA");
|
||||
var repoB = Path.Combine(_tempDir, "repoB");
|
||||
Directory.CreateDirectory(repoA);
|
||||
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 listId = await SeedListAsync(workingDir: _tempDir);
|
||||
var taskId = Guid.NewGuid().ToString();
|
||||
await SeedTaskAsync(taskId, listId, TaskStatus.Idle);
|
||||
|
||||
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);
|
||||
|
||||
// cwd: no listId -> first existing repo dir.
|
||||
Assert.Equal(repoA, spec.Cwd);
|
||||
Assert.Equal(repo, spec.Cwd);
|
||||
Assert.Equal(_claudeStubPath, spec.Exe);
|
||||
|
||||
var args = spec.Args.ToList();
|
||||
|
||||
// --permission-mode default
|
||||
var pmIdx = args.IndexOf("--permission-mode");
|
||||
Assert.True(pmIdx >= 0);
|
||||
Assert.Equal("default", args[pmIdx + 1]);
|
||||
Assert.Equal("auto", args[pmIdx + 1]);
|
||||
|
||||
// allowedTools string for the merge helper
|
||||
var atIdx = args.IndexOf("--allowedTools");
|
||||
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 appendIdx = args.IndexOf("--append-system-prompt-file");
|
||||
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];
|
||||
Assert.Equal(Path.Combine(sessionDir, "system-prompt.md"), systemPromptPath);
|
||||
Assert.True(File.Exists(systemPromptPath));
|
||||
@@ -434,43 +449,32 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
||||
Assert.Contains(briefPath, 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"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BuildForMergeHelperAsync_WithListId_UsesListWorkingDirAsCwdAndListScope()
|
||||
public async Task BuildForMergeHelperAsync_BriefNamesListRepoAndEveryTask()
|
||||
{
|
||||
var repoA = Path.Combine(_tempDir, "repoA2");
|
||||
var repoB = Path.Combine(_tempDir, "repoB2");
|
||||
Directory.CreateDirectory(repoA);
|
||||
Directory.CreateDirectory(repoB);
|
||||
var repo = Path.Combine(_tempDir, "repoBrief");
|
||||
Directory.CreateDirectory(repo);
|
||||
|
||||
var listA = await SeedListAsync(workingDir: repoA, name: "Alpha");
|
||||
var listB = await SeedListAsync(workingDir: repoB, name: "Beta");
|
||||
var listId = await SeedListAsync(workingDir: repo, name: "Alpha");
|
||||
var t1 = Guid.NewGuid().ToString();
|
||||
var t2 = Guid.NewGuid().ToString();
|
||||
await SeedTaskAsync(t1, listA, TaskStatus.WaitingForReview, title: "Task in Alpha");
|
||||
await SeedTaskAsync(t2, listB, TaskStatus.WaitingForReview, title: "Task in Beta");
|
||||
await SeedTaskAsync(t1, listId, TaskStatus.WaitingForReview, title: "First task");
|
||||
await SeedTaskAsync(t2, listId, TaskStatus.Idle, title: "Second task");
|
||||
|
||||
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 }, listB, CancellationToken.None);
|
||||
var spec = await svc.BuildForMergeHelperAsync(new[] { t1, t2 }, listId, CancellationToken.None);
|
||||
var sessionDir = TrackSessionDir(spec);
|
||||
|
||||
Assert.Equal(repoB, spec.Cwd);
|
||||
|
||||
var brief = File.ReadAllText(Path.Combine(sessionDir, "brief.md"));
|
||||
Assert.Contains("Scope: List: Beta", brief);
|
||||
Assert.Contains("Task in Alpha", brief);
|
||||
Assert.Contains("Task in Beta", brief);
|
||||
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);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -505,11 +509,12 @@ public sealed class InteractiveLaunchSpecServiceTests : IDisposable
|
||||
|
||||
var spec = BuildService().BuildPlanningResume(ctx);
|
||||
|
||||
Assert.Equal("--permission-mode", spec.Args[0]);
|
||||
Assert.Equal("default", spec.Args[1]);
|
||||
Assert.Equal("--allowedTools", spec.Args[2]);
|
||||
Assert.Contains("mcp__claudedo__", spec.Args[3]);
|
||||
Assert.Equal(new[] { "--resume", "sess-42" }, spec.Args.Skip(4).ToArray());
|
||||
var args = ArgsAfterEffort(spec);
|
||||
Assert.Equal("--permission-mode", args[0]);
|
||||
Assert.Equal("default", args[1]);
|
||||
Assert.Equal("--allowedTools", args[2]);
|
||||
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(_worktreeDir, spec.Cwd);
|
||||
}
|
||||
|
||||
@@ -41,7 +41,8 @@ public sealed class TaskStateServiceTests : IDisposable
|
||||
string? parentId = null,
|
||||
int sortOrder = 0,
|
||||
string? blockedBy = null,
|
||||
PlanningPhase phase = PlanningPhase.None)
|
||||
PlanningPhase phase = PlanningPhase.None,
|
||||
bool isManual = false)
|
||||
{
|
||||
var id = Guid.NewGuid().ToString();
|
||||
await using var ctx = _factory.CreateDbContext();
|
||||
@@ -56,6 +57,7 @@ public sealed class TaskStateServiceTests : IDisposable
|
||||
ParentTaskId = parentId,
|
||||
SortOrder = sortOrder,
|
||||
BlockedByTaskId = blockedBy,
|
||||
IsManual = isManual,
|
||||
});
|
||||
await ctx.SaveChangesAsync();
|
||||
return id;
|
||||
@@ -89,6 +91,30 @@ public sealed class TaskStateServiceTests : IDisposable
|
||||
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]
|
||||
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<LaunchSpec> GetInteractiveLaunchSpecAsync(string taskId, CancellationToken ct = default)
|
||||
=> 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>()));
|
||||
public Task<LaunchSpec> GetAdHocLaunchSpecAsync(string directory, CancellationToken ct = default)
|
||||
=> Task.FromResult(new LaunchSpec(directory, "claude", Array.Empty<string>(), new Dictionary<string, string>()));
|
||||
|
||||
Reference in New Issue
Block a user