42 Commits

Author SHA1 Message Date
mika kuns
07a9d07cf6 style(ui): align refine button with star and update refine icon
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 08:57:50 +02:00
mika kuns
19435b2d48 style(ui): render review actions as bracketed terminal text
Replace the chromed btn/accent buttons in the review prompt with borderless
bracketed text actions ([Retry] [Reset]) so they read as terminal commands.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 08:42:17 +02:00
mika kuns
e22a3267fe refactor(ui): blend review prompt into the terminal instead of a boxed footer
Drop the bordered Surface2 footer and lay the feedback prompt directly on the
terminal background, aligned with the log lines, so it reads as a shell input
line rather than a separate panel.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 08:39:00 +02:00
mika kuns
9c5872eb27 feat(ui): send Retry on Enter in the review prompt
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 08:34:30 +02:00
mika kuns
8819a56496 feat(ui): rework review into terminal footer and add Git tab
Move review feedback into a prompt-style footer on the Output tab with
Retry/Reset actions, relocate Approve and all merge/worktree controls to a
new Git tab, and reduce the Session tab to subtask outcomes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 08:34:29 +02:00
mika kuns
6c65158be8 feat(ui): add IsGitTab flag to work console view model 2026-06-05 08:28:12 +02:00
mika kuns
096519b978 docs(review): add implementation plan for terminal-style review controls
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 08:21:33 +02:00
mika kuns
266e6d191b docs(review): spec terminal-style review with Git tab and footer actions
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 08:17:17 +02:00
mika kuns
cb4c396a53 docs(merge): document real git merge on approve, PreviewMerge hub method, and new GitService/WorkerClient members
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 23:43:44 +02:00
mika kuns
6e3f90d289 fix(ui): discard stale mergeability probe after task or target switch
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 23:41:59 +02:00
mika kuns
de01579e84 feat(ui): add mergeability indicator and Merge button to work console
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 23:36:56 +02:00
mika kuns
0d8999dc20 feat(ui): show mergeability and surface approve conflicts in the work console
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 23:35:53 +02:00
mika kuns
3202c76674 feat(ui): wire merge-aware approve and preview into the worker client
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 23:32:12 +02:00
mika kuns
43f8f7f7d8 feat(worker): expose PreviewMerge hub method and merge-on-approve 2026-06-04 23:29:05 +02:00
mika kuns
f1cf29b58d fix(worker): guard blank working dir in approve-merge before resolving target
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 23:27:59 +02:00
mika kuns
98b0d58e03 fix(worker-tests): update TaskMergeService ctor calls after ITaskStateService injection 2026-06-04 23:25:03 +02:00
mika kuns
b817c87656 feat(worker): approve merges worktree before marking task done 2026-06-04 23:24:50 +02:00
mika kuns
2a6781f80f feat(ui): add Refine button, icon, and command to task card 2026-06-04 23:21:30 +02:00
mika kuns
4098f7f341 feat(git): add non-destructive merge-tree conflict probe 2026-06-04 23:18:54 +02:00
mika kuns
82390047d2 feat(ui): add RefineTask client call and refine events
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 23:16:58 +02:00
mika kuns
75ad7b1735 docs(merge): add approve-merge + conflict-preview implementation plan
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 23:16:11 +02:00
mika kuns
e523ed85eb feat(refine): wire RefineTask hub method, broadcaster events, and DI 2026-06-04 23:14:00 +02:00
mika kuns
0460d7bea5 feat(refine): add RefineRunner, prompt/args helper, and interfaces
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 23:09:30 +02:00
mika kuns
66a7b2377f docs(merge): add approve-merge + conflict-preview design spec
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 23:07:25 +02:00
mika kuns
eca6813cdb feat(prompts): add Refine prompt kind and default 2026-06-04 23:04:24 +02:00
mika kuns
22830d3ea8 feat(mcp): add add_subtask tool to claudedo MCP 2026-06-04 23:03:07 +02:00
mika kuns
3573548348 docs(refine): add Refine Task implementation plan
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 23:00:01 +02:00
mika kuns
0867bc8296 docs(refine): add Refine Task design spec
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 22:53:17 +02:00
mika kuns
1603be0c78 fix(ui): stop the console clipping the last log line
The tab body ran flush into the console's rounded bottom corner, so the final
log line was shaved off. Inset the tab body from the bottom so the scroll
viewport ends above the corner and ScrollToEnd reveals the whole last line.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 22:51:49 +02:00
mika kuns
71a3765c07 fix(ui): render Output log directly on the console, not as a nested card
The Output tab embedded SessionTerminalView, which is itself a bordered terminal
card with its own header — a card inside the console card. Render the log lines
directly on the console body instead (the console already provides the terminal
chrome, traffic lights, and status chip), with auto-scroll moved to code-behind.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 20:27:03 +02:00
mika kuns
b840655163 feat(ui): resize detail split by dragging the console's top edge
Replace the standalone GridSplitter bar between the details card and the work
console with a transparent splitter over the gap above the console, so the user
drags the console's top edge to resize. Restore the prep-log terminal's inset
now that SessionTerminalView no longer hard-codes its own margin.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 20:18:44 +02:00
mika kuns
ac9bae9546 feat(ui): rework work console — single Session tab, right-aligned header, turns x/y
- Merge "Actions" + "Session" into one state-aware Session tab: review controls
  on top, then merge/worktree management, then child outcomes — each gated on the
  current state, with an empty-state hint when there's nothing to manage. This is
  the home the real merge/diff work (task 09eb5d52) will slot into.
- Move the model · turns · diff info block to the right of the title bar.
- Show turns as current/max using the resolved turn budget (task → list → global).
- Output terminal now fills the console body cleanly: clip the console to its
  rounded corners and inset the embedded terminal instead of clipping its bottom.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 20:18:36 +02:00
mika kuns
99c6bf4478 feat(ui): make steps visible at a glance; lift details card off background
The single flip-icon hid that steps existed until toggled. Replace it with an
always-visible "STEPS" summary strip below the description (open/total count,
click to expand and manage). Description is now always the card body. Give the
card a Surface2 background + LineBrush border so it separates from the window.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 20:18:24 +02:00
mika kuns
3e848710b8 refactor(ui): remove dead inline-layout handlers from DetailsIslandView
The redesigned detail island moved the title, subtask rows, and copy/edit
controls into TaskHeaderBar and DescriptionStepsCard, leaving four unused
code-behind handlers (OnSubtaskTitleTapped, OnSubtaskEditLostFocus,
OnTaskIdTapped, OnCopyDescriptionClick) and their imports.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 20:17:45 +02:00
mika kuns
a2c339cd87 docs(web): add ClaudeDo distribution website design spec
Approved design for a Nuxt 3 site at claudedo.kuns.dev: "the page is the
app" concept (3-island layout), build-time release fetch, and a Nitro
release proxy that fronts the self-updater to hide the Gitea URL.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 20:07:58 +02:00
mika kuns
c71026d125 feat(ui): wire redesigned detail island (header + description/steps card + work console)
Replace the long scrolling DetailsIslandView with the new pinned layout: a
separated TaskHeaderBar (trash↔skull, gear), a DescriptionStepsCard (text⇄steps
toggle, Preview = composed prompt), and a pinned WorkConsole (Output/Actions/
Session tabs). The three components now bind to DetailsIslandViewModel; their
scaffolding sample VMs are removed. Drops the old inline sections + AgentStripView.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 19:49:41 +02:00
mika kuns
ce50f9fcce feat(ui): add WorkConsole detail component
Standalone terminal-styled card with traffic-light title bar, roadblock
band, and three tabs (Output / Actions / Session). Renders fully via
design-time sample data; does not touch DetailsIslandView.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 19:35:35 +02:00
mika kuns
c323953f8c feat(ui): add DescriptionStepsCard detail component
Standalone UserControl combining Description + Steps into one card with
a top-right toggle. Description view shows raw editor or composed
MarkdownView (title + description + open steps). Steps view has an
add-step input and subtask rows with inline editing and check circles.
Adds Icon.Text geometry to IslandStyles for the steps→description toggle.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 19:35:28 +02:00
mika kuns
9f95942dd1 feat(ui): add TaskHeaderBar detail component
Standalone UserControl for the task detail island redesign.
Grid layout: id badge + editable title | trash/skull toggle | gear flyout.
Skull geometry added to IslandStyles.axaml (Icon.Skull, EvenOdd fill).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-04 19:35:21 +02:00
mika kuns
299867d8df feat(worker): compose task prompt from title + description + open steps only
Resolved sub-tasks are no longer appended to the prompt. Extracted into a
shared TaskPromptComposer so the UI's description preview can render the same
'what Claude gets' text.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-04 19:16:37 +02:00
mika kuns
8f7e2898fe docs(ui): task-detail island redesign spec + component build prompts 2026-06-04 19:12:04 +02:00
mika kuns
9f37b1e21e feat:(workflows) Add Changelogs to Relase 2026-06-04 19:07:05 +02:00
64 changed files with 5456 additions and 466 deletions

View File

@@ -5,6 +5,10 @@ on:
tags:
- 'v*'
concurrency:
group: release-${{ github.ref_name }}
cancel-in-progress: false
jobs:
release:
runs-on: ubuntu-latest
@@ -38,11 +42,52 @@ jobs:
TAG: ${{ steps.ver.outputs.tag }}
run: |
set -euo pipefail
git clone --depth 1 --branch "$TAG" \
# Full clone (with tags) so release notes can diff against the previous tag.
git clone --branch "$TAG" \
"https://oauth2:${TOKEN}@git.kuns.dev/${REPO}.git" \
"$WORK/src"
git -C "$WORK/src" log -1 --oneline
- name: Generate release notes
env:
WORK: ${{ steps.ws.outputs.dir }}
TAG: ${{ steps.ver.outputs.tag }}
run: |
set -euo pipefail
cd "$WORK/src"
PREV="$(git tag --sort=v:refname | grep -E '^v' \
| awk -v t="$TAG" '$0==t{print prev} {prev=$0}')"
if [ -n "$PREV" ]; then
RANGE="${PREV}..${TAG}"
else
RANGE="$TAG"
fi
emit_group() {
# $1 conventional-type, $2 heading
local lines
lines="$(git log "$RANGE" --no-merges --pretty=format:'%s|%h' \
| grep -E "^${1}(\([^)]*\))?(!)?: " || true)"
[ -z "$lines" ] && return 0
printf '### %s\n\n' "$2"
while IFS='|' read -r subject hash; do
printf -- '- %s (%s)\n' "${subject#*: }" "$hash"
done <<< "$lines"
printf '\n'
}
{
emit_group feat "Features"
emit_group fix "Fixes"
emit_group perf "Performance"
emit_group refactor "Refactoring"
emit_group docs "Documentation"
} > RELEASE_NOTES.md
echo "--- release notes ---"
cat RELEASE_NOTES.md
- name: Publish ClaudeDo.App (win-x64, self-contained)
env:
WORK: ${{ steps.ws.outputs.dir }}
@@ -128,7 +173,8 @@ jobs:
BODY=$(jq -n \
--arg tag "$TAG" \
--arg name "$TAG" \
'{tag_name:$tag, name:$name, body:"", draft:false, prerelease:false, target_commitish:"main"}')
--rawfile body "$WORK/src/RELEASE_NOTES.md" \
'{tag_name:$tag, name:$name, body:$body, draft:true, prerelease:false, target_commitish:"main"}')
RESP=$(curl -sS -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
@@ -166,6 +212,32 @@ jobs:
done
echo "All assets uploaded."
- name: Publish release
env:
RELEASE_ID: ${{ steps.release.outputs.release_id }}
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
curl -sS --fail-with-body -X PATCH \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d '{"draft":false}' \
"${GITEA_API}/repos/${REPO}/releases/${RELEASE_ID}" \
> /dev/null
echo "Release ${RELEASE_ID} published."
- name: Delete draft release on failure
if: failure() && steps.release.outputs.release_id != ''
env:
RELEASE_ID: ${{ steps.release.outputs.release_id }}
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
curl -sS -X DELETE \
-H "Authorization: token ${TOKEN}" \
"${GITEA_API}/repos/${REPO}/releases/${RELEASE_ID}" \
> /dev/null || true
echo "Cleaned up draft release ${RELEASE_ID}."
- name: Cleanup workspace
if: always()
env:

3
.gitignore vendored
View File

@@ -1,6 +1,9 @@
# Local dev worktrees (created by using-git-worktrees skill)
.worktrees/
# Brainstorming visual companion artifacts
.superpowers/
# .NET build output
bin/
obj/

View File

@@ -35,7 +35,7 @@ Two-process system communicating over SignalR (`127.0.0.1:47821`):
- EF Core migrations manage schema (Migrations/ folder in ClaudeDo.Data)
- `IDbContextFactory<ClaudeDoDbContext>` used by singleton consumers (e.g. Worker)
- Entity configuration via `IEntityTypeConfiguration<T>` in Configuration/ folder
- Task status flow: Idle | Queued -> Running -> WaitingForReview -> Done | Failed | Cancelled. A standalone task's successful run lands in WaitingForReview (planning children go straight to Done); from review you can approve (Done), reject-rerun (Queued, resumes the session with feedback), reject-park (Idle), or cancel (Cancelled).
- Task status flow: Idle | Queued -> Running -> WaitingForReview -> Done | Failed | Cancelled. A standalone task's successful run lands in WaitingForReview (planning children go straight to Done); from review you can approve (merges the worktree into the target branch, then Done; conflicts keep it in WaitingForReview), reject-rerun (Queued, resumes the session with feedback), reject-park (Idle), or cancel (Cancelled). Tasks with no active worktree (sandbox run / improvement parent) are approved straight to Done.
- Worktree state flow: Active -> Merged | Discarded | Kept
- The queue picker claims tasks by `Status=Queued` (with `BlockedByTaskId IS NULL`); the legacy tag system was removed
- Interfaces live in an `Interfaces/` subfolder beside their consumers (namespace unchanged)

View File

@@ -0,0 +1,994 @@
# Approve = Merge → Done + Conflict Preview — 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:** Approving a `WaitingForReview` task merges its worktree into the target branch first and only marks the task `Done` on a clean merge; conflicts keep it in review and are surfaced. Add a non-destructive "merges cleanly / conflicts" indicator and a direct single-task Merge button.
**Architecture:** A new `GitService.PreviewMergeAsync` probes mergeability via `git merge-tree --write-tree` (no working-tree mutation). `TaskMergeService` gains `PreviewAsync` and `ApproveAndMergeAsync` (merge first, then delegate the `Done` flip to `ITaskStateService`). `WorkerHub` exposes `PreviewMerge` and a result-returning `ApproveReview(taskId, targetBranch)`. The UI loads merge targets whenever a worktree exists, shows the preview, and reacts to conflict results.
**Tech Stack:** .NET 8, Avalonia, EF Core/SQLite, SignalR, xUnit with real git (`GitRepoFixture`) and real SQLite (`DbFixture`).
**Conventions for the implementer:**
- Use the **sonnet** model.
- **Stage files explicitly by path** — never `git add -A` (parallel sessions leave unrelated WIP).
- Build with `-c Release` (a running Worker locks `Debug` output).
- Conventional Commit messages: `type(scope): description`.
- New UI strings use **plain English literals** to match the surrounding merge controls (no `loc:Tr`) — this avoids Localization.Tests parity churn.
- Ignore anything under `.claude/worktrees/` — those are stale worktrees, not the build tree.
---
## File map
| File | Change |
|------|--------|
| `src/ClaudeDo.Data/Git/GitService.cs` | Add `MergePreview` record + `PreviewMergeAsync` + `CountChangedFilesAsync` |
| `src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs` | Inject `ITaskStateService`; add `MergePreviewResult` + `PreviewAsync` + `ApproveAndMergeAsync` |
| `src/ClaudeDo.Worker/Hub/WorkerHub.cs` | Add `MergePreviewDto` + `PreviewMerge`; change `ApproveReview` to `(taskId, targetBranch) → MergeResultDto` |
| `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs` | Change `ApproveReviewAsync`; add `PreviewMergeAsync`, `MergeTaskAsync` |
| `src/ClaudeDo.Ui/Services/WorkerClient.cs` | Implement the above; add UI `MergePreviewDto` record |
| `src/ClaudeDo.Ui/ViewModels/Islands/MergePreviewPresenter.cs` | New pure presenter (text + color flags) |
| `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` | Load targets for worktree tasks; preview props; approve conflict handling; `MergeCommand` |
| `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` | Update the list-level approve call to new signature |
| `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml` | Mergeability status line + Merge button |
| `tests/ClaudeDo.Worker.Tests/Runner/GitServicePreviewMergeTests.cs` | New — git-backed preview tests |
| `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs` | Update `BuildService`; add preview + approve-merge tests |
| `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs` | Update `FakeWorkerClient` |
| `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs` | Update fake |
| `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs` | Update the `ApproveReviewAsync` override |
| `tests/ClaudeDo.Ui.Tests/ViewModels/MergePreviewPresenterTests.cs` | New — presenter unit tests |
---
## Task 1: GitService non-destructive merge probe
**Files:**
- Modify: `src/ClaudeDo.Data/Git/GitService.cs`
- Test: `tests/ClaudeDo.Worker.Tests/Runner/GitServicePreviewMergeTests.cs` (create)
Behaviour verified on git 2.50: `git merge-tree --write-tree --name-only <target> <source>` exits `0` when clean (stdout = a single tree-OID line) and `1` on conflict (stdout = tree-OID line, then conflicted file names, then a blank line, then informational messages). It writes only loose objects — the working tree, index, and refs are untouched.
- [ ] **Step 1: Write the failing tests**
Create `tests/ClaudeDo.Worker.Tests/Runner/GitServicePreviewMergeTests.cs`:
```csharp
using ClaudeDo.Data.Git;
using ClaudeDo.Worker.Tests.Infrastructure;
namespace ClaudeDo.Worker.Tests.Runner;
public class GitServicePreviewMergeTests : IDisposable
{
private readonly List<GitRepoFixture> _repos = new();
private GitRepoFixture NewRepo() { var r = new GitRepoFixture(); _repos.Add(r); return r; }
public void Dispose() { foreach (var r in _repos) try { r.Dispose(); } catch { } }
[Fact]
public async Task PreviewMergeAsync_NonConflicting_ReportsCleanWithChangedCount()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var git = new GitService();
var baseBranch = await git.GetCurrentBranchAsync(repo.RepoDir);
GitRepoFixture.RunGit(repo.RepoDir, "checkout", "-b", "feature");
File.WriteAllText(Path.Combine(repo.RepoDir, "newfile.txt"), "x\n");
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "feat");
GitRepoFixture.RunGit(repo.RepoDir, "checkout", baseBranch);
var preview = await git.PreviewMergeAsync(repo.RepoDir, baseBranch, "feature", CancellationToken.None);
Assert.True(preview.Supported);
Assert.True(preview.Clean);
Assert.Empty(preview.ConflictFiles);
var count = await git.CountChangedFilesAsync(repo.RepoDir, baseBranch, "feature", CancellationToken.None);
Assert.Equal(1, count);
}
[Fact]
public async Task PreviewMergeAsync_Conflicting_ReportsFilesAndDoesNotMutateTree()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var git = new GitService();
var baseBranch = await git.GetCurrentBranchAsync(repo.RepoDir);
GitRepoFixture.RunGit(repo.RepoDir, "checkout", "-b", "feature");
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# from feature\n");
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "feat readme");
GitRepoFixture.RunGit(repo.RepoDir, "checkout", baseBranch);
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# from base\n");
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "base readme");
var headBefore = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
var preview = await git.PreviewMergeAsync(repo.RepoDir, baseBranch, "feature", CancellationToken.None);
Assert.True(preview.Supported);
Assert.False(preview.Clean);
Assert.Contains("README.md", preview.ConflictFiles);
// Non-destructive: HEAD unchanged, no mid-merge state.
Assert.Equal(headBefore, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim());
Assert.False(await git.IsMidMergeAsync(repo.RepoDir));
}
}
```
- [ ] **Step 2: Run the tests, verify they fail to compile**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~GitServicePreviewMergeTests`
Expected: build error — `PreviewMergeAsync`/`CountChangedFilesAsync` do not exist.
- [ ] **Step 3: Implement the probe**
In `src/ClaudeDo.Data/Git/GitService.cs`, add this record just under `namespace ClaudeDo.Data.Git;`:
```csharp
public sealed record MergePreview(bool Supported, bool Clean, IReadOnlyList<string> ConflictFiles);
```
Add these methods inside the `GitService` class (e.g. after `ListConflictedFilesAsync`):
```csharp
/// <summary>
/// Non-destructive mergeability probe via `git merge-tree --write-tree`. Writes only
/// loose objects — the working tree, index, and refs are left untouched.
/// </summary>
public async Task<MergePreview> PreviewMergeAsync(
string repoDir, string targetBranch, string sourceBranch, CancellationToken ct = default)
{
var (exitCode, stdout, _) = await RunGitAsync(repoDir,
["merge-tree", "--write-tree", "--name-only", targetBranch, sourceBranch], ct);
if (exitCode == 0)
return new MergePreview(true, true, Array.Empty<string>());
if (exitCode == 1)
{
// stdout: <tree-oid>\n<file>\n...\n\n<informational messages>
var lines = stdout.Split('\n');
var files = new List<string>();
for (int i = 1; i < lines.Length; i++)
{
var line = lines[i].TrimEnd('\r');
if (string.IsNullOrWhiteSpace(line)) break;
files.Add(line.Trim());
}
return new MergePreview(true, false, files);
}
// Any other exit (e.g. git too old: "unknown option --write-tree").
return new MergePreview(false, false, Array.Empty<string>());
}
/// <summary>Count of files that differ on <paramref name="sourceBranch"/> since its merge base with the target.</summary>
public async Task<int> CountChangedFilesAsync(
string repoDir, string targetBranch, string sourceBranch, CancellationToken ct = default)
{
var (exitCode, stdout, _) = await RunGitAsync(repoDir,
["diff", "--name-only", $"{targetBranch}...{sourceBranch}"], ct);
if (exitCode != 0) return 0;
return stdout
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Count(s => s.Length > 0);
}
```
- [ ] **Step 4: Run the tests, verify they pass**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~GitServicePreviewMergeTests`
Expected: PASS (2 tests).
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Data/Git/GitService.cs tests/ClaudeDo.Worker.Tests/Runner/GitServicePreviewMergeTests.cs
git commit -m "feat(git): add non-destructive merge-tree conflict probe"
```
---
## Task 2: TaskMergeService preview + approve-merge orchestration
**Files:**
- Modify: `src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs`
- Test: `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs`
`ApproveAndMergeAsync` merges first (reusing `MergeAsync`, `removeWorktree:false`) and only then delegates the `Done` flip to `ITaskStateService.ApproveReviewAsync` (the sole owner of Status writes). Conflicts/blocks return without flipping status. No DI cycle: `TaskStateService` and `PlanningChainCoordinator` do not depend on `TaskMergeService`.
- [ ] **Step 1: Update `BuildService` and add failing tests**
In `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs`, replace the `BuildService` helper so it also constructs a real `TaskStateService` (existing merge tests still pass — they only inspect the merge service's own broadcaster proxy):
```csharp
private static (TaskMergeService svc, MergeRecordingClientProxy proxy) BuildService(DbFixture db)
{
var fakeHub = new MergeRecordingHubContext();
var broadcaster = new HubBroadcaster(fakeHub);
var state = TaskStateServiceBuilder.Build(db.CreateFactory()).State;
var svc = new TaskMergeService(
db.CreateFactory(),
new GitService(),
broadcaster,
state,
NullLogger<TaskMergeService>.Instance);
return (svc, fakeHub.Proxy);
}
```
Add these tests to the class:
```csharp
[Fact]
public async Task PreviewAsync_CleanWorktree_ReturnsClean()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
var wtMgr = BuildWorktreeManager(db);
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "x\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
var (svc, _) = BuildService(db);
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var preview = await svc.PreviewAsync(task.Id, target, CancellationToken.None);
Assert.Equal(TaskMergeService.PreviewClean, preview.Status);
Assert.True(preview.ChangedFileCount >= 1);
}
[Fact]
public async Task PreviewAsync_Conflict_ReturnsConflictFiles()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
var wtMgr = BuildWorktreeManager(db);
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "README.md"), "# from worktree\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# from main\n");
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main edit");
var (svc, _) = BuildService(db);
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var preview = await svc.PreviewAsync(task.Id, target, CancellationToken.None);
Assert.Equal(TaskMergeService.PreviewConflict, preview.Status);
Assert.Contains("README.md", preview.ConflictFiles);
}
[Fact]
public async Task PreviewAsync_NoActiveWorktree_ReturnsUnavailable()
{
var db = NewDb();
var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.WaitingForReview);
var (svc, _) = BuildService(db);
var preview = await svc.PreviewAsync(task.Id, "main", CancellationToken.None);
Assert.Equal(TaskMergeService.PreviewUnavailable, preview.Status);
}
[Fact]
public async Task ApproveAndMergeAsync_CleanWorktree_MergesAndMarksDone()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
var wtMgr = BuildWorktreeManager(db);
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "new\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
var (svc, _) = BuildService(db);
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var result = await svc.ApproveAndMergeAsync(task.Id, target, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
using var ctx = db.CreateContext();
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.Done, updated!.Status);
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
Assert.Equal(WorktreeState.Merged, wt!.State);
}
[Fact]
public async Task ApproveAndMergeAsync_Conflict_LeavesTaskWaitingForReview()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
var wtMgr = BuildWorktreeManager(db);
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "README.md"), "# from worktree\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# from main\n");
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main edit");
var headBefore = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
var (svc, _) = BuildService(db);
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var result = await svc.ApproveAndMergeAsync(task.Id, target, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusConflict, result.Status);
Assert.Contains("README.md", result.ConflictFiles);
using var ctx = db.CreateContext();
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.WaitingForReview, updated!.Status);
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
Assert.Equal(WorktreeState.Active, wt!.State);
Assert.Equal(headBefore, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim());
Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir));
}
[Fact]
public async Task ApproveAndMergeAsync_NoWorktree_MarksDone()
{
var db = NewDb();
var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.WaitingForReview);
var (svc, _) = BuildService(db);
var result = await svc.ApproveAndMergeAsync(task.Id, "main", CancellationToken.None);
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
using var ctx = db.CreateContext();
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.Done, updated!.Status);
}
```
- [ ] **Step 2: Run the tests, verify they fail**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~TaskMergeServiceTests`
Expected: build error — `ITaskStateService` ctor arg, `PreviewAsync`, `ApproveAndMergeAsync`, `PreviewClean/PreviewConflict/PreviewUnavailable` do not exist.
- [ ] **Step 3: Implement in TaskMergeService**
In `src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs`:
Add `using ClaudeDo.Worker.State;` to the usings.
Add the preview-result record beside `MergeTargets`:
```csharp
public sealed record MergePreviewResult(
string Status,
IReadOnlyList<string> ConflictFiles,
int ChangedFileCount);
```
Add the status constants beside the existing `StatusMerged` etc.:
```csharp
public const string PreviewClean = "clean";
public const string PreviewConflict = "conflict";
public const string PreviewUnavailable = "unavailable";
```
Add the field and constructor param (inject `ITaskStateService`):
```csharp
private readonly ITaskStateService _state;
public TaskMergeService(
IDbContextFactory<ClaudeDoDbContext> dbFactory,
GitService git,
HubBroadcaster broadcaster,
ITaskStateService state,
ILogger<TaskMergeService> logger)
{
_dbFactory = dbFactory;
_git = git;
_broadcaster = broadcaster;
_state = state;
_logger = logger;
}
```
Add the two methods (e.g. after `GetTargetsAsync`):
```csharp
public async Task<MergePreviewResult> PreviewAsync(string taskId, string targetBranch, CancellationToken ct)
{
var (_, list, wt) = await LoadMergeContextAsync(taskId, ct);
if (wt is null || wt.State != WorktreeState.Active)
return new MergePreviewResult(PreviewUnavailable, Array.Empty<string>(), 0);
if (string.IsNullOrWhiteSpace(list.WorkingDir) || !await _git.IsGitRepoAsync(list.WorkingDir, ct))
return new MergePreviewResult(PreviewUnavailable, Array.Empty<string>(), 0);
var target = string.IsNullOrWhiteSpace(targetBranch)
? await _git.GetCurrentBranchAsync(list.WorkingDir, ct)
: targetBranch;
var preview = await _git.PreviewMergeAsync(list.WorkingDir, target, wt.BranchName, ct);
if (!preview.Supported)
return new MergePreviewResult(PreviewUnavailable, Array.Empty<string>(), 0);
if (!preview.Clean)
return new MergePreviewResult(PreviewConflict, preview.ConflictFiles, 0);
var count = await _git.CountChangedFilesAsync(list.WorkingDir, target, wt.BranchName, ct);
return new MergePreviewResult(PreviewClean, Array.Empty<string>(), count);
}
public async Task<MergeResult> ApproveAndMergeAsync(string taskId, string targetBranch, CancellationToken ct)
{
var (task, list, wt) = await LoadMergeContextAsync(taskId, ct);
if (task.Status != TaskStatus.WaitingForReview)
return Blocked("task is not waiting for review");
// No worktree to merge (sandbox run, or an improvement parent whose children own
// the worktrees) — approve straight to Done.
if (wt is null || wt.State != WorktreeState.Active)
{
var done = await _state.ApproveReviewAsync(taskId, ct);
return done.Ok
? new MergeResult(StatusMerged, Array.Empty<string>(), null)
: Blocked(done.Reason ?? "approve failed");
}
var target = string.IsNullOrWhiteSpace(targetBranch)
? await _git.GetCurrentBranchAsync(list.WorkingDir, ct)
: targetBranch;
var merge = await MergeAsync(taskId, target, removeWorktree: false, $"Merge {wt.BranchName}", ct);
if (merge.Status != StatusMerged)
return merge; // conflict or blocked — leave the task in WaitingForReview
var approve = await _state.ApproveReviewAsync(taskId, ct);
return approve.Ok ? merge : Blocked(approve.Reason ?? "approve failed");
}
```
- [ ] **Step 4: Run the tests, verify they pass**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~TaskMergeServiceTests`
Expected: PASS (all existing + 6 new).
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs
git commit -m "feat(worker): approve merges worktree before marking task done"
```
---
## Task 3: WorkerHub — PreviewMerge + result-returning ApproveReview
**Files:**
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
This is SignalR wiring (no unit test); verify by building the Worker.
- [ ] **Step 1: Add the DTO**
Beside the existing `MergeResultDto`/`MergeTargetsDto` records (around line 56):
```csharp
public record MergePreviewDto(string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount);
```
- [ ] **Step 2: Add `PreviewMerge` and replace `ApproveReview`**
Add a `PreviewMerge` method beside `GetMergeTargets`:
```csharp
public Task<MergePreviewDto> PreviewMerge(string taskId, string targetBranch)
=> HubGuard(async () =>
{
var p = await _mergeService.PreviewAsync(taskId, targetBranch ?? "", CancellationToken.None);
return new MergePreviewDto(p.Status, p.ConflictFiles, p.ChangedFileCount);
});
```
Replace the existing `ApproveReview` method (currently lines ~383-387, delegating to `_state.ApproveReviewAsync`) with:
```csharp
public Task<MergeResultDto> ApproveReview(string taskId, string targetBranch)
=> HubGuard(async () =>
{
var r = await _mergeService.ApproveAndMergeAsync(taskId, targetBranch ?? "", CancellationToken.None);
if (r.Status == TaskMergeService.StatusBlocked)
throw new HubException(r.ErrorMessage ?? "approve failed");
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
});
```
(Conflicts are returned, not thrown, so the UI can display the conflicting files; only hard blocks throw.)
- [ ] **Step 3: Build the Worker, verify green**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`
Expected: Build succeeded. (DI resolves the new `ITaskStateService` dependency of `TaskMergeService` automatically — it is already registered.)
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Worker/Hub/WorkerHub.cs
git commit -m "feat(worker): expose PreviewMerge hub method and merge-on-approve"
```
---
## Task 4: UI client + interface + test fakes
**Files:**
- Modify: `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs`
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` (caller at line 648)
- Modify: `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs` (`FakeWorkerClient`)
- Modify: `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`
- Modify: `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs` (override)
Note: `DetailsIslandViewModel.ApproveReviewAsync` (line 1368) is updated in Task 5, not here — but the interface change forces it to compile, so Task 5 must follow before the Ui project builds. To keep this task self-contained and green on its own, update that call site here too (the conflict-handling logic lands in Task 5).
- [ ] **Step 1: Add the UI DTO**
In `src/ClaudeDo.Ui/Services/WorkerClient.cs`, beside the existing `MergeResultDto`/`MergeTargetsDto` records (lines 521-522):
```csharp
public record MergePreviewDto(string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount);
```
- [ ] **Step 2: Update the interface**
In `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs`, replace `Task ApproveReviewAsync(string taskId);` (line 40) with:
```csharp
Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch);
Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch);
Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage);
```
(`MergeTaskAsync` already exists on the concrete `WorkerClient` — this only adds it to the interface.)
- [ ] **Step 3: Update the concrete client**
In `src/ClaudeDo.Ui/Services/WorkerClient.cs`, replace the existing `ApproveReviewAsync` (line ~389) and add `PreviewMergeAsync`. Mirror the existing `GetMergeTargetsAsync` pattern (it uses the `TryInvokeAsync<T>` helper which returns `null` when disconnected):
```csharp
public Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch)
=> TryInvokeAsync<MergeResultDto>("ApproveReview", taskId, targetBranch);
public Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch)
=> TryInvokeAsync<MergePreviewDto>("PreviewMerge", taskId, targetBranch);
```
Ensure the existing `public async Task<MergeResultDto> MergeTaskAsync(...)` signature matches the interface exactly (params: `string taskId, string targetBranch, bool removeWorktree, string commitMessage`). Leave its body as-is.
- [ ] **Step 4: Update the two callers**
`src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` line 648 — the list-level quick approve has no merge-target selector, so it merges into the repo's current branch (empty string resolves server-side):
```csharp
try { await _worker.ApproveReviewAsync(row.Id, ""); }
```
`src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` line 1368 — update to the new signature for now (full conflict handling is added in Task 5):
```csharp
try { await _worker.ApproveReviewAsync(Task.Id, SelectedMergeTarget ?? ""); }
```
- [ ] **Step 5: Update the three test fakes**
`tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs` line 53 — replace and add:
```csharp
public virtual Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) => Task.FromResult<MergeResultDto?>(null);
public virtual Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch) => Task.FromResult<MergePreviewDto?>(null);
public virtual Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));
```
`tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs` line 45 (`FakeWorkerClient`) — replace and add:
```csharp
public Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) => Task.FromResult<MergeResultDto?>(null);
public Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch) => Task.FromResult<MergePreviewDto?>(null);
public Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));
```
(Confirm whether `FakeWorkerClient` already implements `MergeTaskAsync`; if so, only change `ApproveReviewAsync` and add `PreviewMergeAsync`. Add `using` for the DTO namespace if needed — same namespace as `IWorkerClient`.)
`tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs` line 77 — update the override signature:
```csharp
public override Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) =>
/* keep whatever recording/behavior this override had, now returning Task<MergeResultDto?> */
Task.FromResult<MergeResultDto?>(null);
```
(Preserve any side effect the existing override performed — e.g. recording the call — just change the signature and return type.)
- [ ] **Step 6: Build UI + run both UI-touching test projects**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~TasksIslandViewModelPlanning`
Expected: all green.
- [ ] **Step 7: Commit**
```bash
git add src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs src/ClaudeDo.Ui/Services/WorkerClient.cs src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs
git commit -m "feat(ui): wire merge-aware approve and preview into the worker client"
```
---
## Task 5: Mergeability presenter + DetailsIslandViewModel wiring
**Files:**
- Create: `src/ClaudeDo.Ui/ViewModels/Islands/MergePreviewPresenter.cs`
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/MergePreviewPresenterTests.cs` (create)
- [ ] **Step 1: Write the failing presenter tests**
Create `tests/ClaudeDo.Ui.Tests/ViewModels/MergePreviewPresenterTests.cs`:
```csharp
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Islands;
namespace ClaudeDo.Ui.Tests.ViewModels;
public class MergePreviewPresenterTests
{
[Fact]
public void Clean_Plural()
{
var (text, clean, conflict) = MergePreviewPresenter.Describe(
new MergePreviewDto("clean", System.Array.Empty<string>(), 3));
Assert.Equal("Merges cleanly · 3 files", text);
Assert.True(clean);
Assert.False(conflict);
}
[Fact]
public void Clean_Singular()
{
var (text, _, _) = MergePreviewPresenter.Describe(
new MergePreviewDto("clean", System.Array.Empty<string>(), 1));
Assert.Equal("Merges cleanly · 1 file", text);
}
[Fact]
public void Conflict_ListsUpToThree()
{
var (text, clean, conflict) = MergePreviewPresenter.Describe(
new MergePreviewDto("conflict", new[] { "a.cs", "b.cs" }, 0));
Assert.Equal("Conflicts in a.cs, b.cs", text);
Assert.False(clean);
Assert.True(conflict);
}
[Fact]
public void Conflict_TruncatesWithMore()
{
var (text, _, _) = MergePreviewPresenter.Describe(
new MergePreviewDto("conflict", new[] { "a", "b", "c", "d", "e" }, 0));
Assert.Equal("Conflicts in a, b, c (+2 more)", text);
}
[Fact]
public void Unavailable_IsMuted()
{
var (text, clean, conflict) = MergePreviewPresenter.Describe(
new MergePreviewDto("unavailable", System.Array.Empty<string>(), 0));
Assert.Equal("Mergeability unknown", text);
Assert.False(clean);
Assert.False(conflict);
}
[Fact]
public void Null_IsEmpty()
{
var (text, clean, conflict) = MergePreviewPresenter.Describe(null);
Assert.Equal("", text);
Assert.False(clean);
Assert.False(conflict);
}
}
```
- [ ] **Step 2: Run, verify it fails to compile**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter FullyQualifiedName~MergePreviewPresenterTests`
Expected: build error — `MergePreviewPresenter` does not exist.
- [ ] **Step 3: Create the presenter**
Create `src/ClaudeDo.Ui/ViewModels/Islands/MergePreviewPresenter.cs`:
```csharp
using System.Linq;
using ClaudeDo.Ui.Services;
namespace ClaudeDo.Ui.ViewModels.Islands;
/// Pure mapping from a merge-preview DTO to display text + color flags.
public static class MergePreviewPresenter
{
public static (string Text, bool IsClean, bool IsConflict) Describe(MergePreviewDto? dto)
{
if (dto is null) return ("", false, false);
switch (dto.Status)
{
case "clean":
var unit = dto.ChangedFileCount == 1 ? "file" : "files";
return ($"Merges cleanly · {dto.ChangedFileCount} {unit}", true, false);
case "conflict":
var names = string.Join(", ", dto.ConflictFiles.Take(3));
var more = dto.ConflictFiles.Count > 3 ? $" (+{dto.ConflictFiles.Count - 3} more)" : "";
return ($"Conflicts in {names}{more}", false, true);
default:
return ("Mergeability unknown", false, false);
}
}
}
```
- [ ] **Step 4: Run, verify the presenter tests pass**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter FullyQualifiedName~MergePreviewPresenterTests`
Expected: PASS (6 tests).
- [ ] **Step 5: Wire the presenter into DetailsIslandViewModel**
In `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`:
(a) Add observable properties (near the other merge properties, ~line 334):
```csharp
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowMergePreviewMuted))]
private string _mergePreviewText = "";
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowMergePreviewMuted))]
private bool _mergeIsClean;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowMergePreviewMuted))]
private bool _mergeIsConflict;
public bool ShowMergePreviewMuted =>
!MergeIsClean && !MergeIsConflict && !string.IsNullOrEmpty(MergePreviewText);
public bool ShowSingleMerge =>
WorktreePath != null && Task?.IsPlanningParent != true;
```
(b) Add the refresh method:
```csharp
private async System.Threading.Tasks.Task RefreshMergePreviewAsync()
{
if (Task is null || WorktreePath is null)
{
MergePreviewText = ""; MergeIsClean = false; MergeIsConflict = false;
return;
}
// Only probe Active worktrees; terminal states show their label instead.
if (WorktreeStateLabel is { } label && label != "Active")
{
MergePreviewText = label; MergeIsClean = false; MergeIsConflict = false;
return;
}
var dto = await _worker.PreviewMergeAsync(Task.Id, SelectedMergeTarget ?? "");
var (text, clean, conflict) = MergePreviewPresenter.Describe(dto);
MergePreviewText = text; MergeIsClean = clean; MergeIsConflict = conflict;
}
```
(c) Recompute when the merge target changes — add (or extend) the generated partial:
```csharp
partial void OnSelectedMergeTargetChanged(string? value)
{
_ = RefreshMergePreviewAsync();
}
```
(d) Notify `ShowSingleMerge` when the worktree path changes. In the existing `OnWorktreePathChanged` (line ~1141) add:
```csharp
OnPropertyChanged(nameof(ShowSingleMerge));
```
(e) Load merge targets for standalone worktree tasks. In `BindAsync`, after the `if (entity.PlanningPhase != None) {...} else {...}` block (~line 814), add:
```csharp
if (entity.Worktree != null
&& entity.PlanningPhase == ClaudeDo.Data.Models.PlanningPhase.None
&& MergeTargetBranches.Count == 0)
{
var targets = await _worker.GetMergeTargetsAsync(row.Id);
if (targets != null)
{
MergeTargetBranches.Clear();
foreach (var b in targets.LocalBranches) MergeTargetBranches.Add(b);
SelectedMergeTarget = targets.DefaultBranch; // triggers OnSelectedMergeTargetChanged → preview
}
}
await RefreshMergePreviewAsync();
```
(f) Replace the body of `ApproveReviewAsync` (line ~1362) to surface conflicts:
```csharp
[RelayCommand]
private async System.Threading.Tasks.Task ApproveReviewAsync()
{
if (Task is null || !_worker.IsConnected) return;
try
{
var result = await _worker.ApproveReviewAsync(Task.Id, SelectedMergeTarget ?? "");
if (result?.Status == "conflict")
{
var (text, _, _) = MergePreviewPresenter.Describe(
new MergePreviewDto("conflict", result.ConflictFiles, 0));
MergePreviewText = text; MergeIsClean = false; MergeIsConflict = true;
}
}
catch { /* stale review action; broadcast reconciles */ }
}
```
(g) Add the single-task `MergeCommand` (place near `OpenDiffAsync`):
```csharp
[RelayCommand]
private async System.Threading.Tasks.Task MergeAsync()
{
if (Task is null || WorktreePath is null || !_worker.IsConnected) return;
try
{
var result = await _worker.MergeTaskAsync(Task.Id, SelectedMergeTarget ?? "", false, "Merge task");
if (result.Status == "conflict")
{
var (text, _, _) = MergePreviewPresenter.Describe(
new MergePreviewDto("conflict", result.ConflictFiles, 0));
MergePreviewText = text; MergeIsClean = false; MergeIsConflict = true;
}
else
{
await RefreshMergePreviewAsync();
}
}
catch { /* broadcast reconciles */ }
}
```
- [ ] **Step 6: Build UI + run the UI tests**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
Expected: green. (If `OnSelectedMergeTargetChanged` already exists, merge the new line into it instead of duplicating.)
- [ ] **Step 7: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Islands/MergePreviewPresenter.cs src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/MergePreviewPresenterTests.cs
git commit -m "feat(ui): show mergeability and surface approve conflicts in the work console"
```
---
## Task 6: WorkConsole — status line + Merge button
**Files:**
- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml`
No unit test (XAML); verified by build + manual visual check in Task 7.
- [ ] **Step 1: Add the mergeability status line and the Merge button**
In the `MERGE & WORKTREE` `StackPanel` (starts line 196), insert the status line **between** the merge-target `StackPanel` (ends line 203) and the `<WrapPanel>` (line 204). Three single-line `TextBlock`s, one visible at a time by color:
```xml
<StackPanel Spacing="0">
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
Foreground="{DynamicResource MossBrush}"
IsVisible="{Binding MergeIsClean}" />
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
Foreground="{DynamicResource BloodBrush}"
IsVisible="{Binding MergeIsConflict}" />
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
Foreground="{DynamicResource TextMuteBrush}"
IsVisible="{Binding ShowMergePreviewMuted}" />
</StackPanel>
```
In the `<WrapPanel>` (line 204), add a **Merge** button immediately after the "Open Diff" button (line 206):
```xml
<Button Classes="btn accent" Content="Merge" Margin="0,0,8,8"
Command="{Binding MergeCommand}"
IsVisible="{Binding ShowSingleMerge}" />
```
- [ ] **Step 2: Build UI, verify green**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Expected: Build succeeded (XAML compiles; all bound members exist from Task 5).
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml
git commit -m "feat(ui): add mergeability indicator and Merge button to work console"
```
---
## Task 7: Full build, full test, manual verification
**Files:** none (verification only)
- [ ] **Step 1: Build the whole app + worker**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`
Expected: both succeed.
- [ ] **Step 2: Run all touched test projects**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release`
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
Expected: all green.
- [ ] **Step 3: Manual verification (cannot be automated — no real Claude in tests)**
Start the Worker, then the App. Pick a list whose `WorkingDir` is a real git repo and use a task that already has an Active worktree (or create one).
Verify each acceptance criterion:
1. **Clean approve:** Open a `WaitingForReview` task whose worktree merges cleanly → the Session tab shows green "Merges cleanly · N files". Click **Approve** → the worktree merges into the target, the task becomes **Done**, and the worktree state becomes **Merged** (check the worktree overview).
2. **Conflicting approve:** Open a task whose worktree conflicts with the target → the Session tab shows red "Conflicts in …". Click **Approve** → the task stays **WaitingForReview** (NOT Done), the conflict line remains, and the target branch is unchanged.
3. **Done task preview:** Open a previously-Done task that was never merged (worktree still Active) → the merge/conflict status appears without any tree mutation; the **Merge** button merges it on demand.
Report the result of each check explicitly. If any visual issue appears (colors, layout, missing controls), note it for the user — do not claim the UI works without running it.
---
## Self-review notes
- **Spec coverage:** Approve-merge (Task 2/3/5), conflict-keeps-review (Task 2 test + Task 5 surfacing), non-destructive preview (Task 1/2 + indicator in Task 5/6), real single-task Merge button (Task 5/6), standalone target-loading gap (Task 5e). All spec sections map to a task.
- **Type consistency:** `MergePreview` (Data) → `MergePreviewResult` (Worker service) → `MergePreviewDto` (hub + UI). Status strings `clean`/`conflict`/`unavailable` and merge statuses `merged`/`conflict`/`blocked` are used consistently across worker, client, presenter, and VM.
- **No new statuses, no DB migration, no localization keys** (literals match the surrounding controls).
- **External MCP unchanged:** `ExternalMcpService.ReviewTask` keeps calling `TaskStateService.ApproveReviewAsync` directly (its documented scope excludes merges); that method's signature is unchanged.

View File

@@ -0,0 +1,801 @@
# Refine Task Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Subagents use the `sonnet` model and stage files explicitly by path (never `git add -A`).
**Goal:** Add a one-click "Refine Task" button to each Idle task card that spawns a headless Claude session which rewrites the task's description and adds subtasks (steps), then updates the task live in the UI.
**Architecture:** A new headless `RefineRunner` (modeled on `PrimeRunner`) runs `claude -p` read-only in the list's working dir, using the globally-registered `claudedo` MCP. Claude calls `update_task` (existing) and a new `add_subtask` tool. The task stays `Idle`; refine only mutates Title/Description/subtasks. UI shows a busy state via new `RefineStarted`/`RefineFinished` SignalR events; content updates arrive via the existing `TaskUpdated` events.
**Tech Stack:** .NET 8, ASP.NET Core + SignalR, EF Core (SQLite), Avalonia 12 (CommunityToolkit.Mvvm), ModelContextProtocol server tools, xUnit.
**Spec:** `docs/superpowers/specs/2026-06-04-refine-task-design.md`
**Build/test reminders:** Build individual csproj with `-c Release` (a running Worker locks Debug). `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`, `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`, `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release`. Keep `locales/en.json` and `locales/de.json` keys in parity.
---
## File structure
**Create:**
- `src/ClaudeDo.Worker/Refine/RefineRunner.cs` — headless refine run orchestrator
- `src/ClaudeDo.Worker/Refine/RefinePrompt.cs` — prompt + CLI args + log path helper
- `src/ClaudeDo.Worker/Refine/Interfaces/IRefineRunner.cs` — interface + `RefineRunOutcome`
- `src/ClaudeDo.Worker/Refine/Interfaces/IRefineBroadcaster.cs``RefineStartedAsync`/`RefineFinishedAsync`
**Modify:**
- `src/ClaudeDo.Data/PromptFiles.cs` — add `Refine` to `PromptKind`, path, default
- `src/ClaudeDo.Worker/External/ExternalMcpService.cs` — add `add_subtask` tool
- `src/ClaudeDo.Worker/Hub/HubBroadcaster.cs` — implement `RefineStarted`/`RefineFinished` + `IRefineBroadcaster`
- `src/ClaudeDo.Worker/Hub/WorkerHub.cs` — add `RefineTask(string taskId)` method
- `src/ClaudeDo.Worker/Program.cs` — register `IRefineRunner`/`IRefineBroadcaster`
- `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs``RefineTaskAsync` + `RefineStartedEvent`/`RefineFinishedEvent`
- `src/ClaudeDo.Ui/Services/WorkerClient.cs` — implement call + subscribe events
- `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs``IsRefining` + `CanRefine`
- `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs``RefineTaskCommand` + event wiring
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml``Icon.Refine` geometry
- `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml` — refine button
- `locales/en.json`, `locales/de.json` — tooltip key
- Test fakes implementing `IWorkerClient` in `tests/ClaudeDo.Ui.Tests` (and any other project that hand-rolls it)
**Test:**
- `tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs`
- `tests/ClaudeDo.Worker.Tests/Refine/RefinePromptTests.cs`
- `tests/ClaudeDo.Worker.Tests/Refine/RefineRunnerTests.cs`
---
## Task 1: `add_subtask` MCP tool
**Files:**
- Modify: `src/ClaudeDo.Worker/External/ExternalMcpService.cs`
- Test: `tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs`
The `ExternalMcpService` already injects `IDbContextFactory<ClaudeDoDbContext> _dbFactory`, `TaskRepository _tasks`, and `HubBroadcaster _broadcaster`. Reuse them; new up a `SubtaskRepository` from a fresh context (matching the `SetMyDay`/`GetDailyPrepCandidates` pattern in the same file).
- [ ] **Step 1: Write the failing test**
Create `tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs`. Follow the existing External tool test setup in that test project (look at a sibling test, e.g. an `ExternalMcpService`/`UpdateTask` test, for the in-memory-real-SQLite fixture + broadcaster fake construction; reuse that exact fixture pattern).
```csharp
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
public class AddSubtaskToolTests
{
[Fact]
public async Task AddSubtask_appends_row_with_next_order()
{
await using var f = new ExternalMcpServiceFixture(); // reuse the project's existing fixture helper
var list = await f.SeedListAsync();
var task = await f.SeedTaskAsync(list.Id, status: TaskStatus.Idle);
await f.Service.AddSubtask(task.Id, "First step", orderNum: null, CancellationToken.None);
await f.Service.AddSubtask(task.Id, "Second step", orderNum: null, CancellationToken.None);
await using var ctx = f.CreateContext();
var subs = await new SubtaskRepository(ctx).GetByTaskIdAsync(task.Id);
Assert.Equal(new[] { "First step", "Second step" }, subs.Select(s => s.Title));
Assert.Equal(new[] { 0, 1 }, subs.Select(s => s.OrderNum));
Assert.All(subs, s => Assert.False(s.Completed));
}
[Fact]
public async Task AddSubtask_refuses_running_task()
{
await using var f = new ExternalMcpServiceFixture();
var list = await f.SeedListAsync();
var task = await f.SeedTaskAsync(list.Id, status: TaskStatus.Running);
await Assert.ThrowsAsync<InvalidOperationException>(
() => f.Service.AddSubtask(task.Id, "x", null, CancellationToken.None));
}
}
```
> If the test project has no reusable `ExternalMcpServiceFixture`, mirror the construction already used by the nearest existing `ExternalMcpService` test (same ctor args, real SQLite via `IDbContextFactory`, a no-op/recording broadcaster). Do not invent a new pattern.
- [ ] **Step 2: Run the test to verify it fails** (compile error / method missing)
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter AddSubtaskToolTests`
Expected: FAIL — `AddSubtask` not defined.
- [ ] **Step 3: Implement `add_subtask`**
Add to `ExternalMcpService` (near `UpdateTask`):
```csharp
[McpServerTool, Description(
"Append a subtask (step) to a task. orderNum defaults to the end. " +
"Refuses if the task is currently Running. Subtasks are surfaced to the agent at run time and shown in the task's Steps list.")]
public async Task<TaskDto> AddSubtask(
string taskId,
string title,
int? orderNum,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(title))
throw new InvalidOperationException("title is required.");
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
var tasks = new TaskRepository(ctx);
var subtasks = new SubtaskRepository(ctx);
var task = await tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (task.Status == TaskStatus.Running)
throw new InvalidOperationException("Cannot add a subtask to a running task. Cancel it first.");
var existing = await subtasks.GetByTaskIdAsync(taskId, cancellationToken);
var order = orderNum ?? (existing.Count == 0 ? 0 : existing.Max(s => s.OrderNum) + 1);
await subtasks.AddAsync(new SubtaskEntity
{
Id = Guid.NewGuid().ToString(),
TaskId = taskId,
Title = title.Trim(),
Completed = false,
OrderNum = order,
CreatedAt = DateTime.UtcNow,
}, cancellationToken);
await _broadcaster.TaskUpdated(taskId);
return ToDto(task);
}
```
Add `using ClaudeDo.Data.Repositories;` if not present (it is). `SubtaskEntity` is in `ClaudeDo.Data.Models` (already imported).
- [ ] **Step 4: Run the test to verify it passes**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter AddSubtaskToolTests`
Expected: PASS (2 tests).
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/External/ExternalMcpService.cs tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs
git commit -m "feat(mcp): add add_subtask tool to claudedo MCP"
```
---
## Task 2: Refine prompt (`PromptKind.Refine`)
**Files:**
- Modify: `src/ClaudeDo.Data/PromptFiles.cs`
- [ ] **Step 1: Add the enum value**
Change the enum line in `PromptFiles.cs`:
```csharp
public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport, ImprovementChild, Refine }
```
- [ ] **Step 2: Add the path mapping**
In `PathFor`, add before the `_ => throw`:
```csharp
PromptKind.Refine => Path.Combine(Root, "refine.md"),
```
- [ ] **Step 3: Add the default mapping**
In `DefaultFor`, add:
```csharp
PromptKind.Refine => RefineDefault,
```
- [ ] **Step 4: Add the default prompt constant**
Add near the other `private const string ...Default` blocks:
```csharp
private const string RefineDefault = """
You are refining ONE ClaudeDo task so it is ready to run autonomously later.
You are NOT executing the task only improving its specification.
The task you are refining:
- id: {taskId}
- title: {title}
- description: {description}
- current subtasks (steps):
{subtasks}
What to do:
1. If a repository is available, read the relevant code (read-only) to ground your
understanding. Do NOT edit, create, or delete any files. Do NOT run commands.
2. Rewrite the description so it is clear, specific, and self-contained: what to change,
where, and what "done" looks like. Keep scope tight do not invent adjacent work.
3. Call mcp__claudedo__update_task to save the improved title (only if it genuinely
helps) and description.
4. If the work is clearer as discrete steps, add them as subtasks with
mcp__claudedo__add_subtask (one call per step, in order). Only add steps that are
not already present in the current subtasks above.
Use ONLY these tools: mcp__claudedo__get_task, mcp__claudedo__update_task,
mcp__claudedo__add_subtask, and read-only Read/Grep/Glob. When you have updated the
task, stop.
""";
```
- [ ] **Step 5: Build to verify it compiles**
Run: `dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj -c Release`
Expected: Build succeeded.
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Data/PromptFiles.cs
git commit -m "feat(prompts): add Refine prompt kind and default"
```
---
## Task 3: RefineRunner, interfaces, prompt/args helper
**Files:**
- Create: `src/ClaudeDo.Worker/Refine/Interfaces/IRefineRunner.cs`
- Create: `src/ClaudeDo.Worker/Refine/Interfaces/IRefineBroadcaster.cs`
- Create: `src/ClaudeDo.Worker/Refine/RefinePrompt.cs`
- Create: `src/ClaudeDo.Worker/Refine/RefineRunner.cs`
- Test: `tests/ClaudeDo.Worker.Tests/Refine/RefinePromptTests.cs`
- Test: `tests/ClaudeDo.Worker.Tests/Refine/RefineRunnerTests.cs`
- [ ] **Step 1: Create `IRefineRunner.cs`**
```csharp
namespace ClaudeDo.Worker.Refine;
public interface IRefineRunner
{
Task<RefineRunOutcome> RefineAsync(string taskId, CancellationToken ct);
}
public sealed record RefineRunOutcome(bool Success, string Message);
```
- [ ] **Step 2: Create `IRefineBroadcaster.cs`**
```csharp
namespace ClaudeDo.Worker.Refine;
public interface IRefineBroadcaster
{
Task RefineStartedAsync(string taskId);
Task RefineFinishedAsync(string taskId, bool success, string? error);
}
```
- [ ] **Step 3: Create `RefinePrompt.cs`**
```csharp
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
namespace ClaudeDo.Worker.Refine;
public static class RefinePrompt
{
public const string GetTaskTool = "mcp__claudedo__get_task";
public const string UpdateTaskTool = "mcp__claudedo__update_task";
public const string AddSubtaskTool = "mcp__claudedo__add_subtask";
public static string LogPath(string taskId) =>
System.IO.Path.Combine(Paths.AppDataRoot(), "logs", $"refine-{Short(taskId)}.log");
// canReadRepo=false drops the read-only filesystem tools (text-only fallback).
public static string BuildArgs(int maxTurns, bool canReadRepo)
{
var tools = canReadRepo
? $"{GetTaskTool} {UpdateTaskTool} {AddSubtaskTool} Read Grep Glob"
: $"{GetTaskTool} {UpdateTaskTool} {AddSubtaskTool}";
return "-p --output-format stream-json --verbose --permission-mode acceptEdits " +
$"--max-turns {maxTurns} --allowedTools {tools}";
}
public static string BuildPrompt(TaskEntity task, IEnumerable<SubtaskEntity> subtasks)
{
var open = subtasks.Where(s => !s.Completed).Select(s => $"- {s.Title}").ToList();
var subText = open.Count == 0 ? "(none)" : string.Join("\n", open);
return PromptFiles.Render(PromptKind.Refine, new Dictionary<string, string>
{
["taskId"] = task.Id,
["title"] = task.Title,
["description"] = string.IsNullOrWhiteSpace(task.Description) ? "(empty)" : task.Description!,
["subtasks"] = subText,
});
}
private static string Short(string id) => id.Length >= 8 ? id[..8] : id;
}
```
- [ ] **Step 4: Write `RefinePromptTests.cs`**
```csharp
using ClaudeDo.Data.Models;
using ClaudeDo.Worker.Refine;
public class RefinePromptTests
{
[Fact]
public void BuildArgs_includes_read_tools_when_repo_available()
{
var args = RefinePrompt.BuildArgs(20, canReadRepo: true);
Assert.Contains("--permission-mode acceptEdits", args);
Assert.Contains("mcp__claudedo__add_subtask", args);
Assert.Contains(" Read Grep Glob", args);
}
[Fact]
public void BuildArgs_drops_read_tools_in_text_only_mode()
{
var args = RefinePrompt.BuildArgs(20, canReadRepo: false);
Assert.DoesNotContain("Glob", args);
Assert.Contains("mcp__claudedo__update_task", args);
}
[Fact]
public void BuildPrompt_seeds_task_fields_and_open_subtasks()
{
var task = new TaskEntity { Id = "abc12345", ListId = "l", Title = "T", Description = "D",
Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow };
var subs = new[]
{
new SubtaskEntity { Id="1", TaskId="abc12345", Title="open one", Completed=false, OrderNum=0, CreatedAt=DateTime.UtcNow },
new SubtaskEntity { Id="2", TaskId="abc12345", Title="done one", Completed=true, OrderNum=1, CreatedAt=DateTime.UtcNow },
};
var prompt = RefinePrompt.BuildPrompt(task, subs);
Assert.Contains("abc12345", prompt);
Assert.Contains("open one", prompt);
Assert.DoesNotContain("done one", prompt);
}
}
```
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter RefinePromptTests`
Expected: PASS (3 tests).
- [ ] **Step 5: Create `RefineRunner.cs`**
`IClaudeProcess.RunAsync(arguments, prompt, workingDirectory, onStdoutLine, ct)` returns a result with `.IsSuccess` and `.ExitCode` (same as used by `PrimeRunner`). Resolve the working dir from the task's list; fall back to a sandbox dir + text-only when missing/invalid. Per-task single-flight via a guarded `HashSet<string>`.
```csharp
using ClaudeDo.Data;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Runner;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Refine;
public sealed class RefineRunner : IRefineRunner
{
private static readonly TimeSpan RunTimeout = TimeSpan.FromMinutes(5);
private const int MaxTurns = 25;
private readonly IClaudeProcess _claude;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly ILogger<RefineRunner> _logger;
private readonly IRefineBroadcaster _broadcaster;
private readonly object _lock = new();
private readonly HashSet<string> _inFlight = new();
public RefineRunner(
IClaudeProcess claude,
IDbContextFactory<ClaudeDoDbContext> dbFactory,
ILogger<RefineRunner> logger,
IRefineBroadcaster broadcaster)
{
_claude = claude;
_dbFactory = dbFactory;
_logger = logger;
_broadcaster = broadcaster;
}
public async Task<RefineRunOutcome> RefineAsync(string taskId, CancellationToken ct)
{
lock (_lock)
{
if (!_inFlight.Add(taskId))
return new RefineRunOutcome(false, "Already refining this task");
}
var success = false;
string? error = null;
try
{
ClaudeDo.Data.Models.TaskEntity task;
List<ClaudeDo.Data.Models.SubtaskEntity> subs;
string? workingDir;
await using (var dbCtx = await _dbFactory.CreateDbContextAsync(ct))
{
var tasks = new TaskRepository(dbCtx);
task = await tasks.GetByIdAsync(taskId, ct)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (task.Status != TaskStatus.Idle)
return new RefineRunOutcome(false, $"Task must be Idle to refine (is {task.Status}).");
subs = await new SubtaskRepository(dbCtx).GetByTaskIdAsync(taskId, ct);
var list = await new ListRepository(dbCtx).GetByIdAsync(task.ListId, ct);
workingDir = list?.WorkingDir;
}
var canReadRepo = !string.IsNullOrWhiteSpace(workingDir) && Directory.Exists(workingDir);
var cwd = canReadRepo ? workingDir! : Paths.AppDataRoot();
Directory.CreateDirectory(cwd);
var logPath = RefinePrompt.LogPath(taskId);
try { if (File.Exists(logPath)) File.Delete(logPath); } catch { }
await using var logWriter = new LogWriter(logPath);
await _broadcaster.RefineStartedAsync(taskId);
var prompt = RefinePrompt.BuildPrompt(task, subs);
var args = RefinePrompt.BuildArgs(MaxTurns, canReadRepo);
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(RunTimeout);
var result = await _claude.RunAsync(
arguments: args,
prompt: prompt,
workingDirectory: cwd,
onStdoutLine: async line => await logWriter.WriteLineAsync(line),
ct: timeoutCts.Token);
success = result.IsSuccess;
if (!success) error = $"exit code {result.ExitCode}";
return success
? new RefineRunOutcome(true, "Refine complete")
: new RefineRunOutcome(false, error!);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
error = $"timed out after {RunTimeout.TotalMinutes:0} min";
return new RefineRunOutcome(false, error);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Refine run failed for {TaskId}", taskId);
error = ex.Message;
return new RefineRunOutcome(false, ex.Message);
}
finally
{
await _broadcaster.RefineFinishedAsync(taskId, success, error);
lock (_lock) { _inFlight.Remove(taskId); }
}
}
}
```
- [ ] **Step 6: Write `RefineRunnerTests.cs` (guards, with a fake IClaudeProcess)**
The test project already has a fake/stub for `IClaudeProcess` used by Prime tests — reuse it (recording invocation + returning a configurable success result). Do NOT spawn the real CLI.
```csharp
public class RefineRunnerTests
{
[Fact]
public async Task Refuses_when_task_not_idle()
{
await using var f = new RefineRunnerFixture(); // mirror Prime test fixture wiring
var task = await f.SeedTaskAsync(status: TaskStatus.Queued);
var outcome = await f.Runner.RefineAsync(task.Id, CancellationToken.None);
Assert.False(outcome.Success);
Assert.Equal(0, f.Claude.RunCount); // never invoked the CLI
}
[Fact]
public async Task Idle_task_invokes_claude_once_and_brackets_with_events()
{
await using var f = new RefineRunnerFixture();
var task = await f.SeedTaskAsync(status: TaskStatus.Idle);
var outcome = await f.Runner.RefineAsync(task.Id, CancellationToken.None);
Assert.True(outcome.Success);
Assert.Equal(1, f.Claude.RunCount);
Assert.Equal(1, f.Broadcaster.Started);
Assert.Equal(1, f.Broadcaster.Finished);
}
}
```
> Build the `RefineRunnerFixture`/fakes by copying the Prime test's `IClaudeProcess` stub + real-SQLite `IDbContextFactory` setup and a recording `IRefineBroadcaster`. If a Prime fixture exists, mirror it; otherwise construct inline.
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter RefineRunnerTests`
Expected: PASS (2 tests).
- [ ] **Step 7: Commit**
```bash
git add src/ClaudeDo.Worker/Refine tests/ClaudeDo.Worker.Tests/Refine
git commit -m "feat(refine): add RefineRunner, prompt/args helper, and interfaces"
```
---
## Task 4: Worker wiring — broadcaster, hub, DI
**Files:**
- Modify: `src/ClaudeDo.Worker/Hub/HubBroadcaster.cs`
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
- Modify: `src/ClaudeDo.Worker/Program.cs`
- [ ] **Step 1: Implement events on `HubBroadcaster`**
Add `IRefineBroadcaster` to the class's interface list (`public sealed class HubBroadcaster : ..., IRefineBroadcaster`) and add (mirroring the `Prep*` block):
```csharp
public Task RefineStarted(string taskId) => _hub.Clients.All.SendAsync("RefineStarted", taskId);
public Task RefineFinished(string taskId, bool success, string? error) =>
_hub.Clients.All.SendAsync("RefineFinished", taskId, success, error);
Task IRefineBroadcaster.RefineStartedAsync(string taskId) => RefineStarted(taskId);
Task IRefineBroadcaster.RefineFinishedAsync(string taskId, bool success, string? error) =>
RefineFinished(taskId, success, error);
```
Add `using ClaudeDo.Worker.Refine;`.
- [ ] **Step 2: Add `RefineTask` to `WorkerHub`**
`WorkerHub` injects services via its constructor. Add a `private readonly IRefineRunner _refineRunner;` field, add the parameter to the constructor and assign it. Add the method (fire-and-forget; the runner brackets with its own events):
```csharp
public Task RefineTask(string taskId)
{
_ = _refineRunner.RefineAsync(taskId, CancellationToken.None);
return Task.CompletedTask;
}
```
Add `using ClaudeDo.Worker.Refine;`.
- [ ] **Step 3: Register DI in `Program.cs`**
Near the Prime registrations:
```csharp
builder.Services.AddSingleton<IRefineRunner, RefineRunner>();
builder.Services.AddSingleton<IRefineBroadcaster>(sp => sp.GetRequiredService<HubBroadcaster>());
```
Add `using ClaudeDo.Worker.Refine;` if needed. (`HubBroadcaster` is already registered as a singleton — confirm and reuse that registration; do not double-register it.)
- [ ] **Step 4: Build the worker**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`
Expected: Build succeeded.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/Hub/HubBroadcaster.cs src/ClaudeDo.Worker/Hub/WorkerHub.cs src/ClaudeDo.Worker/Program.cs
git commit -m "feat(refine): wire RefineTask hub method, broadcaster events, and DI"
```
---
## Task 5: UI worker client — call + events + fakes
**Files:**
- Modify: `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs`
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
- Modify: test fakes implementing `IWorkerClient`
- [ ] **Step 1: Extend the interface**
In `IWorkerClient.cs` add (near `RunDailyPrepNowAsync` and the `Prep*` events):
```csharp
Task RefineTaskAsync(string taskId);
event Action<string>? RefineStartedEvent;
event Action<string, bool, string?>? RefineFinishedEvent;
```
- [ ] **Step 2: Implement in `WorkerClient`**
Add the method (mirror `RunDailyPrepNowAsync`):
```csharp
public Task RefineTaskAsync(string taskId) => _hub.InvokeAsync("RefineTask", taskId);
```
Declare the events:
```csharp
public event Action<string>? RefineStartedEvent;
public event Action<string, bool, string?>? RefineFinishedEvent;
```
Subscribe in the constructor (mirror the `Prep*` subscriptions block):
```csharp
_hub.On<string>("RefineStarted", id =>
Dispatcher.UIThread.Post(() => RefineStartedEvent?.Invoke(id)));
_hub.On<string, bool, string?>("RefineFinished", (id, ok, err) =>
Dispatcher.UIThread.Post(() => RefineFinishedEvent?.Invoke(id, ok, err)));
```
- [ ] **Step 3: Update test fakes**
Find every hand-rolled `IWorkerClient` implementation (search the test projects) and add `RefineTaskAsync` (return `Task.CompletedTask`) plus the two events (`= delegate {}` or `add{}remove{}` no-ops as the fake convention dictates). Build each affected test project.
- [ ] **Step 4: Build UI + test projects**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Then build the UI test project(s). Expected: Build succeeded.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs src/ClaudeDo.Ui/Services/WorkerClient.cs <fake files>
git commit -m "feat(ui): add RefineTask client call and refine events"
```
---
## Task 6: UI — icon, button, view model, command
**Files:**
- Modify: `src/ClaudeDo.Ui/Design/IslandStyles.axaml`
- Modify: `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs`
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs`
- Modify: `locales/en.json`, `locales/de.json`
- [ ] **Step 1: Add the `Icon.Refine` geometry**
In `IslandStyles.axaml`, near the other `Icon.*` `StreamGeometry` resources, add the supplied SVG converted to path data (line-art, rendered stroked via `plan-icon`):
```xml
<StreamGeometry x:Key="Icon.Refine">M3,5 L11,5 M3,9 L9,9 M3,13 L7,13 M19,1.8 L19.7,3.9 L21.7,4.6 L19.7,5.3 L19,7.4 L18.3,5.3 L16.3,4.6 L18.3,3.9 Z M18,10.5 L12.2,16.3 M16.6,9.1 L19.4,11.9 M12.2,16.3 L11,18.5 L13.2,17.5 Z</StreamGeometry>
```
- [ ] **Step 2: Add `IsRefining`/`CanRefine` to `TaskRowViewModel`**
Add the observable property (with the other `[ObservableProperty]` fields):
```csharp
[ObservableProperty] private bool _isRefining;
```
Add a computed gate (refine is only offered for Idle, non-parent tasks). Place near other `Can*` getters:
```csharp
public bool CanRefine => Status == TaskStatus.Idle && PlanningPhase == PlanningPhase.None && !IsRefining;
```
If `Status`/`PlanningPhase`/`IsRefining` are `[ObservableProperty]`, raise `CanRefine` change notifications via partial `On<Prop>Changed` hooks:
```csharp
partial void OnStatusChanged(TaskStatus value) => OnPropertyChanged(nameof(CanRefine));
partial void OnPlanningPhaseChanged(PlanningPhase value) => OnPropertyChanged(nameof(CanRefine));
partial void OnIsRefiningChanged(bool value) => OnPropertyChanged(nameof(CanRefine));
```
> If `On...Changed` partials already exist for `Status`/`PlanningPhase`, add the `OnPropertyChanged(nameof(CanRefine))` line inside them instead of redeclaring.
- [ ] **Step 3: Add `RefineTaskCommand` + event wiring to `TasksIslandViewModel`**
Add the command (mirror an existing per-row command like `ToggleStarCommand`, which takes a `TaskRowViewModel`):
```csharp
[RelayCommand]
private async Task RefineTask(TaskRowViewModel row)
{
if (row is null || !row.CanRefine) return;
row.IsRefining = true;
try { await _worker.RefineTaskAsync(row.Id); }
catch { row.IsRefining = false; }
}
```
> Use the same injected worker-client field name this VM already uses (e.g. `_worker`/`_client`). Match it.
Subscribe to the refine events where the VM wires other worker events (where `OnWorkerTaskUpdated` is subscribed). Add handlers that flip the row flag:
```csharp
private void OnRefineStarted(string taskId)
{
var row = Items.FirstOrDefault(r => r.Id == taskId);
if (row is not null) row.IsRefining = true;
}
private void OnRefineFinished(string taskId, bool ok, string? error)
{
var row = Items.FirstOrDefault(r => r.Id == taskId);
if (row is not null) row.IsRefining = false;
}
```
Wire them next to the existing subscriptions (and unsubscribe in the same place the VM unsubscribes others, if it does):
```csharp
_worker.RefineStartedEvent += OnRefineStarted;
_worker.RefineFinishedEvent += OnRefineFinished;
```
(Content changes—new description/subtasks—arrive through the existing `TaskUpdated``OnWorkerTaskUpdated` path; no extra work needed.)
- [ ] **Step 4: Add the button to `TaskRowView.axaml`**
Mirror the star button (`Grid.Column="5"` area). Add a refine `icon-btn` (e.g. as a new column or beside the star) bound to the parent ItemsControl's command, passing the row as parameter. Use the `plan-icon` stroked `Path` inside a `Viewbox` (matching the Plan-day button), gate visibility on `CanRefine`, and disable/spin on `IsRefining`:
```xml
<Button Classes="icon-btn refine-btn"
IsVisible="{Binding CanRefine}"
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>
```
> Match the column layout already in `TaskRowView.axaml`. If a new grid column is needed, widen `ColumnDefinitions` accordingly and place the refine button left of the star (`Grid.Column`). Keep the existing `vm:` / `loc:` xmlns aliases the file already declares.
Optionally show a spinning/dimmed state while `IsRefining` (e.g. a style `Selector="Button.refine-btn:disabled"` or bind opacity to `IsRefining`). Keep it simple; a disabled look is enough.
- [ ] **Step 5: Add localization keys**
Add to both `locales/en.json` and `locales/de.json` under the `tasks` group (keys must stay in parity):
- en: `"tasks.refineTip": "Refine this task with Claude"`
- de: `"tasks.refineTip": "Aufgabe mit Claude verfeinern"`
> Match the file's actual key structure (flat `"tasks.x"` vs nested `tasks: { x }`)—look at an existing `tasks.*` tooltip key (e.g. the plan-day tip) and follow it exactly.
- [ ] **Step 6: Build UI**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Then run the Localization parity tests: `dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release`
Expected: Build succeeded; locale parity passes.
- [ ] **Step 7: Commit**
```bash
git add src/ClaudeDo.Ui/Design/IslandStyles.axaml src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs locales/en.json locales/de.json
git commit -m "feat(ui): add Refine button, icon, and command to task card"
```
---
## Task 7: Full build + test sweep, manual smoke
- [ ] **Step 1: Build all main projects**
```bash
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
```
Expected: Build succeeded for both.
- [ ] **Step 2: Run the worker + UI test suites**
```bash
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.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
```
Expected: all green.
- [ ] **Step 3: Manual smoke (visual + real CLI — flag to user)**
Cannot be automated (no real-Claude in tests). Verify by hand: start Worker + UI, on an Idle task click the refine icon → button shows busy → after the run the description improves and steps appear in the Steps card → task stays Idle. Confirm the refine icon is hidden for Queued/Running/Done tasks and for planning parents. **Report this as a visual-verification gap for the user to confirm.**
---
## Notes on parallelism / execution
- Tasks 14 are backend and largely sequential (4 depends on 3). Tasks 1 and 2 are independent and could be done first in either order.
- Tasks 56 (UI) depend on Task 4's hub/event contract.
- Per project convention: subagents use `sonnet`, stage files by explicit path, and do NOT run git/build inside parallel agents — the orchestrator builds, tests, and commits after each task.

View File

@@ -0,0 +1,33 @@
# Task Detail Redesign — Component Build Prompts
Three isolated build tasks (one per component). Each runs in its own worktree off
`main`, with the project CLAUDE.md auto-loaded. Full design context lives in
`docs/superpowers/specs/2026-06-04-task-detail-redesign-design.md` — every task
must read it first.
Shared rules (all three):
- Build a **standalone** `UserControl` + dedicated `ViewModel` that renders fully
in the Avalonia previewer via **design-time sample data** (parameterless ctor
populating realistic values). Do **not** bind to `DetailsIslandViewModel`.
- New files under `src/ClaudeDo.Ui/Views/Islands/Detail/` and
`src/ClaudeDo.Ui/ViewModels/Islands/Detail/`.
- Use **only** tokens from `Design/Tokens.axaml` and classes from
`Design/IslandStyles.axaml`. No inline hex, no magic numbers where a token
exists. `PathIcon` fills geometry — stroke-only art is invisible.
- Compiled bindings (`x:DataType`). MVVM via CommunityToolkit
(`[ObservableProperty]`, `[RelayCommand]`); VM inherits `ViewModelBase`.
- **Do NOT modify** `DetailsIslandView.axaml`, `DetailsIslandViewModel.cs`,
`AgentStripView`, `SessionTerminalView`, or `TaskRunner.cs`.
- Verify: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj -c Release` is green.
Stage files explicitly by path (never `git add -A`). Commit with a conventional
message.
---
## TASK 1 — TaskHeaderBar
(prompt text = task description; see below)
## TASK 2 — DescriptionStepsCard
## TASK 3 — WorkConsole

View File

@@ -0,0 +1,522 @@
# Terminal-style Review Controls 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:** Move review feedback into the Output (terminal) tab as a prompt-style input with `[Retry]`/`[Reset]` actions, and relocate Approve + all merge/worktree controls to a new **Git** tab.
**Architecture:** Pure UI-layer change in `ClaudeDo.Ui`. Add an `IsGitTab` computed flag to `DetailsIslandViewModel`, re-home existing XAML blocks across three tabs (Output · Git · Session) in `WorkConsole.axaml`, add a bottom-docked review footer to the Output tab, and intercept Enter in `WorkConsole.axaml.cs`. No worker-side or `IWorkerClient` changes; no ViewModel command renames.
**Tech Stack:** .NET 8, Avalonia 12 (Fluent), CommunityToolkit.Mvvm, xUnit (ClaudeDo.Ui.Tests).
**Reference spec:** `docs/superpowers/specs/2026-06-05-terminal-review-design.md`
**Build/test note (from CLAUDE.md):** A running Worker locks `Debug` output — build UI in `-c Release`:
`dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
`dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
---
## File Structure
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` — add `IsGitTab`, wire notifications.
- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml` — add Git tab button; split tab bodies; add Output-tab review footer; update Session empty-state text.
- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml.cs` — Enter-to-Retry key handling.
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandTabsTests.cs` (create) — `IsGitTab` behavior.
---
### Task 1: Add `IsGitTab` tab flag to the ViewModel
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs:139-147`
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandTabsTests.cs` (create)
- [ ] **Step 1: Write the failing test**
Create `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandTabsTests.cs`. Mirror the
construction pattern from `DetailsIslandPrepModeTests.cs` (temp SQLite db,
`TestDbFactory`, `StubWorkerClient`, `NullServiceProvider`, `StubNotesApi`).
```csharp
using ClaudeDo.Data;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Islands;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Ui.Tests.ViewModels;
public class DetailsIslandTabsTests : IDisposable
{
private readonly string _dbPath;
public DetailsIslandTabsTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_tabs_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 StubNotesApi : ClaudeDo.Ui.Services.Interfaces.INotesApi
{
public Task<List<DailyNoteDto>> ListAsync(DateOnly day) => Task.FromResult(new List<DailyNoteDto>());
public Task<DailyNoteDto?> AddAsync(DateOnly day, string text) => Task.FromResult<DailyNoteDto?>(null);
public Task UpdateAsync(string id, string text) => Task.CompletedTask;
public Task DeleteAsync(string id) => Task.CompletedTask;
}
private sealed class NullServiceProvider : IServiceProvider
{
public object? GetService(Type serviceType) => null;
}
// StubWorkerClient is abstract — use a concrete no-op subclass (same pattern as DetailsIslandPrepModeTests).
private sealed class DefaultStub : StubWorkerClient { }
private DetailsIslandViewModel NewVm()
{
var factory = new TestDbFactory(NewContext);
return new DetailsIslandViewModel(factory, new DefaultStub(), new NullServiceProvider(), new StubNotesApi());
}
[Fact]
public void SelectTab_git_sets_IsGitTab_and_clears_others()
{
var vm = NewVm();
vm.SelectTabCommand.Execute("git");
Assert.True(vm.IsGitTab);
Assert.False(vm.IsOutputTab);
Assert.False(vm.IsSessionTab);
}
[Fact]
public void Default_tab_is_output_not_git()
{
var vm = NewVm();
Assert.True(vm.IsOutputTab);
Assert.False(vm.IsGitTab);
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter DetailsIslandTabsTests`
Expected: FAIL — compile error, `DetailsIslandViewModel` has no `IsGitTab`.
- [ ] **Step 3: Add `IsGitTab` to the ViewModel**
In `DetailsIslandViewModel.cs`, find the `SelectedTab` property notifications and the
tab getters (around lines 139-147). Add the `IsGitTab` notification and getter:
```csharp
[NotifyPropertyChangedFor(nameof(IsOutputTab))]
[NotifyPropertyChangedFor(nameof(IsSessionTab))]
[NotifyPropertyChangedFor(nameof(IsGitTab))]
```
```csharp
public bool IsOutputTab => SelectedTab == "output";
public bool IsGitTab => SelectedTab == "git";
public bool IsSessionTab => SelectedTab == "session";
```
(Leave `SelectTab` unchanged — it already accepts any string and defaults to `"output"`.)
- [ ] **Step 4: Run test to verify it passes**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter DetailsIslandTabsTests`
Expected: PASS (2 tests).
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandTabsTests.cs
git commit -m "feat(ui): add IsGitTab flag to work console view model"
```
---
### Task 2: Add the Git tab button and move the merge/worktree block onto it
**Files:**
- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml:124-135` (tab strip)
- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml:164-273` (tab body)
- [ ] **Step 1: Add the Git tab button**
In the tab strip `StackPanel` (lines 124-135), insert a Git button between the Output
and Session buttons:
```xml
<StackPanel Orientation="Horizontal">
<Button Classes="tab-btn"
Classes.active="{Binding IsOutputTab}"
Content="Output"
Command="{Binding SelectTabCommand}"
CommandParameter="output" />
<Button Classes="tab-btn"
Classes.active="{Binding IsGitTab}"
Content="Git"
Command="{Binding SelectTabCommand}"
CommandParameter="git" />
<Button Classes="tab-btn"
Classes.active="{Binding IsSessionTab}"
Content="Session"
Command="{Binding SelectTabCommand}"
CommandParameter="session" />
</StackPanel>
```
- [ ] **Step 2: Move the "Merge & worktree" block to a new Git-tab ScrollViewer**
In the tab body `Grid` (starts line 139), the body currently holds the Output
`ScrollViewer` (`IsVisible="{Binding IsOutputTab}"`, lines 142-162) and the Session
`ScrollViewer` (`IsVisible="{Binding IsSessionTab}"`, lines 165-273).
Cut the **entire "Merge & worktree management" `StackPanel`** — the block currently at
lines 195-241, beginning with the comment `<!-- Merge & worktree management -->` and the
`<StackPanel Spacing="10" IsVisible="{Binding ShowMergeSection}">` and ending at its
matching `</StackPanel>` after the `MergeAllError` `TextBlock` (line 241).
Add a new Git-tab `ScrollViewer` between the Output and Session `ScrollViewer`s, and
paste the cut block inside it:
```xml
<!-- Git: merge target, approve, diff, worktree -->
<ScrollViewer IsVisible="{Binding IsGitTab}" Padding="14,10">
<StackPanel Spacing="14">
<!-- Approve (review-gated) -->
<StackPanel Spacing="8" IsVisible="{Binding IsWaitingForReview}">
<TextBlock Classes="section-label" Text="REVIEW" />
<Button Classes="btn accent" Content="Approve"
Command="{Binding ApproveReviewCommand}" />
</StackPanel>
<!-- Merge & worktree management (moved from Session tab) -->
<StackPanel Spacing="10" IsVisible="{Binding ShowMergeSection}">
<TextBlock Classes="section-label" Text="MERGE &amp; WORKTREE" />
<StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="Merge target" />
<ComboBox ItemsSource="{Binding MergeTargetBranches}"
SelectedItem="{Binding SelectedMergeTarget, Mode=TwoWay}"
HorizontalAlignment="Stretch" />
</StackPanel>
<StackPanel Spacing="0">
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
Foreground="{DynamicResource MossBrush}"
IsVisible="{Binding MergeIsClean}" />
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
Foreground="{DynamicResource BloodBrush}"
IsVisible="{Binding MergeIsConflict}" />
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
Foreground="{DynamicResource TextMuteBrush}"
IsVisible="{Binding ShowMergePreviewMuted}" />
</StackPanel>
<WrapPanel Orientation="Horizontal">
<Button Classes="btn" Content="Open Diff" Margin="0,0,8,8"
Command="{Binding OpenDiffCommand}" />
<Button Classes="btn accent" Content="Merge" Margin="0,0,8,8"
Command="{Binding MergeCommand}"
IsVisible="{Binding ShowSingleMerge}" />
<Button Classes="btn" Margin="0,0,8,8"
Command="{Binding OpenWorktreeCommand}">
<StackPanel Orientation="Horizontal" Spacing="5">
<TextBlock Text="Worktree" />
<PathIcon Data="{StaticResource Icon.ArrowOut}" Width="11" Height="11" />
</StackPanel>
</Button>
<Button Classes="btn" Content="Review Combined Diff" Margin="0,0,8,8"
Command="{Binding ReviewCombinedDiffCommand}" />
<Button Classes="btn accent" Content="Merge All Subtasks" Margin="0,0,0,8"
Command="{Binding MergeAllCommand}"
IsEnabled="{Binding CanMergeAll}"
ToolTip.Tip="{Binding MergeAllDisabledReason}" />
</WrapPanel>
<TextBlock Text="{Binding MergeAllError}"
Foreground="{DynamicResource BloodBrush}"
TextWrapping="Wrap"
IsVisible="{Binding MergeAllError,
Converter={x:Static ObjectConverters.IsNotNull}}" />
</StackPanel>
</StackPanel>
</ScrollViewer>
```
- [ ] **Step 3: Remove the old review block from the Session tab**
In the Session `ScrollViewer` (`IsVisible="{Binding IsSessionTab}"`), delete the
**"Review controls" `StackPanel`** currently at lines 168-193 (the
`<!-- Review controls -->` comment, the `<StackPanel Spacing="8" IsVisible="{Binding IsWaitingForReview}">`,
the REVIEW label, Feedback label, the `ReviewFeedback` TextBox, and the four buttons).
After this and Step 2, the Session tab's `StackPanel` should contain only the Child
outcomes block (lines 244-263) and the empty-state `TextBlock` (lines 266-270).
- [ ] **Step 4: Build and verify it compiles**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Expected: Build succeeded, 0 errors.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml
git commit -m "feat(ui): add Git tab and move merge/approve controls onto it"
```
---
### Task 3: Add the prompt-style review footer to the Output tab
**Files:**
- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml` (Output-tab area + the `Grid` body)
- [ ] **Step 1: Restructure the Output tab body to dock a footer below the log**
The body `Grid` (line 139) overlays all three tab `ScrollViewer`s. Replace the Output
`ScrollViewer` (lines 142-162) with a `DockPanel` that keeps the log filling and docks
the review footer at the bottom. Keep `Name="LogScroll"` on the `ScrollViewer` (the
code-behind references it). Use this exact markup:
```xml
<!-- Output: log + review footer, both gated on IsOutputTab -->
<DockPanel IsVisible="{Binding IsOutputTab}" LastChildFill="True">
<!-- Review footer (terminal prompt) — only while awaiting review -->
<Border DockPanel.Dock="Bottom"
IsVisible="{Binding IsWaitingForReview}"
Background="{DynamicResource Surface2Brush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,1,0,0"
Padding="10,6">
<DockPanel LastChildFill="True">
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" Spacing="8"
VerticalAlignment="Bottom" Margin="8,0,0,0">
<Button Classes="btn accent" Content="Retry"
Command="{Binding RejectReviewCommand}" />
<Button Classes="btn" Content="Reset"
Command="{Binding ParkReviewCommand}" />
</StackPanel>
<TextBlock DockPanel.Dock="Left" Text="&#x276F;"
FontFamily="{StaticResource MonoFont}"
Foreground="{DynamicResource TextMuteBrush}"
VerticalAlignment="Top" Margin="0,4,8,0" />
<TextBox Name="ReviewInput"
Text="{Binding ReviewFeedback, Mode=TwoWay}"
AcceptsReturn="True"
TextWrapping="Wrap"
MaxHeight="160"
PlaceholderText="Feedback for the next run…"
Background="Transparent"
BorderThickness="0"
Padding="0,2"
FontFamily="{StaticResource MonoFont}"
FontSize="{StaticResource FontSizeMono}" />
</DockPanel>
</Border>
<ScrollViewer Name="LogScroll"
VerticalScrollBarVisibility="Visible"
AllowAutoHide="False"
Padding="12,8,12,4">
<ItemsControl ItemsSource="{Binding Log}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:LogLineViewModel">
<Grid ColumnDefinitions="60,*" Margin="0,1">
<TextBlock Grid.Column="0"
Classes="log-ts"
Text="{Binding TimestampFormatted}" />
<SelectableTextBlock Grid.Column="1"
Text="{Binding Text}" Tag="{Binding ClassName}"
Foreground="{DynamicResource TextDimBrush}"
TextWrapping="Wrap" />
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</DockPanel>
```
- [ ] **Step 2: Build and verify it compiles**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Expected: Build succeeded, 0 errors.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml
git commit -m "feat(ui): add terminal review footer with Retry/Reset to Output tab"
```
---
### Task 4: Enter-to-Retry key handling
**Files:**
- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml.cs`
- [ ] **Step 1: Add the KeyDown handler**
In `WorkConsole.axaml.cs`, add `using Avalonia.Input;` at the top. Add a handler that
runs `RejectReviewCommand` on Enter (without Shift) and lets Shift+Enter insert a
newline. Wire it from the `ReviewInput` TextBox. Full file:
```csharp
using System;
using System.Collections.Specialized;
using Avalonia.Controls;
using Avalonia.Input;
using ClaudeDo.Ui.ViewModels.Islands;
namespace ClaudeDo.Ui.Views.Islands.Detail;
public partial class WorkConsole : UserControl
{
private INotifyCollectionChanged? _log;
public WorkConsole()
{
InitializeComponent();
DataContextChanged += OnDataContextChanged;
}
private void OnDataContextChanged(object? sender, EventArgs e)
{
if (_log is not null)
_log.CollectionChanged -= OnLogChanged;
_log = (DataContext as DetailsIslandViewModel)?.Log;
if (_log is not null)
_log.CollectionChanged += OnLogChanged;
}
private void OnLogChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (e.Action != NotifyCollectionChangedAction.Add) return;
EventHandler? handler = null;
handler = (_, _) =>
{
LogScroll.LayoutUpdated -= handler;
LogScroll.ScrollToEnd();
};
LogScroll.LayoutUpdated += handler;
}
private void OnReviewInputKeyDown(object? sender, KeyEventArgs e)
{
if (e.Key != Key.Enter || e.KeyModifiers.HasFlag(KeyModifiers.Shift))
return;
if (DataContext is DetailsIslandViewModel vm &&
vm.RejectReviewCommand.CanExecute(null))
{
vm.RejectReviewCommand.Execute(null);
}
e.Handled = true;
}
}
```
- [ ] **Step 2: Wire the handler in XAML**
On the `ReviewInput` TextBox added in Task 3, add the event hookup attribute:
```xml
<TextBox Name="ReviewInput"
KeyDown="OnReviewInputKeyDown"
Text="{Binding ReviewFeedback, Mode=TwoWay}"
```
- [ ] **Step 3: Build and verify it compiles**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Expected: Build succeeded, 0 errors.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml.cs
git commit -m "feat(ui): send Retry on Enter in the review prompt"
```
---
### Task 5: Update the Session empty-state copy
**Files:**
- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml` (empty-state `TextBlock`, was line 266-270)
- [ ] **Step 1: Reword the empty-state text**
The Session empty-state still says review/merge controls appear there. Replace its
`Text` so it reflects that those moved:
```xml
<TextBlock IsVisible="{Binding ShowSessionEmpty}"
Classes="meta"
Foreground="{DynamicResource TextMuteBrush}"
TextWrapping="Wrap"
Text="Nothing to manage yet — subtask outcomes appear here once the run finishes. Review in the Output tab, merge in the Git tab." />
```
- [ ] **Step 2: Build and verify it compiles**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Expected: Build succeeded, 0 errors.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml
git commit -m "docs(ui): reword Session empty-state for relocated review/merge controls"
```
---
### Task 6: Final verification
- [ ] **Step 1: Run the full UI test project**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
Expected: all tests PASS.
- [ ] **Step 2: Manual visual verification (cannot be auto-verified — flag to user)**
Launch the app with a task in `WaitingForReview` and confirm:
- Output tab shows the prompt footer (`` + input + `[Retry]` `[Reset]`) only while awaiting review; it is hidden otherwise.
- Typing + **Enter** sends Retry (requeues with feedback); **Shift+Enter** inserts a newline; **Enter on empty input** does nothing.
- `[Reset]` parks the task to Idle.
- Git tab shows **Approve** + merge target + Open Diff / Merge / Worktree / Review Combined Diff / Merge All Subtasks.
- Session tab shows only subtask outcomes / the reworded empty state.
- Tab switching highlights the active tab correctly (Output ↔ Git ↔ Session).

View File

@@ -0,0 +1,173 @@
# Approve = Merge → Done, plus Conflict Preview — Design
**Date:** 2026-06-04
**Status:** Approved (autonomous — user on break, authorized to continue)
**Author:** brainstormed from issue "Make merge/diff real"
## Problem
Approving a `WaitingForReview` task flips it straight to `Done`
(`TaskStateService.ApproveReviewAsync`) and **never merges** its worktree — the
worktree stays `Active`. The user approved three component tasks expecting them
to merge; none did. Separately, there is **no way to see whether a task's
worktree merges cleanly** before acting, and a standalone task has no direct
**Merge** button (single-task merge is only reachable from inside the Diff
modal).
What is already real (verified): `WorkerHub.MergeTask → TaskMergeService.MergeAsync`
performs a real `git merge --no-ff`, aborts on conflict, and marks the worktree
`Merged`. **Open Diff** opens a real in-app diff. **Merge All Subtasks**
(planning) is real. So the gaps are narrow.
## Scope decisions (autonomous)
- **Tab location:** keep the **single "Session" tab** that the recent commit
`ac9bae9` deliberately consolidated. All new controls go in its existing
`MERGE & WORKTREE` block (`WorkConsole.axaml:196`). Do **not** re-introduce a
separate "Actions" tab.
- **Approve target:** Approve merges into the UI-selected merge target
(`SelectedMergeTarget`); when blank, the worker resolves to the repo's current
branch.
- **On conflict:** task stays in `WaitingForReview` (no new status). The conflict
is surfaced inline. No automatic state change to a "blocked" status.
- **Worktree removal on approve:** do **not** remove — merge marks the worktree
`Merged` and existing auto-cleanup handles disposal (matches the single-task
merge default `removeWorktree:false`).
- **Applies to:** standalone leaf tasks with an active worktree. A
`WaitingForReview` task with **no** active worktree (e.g. ran in a sandbox, or
an improvement parent whose children own the worktrees) is just marked `Done`
— current behavior preserved. Planning parents keep "Merge All Subtasks".
## Acceptance (restated)
1. Approve a clean-merging task → worktree merged into target, worktree `Merged`,
task `Done`.
2. Approve a conflicting task → task **not** `Done`, conflict surfaced.
3. Opening a Done/WaitingForReview task shows clean/conflict status **without
mutating** the tree (use `git merge-tree`, not a real merge).
## Architecture
Three layers, each single-purpose; the only new cross-dependency is
`TaskMergeService → ITaskStateService` (one-way; verify no DI cycle).
### 1. GitService — non-destructive conflict probe (`ClaudeDo.Data`)
New method:
```csharp
public sealed record MergePreview(bool Supported, bool Clean, IReadOnlyList<string> ConflictFiles);
public async Task<MergePreview> PreviewMergeAsync(
string repoDir, string targetBranch, string sourceBranch, CancellationToken ct = default)
```
- Runs `git merge-tree --write-tree --name-only <target> <source>` from `repoDir`.
`merge-tree` computes the merge base itself and writes only loose objects — it
does **not** touch the working tree, index, or refs.
- Exit code `0``Clean = true`, no conflict files.
- Exit code `1``Clean = false`; conflicted paths are the lines after the
first (tree-OID) line, up to the first blank line.
- Any other outcome (e.g. git too old → "unknown option") → `Supported = false`
(UI shows "mergeability unknown").
New helper for the "· N files" count (clean case):
`git diff --name-only <target>...<source>` (three-dot = changes on source since
the merge base); count non-empty lines. May reuse/extend existing diff helpers.
### 2. TaskMergeService — preview + approve orchestration (`ClaudeDo.Worker`)
Inject `ITaskStateService` (verify `PlanningChainCoordinator` has no back-edge to
`TaskMergeService`; if a cycle exists, fall back to orchestrating in the hub).
```csharp
public sealed record MergePreviewResult(string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount);
// Status: "clean" | "conflict" | "unavailable"
public Task<MergePreviewResult> PreviewAsync(string taskId, string targetBranch, CancellationToken ct);
public Task<MergeResult> ApproveAndMergeAsync(string taskId, string targetBranch, CancellationToken ct);
```
**PreviewAsync:** load context. If no active worktree → `"unavailable"`. Resolve
`targetBranch` (blank → current branch). Call `GitService.PreviewMergeAsync`; map
`Supported=false``"unavailable"`, else clean/conflict (+ ChangedFileCount on
clean).
**ApproveAndMergeAsync:** load context; require `task.Status == WaitingForReview`
(else `Blocked`). Resolve target (blank → current branch).
- **No active worktree** → `_state.ApproveReviewAsync(taskId)` → return
`MergeResult(StatusMerged, [], null)` ("approved, nothing to merge").
- **Active worktree** → `MergeAsync(taskId, target, removeWorktree:false,
"Merge {branch}", ct)`. On `StatusMerged` → `_state.ApproveReviewAsync(taskId)`
then return the merged result. On `StatusConflict`/`StatusBlocked` → return as-is;
**do not** flip status (task stays `WaitingForReview`).
`TaskStateService.ApproveReviewAsync` is unchanged (still the sole Status writer;
still runs `OnChildTerminalAsync`).
### 3. WorkerHub — signatures (`ClaudeDo.Worker`)
```csharp
public record MergePreviewDto(string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount);
public Task<MergePreviewDto> PreviewMerge(string taskId, string targetBranch); // new
public Task<MergeResultDto> ApproveReview(string taskId, string targetBranch); // CHANGED: was void(taskId)
```
`ApproveReview` returns the orchestration result so the UI can react to conflicts.
`MergeTask` / `GetMergeTargets` unchanged.
### 4. UI (`ClaudeDo.Ui`)
`IWorkerClient` (+ `WorkerClient` + **both test-project fakes** — see memory:
changing `IWorkerClient` breaks hand-rolled fakes):
- Change `Task ApproveReviewAsync(string)` → `Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch)`.
- Add `Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch)`.
- Add `Task<MergeResultDto> MergeTaskAsync(...)` to the **interface** (already on
the concrete client) so the single-task Merge button can use `_worker`.
`DetailsIslandViewModel`:
- **Load merge targets whenever a worktree exists.** In `BindAsync`, when
`entity.Worktree != null` and the task is not a planning parent, call
`GetMergeTargetsAsync(taskId)` and set `SelectedMergeTarget = DefaultBranch`
(fixes the standalone-task gap where targets were never loaded).
- **Mergeability indicator** properties: `MergePreviewText` (string),
`MergeIsClean` / `MergeIsConflict` (bool, for color). Compute via
`PreviewMergeAsync` when the merge section is shown for an **Active** worktree;
recompute on `SelectedMergeTarget` change. If worktree state is
`Merged/Discarded/Kept`, show that label instead of probing. Text examples:
"Merges cleanly · 7 files" / "Conflicts in a.cs, b.cs" / "Mergeability unknown".
- **Approve** (`ApproveReviewAsync`): pass `SelectedMergeTarget ?? ""`; inspect
result — on `"conflict"` set the conflict indicator + a short notice
("Approve blocked — resolve conflicts first"); success path relies on the
existing `TaskUpdated` broadcast to refresh.
- **Single-task Merge** (`MergeCommand`): `MergeTaskAsync(taskId,
SelectedMergeTarget ?? "", removeWorktree:false, "Merge task")`; on `"conflict"`
show the conflict indicator. Shown for non-planning tasks with an active
worktree (planning parents keep "Merge All Subtasks").
`WorkConsole.axaml` (Session tab, `MERGE & WORKTREE` block):
- Add a status line above the button row bound to `MergePreviewText`, colored
green (`MossBrush`) when `MergeIsClean`, red (`BloodBrush`) when
`MergeIsConflict`, muted otherwise. Use existing tokens/classes only.
- Add a **Merge** button (`MergeCommand`) beside **Open Diff** for the
single-task path.
## Testing (git-backed, no real Claude)
In `ClaudeDo.Worker.Tests` (real temp git repos + real SQLite), and/or
`ClaudeDo.Data.Tests` for the pure git probe:
- `GitService.PreviewMergeAsync`: clean branches → `Clean=true`; a real
edit-conflict on the same lines → `Clean=false` with the expected file in
`ConflictFiles`.
- `ApproveAndMergeAsync`: clean worktree → returns `merged`, task is `Done`,
worktree state `Merged`. Conflicting worktree → returns `conflict`, task still
`WaitingForReview`, worktree still `Active`, target branch unmodified
(HEAD unchanged, no `MERGE_HEAD`).
- No-worktree `WaitingForReview` task → returns `merged`, task `Done`.
## Out of scope
External difftools, new task statuses, auto-removing worktrees on approve,
re-splitting the console into separate tabs, conflict resolution UI (the existing
`ContinueMerge`/`AbortMerge` paths remain as-is for mid-merge cases).

View File

@@ -0,0 +1,200 @@
# ClaudeDo distribution website — design
**Date:** 2026-06-04
**Status:** Approved (design), ready for implementation planning
**Repo:** new standalone repo `claudedo-web` (not part of the ClaudeDo app solution)
**Domain:** `claudedo.kuns.dev` (Coolify on the user's VPS)
## Purpose
Give friends a public place to download ClaudeDo and learn what it does, without
sending them to the Gitea repo — so the source repo can be made more private. The
site also fronts the app's self-updater so the Gitea URL is never exposed in the
app or on the page.
## Goals / non-goals
**Goals**
- Public, no-auth landing page at `claudedo.kuns.dev` that matches the app's visual identity.
- A primary download (installer `.exe`) plus the portable `.zip` and checksums.
- A release proxy that (a) feeds the page the current version and (b) serves the
app's self-updater the same JSON shape Gitea returns, with download URLs rewritten
to route through `claudedo.kuns.dev` — hiding Gitea entirely.
**Non-goals**
- No docs site / getting-started page (the app ships an installer that handles setup).
- No changelog page (release notes already live on Gitea releases).
- No auth, accounts, analytics, or CMS.
- No CI/PR tooling for this repo beyond what Coolify needs to deploy.
## Access & distribution decisions
- **Access:** fully public. No password/login. Relies on the unadvertised URL.
- **Download source:** build-time fetch of the latest Gitea release for the displayed
version; actual download links route through the proxy and resolve the latest asset
at request time (so a stale page still downloads the current build).
- **Self-updater proxy:** in scope for v1 (not deferred).
## Tech stack
- **Nuxt 3** (Vue 3) — single framework, single repo, single Coolify deploy.
- **Nitro** server routes for the release proxy + asset streaming.
- **No DB, no auth, no secrets** (the `releases/ClaudeDo` repo is public).
- Fonts: **Inter Tight** (display/body) + **JetBrains Mono** (mono), self-hosted or via
Google Fonts. Design tokens ported from `docs/UI Rewrite/design_handoff_claudedo/Tokens.axaml`
and `styles.css` (moss/sage/peat palette, dark-first, 14px island radius, grain texture).
## Concept: "the page IS the app"
The landing page is a faithful, in-browser rendering of the ClaudeDo desktop — the
window chrome + the three islands — rather than a conventional marketing page. This
is the chosen direction (over a cinematic single-window scroll and a worklog feed).
### Layout — three islands on the app "desktop"
Desktop background = the app's layered moss gradients + 3px grain overlay. A centered
app `window` (titlebar + body) holds a 3-column island grid:
1. **Lists island (left)** — repurposed as page nav.
- Header "Lists" + a decorative search box (`Ctrl K` kbd chip).
- "Pages" group: Overview · Features (6) · How it works · Screenshots (3) · Download,
each with a colored swatch dot; active item gets the accent left-bar.
- Footer styled like the app's user footer: avatar, "For friends", `claudedo.kuns.dev`.
2. **Tasks island (middle)** — features rendered as **task cards**.
- Header: date eyebrow, big title (the hero line "Queue the work. Claude does it."),
a `running · review` badge, eye/gear icon buttons, and a subtitle.
- A decorative "Add a task…" row.
- Six **feature cards** (circle check — done cards filled; title; a status chip
`done`/`running`/`waiting for review`; a star). The features:
1. Isolated worktrees
2. The task queue
3. Review & merge
4. Live session log
5. Per-list & per-task config
6. Self-updating
- A "Ready" group with the final **"↓ Download ClaudeDo"** card.
3. **Detail island (right)** — faithful to the reworked Task-Detail island.
- **Source of truth for the detail visuals:** `docs/superpowers/specs/2026-06-04-task-detail-redesign-design.md`
(the app's in-progress rework). The website's detail pane must track that design.
- Three-zone stack:
- **Task header** — mono id (`#F01…`), title, trash/gear action icons.
- **DETAILS bar** — `DETAILS` eyebrow + `Edit` / copy / `⋯`, then a **markdown body**
(headings, paragraphs, inline `code`, ordered/unordered lists) describing the feature.
- **WorkConsole** docked at the bottom — traffic-light dots, `· N turns · +x y`,
tabs **Output / Actions / Session**, and a `Created …` footer.
- Per-feature mapping:
- Feature panels: markdown writeup in DETAILS + a short relevant **Output** log.
- "Review & merge": opens on the **Actions** tab with `Merge target` + `Open Diff` /
`Approve & merge`.
- **Download**: DETAILS shows requirements (`.NET 8 Desktop Runtime`, `Claude CLI`,
`Git`); the **Actions** tab holds the install controls, with `Merge target`
repurposed as a **Build** selector and buttons `↓ Download installer` /
`Portable .zip` / `checksums.txt`.
4. **Statusbar (bottom)**`● Online · claudedo.kuns.dev · private build`.
### Interaction
- Clicking a task card selects it (accent bar + card highlight) and swaps the active
detail panel; the WorkConsole tabs are clickable within a panel.
- **All panels are server-rendered and present in the DOM**, toggled by class — the
page is fully readable and downloadable **without JavaScript** (progressive
enhancement). Vue handles the selection state on the client.
### Responsive
- ≤ ~1100px: drop the Detail island; show Lists + Tasks.
- ≤ ~780px: single column — the Tasks list; tapping a feature pushes to a full-screen
Detail view (mirrors the app's narrow-window behavior) with a back affordance.
## Server: release proxy (Nitro)
The app's `ReleaseClient` (`src/ClaudeDo.Releases/ReleaseClient.cs`) calls
`{apiBase}/releases/latest` and reads `tag_name`, `name`, and
`assets[].browser_download_url`; `DownloadAsync` GETs an asset URL directly.
- **`GET /api/releases/latest`** — fetches `https://git.kuns.dev/api/v1/repos/releases/ClaudeDo/releases/latest`,
returns the **same JSON shape**, but every `assets[].browser_download_url` is rewritten
from the Gitea URL to `https://claudedo.kuns.dev/api/download/<encoded-asset-path>`.
Cached briefly (e.g. 5 min) server-side.
- **`GET /api/download/[...path]`** — reconstructs the Gitea asset URL from the path and
**streams** the binary back (no redirect to Gitea, so the URL stays hidden). Sets
appropriate `Content-Type`/`Content-Disposition`.
- **`server/utils/gitea.ts`** — shared base URL (`GITEA_API`, `REPO` from env), fetch
helper, and the URL-rewrite/asset-path round-trip.
- The page's download buttons point at the same `/api/download/...` routes (with a
stable "latest installer" path), so links never go stale between deploys.
### App-side coordinating change (separate, in the ClaudeDo repo)
Point `ReleaseClient`'s `apiBase` at `https://claudedo.kuns.dev/api` instead of the
Gitea default (one-line DI change where `ReleaseClient`/`UpdateCheckService` are
constructed). Tracked as a follow-up; not part of the `claudedo-web` repo. The proxy
path (`/api/releases/latest`) is chosen to match the existing
`{apiBase}/releases/latest` call so the parser is untouched.
## Content / assets
- **Screenshots (provided):** main 3-column view (hero), diff review modal, worktrees
panel. Stored in `public/screenshots/`. Placeholders sized for them until dropped in.
- Copy: hero "Queue the work. Claude does it."; six feature writeups as above (final
wording during implementation).
## Error handling
- **Build-time release fetch fails:** render the page with a last-known/placeholder
version label; download buttons still work because they resolve via the runtime
proxy route.
- **Proxy `/api/releases/latest` upstream failure:** return a 502/`null`-equivalent the
way Gitea would on miss; the app's `UpdateCheckService` already treats null/exception
as `CheckFailed` and degrades gracefully.
- **`/api/download` upstream failure:** surface a 502; the button shows an error state.
- No retries beyond a single upstream attempt for v1 (low traffic, friends-only).
## Testing
- **Vitest** unit tests for `server/utils/gitea.ts`: URL rewrite (Gitea → proxy) and the
asset-path round-trip (proxy path → Gitea URL), and release-JSON shape preservation.
- A light component smoke test that the page renders the islands and the download
controls without JS errors.
- No real-network/Gitea calls in tests — mock the upstream fetch.
## Deployment (Coolify)
- **Dockerfile**: `node:20-alpine` build → `nuxt build` → run `.output/server/index.mjs`.
- Coolify app bound to `claudedo.kuns.dev` with TLS via its reverse proxy.
- Env: `GITEA_API` (default `https://git.kuns.dev/api/v1`), `REPO` (`releases/ClaudeDo`),
`PUBLIC_BASE_URL` (`https://claudedo.kuns.dev`) for URL rewriting.
- Deploy on push to `main`; re-deploy (or a periodic rebuild) refreshes the displayed
version. No PR/CI tooling beyond Coolify's build.
## Open risk
- The reworked Detail island in the app is still in flux. The website's detail pane
must be kept in sync with `2026-06-04-task-detail-redesign-design.md`; expect a
visual-polish pass once that rework lands.
## Repo layout
```
claudedo-web/
├── nuxt.config.ts
├── app.vue
├── pages/index.vue # the one landing page
├── components/
│ ├── AppWindow.vue # window chrome + statusbar
│ ├── ListsIsland.vue # page nav
│ ├── TasksIsland.vue # feature cards + download card
│ ├── DetailIsland.vue # three-zone detail (header / DETAILS md / WorkConsole)
│ ├── WorkConsole.vue # tabs: Output / Actions / Session
│ └── content/ # per-feature markdown/blurbs + download panel
├── server/
│ ├── api/releases/latest.get.ts
│ ├── api/download/[...path].get.ts
│ └── utils/gitea.ts
├── assets/css/tokens.css # palette + type ported from Tokens.axaml/styles.css
├── public/screenshots/ # 3 PNGs
└── Dockerfile
```

View File

@@ -0,0 +1,127 @@
# Refine Task — Design
**Date:** 2026-06-04
**Status:** Approved (pending spec review)
## Goal
Add a one-click **Refine Task** action to a task card. Clicking it spawns a
headless Claude session that reads the task (and the repo), rewrites the task's
description to be clearer and runnable autonomously, and — where it helps —
breaks the work into subtasks. The user then reviews/hand-edits the result and
queues the task manually.
This is **not** an interactive terminal session. It is a fire-and-forget
headless run, structurally similar to the existing daily-prep ("Prime Claude")
flow (`PrimeRunner`), not the interactive planning flow.
## Non-goals / scope
- No new task status. The task stays `Idle` throughout; refine only mutates the
task's `Title`/`Description` and its subtasks.
- No worktree, no interactive terminal, no auto-queue.
- No per-task refine config (model, turns) — uses the worker's defaults.
- Refine does not edit repository files; repo access is read-only.
## User flow
1. User clicks the refine icon on an `Idle` task's card.
2. UI calls `WorkerHub.RefineTask(taskId)``RefineRunner`.
3. `RefineRunner` spawns `claude -p` headless in the list's working directory,
seeded with a fixed refine prompt + the task's title/description/current
subtasks + the task id.
4. Claude reads the repo (read-only), then calls:
- `mcp__claudedo__update_task` to improve title/description, and
- `mcp__claudedo__add_subtask` to add steps where useful.
Each MCP call broadcasts `TaskUpdated`, so the description and Steps card
update live in the UI.
5. Run finishes; the card's refine button returns to its idle state. User
reviews, optionally hand-edits the description/steps, then queues manually.
## Architecture
### Worker — `RefineRunner`
- New `Worker/Refine/RefineRunner.cs` implementing `IRefineRunner`
(`Worker/Refine/Interfaces/IRefineRunner.cs`). Modeled on `PrimeRunner`.
- **Concurrency / single-flight:** an in-flight `HashSet<string>` of task ids
guarded by a lock (or `SemaphoreSlim`), so the *same* task cannot refine
twice concurrently, but different tasks may refine in parallel. A second
click on an already-refining task is a no-op.
- **Guards:** only runs when `task.Status == Idle`. Resolves the list's working
directory. If the list has **no valid working dir**, fall back to a sandbox
directory and run text-only (drop `Read`/`Grep`/`Glob` from the allowlist).
- **CLI invocation** (relies on the globally-registered `claudedo` MCP, like
daily-prep — no `--mcp-config`):
```
claude -p --output-format stream-json --verbose
--permission-mode acceptEdits
--max-turns <N>
--allowedTools mcp__claudedo__get_task,mcp__claudedo__update_task,mcp__claudedo__add_subtask,Read,Grep,Glob
```
`Edit`/`Write`/`Bash` are deliberately **not** whitelisted, so the run is
read-only on the repo even under `acceptEdits`. (Chosen over `plan` mode to
avoid the headless "exit plan mode to act" friction; the allowlist is the
real read-only gate.)
- **Logging:** stream stdout to a per-run log at
`logs/refine-<taskId[:8]>.log`, truncated at the start of each run.
### Prompt — `PromptKind.Refine`
- Add `Refine` to the `PromptKind` enum in `PromptFiles.cs`, file
`prompts/refine.md`, with a bundled default.
- Default prompt instructs: refine one ClaudeDo task so it is ready to run
autonomously; ground the description in the actual code (read-only); keep
scope tight (no scope creep into adjacent work); add steps as subtasks only
when they genuinely help; use only `get_task`, `update_task`, `add_subtask`
and the read-only tools; never edit files.
- Rendered via `PromptFiles.Render` with `{taskId}`, `{title}`,
`{description}`, and the current subtask list seeded into the prompt so the
agent knows which steps already exist.
### MCP tool — `add_subtask`
- New `[McpServerTool]` on `ExternalMcpService` (part of the global `claudedo`
MCP), signature `add_subtask(taskId, title, orderNum?)`.
- Creates a `SubtaskEntity` via `SubtaskRepository`; `orderNum` defaults to
append-at-end (max existing + 1). Refuses if the task is `Running`.
Broadcasts `TaskUpdated`.
- **Append semantics, not replace:** the current subtasks are already in the
prompt, so the agent only adds missing steps; re-running refine will not
silently wipe steps the user hand-edited.
- `update_task` already exists (title/description/commitType) and is reused
unchanged.
### UI — button, icon, feedback
- **Icon:** add the supplied SVG as an `Icon.Refine` `StreamGeometry` in
`IslandStyles.axaml`, rendered as a **stroked `Path`** (`plan-icon` style,
fill none) — it is line art, so per the PathIcon-fills-geometry gotcha it
must be stroked, not filled.
- **Button:** a new `icon-btn` in `TaskRowView.axaml` near the star button,
visible only when the task is `Idle`. Bound to a new `RefineTaskCommand` on
`TasksIslandViewModel`.
- **Feedback:** new broadcaster events `RefineStarted(taskId)` /
`RefineFinished(taskId, ok, error?)` drive an `IsRefining` flag on
`TaskRowViewModel`; the button shows a busy/disabled state while running. The
description and Steps card update live via the existing `TaskUpdated` events
fired by the MCP calls.
- Wire `RefineTask` through `IWorkerClient` / `WorkerClient`, the `WorkerHub`
method, and update the hand-rolled test fakes in both test projects.
## Testing
- `add_subtask`: creates the row, appends order correctly, refuses when
`Running`, broadcasts `TaskUpdated`.
- Refine prompt builder and CLI-args builder produce the expected prompt/flags
(including the text-only fallback when no working dir).
- `RefineRunner` guards: `Idle`-only, per-task single-flight no-op on a second
concurrent call.
- **No test spawns the real `claude` CLI** (project rule). The end-to-end run
is a manual smoke step.
## Open implementation calls (decided)
- **Permission mode:** `acceptEdits` + restricted allowlist for read-only
(rather than `plan` mode).
- **`add_subtask`:** append-only (rather than replace-all).

View File

@@ -0,0 +1,200 @@
# Task Detail Island Redesign — Design
**Date:** 2026-06-04
**Status:** Approved (design), pending implementation
**Author:** brainstormed with user via visual companion
## Problem
The Detail island (`DetailsIslandView`, 413 lines) grew into one long scrolling
column as features piled on. The user has to scroll constantly. Specific pains
(confirmed by the user):
- **Everything is always stacked** — Steps, Description, Terminal, and several
conditional sections share one scroll column with no way to hide/fold.
- **Duplicated info** — `model` shows in the gear flyout *and* the agent strip;
the branch line shows in the agent strip *and* as the terminal label.
- **Agent strip is a heavy 5-row block** pinned near the bottom even when idle.
- **Steps + Description take a lot of room** before the action controls.
The terminal staying prominent is *fine* — not a pain point.
## Solution overview
Replace the linear body with a **fixed-region layout** built from **3 new
self-contained components**, plus a roadblock band. Top region (header + details
card) stays put; the work console is pinned to the lower third.
```
┌─────────────────────────────────────┐
│ TaskHeaderBar (separated title) │ #T42 · title · 🗑/💀 · ⚙
├─────────────────────────────────────┤
│ DescriptionStepsCard │ card; text ⇄ steps toggle icon
│ (Preview = what Claude gets) │ copy · preview/edit
├─────────────────────────────────────┤
│ Roadblock band (only when failed) │ ⚠ message · Continue · Reset&Retry
├─────────────────────────────────────┤
│ WorkConsole (pinned, terminal) │ ●●● · model·turns·diff
│ tabs: Output | Actions | Session │
└─────────────────────────────────────┘
```
## The 3 components
Each is a standalone `UserControl` + dedicated `ViewModel` with **design-time
sample data** so it renders fully in the Avalonia previewer in isolation. Built
in separate worktrees; **none touch `DetailsIslandView.axaml` or
`DetailsIslandViewModel.cs`** (that is the wiring session). All visuals use
**only** the existing design tokens (`Design/Tokens.axaml`) and style classes
(`Design/IslandStyles.axaml`) — no hardcoded colors/sizes.
New folder: `src/ClaudeDo.Ui/Views/Islands/Detail/`
New VMs: `src/ClaudeDo.Ui/ViewModels/Islands/Detail/`
### 1. TaskHeaderBar
- **Layout:** one row — `#T42` id badge (mono `meta`, copyable) · editable title
`TextBox` (transparent, `FontSizeTaskTitle`, wraps) · **trash/skull button** ·
⚙ gear button with the agent-settings flyout.
- **Trash → Skull:** when **not** running show `Icon.Trash` (delete task,
`BloodBrush`); when **running** show a **skull** glyph (kill session). One
button, swaps icon + command on running state. Skull is a *new* filled
geometry to add to `IslandStyles.axaml` resources (`Icon.Skull`).
- **No done circle. No star** (the star lives on the task card/row already).
- **Gear flyout:** keep the existing agent-settings content verbatim — Model
combo + `InheritedBadge` + reset; Max Turns `NumericUpDown` + badge + reset;
System Prompt `TextBox` + "prepended" hint; Agent File combo + badge + reset.
Disabled while running (`IsAgentSectionEnabled`).
- **Existing bindings reused:** `TaskIdBadge`, `EditableTitle`, `DeleteTaskCommand`,
`StopCommand`, `IsRunning`, `IsAgentSectionEnabled`, all the agent-settings
members (`TaskModelOptions`/`TaskModelSelection`/`ModelBadge`/
`ResetTaskModelCommand`/`TaskMaxTurns`/`TurnsBadge`/`ResetTaskTurnsCommand`/
`TaskSystemPrompt`/`EffectiveSystemPromptHint`/`TaskAgentOptions`/
`TaskSelectedAgent`/`AgentBadge`/`ResetTaskAgentCommand`).
### 2. DescriptionStepsCard
A `Border.island`-style card. The single explicitly-requested "separate
component." Top-right **toggle icon** switches the card between **Description**
and **Steps** views; the icon shows the *other* mode (in Description view → steps
icon `Icon.MoreHorizontal`/list glyph; in Steps view → text glyph).
- **Header row:** small `section-label` ("DETAILS" / "STEPS") · spacer · **Copy**
icon button (`Icon.Copy`) · **Preview/Edit** toggle button (Description view
only) · **toggle icon** (top-right).
- **Description view:**
- *Preview mode* = renders **what Claude gets** via `MarkdownView`: the
canonical composed text (Title + Description + open steps — see below).
- *Edit mode* = raw description `TextBox` (mono, `Surface2Brush`, multiline).
- **Steps view:** add-step input (Enter to add) + list of step rows (check
circle `Ellipse.task-check` + inline-editable title, `subtask-row` style).
- **Copy** copies the **formatted** version (Title + Description + open steps),
nothing else, to the clipboard.
- **Existing bindings reused (when wired):** `EditableDescription`,
`IsEditingDescription`/`ToggleEditDescriptionCommand`, `Subtasks`,
`NewSubtaskTitle`/`AddSubtaskCommand`, `ToggleSubtaskDoneCommand`,
`CommitSubtaskEditCommand`.
- **New members (defined on the component VM now, lifted into
`DetailsIslandViewModel` at wiring):** `IsStepsView` + `ToggleCardViewCommand`;
`ComposedPreview` (string, the canonical format); `CopyFormattedCommand`.
### 3. WorkConsole
Terminal-styled card (`Border.terminal`) pinned to the lower third.
- **Title bar:** three cosmetic traffic-light dots (`Ellipse.dot-red`,
`dot-yellow`, `dot-green`) on the left; centered/!right small **info header**:
`model · {turns} turns · +adds dels` (mono `meta`; `diff-add`/`diff-del`
classes for the numbers). **No branch line.** LIVE/DONE/FAILED chip
(`live-chip`) on the right.
- **Tab strip:** `Output` | `Actions` | `Session`.
- **Output** — the live log. Reuse `SessionTerminalView` (`Entries`, `Label`,
`IsRunning`, `IsDone`, `IsFailed`) for the body, *or* the same
timestamp+`SelectableTextBlock` row template.
- **Actions** — worktree management: merge-target `ComboBox`, **Open Diff**,
**Worktree**, **Merge** (+ planning **Merge All Subtasks** when planning
parent). Bindings: `MergeTargetBranches`/`SelectedMergeTarget`,
`OpenDiffCommand`, `OpenWorktreeCommand`, `MergeAllCommand`/`CanMergeAll`/
`MergeAllDisabledReason`/`MergeAllError`, `ReviewCombinedDiffCommand`.
- **Session** — review + outcomes: feedback `TextBox` + Approve/Reject/Park/
Cancel (`ReviewFeedback`, `ApproveReviewCommand`, `RejectReviewCommand`,
`ParkReviewCommand`, `CancelReviewCommand`, shown when `IsWaitingForReview`)
and the child-outcomes list (`ChildOutcomes`, `HasChildOutcomes`).
- **Roadblock band** (above the tabs, inside or just above the card): visible on
`IsFailed`/`IsCancelled`; shows a warning (`Icon.Warning`, `BloodBrush`) and
**Continue** (`ContinueCommand`, `ShowContinue`) + **Reset & Retry**
(`ResetAndRetryCommand`, `ShowResetAndRetry`).
- **Info-header bindings:** `Model`, `Turns`, `DiffAdditions`, `DiffDeletions`,
`IsRunning`/`IsDone`/`IsFailed`.
## Combined Description + Steps behavior
Steps are part of the description. When the task runs, the **effective prompt =
Title + Description + only the OPEN steps**. Resolved steps are dropped.
**Canonical composed format** (shared by the Worker prompt, the card's Preview,
and Copy):
```
<Title>
<Description>
## Sub-Tasks
- [ ] <open step 1>
- [ ] <open step 2>
```
- Omit the `## Sub-Tasks` section entirely when no open steps remain.
- Omit the description paragraph when description is empty.
**Worker change (wiring session, by Claude):** `TaskRunner.cs:104-113` currently
appends *all* subtasks with `[x]`/`[ ]`. Change to append **only incomplete**
subtasks as `- [ ]` lines (drop completed). Factor the format into a shared
`TaskPromptComposer` in `ClaudeDo.Data` (referenced by both Worker and UI) so the
card's Preview and the real prompt never diverge.
## Color / token guidelines (mandatory)
- Backgrounds: `IslandBackgroundBrush`, `Surface2Brush`, `Surface3Brush`,
`DeepBrush`, `VoidBrush` (terminal). Borders: `LineBrush`/`LineBrightBrush`,
`HairlineOverlayBrush`. Text: `TextBrush`/`TextDimBrush`/`TextMuteBrush`/
`TextFaintBrush`. Accent: `AccentBrush`/`AccentDimBrush`. Status: blood/peat/
moss/sage + the `*TintBrush` pairs.
- Radii: `IslandCornerRadius` (14), `ButtonCornerRadius` (6), `InputCornerRadius`
(8). Spacing: `SpaceXs..Space2Xl`. Fonts: `SansFont`, `MonoFont`; sizes
`FontSizeMono`/`FontSizeBody`/`FontSizeTaskTitle`.
- Reuse style classes: `island`, `island-header`, `chip`, `btn`/`btn accent`/
`primary`/`danger`, `icon-btn`, `flat`, `terminal`, `dot-red/yellow/green`,
`live-chip`, `task-check`, `subtask-row`, `section-label`, `field-label`,
`meta`, `diff-add`/`diff-del`, `diff-meter-*`.
- **No inline hex, no magic numbers** where a token exists. `PathIcon` fills
geometry — line-art must be filled or stroked via `Path`.
## Build / isolation strategy
1. Three ClaudeDo tasks (list "Claude do", repo `C:\Private\ClaudeDo`), one per
component, run sequentially in their own worktrees.
2. Each delivers: `Detail/<Name>.axaml` + `.axaml.cs` + `Detail/<Name>ViewModel.cs`
with design-time sample data; the `ClaudeDo.Ui` project **builds green**
(`dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj -c Release`).
3. Components are visual-only against sample data. Real `DetailsIslandViewModel`
binding + the Worker steps→prompt change happen in the **wiring session**
(this Claude session, done while the build tasks run).
## Wiring plan (this session)
- Implement `TaskPromptComposer` + the `TaskRunner` open-steps change + a unit
test in `ClaudeDo.Worker.Tests`/`Data.Tests`.
- After the 3 components land: host them in `DetailsIslandView` (header top,
card below, roadblock band, work console pinned bottom), lift the new card VM
members into `DetailsIslandViewModel`, repoint `x:DataType`, delete the
superseded inline sections + `AgentStripView` usage. Update locale parity and
the test fakes.
## Monitoring loop (this session)
While the build tasks run: poll each via `get_task` / `get_task_log` /
`get_task_diff`, summarize progress and anything a session got stuck on, and if a
session is blocked on something missing, add a small follow-up task to the
"Claude do" list.

View File

@@ -0,0 +1,99 @@
# Terminal-style review controls
**Date:** 2026-06-05
**Status:** Approved (design)
## Problem
Review feedback today is a multi-line `TextBox` plus four buttons (Approve / Reject /
Park / Cancel) tucked into the WorkConsole **Session** tab
(`WorkConsole.axaml:169-193`). It feels disconnected from the live terminal. Entering
feedback should feel like typing into the terminal, with action buttons docked at the
bottom — and merge/approve actions should live in an obvious, dedicated place.
## Goal
- Type review feedback directly in the **Output (terminal)** tab, prompt-style.
- Bottom-docked action strip on the terminal: `[Retry]` `[Reset]`.
- Move all git/merge/worktree actions (including **Approve**) into a new **Git** tab so
it is obvious where each action lives.
## Tab structure
Three tabs in WorkConsole: **Output** · **Git** · **Session**.
| Tab | Contents |
| --- | --- |
| **Output** | Live `Log` (unchanged) + new review footer (below), footer gated on `IsWaitingForReview`. |
| **Git** | The current "Merge & worktree" block — merge-target dropdown, mergeability indicator, **Approve**, Open Diff, Merge, Worktree, Review Combined Diff, Merge All Subtasks. Visibility gated on `ShowMergeSection` / `IsWaitingForReview` as today. |
| **Session** | Child outcomes + empty-state only. |
### ViewModel changes (`DetailsIslandViewModel`)
- Add `public bool IsGitTab => SelectedTab == "git";`
- Add `[NotifyPropertyChangedFor(nameof(IsGitTab))]` alongside the existing
`IsOutputTab` / `IsSessionTab` notifications on `SelectedTab` (`:139-144`).
- `SelectTab` already accepts a string parameter — no change beyond the new `"git"`
value wired from XAML.
- No command renames (avoids breaking hand-rolled test fakes).
## Terminal footer (Output tab)
A `Border` docked `Bottom` inside the Output tab body, visible only when
`IsWaitingForReview`:
- Background `Surface2Brush`, top border `LineBrush` (`BorderThickness="0,1,0,0"`).
- A `` prompt-prefix `TextBlock` (mono, `TextMuteBrush`) + a borderless mono `TextBox`:
- Bound `Text="{Binding ReviewFeedback, Mode=TwoWay}"`.
- `AcceptsReturn="True"`, `TextWrapping="Wrap"`, transparent background, no border.
- Starts ~1 line tall; grows with content up to `MaxHeight≈160`, then scrolls.
- `PlaceholderText` e.g. "Feedback for the next run…".
- Right-aligned button strip:
- `[Retry]``Classes="btn accent"``RejectReviewCommand`.
- `[Reset]``Classes="btn"``ParkReviewCommand`.
`[Accept]` is **not** in the footer; approval happens on the Git tab via
`ApproveReviewCommand`. The old `Cancel` review button is dropped from this UI; cancel
remains reachable through the task's existing cancel control (`CancelReviewCommand`
stays on the ViewModel, just not surfaced here).
### Enter handling (`WorkConsole.axaml.cs`)
- Handle `KeyDown` on the input `TextBox`:
- **Enter** without Shift → execute `RejectReviewCommand` (if it can execute) and set
`e.Handled = true`.
- **Shift+Enter** → fall through to default behavior (inserts newline).
- `RejectReviewAsync` already returns early on whitespace-only feedback
(`DetailsIslandViewModel.cs:1464`), so pressing Enter with an empty prompt is a no-op
with no extra guard needed.
## Command mapping
| Button | Location | Command | Effect |
| --- | --- | --- | --- |
| `[Retry]` | Output footer | `RejectReviewCommand` | Reject-to-queue with feedback; resumes the session (Queued). |
| `[Reset]` | Output footer | `ParkReviewCommand` | Park back to Idle. |
| `[Approve]` | Git tab | `ApproveReviewCommand` | Merge `SelectedMergeTarget` → Done (conflict keeps it in review). |
## Copy / empty state
- Update the Session empty-state text (`WorkConsole.axaml:270`) — it currently says
"review and merge controls appear here once the run finishes", which is no longer
accurate. Reword to reflect that only outcomes live on Session.
- Button labels remain literal strings (`Retry`, `Reset`, `Approve`), matching the
existing review buttons (no new localization keys).
## Out of scope
- No changes to worker-side review/merge logic or `IWorkerClient` signatures.
- No merge-target selector duplicated into the terminal footer (Approve uses the Git
tab dropdown / default target).
- No command renames on the ViewModel.
## Testing / verification
- Build `ClaudeDo.App` and `ClaudeDo.Worker` in `-c Release`.
- Manual visual verification (must be flagged — cannot be auto-verified):
- Footer appears only in `WaitingForReview`, on the Output tab.
- Enter sends Retry; Shift+Enter inserts a newline; empty Enter does nothing.
- Git tab shows Approve + merge/worktree controls; Session shows only outcomes.

View File

@@ -35,7 +35,7 @@ All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. The atomic `Q
## Git
- **GitService** — async wrapper around git CLI (ProcessStartInfo, no shell). Operations: worktree add/remove, add all, commit (stdin for message), merge ff-only, rev-parse, diff-stat, has-changes, is-git-repo
- **GitService** — async wrapper around git CLI (ProcessStartInfo, no shell). Operations: worktree add/remove, add all, commit (stdin for message), merge ff-only, rev-parse, diff-stat, has-changes, is-git-repo, `PreviewMergeAsync` (non-destructive mergeability check via `git merge-tree --write-tree`), `CountChangedFilesAsync`
## Schema

View File

@@ -3,6 +3,8 @@ using System.Text;
namespace ClaudeDo.Data.Git;
public sealed record MergePreview(bool Supported, bool Clean, IReadOnlyList<string> ConflictFiles);
public sealed class GitService
{
public async Task<bool> IsGitRepoAsync(string dir, CancellationToken ct = default)
@@ -236,6 +238,49 @@ public sealed class GitService
.ToList();
}
/// <summary>
/// Non-destructive mergeability probe via `git merge-tree --write-tree`. Writes only
/// loose objects — the working tree, index, and refs are left untouched.
/// </summary>
public async Task<MergePreview> PreviewMergeAsync(
string repoDir, string targetBranch, string sourceBranch, CancellationToken ct = default)
{
var (exitCode, stdout, _) = await RunGitAsync(repoDir,
["merge-tree", "--write-tree", "--name-only", targetBranch, sourceBranch], ct);
if (exitCode == 0)
return new MergePreview(true, true, Array.Empty<string>());
if (exitCode == 1)
{
// stdout: <tree-oid>\n<file>\n...\n\n<informational messages>
var lines = stdout.Split('\n');
var files = new List<string>();
for (int i = 1; i < lines.Length; i++)
{
var line = lines[i].TrimEnd('\r');
if (string.IsNullOrWhiteSpace(line)) break;
files.Add(line.Trim());
}
return new MergePreview(true, false, files);
}
// Any other exit (e.g. git too old: "unknown option --write-tree").
return new MergePreview(false, false, Array.Empty<string>());
}
/// <summary>Count of files that differ on <paramref name="sourceBranch"/> since its merge base with the target.</summary>
public async Task<int> CountChangedFilesAsync(
string repoDir, string targetBranch, string sourceBranch, CancellationToken ct = default)
{
var (exitCode, stdout, _) = await RunGitAsync(repoDir,
["diff", "--name-only", $"{targetBranch}...{sourceBranch}"], ct);
if (exitCode != 0) return 0;
return stdout
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Count(s => s.Length > 0);
}
public async Task MergeFfOnlyAsync(string repoDir, string branchName, CancellationToken ct = default)
{
var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["merge", "--ff-only", branchName], ct);

View File

@@ -2,7 +2,7 @@ using System.Text;
namespace ClaudeDo.Data;
public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport, ImprovementChild }
public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport, ImprovementChild, Refine }
public static class PromptFiles
{
@@ -17,6 +17,7 @@ public static class PromptFiles
PromptKind.DailyPrep => Path.Combine(Root, "daily-prep.md"),
PromptKind.WeeklyReport => Path.Combine(Root, "weekly-report.md"),
PromptKind.ImprovementChild => Path.Combine(Root, "improvement-child.md"),
PromptKind.Refine => Path.Combine(Root, "refine.md"),
_ => throw new ArgumentOutOfRangeException(nameof(kind))
};
@@ -61,6 +62,7 @@ public static class PromptFiles
PromptKind.DailyPrep => DailyPrepDefault,
PromptKind.WeeklyReport => WeeklyReportDefault,
PromptKind.ImprovementChild => ImprovementChildDefault,
PromptKind.Refine => RefineDefault,
_ => ""
};
@@ -181,6 +183,33 @@ public static class PromptFiles
If there are no candidates, do nothing.
""";
private const string RefineDefault = """
You are refining ONE ClaudeDo task so it is ready to run autonomously later.
You are NOT executing the task only improving its specification.
The task you are refining:
- id: {taskId}
- title: {title}
- description: {description}
- current subtasks (steps):
{subtasks}
What to do:
1. If a repository is available, read the relevant code (read-only) to ground your
understanding. Do NOT edit, create, or delete any files. Do NOT run commands.
2. Rewrite the description so it is clear, specific, and self-contained: what to change,
where, and what "done" looks like. Keep scope tight do not invent adjacent work.
3. Call mcp__claudedo__update_task to save the improved title (only if it genuinely
helps) and description.
4. If the work is clearer as discrete steps, add them as subtasks with
mcp__claudedo__add_subtask (one call per step, in order). Only add steps that are
not already present in the current subtasks above.
Use ONLY these tools: mcp__claudedo__get_task, mcp__claudedo__update_task,
mcp__claudedo__add_subtask, and read-only Read/Grep/Glob. When you have updated the
task, stop.
""";
private const string WeeklyReportDefault = """
You are generating a concise weekly standup report for a software developer,
covering {start} to {end}.

View File

@@ -0,0 +1,29 @@
using System.Text;
namespace ClaudeDo.Data;
/// <summary>
/// Single source of truth for the text handed to Claude as a task prompt:
/// title + description + the OPEN sub-tasks. Resolved sub-tasks are dropped.
/// Shared by the Worker (real prompt) and the UI (the card's "what Claude gets" preview).
/// </summary>
public static class TaskPromptComposer
{
public static string Compose(string title, string? description, IEnumerable<(string Title, bool Completed)> subtasks)
{
var sb = new StringBuilder((title ?? "").Trim());
if (!string.IsNullOrWhiteSpace(description))
sb.Append("\n\n").Append(description.Trim());
var open = subtasks?.Where(s => !s.Completed).ToList() ?? new List<(string, bool)>();
if (open.Count > 0)
{
sb.Append("\n\n## Sub-Tasks\n");
foreach (var s in open)
sb.Append("- [ ] ").Append(s.Title).Append('\n');
}
return sb.ToString();
}
}

View File

@@ -111,7 +111,8 @@
"reviewTitle": "Review",
"feedbackLabel": "FEEDBACK FÜR DEN AGENTEN",
"feedbackPlaceholder": "Was soll der Agent korrigieren?",
"rerun": "Erneut ausführen"
"rerun": "Erneut ausführen",
"refineTip": "Aufgabe mit Claude verfeinern"
},
"lists": {
"heading": "Listen",

View File

@@ -111,7 +111,8 @@
"reviewTitle": "Review",
"feedbackLabel": "FEEDBACK FOR THE AGENT",
"feedbackPlaceholder": "What should the agent fix?",
"rerun": "Re-run"
"rerun": "Re-run",
"refineTip": "Refine this task with Claude"
},
"lists": {
"heading": "Lists",

View File

@@ -38,12 +38,12 @@ All views use compiled bindings (`x:DataType`).
- **StatusBarViewModel** — connection state and active tasks
- **WeeklyReportModalViewModel** — drives the weekly report modal
- **NotesEditorViewModel** — manages daily note bullet CRUD for the selected day
- **DetailsIslandViewModel** gains `IsNotesMode`, `ShowNotes()`, and hosts `NotesEditorViewModel`. Also gains daily-prep mode: `IsPrepMode`, `PrepLog` (`ObservableCollection<LogLineViewModel>`), `ShowPrep()`, `PlanDayCommand` (calls `RunDailyPrepNowAsync`), `ShowPrepEmptyState`, and computed `IsTaskDetailVisible` (= `!IsNotesMode && !IsPrepMode`). Subscribes to `PrepStartedEvent`, `PrepLineEvent`, `PrepFinishedEvent`; streams lines into `PrepLog` via `StreamLineFormatter` (same path as task logs). On open, if log is empty and no run is in progress, loads persisted last run via `GetLastPrepLogAsync`.
- **DetailsIslandViewModel** gains `IsNotesMode`, `ShowNotes()`, and hosts `NotesEditorViewModel`. Also gains daily-prep mode: `IsPrepMode`, `PrepLog` (`ObservableCollection<LogLineViewModel>`), `ShowPrep()`, `PlanDayCommand` (calls `RunDailyPrepNowAsync`), `ShowPrepEmptyState`, and computed `IsTaskDetailVisible` (= `!IsNotesMode && !IsPrepMode`). Subscribes to `PrepStartedEvent`, `PrepLineEvent`, `PrepFinishedEvent`; streams lines into `PrepLog` via `StreamLineFormatter` (same path as task logs). On open, if log is empty and no run is in progress, loads persisted last run via `GetLastPrepLogAsync`. The WorkConsole Session tab gains a mergeability indicator (`MergePreviewPresenter`) and a single-task Merge button; indicator is populated via `PreviewMergeAsync` and displayed for tasks in WaitingForReview.
- **TasksIslandViewModel** gains a pinned "Notes" pseudo-row (`ShowNotesRow`, `OpenNotesCommand`, `NotesRequested` event) that switches the Details island to notes mode. Also gains `IsMyDayList` (true when selected list is `smart:my-day`), `ShowPrepLogCommand` (raises `PrepRequested` event → shell calls `Details.ShowPrep()`), and `ClearDayCommand` (calls `ClearMyDayAsync`).
## Services
- **WorkerClient** / **IWorkerClient** — SignalR client connecting to `http://127.0.0.1:47821/hub`. Auto-reconnect with exponential backoff. Methods: StartAsync, RunNowAsync, CancelTaskAsync, WakeQueueAsync, ContinueTaskAsync, ResetTaskAsync, GetAgentsAsync, UpdateAppSettingsAsync, UpdateListAsync, UpdateListConfigAsync, GetListConfigAsync, UpdateTaskAgentSettingsAsync, `GetWeekReportAsync`, `GenerateWeekReportAsync`, `GetDailyNotesAsync`, `AddDailyNoteAsync`, `UpdateDailyNoteAsync`, `DeleteDailyNoteAsync`, `RunDailyPrepNowAsync`, `ClearMyDayAsync`, `GetLastPrepLogAsync`. Events: TaskStarted, TaskFinished, TaskMessage, TaskUpdated, WorktreeUpdated, ListUpdated, `PrepStartedEvent`, `PrepLineEvent`, `PrepFinishedEvent`
- **WorkerClient** / **IWorkerClient** — SignalR client connecting to `http://127.0.0.1:47821/hub`. Auto-reconnect with exponential backoff. Methods: StartAsync, RunNowAsync, CancelTaskAsync, WakeQueueAsync, ContinueTaskAsync, ResetTaskAsync, GetAgentsAsync, UpdateAppSettingsAsync, UpdateListAsync, UpdateListConfigAsync, GetListConfigAsync, UpdateTaskAgentSettingsAsync, `ApproveReviewAsync(taskId, targetBranch) -> MergeResultDto`, `PreviewMergeAsync(taskId, targetBranch) -> MergePreviewDto`, `MergeTaskAsync`, `GetWeekReportAsync`, `GenerateWeekReportAsync`, `GetDailyNotesAsync`, `AddDailyNoteAsync`, `UpdateDailyNoteAsync`, `DeleteDailyNoteAsync`, `RunDailyPrepNowAsync`, `ClearMyDayAsync`, `GetLastPrepLogAsync`. Events: TaskStarted, TaskFinished, TaskMessage, TaskUpdated, WorktreeUpdated, ListUpdated, `PrepStartedEvent`, `PrepLineEvent`, `PrepFinishedEvent`
- **INotesApi** / **WorkerNotesApi** — thin wrapper over the daily-note WorkerClient methods (mirrors `IPrimeScheduleApi`). UI DTO: `DailyNoteDto(Id, Date, Text, SortOrder)`.
## Converters

View File

@@ -76,6 +76,9 @@
<!-- Icon.PlanDay (stroke-rendered via Path.plan-icon — sun over horizon) -->
<StreamGeometry x:Key="Icon.PlanDay">M3,20 L21,20 M8.4,11 a3.6,3.6 0 1,0 7.2,0 a3.6,3.6 0 1,0 -7.2,0 M12,4.5 L12,3 M6,11 L4.5,11 M18,11 L19.5,11 M7.5,6.5 L6.4,5.4 M16.5,6.5 L17.6,5.4</StreamGeometry>
<!-- Icon.Refine (stroke-rendered via Path.plan-icon — list lines + two sparkles) -->
<StreamGeometry x:Key="Icon.Refine">M3,6 L13,6 M3,11 L11,11 M3,16 L9,16 M18.5,3 L19.28,5.22 L21.5,6 L19.28,6.78 L18.5,9 L17.72,6.78 L15.5,6 L17.72,5.22 Z M19.5,14.9 L19.85,16.15 L21.1,16.5 L19.85,16.85 L19.5,18.1 L19.15,16.85 L17.9,16.5 L19.15,16.15 Z</StreamGeometry>
<!-- Icon.X -->
<StreamGeometry x:Key="Icon.X">M6 6l12 12M18 6L6 18</StreamGeometry>
@@ -85,6 +88,9 @@
<!-- Icon.ArrowOut — filled arrow for "open external" button -->
<StreamGeometry x:Key="Icon.ArrowOut">M13 4 H20 V11 H18 V7.4 L11.4 14 L10 12.6 L16.6 6 H13 Z M4 6 H10 V8 H6 V18 H16 V14 H18 V20 H4 Z</StreamGeometry>
<!-- Icon.Text — three filled horizontal bars (paragraph / description icon) -->
<StreamGeometry x:Key="Icon.Text">M4 6 H20 V8 H4 Z M4 11 H20 V13 H4 Z M4 16 H14 V18 H4 Z</StreamGeometry>
<!-- Icon.Warning — filled triangle with exclamation (roadblock badge) -->
<StreamGeometry x:Key="Icon.Warning">F0 M12 3 L22 20 H2 Z M11 9 H13 V14 H11 Z M11 16 H13 V18 H11 Z</StreamGeometry>
@@ -94,6 +100,9 @@
<!-- Icon.Settings (gear) -->
<StreamGeometry x:Key="Icon.Settings">M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65a.5.5 0 0 0 .12-.64l-2-3.46a.5.5 0 0 0-.61-.22l-2.49 1a7.03 7.03 0 0 0-1.69-.98l-.38-2.65a.5.5 0 0 0-.5-.42h-4a.5.5 0 0 0-.5.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1a.5.5 0 0 0-.61.22l-2 3.46a.5.5 0 0 0 .12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65a.5.5 0 0 0-.12.64l2 3.46a.5.5 0 0 0 .61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65a.5.5 0 0 0 .5.42h4a.5.5 0 0 0 .5-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1a.5.5 0 0 0 .61-.22l2-3.46a.5.5 0 0 0-.12-.64l-2.11-1.65z</StreamGeometry>
<!-- Icon.Skull — filled silhouette: rounded cranium + eye holes (EvenOdd) + jaw -->
<StreamGeometry x:Key="Icon.Skull">F0 M12 2 C7 2 4 5.5 4 10 C4 13.5 6 16 8 17.5 L8 19 C8 20 8.9 21 10 21 L10 18.5 L14 18.5 L14 21 C15.1 21 16 20 16 19 L16 17.5 C18 16 20 13.5 20 10 C20 5.5 17 2 12 2 Z M8.5 8 L8.5 12 L11 12 L11 8 Z M13 8 L13 12 L15.5 12 L15.5 8 Z</StreamGeometry>
<!-- Badge brushes -->
<SolidColorBrush x:Key="DraftBadgeBrush" Color="{StaticResource TextMuteColor}"/>
<SolidColorBrush x:Key="PlanningBadgeBrush" Color="{StaticResource PeatColor}"/>

View File

@@ -37,7 +37,9 @@ public interface IWorkerClient : INotifyPropertyChanged
Task<ListConfigDto?> GetListConfigAsync(string listId);
Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto);
Task SetTaskStatusAsync(string taskId, TaskStatus status);
Task ApproveReviewAsync(string taskId);
Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch);
Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch);
Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage);
Task RejectReviewToQueueAsync(string taskId, string feedback);
Task RejectReviewToIdleAsync(string taskId);
Task CancelReviewAsync(string taskId);
@@ -57,6 +59,10 @@ public interface IWorkerClient : INotifyPropertyChanged
Task<string?> GetWeekReportAsync(DateOnly start, DateOnly end);
Task<string> GenerateWeekReportAsync(DateOnly start, DateOnly end);
Task<bool> RunDailyPrepNowAsync();
Task RefineTaskAsync(string taskId);
event Action<string>? RefineStartedEvent;
event Action<string, bool, string?>? RefineFinishedEvent;
Task ClearMyDayAsync();
Task<AppSettingsDto?> GetAppSettingsAsync();
Task<List<DailyNoteDto>> GetDailyNotesAsync(DateOnly day);

View File

@@ -55,6 +55,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public event Action<string>? PrepLineEvent;
public event Action<bool>? PrepFinishedEvent;
public event Action<string>? RefineStartedEvent;
public event Action<string, bool, string?>? RefineFinishedEvent;
public event Action<string, string>? PlanningMergeStartedEvent;
public event Action<string, string>? PlanningSubtaskMergedEvent;
public event Action<string, string, IReadOnlyList<string>>? PlanningMergeConflictEvent;
@@ -179,6 +182,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
_hub.On("PrepStarted", () => Dispatcher.UIThread.Post(() => PrepStartedEvent?.Invoke()));
_hub.On<string>("PrepLine", line => Dispatcher.UIThread.Post(() => PrepLineEvent?.Invoke(line)));
_hub.On<bool>("PrepFinished", ok => Dispatcher.UIThread.Post(() => PrepFinishedEvent?.Invoke(ok)));
_hub.On<string>("RefineStarted", id =>
Dispatcher.UIThread.Post(() => RefineStartedEvent?.Invoke(id)));
_hub.On<string, bool, string?>("RefineFinished", (id, ok, err) =>
Dispatcher.UIThread.Post(() => RefineFinishedEvent?.Invoke(id, ok, err)));
}
public Task StartAsync()
@@ -345,6 +353,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public Task<bool> RunDailyPrepNowAsync()
=> _hub.InvokeAsync<bool>("RunDailyPrepNow");
public Task RefineTaskAsync(string taskId) => _hub.InvokeAsync("RefineTask", taskId);
public Task ClearMyDayAsync()
=> _hub.InvokeAsync("ClearMyDay");
@@ -386,10 +396,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
await _hub.InvokeAsync("SetTaskStatus", taskId, status.ToString());
}
public async Task ApproveReviewAsync(string taskId)
{
await _hub.InvokeAsync("ApproveReview", taskId);
}
public Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch)
=> TryInvokeAsync<MergeResultDto>("ApproveReview", taskId, targetBranch);
public Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch)
=> TryInvokeAsync<MergePreviewDto>("PreviewMerge", taskId, targetBranch);
public async Task RejectReviewToQueueAsync(string taskId, string feedback)
{
@@ -519,6 +530,7 @@ public sealed record AppSettingsDto(
public sealed record WorktreeCleanupDto(int Removed);
public sealed record WorktreeResetDto(int Removed, int TasksAffected, bool Blocked, int RunningTasks);
public record MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage);
public record MergePreviewDto(string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount);
public record MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalBranches);
public sealed record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType);
public sealed record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);

View File

@@ -74,8 +74,12 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
private TaskRowViewModel? _task;
// Editable fields
[ObservableProperty] private string _editableTitle = "";
[ObservableProperty] private string _editableDescription = "";
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ComposedPreview))]
private string _editableTitle = "";
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ComposedPreview))]
private string _editableDescription = "";
[ObservableProperty] private bool _isEditingDescription;
[ObservableProperty] private bool _isDescriptionExpanded = true;
@@ -100,6 +104,80 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
[RelayCommand]
private void ToggleDescriptionExpanded() => IsDescriptionExpanded = !IsDescriptionExpanded;
// ── Description / Steps card (redesign) ─────────────────────────────
// Description is always the card body; steps live in an expandable summary
// strip below it so step presence is visible without switching views.
[ObservableProperty] private bool _isStepsExpanded;
[RelayCommand]
private void ToggleStepsExpanded() => IsStepsExpanded = !IsStepsExpanded;
public int TotalStepCount => Subtasks.Count;
public int OpenStepCount => Subtasks.Count(s => !s.Done);
public string StepsSummary =>
TotalStepCount == 0 ? "no steps yet"
: OpenStepCount == 0 ? $"all done · {TotalStepCount} total"
: $"{OpenStepCount} open · {TotalStepCount} total";
private void NotifyStepsChanged()
{
OnPropertyChanged(nameof(TotalStepCount));
OnPropertyChanged(nameof(OpenStepCount));
OnPropertyChanged(nameof(StepsSummary));
OnPropertyChanged(nameof(ComposedPreview));
}
// The exact text handed to Claude: title + description + open steps only.
public string ComposedPreview =>
ClaudeDo.Data.TaskPromptComposer.Compose(
EditableTitle, EditableDescription, Subtasks.Select(s => (s.Title, s.Done)));
// ── Work console (redesign) ────────────────────────────────────────
// Two tabs: Output (live log) and Session (review + merge/worktree +
// outcomes, each section gated on the relevant state).
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsOutputTab))]
[NotifyPropertyChangedFor(nameof(IsGitTab))]
[NotifyPropertyChangedFor(nameof(IsSessionTab))]
private string _selectedTab = "output";
public bool IsOutputTab => SelectedTab == "output";
public bool IsGitTab => SelectedTab == "git";
public bool IsSessionTab => SelectedTab == "session";
[RelayCommand]
private void SelectTab(string? tab) => SelectedTab = tab ?? "output";
// Merge/worktree controls only matter once there's a worktree to manage
// (standalone task), or a planning parent / improvement parent with children.
public bool ShowMergeSection =>
WorktreePath != null || Task?.IsPlanningParent == true || HasChildOutcomes;
// Nothing to manage yet (idle/queued/running standalone): show a hint.
public bool ShowSessionEmpty =>
!IsWaitingForReview && !ShowMergeSection && !HasChildOutcomes;
private void NotifySessionSections()
{
OnPropertyChanged(nameof(ShowMergeSection));
OnPropertyChanged(nameof(ShowSessionEmpty));
}
public string TurnsText => $"{Turns}/{EffectiveMaxTurns}";
public string DiffAddText => $"+{DiffAdditions}";
public string DiffDelText => $"-{DiffDeletions}";
// Resolved turn budget: per-task override → list default → global default.
public int EffectiveMaxTurns =>
TaskMaxTurns is decimal t ? (int)t : (_listMaxTurns ?? _globalMaxTurns);
public bool ShowRoadblock => IsFailed || IsCancelled;
public string RoadblockMessage =>
IsFailed ? "The session ended with an error." :
IsCancelled ? "The session was cancelled." : "";
public string SessionLabel => "claude-session";
// Short task-id badge, e.g. "#T1A"
public string TaskIdBadge => Task != null ? $"#T{Task.Id[..Math.Min(3, Task.Id.Length)].ToUpperInvariant()}" : "";
@@ -142,6 +220,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
OnPropertyChanged(nameof(ShowContinue));
OnPropertyChanged(nameof(ShowResetAndRetry));
OnPropertyChanged(nameof(IsAgentSectionEnabled));
OnPropertyChanged(nameof(ShowRoadblock));
OnPropertyChanged(nameof(RoadblockMessage));
NotifySessionSections();
}
[ObservableProperty] private string? _model;
@@ -164,7 +245,13 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
private string? _listAgentName;
partial void OnTaskModelSelectionChanged(string? value) { RecomputeModelBadge(); QueueAgentSave(); }
partial void OnTaskMaxTurnsChanged(decimal? value) { RecomputeTurnsBadge(); QueueAgentSave(); }
partial void OnTaskMaxTurnsChanged(decimal? value)
{
RecomputeTurnsBadge();
OnPropertyChanged(nameof(EffectiveMaxTurns));
OnPropertyChanged(nameof(TurnsText));
QueueAgentSave();
}
private void RecomputeModelBadge()
{
@@ -221,8 +308,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
public string ElapsedFormatted => ""; // placeholder — no start-time stored yet
partial void OnTokensChanged(int value) => OnPropertyChanged(nameof(TokensFormatted));
partial void OnDiffAdditionsChanged(int value) => OnPropertyChanged(nameof(DiffMeterRatio));
partial void OnDiffDeletionsChanged(int value) => OnPropertyChanged(nameof(DiffMeterRatio));
partial void OnTurnsChanged(int value) => OnPropertyChanged(nameof(TurnsText));
partial void OnDiffAdditionsChanged(int value) { OnPropertyChanged(nameof(DiffMeterRatio)); OnPropertyChanged(nameof(DiffAddText)); }
partial void OnDiffDeletionsChanged(int value) { OnPropertyChanged(nameof(DiffMeterRatio)); OnPropertyChanged(nameof(DiffDelText)); }
// 0.01.0 additions share for the diff meter
public double DiffMeterRatio
@@ -253,6 +341,24 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
[ObservableProperty] private string? _mergeAllDisabledReason;
[ObservableProperty] private string? _mergeAllError;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowMergePreviewMuted))]
private string _mergePreviewText = "";
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowMergePreviewMuted))]
private bool _mergeIsClean;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowMergePreviewMuted))]
private bool _mergeIsConflict;
public bool ShowMergePreviewMuted =>
!MergeIsClean && !MergeIsConflict && !string.IsNullOrEmpty(MergePreviewText);
public bool ShowSingleMerge =>
WorktreePath != null && Task?.IsPlanningParent != true;
// Claude CLI stream-json parser + buffer for partial text deltas
private readonly StreamLineFormatter _formatter = new();
private readonly StringBuilder _claudeBuf = new();
@@ -328,6 +434,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
_services = services;
_notesApi = notesApi;
Notes = new NotesEditorViewModel(_notesApi);
Subtasks.CollectionChanged += (_, _) => NotifyStepsChanged();
Loc.LanguageChanged += (_, _) =>
{
OnPropertyChanged(nameof(AgentStatusLabel));
@@ -399,6 +506,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
{
RecomputeCanMergeAll();
ReviewCombinedDiffCommand.NotifyCanExecuteChanged();
NotifySessionSections();
};
PrepLog.CollectionChanged += (_, _) => OnPropertyChanged(nameof(ShowPrepEmptyState));
@@ -576,6 +684,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
RecomputeModelBadge();
RecomputeTurnsBadge();
RecomputeAgentBadge();
OnPropertyChanged(nameof(EffectiveMaxTurns));
OnPropertyChanged(nameof(TurnsText));
}
finally
{
@@ -722,6 +832,20 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
{
await LoadChildOutcomesAsync(row.Id, ct);
}
if (entity.Worktree != null
&& entity.PlanningPhase == ClaudeDo.Data.Models.PlanningPhase.None
&& MergeTargetBranches.Count == 0)
{
var targets = await _worker.GetMergeTargetsAsync(row.Id);
if (targets != null)
{
MergeTargetBranches.Clear();
foreach (var b in targets.LocalBranches) MergeTargetBranches.Add(b);
SelectedMergeTarget = targets.DefaultBranch; // triggers OnSelectedMergeTargetChanged → preview
}
}
await RefreshMergePreviewAsync();
}
catch (OperationCanceledException) { }
}
@@ -1012,6 +1136,49 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
catch { /* best-effort refresh */ }
}
private async System.Threading.Tasks.Task RefreshMergePreviewAsync()
{
if (Task is null || WorktreePath is null)
{
MergePreviewText = ""; MergeIsClean = false; MergeIsConflict = false;
return;
}
// Only probe Active worktrees; terminal states show their label instead.
if (WorktreeStateLabel is { } label && label != "Active")
{
MergePreviewText = label; MergeIsClean = false; MergeIsConflict = false;
return;
}
var capturedTaskId = Task.Id;
var capturedTarget = SelectedMergeTarget;
var dto = await _worker.PreviewMergeAsync(capturedTaskId, capturedTarget ?? "");
// Discard a probe that resolved after the user switched task or target.
if (Task?.Id != capturedTaskId || SelectedMergeTarget != capturedTarget) return;
var (text, clean, conflict) = MergePreviewPresenter.Describe(dto);
MergePreviewText = text; MergeIsClean = clean; MergeIsConflict = conflict;
}
[RelayCommand]
private async System.Threading.Tasks.Task MergeAsync()
{
if (Task is null || WorktreePath is null || !_worker.IsConnected) return;
try
{
var result = await _worker.MergeTaskAsync(Task.Id, SelectedMergeTarget ?? "", false, "Merge task");
if (result.Status == "conflict")
{
var (text, _, _) = MergePreviewPresenter.Describe(
new MergePreviewDto("conflict", result.ConflictFiles, 0));
MergePreviewText = text; MergeIsClean = false; MergeIsConflict = true;
}
else
{
await RefreshMergePreviewAsync();
}
}
catch { /* broadcast reconciles */ }
}
[RelayCommand(CanExecute = nameof(CanOpenDiff))]
private async System.Threading.Tasks.Task OpenDiffAsync()
{
@@ -1048,12 +1215,21 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
private bool CanOpenWorktree() => WorktreePath != null;
partial void OnSelectedMergeTargetChanged(string? value)
{
_ = RefreshMergePreviewAsync();
}
partial void OnWorktreePathChanged(string? value)
{
OpenDiffCommand.NotifyCanExecuteChanged();
OpenWorktreeCommand.NotifyCanExecuteChanged();
NotifySessionSections();
OnPropertyChanged(nameof(ShowSingleMerge));
}
partial void OnTaskChanged(TaskRowViewModel? value) => NotifySessionSections();
[RelayCommand]
private void CloseDetails() => CloseDetail?.Invoke();
@@ -1092,6 +1268,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
{
if (row is null) return;
row.Done = !row.Done;
NotifyStepsChanged();
await using var ctx = _dbFactory.CreateDbContext();
var repo = new SubtaskRepository(ctx);
var subs = await repo.GetByTaskIdAsync(Task?.Id ?? "");
@@ -1157,6 +1334,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
await repo.UpdateAsync(entity);
}
row.Title = title;
OnPropertyChanged(nameof(ComposedPreview));
}
[RelayCommand]
@@ -1267,10 +1445,16 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
private async System.Threading.Tasks.Task ApproveReviewAsync()
{
if (Task is null || !_worker.IsConnected) return;
// The hub rejects (HubException) if the task is no longer WaitingForReview
// — e.g. after "Merge all" folded the parent. Swallow it; the TaskUpdated
// broadcast reconciles the UI. An unhandled command exception would crash.
try { await _worker.ApproveReviewAsync(Task.Id); }
try
{
var result = await _worker.ApproveReviewAsync(Task.Id, SelectedMergeTarget ?? "");
if (result?.Status == "conflict")
{
var (text, _, _) = MergePreviewPresenter.Describe(
new MergePreviewDto("conflict", result.ConflictFiles, 0));
MergePreviewText = text; MergeIsClean = false; MergeIsConflict = true;
}
}
catch { /* stale review action; broadcast reconciles */ }
}

View File

@@ -0,0 +1,28 @@
using System.Linq;
using ClaudeDo.Ui.Services;
namespace ClaudeDo.Ui.ViewModels.Islands;
/// Pure mapping from a merge-preview DTO to display text + color flags.
public static class MergePreviewPresenter
{
public static (string Text, bool IsClean, bool IsConflict) Describe(MergePreviewDto? dto)
{
if (dto is null) return ("", false, false);
switch (dto.Status)
{
case "clean":
var unit = dto.ChangedFileCount == 1 ? "file" : "files";
return ($"Merges cleanly · {dto.ChangedFileCount} {unit}", true, false);
case "conflict":
var names = string.Join(", ", dto.ConflictFiles.Take(3));
var more = dto.ConflictFiles.Count > 3 ? $" (+{dto.ConflictFiles.Count - 3} more)" : "";
return ($"Conflicts in {names}{more}", false, true);
default:
return ("Mergeability unknown", false, false);
}
}
}

View File

@@ -32,6 +32,9 @@ public sealed partial class TaskRowViewModel : ViewModelBase
[ObservableProperty] private bool _showListChip = true;
[ObservableProperty] private bool _parentFinalized;
[ObservableProperty] private int _roadblockCount;
[ObservableProperty] private bool _isRefining;
public bool CanRefine => Status == TaskStatus.Idle && PlanningPhase == PlanningPhase.None && !IsRefining;
public DateTime CreatedAt { get; init; }
public string CreatedAtFormatted => CreatedAt == default ? "—" : Loc.T("vm.taskRow.createdPrefix", CreatedAt.ToString("MMM d"));
@@ -125,6 +128,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
OnPropertyChanged(nameof(CanOpenPlanningSession));
OnPropertyChanged(nameof(CanRemoveFromQueue));
OnPropertyChanged(nameof(CanSendToQueue));
OnPropertyChanged(nameof(CanRefine));
}
partial void OnParentTaskIdChanged(string? value)
@@ -155,8 +159,11 @@ public sealed partial class TaskRowViewModel : ViewModelBase
OnPropertyChanged(nameof(CanOpenPlanningSession));
OnPropertyChanged(nameof(CanResumeOrDiscardPlanning));
OnPropertyChanged(nameof(CanQueuePlan));
OnPropertyChanged(nameof(CanRefine));
}
partial void OnIsRefiningChanged(bool value) => OnPropertyChanged(nameof(CanRefine));
partial void OnHasQueuedSubtasksChanged(bool value)
{
OnPropertyChanged(nameof(CanRemoveFromQueue));

View File

@@ -82,6 +82,8 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
_worker.WorktreeUpdatedEvent += OnWorkerTaskUpdated;
_worker.ListUpdatedEvent += OnWorkerListUpdated;
_worker.ConnectionRestoredEvent += () => LoadForList(_currentList);
_worker.RefineStartedEvent += OnRefineStarted;
_worker.RefineFinishedEvent += OnRefineFinished;
}
Loc.LanguageChanged += (_, _) => RefreshLocalizedText();
}
@@ -645,7 +647,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
private async Task ApproveReviewAsync(TaskRowViewModel? row)
{
if (row is null || !row.IsWaitingForReview || _worker is null) return;
try { await _worker.ApproveReviewAsync(row.Id); }
try { await _worker.ApproveReviewAsync(row.Id, ""); }
catch { /* offline; broadcast reconciles on return */ }
}
@@ -830,6 +832,27 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
Regroup();
}
[RelayCommand]
private async Task RefineTask(TaskRowViewModel row)
{
if (row is null || !row.CanRefine) return;
row.IsRefining = true;
try { await _worker!.RefineTaskAsync(row.Id); }
catch { row.IsRefining = false; }
}
private void OnRefineStarted(string taskId)
{
var row = Items.FirstOrDefault(r => r.Id == taskId);
if (row is not null) row.IsRefining = true;
}
private void OnRefineFinished(string taskId, bool ok, string? error)
{
var row = Items.FirstOrDefault(r => r.Id == taskId);
if (row is not null) row.IsRefining = false;
}
partial void OnSelectedTaskChanged(TaskRowViewModel? value)
{
foreach (var i in Items) i.IsSelected = ReferenceEquals(i, value);

View File

@@ -0,0 +1,165 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
x:Class="ClaudeDo.Ui.Views.Islands.Detail.DescriptionStepsCard"
x:DataType="vm:DetailsIslandViewModel">
<Border Classes="island"
Background="{DynamicResource Surface2Brush}"
BorderBrush="{DynamicResource LineBrush}">
<DockPanel>
<!-- Header: DETAILS · copy · preview/edit -->
<Border DockPanel.Dock="Top" Classes="island-header">
<Grid ColumnDefinitions="Auto,*,Auto,Auto" VerticalAlignment="Center">
<TextBlock Grid.Column="0" Classes="section-label" Text="DETAILS"
VerticalAlignment="Center"/>
<!-- Copy formatted -->
<Button Grid.Column="2"
Classes="icon-btn"
Margin="0,0,4,0"
ToolTip.Tip="Copy formatted (title + description + open steps)"
Click="OnCopyClick">
<PathIcon Data="{StaticResource Icon.Copy}" Width="11" Height="11"/>
</Button>
<!-- Preview/Edit toggle -->
<Button Grid.Column="3"
Classes="btn"
Padding="8,3"
Command="{Binding ToggleEditDescriptionCommand}">
<Panel>
<TextBlock Text="Preview" IsVisible="{Binding IsEditingDescription}"/>
<TextBlock Text="Edit" IsVisible="{Binding !IsEditingDescription}"/>
</Panel>
</Button>
</Grid>
</Border>
<!-- Body -->
<StackPanel Margin="14" Spacing="10">
<!-- Description (always visible) -->
<Panel>
<!-- Edit mode: raw TextBox -->
<TextBox Text="{Binding EditableDescription, Mode=TwoWay}"
AcceptsReturn="True"
TextWrapping="Wrap"
MinHeight="80"
MaxHeight="320"
Padding="8"
FontFamily="{DynamicResource MonoFont}"
FontSize="{StaticResource FontSizeBody}"
Background="{DynamicResource Surface3Brush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="1"
CornerRadius="8"
IsVisible="{Binding IsEditingDescription}"/>
<!-- Preview mode: rendered composed text (title + description + open steps) -->
<ctl:MarkdownView Markdown="{Binding ComposedPreview}"
IsVisible="{Binding !IsEditingDescription}"/>
</Panel>
<!-- Steps: always-visible summary strip; expand to manage -->
<Border BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,1,0,0"
Padding="0,8,0,0">
<StackPanel Spacing="6">
<!-- Summary header (click to expand/collapse) -->
<Button Classes="flat" Cursor="Hand"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Stretch"
Command="{Binding ToggleStepsExpandedCommand}">
<Grid ColumnDefinitions="Auto,Auto,*,Auto">
<Panel Grid.Column="0" Width="12" Margin="0,0,6,0" VerticalAlignment="Center">
<TextBlock Classes="meta" Text="▸" IsVisible="{Binding !IsStepsExpanded}"/>
<TextBlock Classes="meta" Text="▾" IsVisible="{Binding IsStepsExpanded}"/>
</Panel>
<TextBlock Grid.Column="1" Classes="section-label" Text="STEPS"
VerticalAlignment="Center"/>
<TextBlock Grid.Column="3" Classes="meta" Text="{Binding StepsSummary}"
Foreground="{DynamicResource TextMuteBrush}"
VerticalAlignment="Center"/>
</Grid>
</Button>
<!-- Expanded: add-step input + step rows -->
<StackPanel IsVisible="{Binding IsStepsExpanded}" Spacing="6">
<TextBox Text="{Binding NewSubtaskTitle, Mode=TwoWay}"
PlaceholderText="Add step…"
Padding="8"
Background="{DynamicResource Surface3Brush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="1"
CornerRadius="8">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding AddSubtaskCommand}"/>
</TextBox.KeyBindings>
</TextBox>
<!-- Subtask rows -->
<ItemsControl ItemsSource="{Binding Subtasks}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:SubtaskRowViewModel">
<Border Classes="subtask-row" Classes.done="{Binding Done}">
<Grid ColumnDefinitions="Auto,*">
<!-- Check circle -->
<Button Grid.Column="0"
Classes="flat"
Padding="0"
Margin="0,0,8,0"
VerticalAlignment="Center"
Command="{Binding $parent[ItemsControl].((vm:DetailsIslandViewModel)DataContext).ToggleSubtaskDoneCommand}"
CommandParameter="{Binding}">
<Ellipse Classes="task-check"
Classes.done="{Binding Done}"
Width="16" Height="16"
Cursor="Hand"/>
</Button>
<!-- Title / edit -->
<Panel Grid.Column="1" VerticalAlignment="Center">
<TextBlock Classes="subtask-title"
Text="{Binding Title}"
IsVisible="{Binding !IsEditing}"
FontSize="{StaticResource FontSizeBody}"
Foreground="{DynamicResource TextDimBrush}"
VerticalAlignment="Center"
TextWrapping="Wrap"
Cursor="Ibeam"
Tapped="OnSubtaskTitleTapped"/>
<TextBox Classes="subtask-edit"
Text="{Binding Title, Mode=TwoWay}"
IsVisible="{Binding IsEditing}"
FontSize="{StaticResource FontSizeBody}"
AcceptsReturn="False"
TextWrapping="Wrap"
LostFocus="OnSubtaskEditLostFocus">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter"
Command="{Binding $parent[ItemsControl].((vm:DetailsIslandViewModel)DataContext).CommitSubtaskEditCommand}"
CommandParameter="{Binding}"/>
</TextBox.KeyBindings>
</TextBox>
</Panel>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</StackPanel>
</Border>
</StackPanel>
</DockPanel>
</Border>
</UserControl>

View File

@@ -0,0 +1,35 @@
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Input.Platform;
using Avalonia.Interactivity;
using ClaudeDo.Ui.ViewModels.Islands;
namespace ClaudeDo.Ui.Views.Islands.Detail;
public partial class DescriptionStepsCard : UserControl
{
public DescriptionStepsCard()
{
InitializeComponent();
}
private async void OnCopyClick(object? sender, RoutedEventArgs e)
{
if (DataContext is not DetailsIslandViewModel vm) return;
var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
if (clipboard is null) return;
await clipboard.SetTextAsync(vm.ComposedPreview);
}
private void OnSubtaskTitleTapped(object? sender, TappedEventArgs e)
{
if (sender is TextBlock { DataContext: SubtaskRowViewModel row })
row.IsEditing = true;
}
private void OnSubtaskEditLostFocus(object? sender, RoutedEventArgs e)
{
if (sender is TextBox { DataContext: SubtaskRowViewModel row })
row.IsEditing = false;
}
}

View File

@@ -0,0 +1,125 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
xmlns:loc="using:ClaudeDo.Ui.Localization"
x:Class="ClaudeDo.Ui.Views.Islands.Detail.TaskHeaderBar"
x:DataType="vm:DetailsIslandViewModel">
<Grid ColumnDefinitions="*,Auto,Auto">
<!-- Column 0: id badge + editable title -->
<StackPanel Grid.Column="0" Spacing="0">
<TextBlock Classes="meta"
Text="{Binding TaskIdBadge}"
Margin="0,0,0,4"
Cursor="Hand"
ToolTip.Tip="{loc:Tr details.copyTaskIdTip}"
Tapped="OnTaskIdTapped"/>
<TextBox Text="{Binding EditableTitle, Mode=TwoWay}"
Background="Transparent"
BorderThickness="0"
FontSize="{StaticResource FontSizeTaskTitle}"
FontWeight="Medium"
Foreground="{DynamicResource TextBrush}"
TextWrapping="Wrap"
AcceptsReturn="False"
Padding="0"/>
</StackPanel>
<!-- Column 1: trash button (not running) -->
<Button Grid.Column="1" Classes="icon-btn"
Command="{Binding DeleteTaskCommand}"
ToolTip.Tip="Delete task"
IsVisible="{Binding !IsRunning}"
VerticalAlignment="Top"
Margin="6,0,0,0">
<PathIcon Data="{StaticResource Icon.Trash}" Width="14" Height="14"
Foreground="{DynamicResource BloodBrush}"/>
</Button>
<!-- Column 1: skull button (running) -->
<Button Grid.Column="1" Classes="icon-btn"
Command="{Binding StopCommand}"
ToolTip.Tip="Kill session"
IsVisible="{Binding IsRunning}"
VerticalAlignment="Top"
Margin="6,0,0,0">
<PathIcon Data="{StaticResource Icon.Skull}" Width="14" Height="14"
Foreground="{DynamicResource BloodBrush}"/>
</Button>
<!-- Column 2: gear button with agent settings flyout -->
<Button Grid.Column="2" Classes="icon-btn"
ToolTip.Tip="{loc:Tr details.agentSettingsTip}"
IsEnabled="{Binding IsAgentSectionEnabled}"
VerticalAlignment="Top"
Margin="6,0,0,0">
<TextBlock Text="⚙" FontSize="{StaticResource FontSizeTaskTitle}"/>
<Button.Flyout>
<Flyout Placement="BottomEdgeAlignedRight" ShowMode="Standard">
<StackPanel Width="340" Spacing="10" Margin="4">
<TextBlock Text="{loc:Tr details.agentSettingsHeading}" FontWeight="SemiBold"/>
<StackPanel Spacing="2">
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
<TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr details.modelLabel}" VerticalAlignment="Center"/>
<ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding ModelBadge}"/>
<Button Grid.Column="3" Classes="icon-btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
Command="{Binding ResetTaskModelCommand}"/>
</Grid>
<ComboBox ItemsSource="{Binding TaskModelOptions}"
SelectedItem="{Binding TaskModelSelection, Mode=TwoWay}"
PlaceholderText="{Binding ModelInheritedHint}"
HorizontalAlignment="Stretch"/>
</StackPanel>
<StackPanel Spacing="2">
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
<TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr details.maxTurnsLabel}" VerticalAlignment="Center"/>
<ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding TurnsBadge}"/>
<Button Grid.Column="3" Classes="icon-btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
Command="{Binding ResetTaskTurnsCommand}"/>
</Grid>
<NumericUpDown Value="{Binding TaskMaxTurns, Mode=TwoWay}"
PlaceholderText="{Binding TurnsInheritedHint}"
Minimum="1" Maximum="200" Increment="1" FormatString="0"
HorizontalAlignment="Stretch"/>
</StackPanel>
<StackPanel Spacing="2">
<TextBlock Classes="field-label" Text="{loc:Tr details.systemPromptLabel}"/>
<TextBox Text="{Binding TaskSystemPrompt, Mode=TwoWay}"
AcceptsReturn="True" TextWrapping="Wrap" MinHeight="70"/>
<TextBlock Classes="meta" Opacity="0.6"
Text="{loc:Tr details.systemPromptPrepended}"
IsVisible="{Binding EffectiveSystemPromptHint, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock Classes="meta" Opacity="0.6" TextWrapping="Wrap"
Text="{Binding EffectiveSystemPromptHint}"
IsVisible="{Binding EffectiveSystemPromptHint, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<StackPanel Spacing="2">
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
<TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr details.agentFileLabel}" VerticalAlignment="Center"/>
<ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding AgentBadge}"/>
<Button Grid.Column="3" Classes="icon-btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
Command="{Binding ResetTaskAgentCommand}"/>
</Grid>
<ComboBox ItemsSource="{Binding TaskAgentOptions}"
SelectedItem="{Binding TaskSelectedAgent, Mode=TwoWay}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Name}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
</StackPanel>
</Flyout>
</Button.Flyout>
</Button>
</Grid>
</UserControl>

View File

@@ -0,0 +1,22 @@
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Input.Platform;
using ClaudeDo.Ui.ViewModels.Islands;
namespace ClaudeDo.Ui.Views.Islands.Detail;
public partial class TaskHeaderBar : UserControl
{
public TaskHeaderBar()
{
InitializeComponent();
}
private async void OnTaskIdTapped(object? sender, TappedEventArgs e)
{
if (DataContext is not DetailsIslandViewModel vm || vm.Task is null) return;
var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
if (clipboard is null) return;
await clipboard.SetTextAsync(vm.Task.Id);
}
}

View File

@@ -0,0 +1,331 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
xmlns:loc="using:ClaudeDo.Ui.Localization"
x:DataType="vm:DetailsIslandViewModel"
x:Class="ClaudeDo.Ui.Views.Islands.Detail.WorkConsole">
<UserControl.Styles>
<Style Selector="Button.tab-btn">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Padding" Value="12,8" />
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="{StaticResource FontSizeMono}" />
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
<Setter Property="CornerRadius" Value="0" />
</Style>
<Style Selector="Button.tab-btn:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
<Setter Property="TextElement.Foreground" Value="{StaticResource TextDimBrush}" />
</Style>
<Style Selector="Button.tab-btn.active /template/ ContentPresenter">
<Setter Property="Background" Value="{StaticResource Surface3Brush}" />
<Setter Property="TextElement.Foreground" Value="{StaticResource AccentBrush}" />
</Style>
<!-- Terminal prompt action: bracketed text, no button chrome -->
<Style Selector="Button.prompt-action">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Padding" Value="2,0" />
<Setter Property="CornerRadius" Value="0" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
<Setter Property="FontSize" Value="{StaticResource FontSizeMono}" />
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
</Style>
<Style Selector="Button.prompt-action /template/ ContentPresenter">
<Setter Property="Background" Value="Transparent" />
</Style>
<Style Selector="Button.prompt-action:pointerover /template/ ContentPresenter">
<Setter Property="Background" Value="Transparent" />
<Setter Property="TextElement.Foreground" Value="{StaticResource TextBrush}" />
</Style>
<Style Selector="Button.prompt-action.accent">
<Setter Property="Foreground" Value="{StaticResource AccentBrush}" />
</Style>
<Style Selector="Button.prompt-action.accent:pointerover /template/ ContentPresenter">
<Setter Property="TextElement.Foreground" Value="{StaticResource MossBrightBrush}" />
</Style>
</UserControl.Styles>
<!-- Outer terminal card — Padding="0" so header/strip span edge-to-edge;
ClipToBounds keeps tab content inside the rounded corners (no bottom clip). -->
<Border Classes="terminal" Padding="0" ClipToBounds="True">
<DockPanel LastChildFill="True">
<!-- ── Title bar ── -->
<Grid DockPanel.Dock="Top" ColumnDefinitions="Auto,*,Auto"
Background="{DynamicResource Surface2Brush}" Height="28">
<!-- Traffic-light dots -->
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="6"
Margin="12,0" VerticalAlignment="Center">
<Ellipse Classes="dot-red" />
<Ellipse Classes="dot-yellow" />
<Ellipse Classes="dot-green" />
</StackPanel>
<!-- Right cluster: info header (model · turns · diff) + status chip -->
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="12"
Margin="0,0,8,0" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
<TextBlock Classes="meta" Text="{Binding Model}"
Foreground="{DynamicResource TextMuteBrush}" />
<TextBlock Classes="meta" Text="·"
Foreground="{DynamicResource TextFaintBrush}" />
<TextBlock Classes="meta" Text="{Binding TurnsText}"
Foreground="{DynamicResource TextMuteBrush}" />
<TextBlock Classes="meta" Text="·"
Foreground="{DynamicResource TextFaintBrush}" />
<TextBlock Classes="diff-add" Text="{Binding DiffAddText}" />
<TextBlock Classes="diff-del" Text="{Binding DiffDelText}" />
</StackPanel>
<Panel VerticalAlignment="Center">
<Border Classes="live-chip pulsing"
IsVisible="{Binding IsRunning}">
<StackPanel Orientation="Horizontal" Spacing="5" VerticalAlignment="Center">
<Ellipse VerticalAlignment="Center" />
<TextBlock Text="{loc:Tr session.chipLive}" VerticalAlignment="Center" />
</StackPanel>
</Border>
<Border Classes="live-chip done"
IsVisible="{Binding IsDone}">
<StackPanel Orientation="Horizontal" Spacing="5" VerticalAlignment="Center">
<Ellipse VerticalAlignment="Center" Fill="{DynamicResource MossBrush}" />
<TextBlock Text="{loc:Tr session.chipDone}" VerticalAlignment="Center"
Foreground="{DynamicResource MossBrush}" />
</StackPanel>
</Border>
<Border Classes="live-chip failed"
IsVisible="{Binding IsFailed}">
<StackPanel Orientation="Horizontal" Spacing="5" VerticalAlignment="Center">
<Ellipse VerticalAlignment="Center" Fill="{DynamicResource BloodBrush}" />
<TextBlock Text="{loc:Tr session.chipFailed}" VerticalAlignment="Center"
Foreground="{DynamicResource BloodBrush}" />
</StackPanel>
</Border>
</Panel>
</StackPanel>
</Grid>
<!-- ── Roadblock band ── -->
<Border DockPanel.Dock="Top"
IsVisible="{Binding ShowRoadblock}"
Background="{DynamicResource ErrorTintBrush}"
BorderBrush="{DynamicResource BloodBrush}"
BorderThickness="0,1"
Padding="14,8">
<StackPanel Spacing="6">
<StackPanel Orientation="Horizontal" Spacing="8">
<PathIcon Data="{StaticResource Icon.Warning}"
Foreground="{DynamicResource BloodBrush}"
Width="14" Height="14" VerticalAlignment="Center" />
<TextBlock Classes="meta" Text="{Binding RoadblockMessage}"
Foreground="{DynamicResource BloodBrush}"
TextWrapping="Wrap" VerticalAlignment="Center" />
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="btn accent" Content="Continue"
Command="{Binding ContinueCommand}"
IsVisible="{Binding ShowContinue}" />
<Button Classes="btn" Content="Reset &amp; Retry"
Command="{Binding ResetAndRetryCommand}"
IsVisible="{Binding ShowResetAndRetry}" />
</StackPanel>
</StackPanel>
</Border>
<!-- ── Tab strip ── -->
<Border DockPanel.Dock="Top"
Background="{DynamicResource Surface2Brush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,0,0,1">
<StackPanel Orientation="Horizontal">
<Button Classes="tab-btn"
Classes.active="{Binding IsOutputTab}"
Content="Output"
Command="{Binding SelectTabCommand}"
CommandParameter="output" />
<Button Classes="tab-btn"
Classes.active="{Binding IsGitTab}"
Content="Git"
Command="{Binding SelectTabCommand}"
CommandParameter="git" />
<Button Classes="tab-btn"
Classes.active="{Binding IsSessionTab}"
Content="Session"
Command="{Binding SelectTabCommand}"
CommandParameter="session" />
</StackPanel>
</Border>
<!-- ── Tab body (bottom inset keeps content clear of the rounded corner) ── -->
<Grid Margin="0,0,0,8">
<!-- Output: log + review footer, both gated on IsOutputTab -->
<DockPanel IsVisible="{Binding IsOutputTab}" LastChildFill="True">
<!-- Review prompt — sits directly on the terminal, like a shell input line;
only while awaiting review. No border/fill so it reads as part of the log. -->
<Grid DockPanel.Dock="Bottom"
IsVisible="{Binding IsWaitingForReview}"
ColumnDefinitions="Auto,*,Auto"
Margin="12,2,12,8">
<TextBlock Grid.Column="0" Text="&#x276F;"
FontFamily="{StaticResource MonoFont}"
FontSize="{StaticResource FontSizeMono}"
Foreground="{DynamicResource AccentBrush}"
VerticalAlignment="Top" Margin="0,2,8,0" />
<TextBox Grid.Column="1"
Name="ReviewInput"
KeyDown="OnReviewInputKeyDown"
Text="{Binding ReviewFeedback, Mode=TwoWay}"
AcceptsReturn="True"
TextWrapping="Wrap"
MaxHeight="160"
PlaceholderText="Feedback for the next run…"
Background="Transparent"
BorderThickness="0"
Padding="0"
VerticalContentAlignment="Center"
FontFamily="{StaticResource MonoFont}"
FontSize="{StaticResource FontSizeMono}" />
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="10"
VerticalAlignment="Top" Margin="12,2,0,0">
<Button Classes="prompt-action accent" Content="[Retry]"
Command="{Binding RejectReviewCommand}" />
<Button Classes="prompt-action" Content="[Reset]"
Command="{Binding ParkReviewCommand}" />
</StackPanel>
</Grid>
<ScrollViewer Name="LogScroll"
VerticalScrollBarVisibility="Visible"
AllowAutoHide="False"
Padding="12,8,12,4">
<ItemsControl ItemsSource="{Binding Log}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:LogLineViewModel">
<Grid ColumnDefinitions="60,*" Margin="0,1">
<TextBlock Grid.Column="0"
Classes="log-ts"
Text="{Binding TimestampFormatted}" />
<SelectableTextBlock Grid.Column="1"
Text="{Binding Text}" Tag="{Binding ClassName}"
Foreground="{DynamicResource TextDimBrush}"
TextWrapping="Wrap" />
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</DockPanel>
<!-- Git: merge target, approve, diff, worktree -->
<ScrollViewer IsVisible="{Binding IsGitTab}" Padding="14,10">
<StackPanel Spacing="14">
<!-- Approve (review-gated) -->
<StackPanel Spacing="8" IsVisible="{Binding IsWaitingForReview}">
<TextBlock Classes="section-label" Text="REVIEW" />
<Button Classes="btn accent" Content="Approve"
Command="{Binding ApproveReviewCommand}" />
</StackPanel>
<!-- Merge & worktree management (moved from Session tab) -->
<StackPanel Spacing="10" IsVisible="{Binding ShowMergeSection}">
<TextBlock Classes="section-label" Text="MERGE &amp; WORKTREE" />
<StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="Merge target" />
<ComboBox ItemsSource="{Binding MergeTargetBranches}"
SelectedItem="{Binding SelectedMergeTarget, Mode=TwoWay}"
HorizontalAlignment="Stretch" />
</StackPanel>
<StackPanel Spacing="0">
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
Foreground="{DynamicResource MossBrush}"
IsVisible="{Binding MergeIsClean}" />
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
Foreground="{DynamicResource BloodBrush}"
IsVisible="{Binding MergeIsConflict}" />
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
Foreground="{DynamicResource TextMuteBrush}"
IsVisible="{Binding ShowMergePreviewMuted}" />
</StackPanel>
<WrapPanel Orientation="Horizontal">
<Button Classes="btn" Content="Open Diff" Margin="0,0,8,8"
Command="{Binding OpenDiffCommand}" />
<Button Classes="btn accent" Content="Merge" Margin="0,0,8,8"
Command="{Binding MergeCommand}"
IsVisible="{Binding ShowSingleMerge}" />
<Button Classes="btn" Margin="0,0,8,8"
Command="{Binding OpenWorktreeCommand}">
<StackPanel Orientation="Horizontal" Spacing="5">
<TextBlock Text="Worktree" />
<PathIcon Data="{StaticResource Icon.ArrowOut}" Width="11" Height="11" />
</StackPanel>
</Button>
<Button Classes="btn" Content="Review Combined Diff" Margin="0,0,8,8"
Command="{Binding ReviewCombinedDiffCommand}" />
<Button Classes="btn accent" Content="Merge All Subtasks" Margin="0,0,0,8"
Command="{Binding MergeAllCommand}"
IsEnabled="{Binding CanMergeAll}"
ToolTip.Tip="{Binding MergeAllDisabledReason}" />
</WrapPanel>
<TextBlock Text="{Binding MergeAllError}"
Foreground="{DynamicResource BloodBrush}"
TextWrapping="Wrap"
IsVisible="{Binding MergeAllError,
Converter={x:Static ObjectConverters.IsNotNull}}" />
</StackPanel>
</StackPanel>
</ScrollViewer>
<!-- Session: subtask outcomes (review lives in Output, merge in Git) -->
<ScrollViewer IsVisible="{Binding IsSessionTab}" Padding="14,10">
<StackPanel Spacing="14">
<!-- Child outcomes -->
<StackPanel Spacing="6" IsVisible="{Binding HasChildOutcomes}">
<TextBlock Classes="section-label" Text="OUTCOMES" />
<ItemsControl ItemsSource="{Binding ChildOutcomes}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ChildOutcomeRowViewModel">
<Grid ColumnDefinitions="*,Auto,Auto" Margin="0,2">
<TextBlock Grid.Column="0" Text="{Binding Title}"
TextTrimming="CharacterEllipsis"
VerticalAlignment="Center" />
<TextBlock Grid.Column="1" Text="{Binding RoadblockText}"
IsVisible="{Binding HasRoadblock}"
Foreground="#E0A030"
Margin="8,0" VerticalAlignment="Center" />
<TextBlock Grid.Column="2" Text="{Binding StatusLabel}"
Opacity="0.75" VerticalAlignment="Center" />
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
<!-- Empty state: nothing to manage yet -->
<TextBlock IsVisible="{Binding ShowSessionEmpty}"
Classes="meta"
Foreground="{DynamicResource TextMuteBrush}"
TextWrapping="Wrap"
Text="Nothing to manage yet — subtask outcomes appear here once the run finishes. Review in the Output tab, merge in the Git tab." />
</StackPanel>
</ScrollViewer>
</Grid>
</DockPanel>
</Border>
</UserControl>

View File

@@ -0,0 +1,55 @@
using System;
using System.Collections.Specialized;
using Avalonia.Controls;
using Avalonia.Input;
using ClaudeDo.Ui.ViewModels.Islands;
namespace ClaudeDo.Ui.Views.Islands.Detail;
public partial class WorkConsole : UserControl
{
private INotifyCollectionChanged? _log;
public WorkConsole()
{
InitializeComponent();
DataContextChanged += OnDataContextChanged;
}
private void OnDataContextChanged(object? sender, EventArgs e)
{
if (_log is not null)
_log.CollectionChanged -= OnLogChanged;
_log = (DataContext as DetailsIslandViewModel)?.Log;
if (_log is not null)
_log.CollectionChanged += OnLogChanged;
}
private void OnLogChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (e.Action != NotifyCollectionChangedAction.Add) return;
EventHandler? handler = null;
handler = (_, _) =>
{
LogScroll.LayoutUpdated -= handler;
LogScroll.ScrollToEnd();
};
LogScroll.LayoutUpdated += handler;
}
private void OnReviewInputKeyDown(object? sender, KeyEventArgs e)
{
if (e.Key != Key.Enter || e.KeyModifiers.HasFlag(KeyModifiers.Shift))
return;
if (DataContext is DetailsIslandViewModel vm &&
vm.RejectReviewCommand.CanExecute(null))
{
vm.RejectReviewCommand.Execute(null);
}
e.Handled = true;
}
}

View File

@@ -2,32 +2,25 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
xmlns:islands="using:ClaudeDo.Ui.Views.Islands"
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
xmlns:detail="using:ClaudeDo.Ui.Views.Islands.Detail"
xmlns:loc="using:ClaudeDo.Ui.Localization"
x:Class="ClaudeDo.Ui.Views.Islands.DetailsIslandView"
x:DataType="vm:DetailsIslandViewModel">
<DockPanel>
<!-- ── Metadata footer (sticky bottom) — task detail only ── -->
<!-- ── Metadata footer (sticky bottom) — created-at + close — task detail only ── -->
<Border DockPanel.Dock="Bottom"
IsVisible="{Binding IsTaskDetailVisible}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,1,0,0"
Padding="14,8">
<Grid ColumnDefinitions="Auto,*,Auto">
<Button Grid.Column="0" Classes="icon-btn"
Command="{Binding DeleteTaskCommand}"
ToolTip.Tip="{loc:Tr details.deleteTaskTip}"
VerticalAlignment="Center">
<PathIcon Data="{StaticResource Icon.Trash}" Width="14" Height="14"
Foreground="{DynamicResource BloodBrush}"/>
</Button>
<TextBlock Grid.Column="1"
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0"
Classes="meta"
Text="{Binding Task.CreatedAtFormatted}"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
<Button Grid.Column="2" Classes="icon-btn"
<Button Grid.Column="1" Classes="icon-btn"
Command="{Binding CloseDetailsCommand}"
ToolTip.Tip="{loc:Tr details.closeTip}"
VerticalAlignment="Center">
@@ -36,377 +29,60 @@
</Grid>
</Border>
<!-- ── Header (sticky top): check · eyebrow · title · status · star · gear — task detail only ── -->
<!-- ── Header (sticky top): id · title · trash/skull · gear — task detail only ── -->
<Border DockPanel.Dock="Top" Classes="island-header"
IsVisible="{Binding IsTaskDetailVisible}">
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
<Button Grid.Column="0" Classes="flat"
Command="{Binding ToggleDoneCommand}"
Padding="0"
VerticalAlignment="Top"
Margin="0,2,10,0">
<Ellipse Classes="task-check"
Classes.done="{Binding Task.Done}"
Width="18" Height="18"
Cursor="Hand"/>
</Button>
<StackPanel Grid.Column="1" Spacing="0">
<TextBlock Classes="meta"
Text="{Binding TaskIdBadge}"
Margin="0,0,0,4"
Cursor="Hand"
ToolTip.Tip="{loc:Tr details.copyTaskIdTip}"
Tapped="OnTaskIdTapped"/>
<TextBox Text="{Binding EditableTitle, Mode=TwoWay}"
FontSize="{StaticResource FontSizeTaskTitle}" FontWeight="Medium"
BorderThickness="0" Background="Transparent"
Foreground="{DynamicResource TextBrush}"
TextWrapping="Wrap"
AcceptsReturn="False"
Padding="0"/>
</StackPanel>
<Button Grid.Column="2"
Classes="icon-btn star-btn"
Classes.on="{Binding Task.IsStarred}"
Command="{Binding ToggleStarCommand}"
ToolTip.Tip="{loc:Tr details.starTip}"
VerticalAlignment="Top"
Margin="6,0,0,0">
<PathIcon Data="{StaticResource Icon.Star}" Width="14" Height="14"/>
</Button>
<Button Grid.Column="3" Classes="icon-btn"
ToolTip.Tip="{loc:Tr details.agentSettingsTip}"
IsEnabled="{Binding IsAgentSectionEnabled}"
VerticalAlignment="Top"
Margin="6,0,0,0">
<TextBlock Text="⚙" FontSize="{StaticResource FontSizeTaskTitle}"/>
<Button.Flyout>
<Flyout Placement="BottomEdgeAlignedRight" ShowMode="Standard">
<StackPanel Width="340" Spacing="10" Margin="4">
<TextBlock Text="{loc:Tr details.agentSettingsHeading}" FontWeight="SemiBold"/>
<StackPanel Spacing="2">
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
<TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr details.modelLabel}" VerticalAlignment="Center"/>
<ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding ModelBadge}"/>
<Button Grid.Column="3" Classes="icon-btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
Command="{Binding ResetTaskModelCommand}"/>
</Grid>
<ComboBox ItemsSource="{Binding TaskModelOptions}"
SelectedItem="{Binding TaskModelSelection, Mode=TwoWay}"
PlaceholderText="{Binding ModelInheritedHint}"
HorizontalAlignment="Stretch"/>
</StackPanel>
<StackPanel Spacing="2">
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
<TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr details.maxTurnsLabel}" VerticalAlignment="Center"/>
<ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding TurnsBadge}"/>
<Button Grid.Column="3" Classes="icon-btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
Command="{Binding ResetTaskTurnsCommand}"/>
</Grid>
<NumericUpDown Value="{Binding TaskMaxTurns, Mode=TwoWay}"
PlaceholderText="{Binding TurnsInheritedHint}"
Minimum="1" Maximum="200" Increment="1" FormatString="0"
HorizontalAlignment="Stretch"/>
</StackPanel>
<StackPanel Spacing="2">
<TextBlock Classes="field-label" Text="{loc:Tr details.systemPromptLabel}"/>
<TextBox Text="{Binding TaskSystemPrompt, Mode=TwoWay}"
AcceptsReturn="True" TextWrapping="Wrap" MinHeight="70"/>
<TextBlock Classes="meta" Opacity="0.6"
Text="{loc:Tr details.systemPromptPrepended}"
IsVisible="{Binding EffectiveSystemPromptHint, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<TextBlock Classes="meta" Opacity="0.6" TextWrapping="Wrap"
Text="{Binding EffectiveSystemPromptHint}"
IsVisible="{Binding EffectiveSystemPromptHint, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<StackPanel Spacing="2">
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
<TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr details.agentFileLabel}" VerticalAlignment="Center"/>
<ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding AgentBadge}"/>
<Button Grid.Column="3" Classes="icon-btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
Command="{Binding ResetTaskAgentCommand}"/>
</Grid>
<ComboBox ItemsSource="{Binding TaskAgentOptions}"
SelectedItem="{Binding TaskSelectedAgent, Mode=TwoWay}"
HorizontalAlignment="Stretch">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Name}"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
</StackPanel>
</StackPanel>
</Flyout>
</Button.Flyout>
</Button>
</Grid>
<detail:TaskHeaderBar/>
</Border>
<!-- ── Agent status strip (sticky, above metadata footer) — task detail only ── -->
<islands:AgentStripView DockPanel.Dock="Bottom"
IsVisible="{Binding IsTaskDetailVisible}"/>
<!-- ── Body: task details (normal), notes editor (notes mode), or prep log (prep mode) ── -->
<Grid>
<ScrollViewer VerticalScrollBarVisibility="Auto"
IsVisible="{Binding IsTaskDetailVisible}">
<StackPanel Spacing="0">
<!-- Planning merge section — visible only for planning parent tasks -->
<Border Classes="section-divider"
IsVisible="{Binding Task.IsPlanningParent}">
<StackPanel Spacing="8">
<TextBlock Classes="section-label" Text="{loc:Tr details.mergeLabel}" Margin="0,0,0,2"/>
<StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="{loc:Tr details.mergeTargetLabel}"/>
<ComboBox ItemsSource="{Binding MergeTargetBranches}"
SelectedItem="{Binding SelectedMergeTarget, Mode=TwoWay}"
HorizontalAlignment="Stretch"/>
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="btn" Content="{loc:Tr details.reviewCombinedDiff}"
Command="{Binding ReviewCombinedDiffCommand}"/>
<Button Classes="btn" Content="{loc:Tr details.mergeAllSubtasks}"
IsEnabled="{Binding CanMergeAll}"
Command="{Binding MergeAllCommand}"
ToolTip.Tip="{Binding MergeAllDisabledReason}"/>
</StackPanel>
<TextBlock Text="{Binding MergeAllError}"
Foreground="{DynamicResource BloodBrush}"
TextWrapping="Wrap"
IsVisible="{Binding MergeAllError, Converter={x:Static ObjectConverters.IsNotNull}}"/>
</StackPanel>
</Border>
<!-- Task detail: description/steps card (upper) + pinned work console (lower) -->
<Grid IsVisible="{Binding IsTaskDetailVisible}"
Margin="14,12,14,12"
RowDefinitions="2*,*">
<ScrollViewer Grid.Row="0" VerticalScrollBarVisibility="Auto">
<detail:DescriptionStepsCard VerticalAlignment="Top"/>
</ScrollViewer>
<detail:WorkConsole Grid.Row="1" Margin="0,10,0,0"/>
<!-- Resize by dragging the console's top edge — a transparent splitter
over the gap above the console; no standalone separator bar. -->
<GridSplitter Grid.Row="1"
VerticalAlignment="Top"
Height="10"
HorizontalAlignment="Stretch"
ResizeDirection="Rows"
Background="Transparent"/>
</Grid>
<!-- Review section — visible when task is WaitingForReview -->
<Border Classes="section-divider"
IsVisible="{Binding IsWaitingForReview}">
<StackPanel Spacing="8">
<TextBlock Classes="section-label" Text="{loc:Tr tasks.reviewTitle}" Margin="0,0,0,2"/>
<TextBlock Classes="field-label" Text="{loc:Tr tasks.feedbackLabel}"/>
<TextBox Text="{Binding ReviewFeedback, Mode=TwoWay}"
AcceptsReturn="True"
TextWrapping="Wrap"
MinHeight="60"
MaxHeight="180"
PlaceholderText="{loc:Tr tasks.feedbackPlaceholder}"
Padding="8"
Background="{DynamicResource Surface2Brush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="1"
CornerRadius="8"/>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="btn accent"
Content="{loc:Tr tasks.approve}"
ToolTip.Tip="{loc:Tr tasks.approveTip}"
Command="{Binding ApproveReviewCommand}"/>
<Button Classes="btn"
Content="{loc:Tr tasks.reject}"
ToolTip.Tip="{loc:Tr tasks.rejectTip}"
Command="{Binding RejectReviewCommand}"/>
<Button Classes="btn"
Content="{loc:Tr tasks.park}"
ToolTip.Tip="{loc:Tr tasks.parkTip}"
Command="{Binding ParkReviewCommand}"/>
<Button Classes="btn"
Content="{loc:Tr tasks.cancel}"
ToolTip.Tip="{loc:Tr tasks.cancelTip}"
Command="{Binding CancelReviewCommand}"/>
</StackPanel>
</StackPanel>
</Border>
<!-- Notes mode -->
<Panel IsVisible="{Binding IsNotesMode}">
<islands:NotesEditorView DataContext="{Binding Notes}"/>
</Panel>
<!-- Improvement-children outcomes — visible when this task has agent-suggested children -->
<Border Classes="section-divider"
IsVisible="{Binding HasChildOutcomes}">
<StackPanel Spacing="6">
<TextBlock Classes="section-label" Text="{loc:Tr details.childOutcomesLabel}" Margin="0,0,0,2"/>
<ItemsControl ItemsSource="{Binding ChildOutcomes}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ChildOutcomeRowViewModel">
<Grid ColumnDefinitions="*,Auto,Auto" Margin="0,2">
<TextBlock Grid.Column="0" Text="{Binding Title}" TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/>
<TextBlock Grid.Column="1" Text="{Binding RoadblockText}"
IsVisible="{Binding HasRoadblock}"
Foreground="#E0A030" Margin="8,0" VerticalAlignment="Center"/>
<TextBlock Grid.Column="2" Text="{Binding StatusLabel}"
Opacity="0.75" VerticalAlignment="Center"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<!-- Daily-prep mode -->
<Panel IsVisible="{Binding IsPrepMode}">
<DockPanel>
<Border DockPanel.Dock="Top" Padding="12,8">
<Button Classes="btn primary"
Command="{Binding PlanDayCommand}"
IsEnabled="{Binding !IsPrepRunning}"
Content="{loc:Tr details.planDay}"/>
</Border>
<Panel>
<islands:SessionTerminalView
Margin="18,8,18,0"
Entries="{Binding PrepLog}" Label="daily-prep"
IsRunning="{Binding IsPrepRunning}"/>
<TextBlock IsVisible="{Binding ShowPrepEmptyState}"
HorizontalAlignment="Center" VerticalAlignment="Center"
Foreground="{DynamicResource TextMuteBrush}"
Text="{loc:Tr details.prepEmpty}"/>
</Panel>
</DockPanel>
</Panel>
<!-- Steps section -->
<Border Classes="section-divider">
<StackPanel Spacing="6">
<TextBlock Classes="section-label" Text="{loc:Tr details.stepsLabel}" Margin="0,0,0,2"/>
<TextBox Text="{Binding NewSubtaskTitle, Mode=TwoWay}"
PlaceholderText="{loc:Tr details.addStepPlaceholder}"
Padding="8"
Background="{DynamicResource Surface2Brush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="1"
CornerRadius="8">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter" Command="{Binding AddSubtaskCommand}"/>
</TextBox.KeyBindings>
</TextBox>
<ItemsControl ItemsSource="{Binding Subtasks}"
IsVisible="{Binding Subtasks.Count}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:SubtaskRowViewModel">
<Border Classes="subtask-row"
Classes.done="{Binding Done}">
<Grid ColumnDefinitions="Auto,*">
<Button Grid.Column="0" Classes="flat"
Padding="0"
Margin="0,0,8,0"
VerticalAlignment="Center"
Command="{Binding $parent[ItemsControl].((vm:DetailsIslandViewModel)DataContext).ToggleSubtaskDoneCommand}"
CommandParameter="{Binding}">
<Ellipse Classes="task-check"
Classes.done="{Binding Done}"
Width="16" Height="16"
Cursor="Hand"/>
</Button>
<Panel Grid.Column="1" VerticalAlignment="Center">
<TextBlock Classes="subtask-title"
Text="{Binding Title}"
IsVisible="{Binding !IsEditing}"
FontSize="{StaticResource FontSizeBody}"
Foreground="{DynamicResource TextDimBrush}"
VerticalAlignment="Center"
TextWrapping="Wrap"
Cursor="Ibeam"
Tapped="OnSubtaskTitleTapped"/>
<TextBox Classes="subtask-edit"
Text="{Binding Title, Mode=TwoWay}"
IsVisible="{Binding IsEditing}"
FontSize="{StaticResource FontSizeBody}"
AcceptsReturn="False"
TextWrapping="Wrap"
LostFocus="OnSubtaskEditLostFocus">
<TextBox.KeyBindings>
<KeyBinding Gesture="Enter"
Command="{Binding $parent[ItemsControl].((vm:DetailsIslandViewModel)DataContext).CommitSubtaskEditCommand}"
CommandParameter="{Binding}"/>
</TextBox.KeyBindings>
</TextBox>
</Panel>
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<!-- Details (description) section -->
<Border Classes="section-divider">
<StackPanel Spacing="6">
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
<Button Grid.Column="0"
Classes="flat"
Command="{Binding ToggleDescriptionExpandedCommand}"
Padding="0"
Margin="0,0,6,2"
VerticalAlignment="Center">
<StackPanel Orientation="Horizontal" Spacing="6">
<TextBlock Classes="meta"
Text="▾"
IsVisible="{Binding IsDescriptionExpanded}"/>
<TextBlock Classes="meta"
Text="▸"
IsVisible="{Binding !IsDescriptionExpanded}"/>
<TextBlock Classes="section-label" Text="{loc:Tr details.detailsLabel}"/>
</StackPanel>
</Button>
<Button Grid.Column="2"
Classes="icon-btn"
Padding="6,2"
Margin="0,0,4,0"
ToolTip.Tip="{loc:Tr details.copyDescriptionTip}"
IsVisible="{Binding IsDescriptionExpanded}"
Click="OnCopyDescriptionClick">
<PathIcon Data="{StaticResource Icon.Copy}" Width="11" Height="11"/>
</Button>
<Button Grid.Column="3"
Classes="btn"
Command="{Binding ToggleEditDescriptionCommand}"
Padding="8,3"
ToolTip.Tip="{loc:Tr details.toggleEditPreviewTip}"
IsVisible="{Binding IsDescriptionEditorVisible}">
<TextBlock Text="{loc:Tr details.previewBtn}"/>
</Button>
<Button Grid.Column="3"
Classes="btn"
Command="{Binding ToggleEditDescriptionCommand}"
Padding="8,3"
ToolTip.Tip="{loc:Tr details.toggleEditPreviewTip}"
IsVisible="{Binding IsDescriptionPreviewVisible}">
<TextBlock Text="{loc:Tr details.editBtn}"/>
</Button>
</Grid>
<TextBox Text="{Binding EditableDescription, Mode=TwoWay}"
AcceptsReturn="True"
TextWrapping="Wrap"
MinHeight="80"
MaxHeight="320"
PlaceholderText="{loc:Tr details.descriptionPlaceholder}"
Padding="8"
FontFamily="{DynamicResource MonoFont}"
FontSize="{StaticResource FontSizeBody}"
Background="{DynamicResource Surface2Brush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="1"
CornerRadius="8"
IsVisible="{Binding IsDescriptionEditorVisible}"/>
<ctl:MarkdownView Markdown="{Binding EditableDescription}"
IsVisible="{Binding IsDescriptionPreviewVisible}"/>
</StackPanel>
</Border>
<!-- Session terminal — auto-sizes to output, scrolls internally after MaxHeight -->
<islands:SessionTerminalView MaxHeight="420"
Entries="{Binding Log}"
Label="{Binding BranchLine, StringFormat='claude-session · {0}'}"
IsRunning="{Binding IsRunning}" IsDone="{Binding IsDone}" IsFailed="{Binding IsFailed}"/>
</StackPanel>
</ScrollViewer>
<Panel IsVisible="{Binding IsNotesMode}">
<islands:NotesEditorView DataContext="{Binding Notes}"/>
</Panel>
<Panel IsVisible="{Binding IsPrepMode}">
<DockPanel>
<Border DockPanel.Dock="Top" Padding="12,8">
<Button Classes="btn primary"
Command="{Binding PlanDayCommand}"
IsEnabled="{Binding !IsPrepRunning}"
Content="{loc:Tr details.planDay}"/>
</Border>
<Panel>
<islands:SessionTerminalView
Entries="{Binding PrepLog}" Label="daily-prep"
IsRunning="{Binding IsPrepRunning}"/>
<TextBlock IsVisible="{Binding ShowPrepEmptyState}"
HorizontalAlignment="Center" VerticalAlignment="Center"
Foreground="{DynamicResource TextMuteBrush}"
Text="{loc:Tr details.prepEmpty}"/>
</Panel>
</DockPanel>
</Panel>
</Grid>
</DockPanel>

View File

@@ -1,13 +1,7 @@
using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Input.Platform;
using Avalonia.Interactivity;
using Avalonia.Layout;
using Avalonia.Media;
using Avalonia.Threading;
using Avalonia.VisualTree;
using ClaudeDo.Ui.ViewModels.Islands;
using ClaudeDo.Ui.Views.Modals;
using ClaudeDo.Ui.Views.Planning;
@@ -138,37 +132,4 @@ public partial class DetailsIslandView : UserControl
_ = dialog.ShowDialog(owner);
return await tcs.Task;
}
private void OnSubtaskTitleTapped(object? sender, TappedEventArgs e)
{
if (sender is not Control c || c.DataContext is not SubtaskRowViewModel row) return;
row.IsEditing = true;
var box = (c.GetVisualParent() as Panel)?.GetVisualDescendants().OfType<TextBox>().FirstOrDefault();
if (box is not null)
Dispatcher.UIThread.Post(() => { box.Focus(); box.SelectAll(); }, DispatcherPriority.Background);
}
private void OnSubtaskEditLostFocus(object? sender, RoutedEventArgs e)
{
if (DataContext is DetailsIslandViewModel vm
&& sender is Control c && c.DataContext is SubtaskRowViewModel row)
vm.CommitSubtaskEditCommand.Execute(row);
}
private async void OnTaskIdTapped(object? sender, TappedEventArgs e)
{
if (DataContext is not DetailsIslandViewModel vm || vm.Task is null) return;
var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
if (clipboard is null) return;
await clipboard.SetTextAsync(vm.Task.Id);
}
private async void OnCopyDescriptionClick(object? sender, RoutedEventArgs e)
{
if (DataContext is not DetailsIslandViewModel vm) return;
var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
if (clipboard is null) return;
await clipboard.SetTextAsync(vm.EditableDescription ?? string.Empty);
}
}

View File

@@ -4,7 +4,7 @@
xmlns:loc="using:ClaudeDo.Ui.Localization"
x:Class="ClaudeDo.Ui.Views.Islands.SessionTerminalView"
x:Name="Root">
<Border Classes="terminal" Margin="18,8,18,0">
<Border Classes="terminal" Margin="0">
<DockPanel LastChildFill="True">
<!-- ── Terminal header bar ── -->

View File

@@ -69,7 +69,7 @@
Click="OnClearScheduleClick"/>
</ContextMenu>
</Border.ContextMenu>
<Grid ColumnDefinitions="0,18,32,*,Auto,32" Margin="6,8,10,8">
<Grid ColumnDefinitions="0,18,32,*,Auto,Auto,32" Margin="6,8,10,8">
<!-- Chevron toggle (only for planning parent tasks) -->
<Button Grid.Column="1"
@@ -194,8 +194,20 @@
</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>
<!-- Star toggle -->
<Button Grid.Column="5" Classes="icon-btn star-btn"
<Button Grid.Column="6" Classes="icon-btn star-btn"
Classes.on="{Binding IsStarred}"
VerticalAlignment="Top" Margin="0,2,0,0"
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).ToggleStarCommand}"

View File

@@ -69,7 +69,7 @@ Allowed transitions (enforced by `TaskStateService`):
Idle → Queued | Running (RunNow)
Queued → Running | Cancelled | Idle
Running → WaitingForReview (standalone success) | Done (planning child success) | Failed | Cancelled
WaitingForReview → Done (approve) | Queued (reject-rerun, +feedback) | Idle (reject-park) | Cancelled
WaitingForReview → Done (approve: merges worktree first; conflicts keep it in WaitingForReview) | Queued (reject-rerun, +feedback) | Idle (reject-park) | Cancelled
Done → Idle (re-run)
Failed → Idle | Queued
Cancelled → Idle | Queued
@@ -121,7 +121,7 @@ Each CLI invocation is recorded in the `task_runs` table via `TaskRunRepository`
## SignalR Hub
**WorkerHub** methods: `Ping`, `GetActive`, `RunNow`, `CancelTask`, `WakeQueue`, `ContinueTask`, `ResetTask`, `ApproveReview`, `RejectReviewToQueue`, `RejectReviewToIdle`, `CancelReview`, `GetAgents`, `RefreshAgents`, `GetAppSettings`, `UpdateAppSettings`, `CleanupFinishedWorktrees`, `ResetAllWorktrees`, `MergeTask`, `GetMergeTargets`, `UpdateList`, `UpdateListConfig`, `GetListConfig`, `UpdateTaskAgentSettings`, `GetWeekReport`, `GenerateWeekReport`, `GetDailyNotes`, `AddDailyNote`, `UpdateDailyNote`, `DeleteDailyNote`, `RunDailyPrepNow`, `ClearMyDay`, `GetLastPrepLog`
**WorkerHub** methods: `Ping`, `GetActive`, `RunNow`, `CancelTask`, `WakeQueue`, `ContinueTask`, `ResetTask`, `ApproveReview(taskId, targetBranch) -> MergeResultDto` (merges worktree then transitions to Done; on conflict stays WaitingForReview), `PreviewMerge(taskId, targetBranch) -> MergePreviewDto` (non-destructive mergeability check), `RejectReviewToQueue`, `RejectReviewToIdle`, `CancelReview`, `GetAgents`, `RefreshAgents`, `GetAppSettings`, `UpdateAppSettings`, `CleanupFinishedWorktrees`, `ResetAllWorktrees`, `MergeTask`, `GetMergeTargets`, `UpdateList`, `UpdateListConfig`, `GetListConfig`, `UpdateTaskAgentSettings`, `GetWeekReport`, `GenerateWeekReport`, `GetDailyNotes`, `AddDailyNote`, `UpdateDailyNote`, `DeleteDailyNote`, `RunDailyPrepNow`, `ClearMyDay`, `GetLastPrepLog`
**HubBroadcaster** events: `TaskStarted`, `TaskFinished`, `TaskMessage`, `WorktreeUpdated`, `TaskUpdated`, `RunCreated`, `ListUpdated`, `PrimeFired`, `PrepStarted`, `PrepLine`, `PrepFinished`

View File

@@ -207,6 +207,44 @@ public sealed class ExternalMcpService
return ToDto(reload);
}
[McpServerTool, Description(
"Append a subtask (step) to a task. orderNum defaults to the end. " +
"Refuses if the task is currently Running. Subtasks are surfaced to the agent at run time and shown in the task's Steps list.")]
public async Task<TaskDto> AddSubtask(
string taskId,
string title,
int? orderNum,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(title))
throw new InvalidOperationException("title is required.");
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
var tasks = new TaskRepository(ctx);
var subtasks = new SubtaskRepository(ctx);
var task = await tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (task.Status == TaskStatus.Running)
throw new InvalidOperationException("Cannot add a subtask to a running task. Cancel it first.");
var existing = await subtasks.GetByTaskIdAsync(taskId, cancellationToken);
var order = orderNum ?? (existing.Count == 0 ? 0 : existing.Max(s => s.OrderNum) + 1);
await subtasks.AddAsync(new SubtaskEntity
{
Id = Guid.NewGuid().ToString(),
TaskId = taskId,
Title = title.Trim(),
Completed = false,
OrderNum = order,
CreatedAt = DateTime.UtcNow,
}, cancellationToken);
await _broadcaster.TaskUpdated(taskId);
return ToDto(task);
}
[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. " +

View File

@@ -1,10 +1,11 @@
using ClaudeDo.Data.Models;
using ClaudeDo.Worker.Prime;
using ClaudeDo.Worker.Refine;
using Microsoft.AspNetCore.SignalR;
namespace ClaudeDo.Worker.Hub;
public sealed class HubBroadcaster : IPrimeBroadcaster
public sealed class HubBroadcaster : IPrimeBroadcaster, IRefineBroadcaster
{
private readonly IHubContext<WorkerHub> _hub;
@@ -62,4 +63,12 @@ public sealed class HubBroadcaster : IPrimeBroadcaster
Task IPrimeBroadcaster.PrepStartedAsync() => PrepStarted();
Task IPrimeBroadcaster.PrepLineAsync(string line) => PrepLine(line);
Task IPrimeBroadcaster.PrepFinishedAsync(bool success) => PrepFinished(success);
public Task RefineStarted(string taskId) => _hub.Clients.All.SendAsync("RefineStarted", taskId);
public Task RefineFinished(string taskId, bool success, string? error) =>
_hub.Clients.All.SendAsync("RefineFinished", taskId, success, error);
Task IRefineBroadcaster.RefineStartedAsync(string taskId) => RefineStarted(taskId);
Task IRefineBroadcaster.RefineFinishedAsync(string taskId, bool success, string? error) =>
RefineFinished(taskId, success, error);
}

View File

@@ -8,6 +8,7 @@ using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Planning;
using ClaudeDo.Worker.Prime;
using ClaudeDo.Worker.Queue;
using ClaudeDo.Worker.Refine;
using ClaudeDo.Worker.Report;
using ClaudeDo.Worker.Report.Interfaces;
using ClaudeDo.Worker.State;
@@ -53,6 +54,7 @@ public record WorktreeOverviewDto(
public record ForceRemoveResultDto(bool Removed, string? Reason);
public record MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage);
public record MergePreviewDto(string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount);
public record MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalBranches);
public record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType);
public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
@@ -83,6 +85,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
private readonly IPrimeRunner _primeRunner;
private readonly ITaskStateService _state;
private readonly IWeekReportService _report;
private readonly IRefineRunner _refineRunner;
public WorkerHub(
QueueService queue,
@@ -102,7 +105,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
IPrimeScheduleSignal primeSignal,
IPrimeRunner primeRunner,
ITaskStateService state,
IWeekReportService report)
IWeekReportService report,
IRefineRunner refineRunner)
{
_queue = queue;
_waker = waker;
@@ -122,6 +126,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
_primeRunner = primeRunner;
_state = state;
_report = report;
_refineRunner = refineRunner;
}
// Maps the two exceptions service methods throw into client-facing HubExceptions:
@@ -316,6 +321,13 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
return new MergeTargetsDto(t.DefaultBranch, t.LocalBranches);
});
public Task<MergePreviewDto> PreviewMerge(string taskId, string targetBranch)
=> HubGuard(async () =>
{
var p = await _mergeService.PreviewAsync(taskId, targetBranch ?? "", CancellationToken.None);
return new MergePreviewDto(p.Status, p.ConflictFiles, p.ChangedFileCount);
});
public async Task UpdateList(UpdateListDto dto)
{
using var ctx = _dbFactory.CreateDbContext();
@@ -380,11 +392,14 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
if (!result.Ok) throw new HubException(result.Reason ?? "set status failed");
}
public async Task ApproveReview(string taskId)
{
var result = await _state.ApproveReviewAsync(taskId, Context.ConnectionAborted);
if (!result.Ok) throw new HubException(result.Reason ?? "approve failed");
}
public Task<MergeResultDto> ApproveReview(string taskId, string targetBranch)
=> HubGuard(async () =>
{
var r = await _mergeService.ApproveAndMergeAsync(taskId, targetBranch ?? "", CancellationToken.None);
if (r.Status == TaskMergeService.StatusBlocked)
throw new HubException(r.ErrorMessage ?? "approve failed");
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
});
public async Task RejectReviewToQueue(string taskId, string feedback)
{
@@ -541,6 +556,12 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
_primeSignal.Signal();
}
public Task RefineTask(string taskId)
{
_ = _refineRunner.RefineAsync(taskId, CancellationToken.None);
return Task.CompletedTask;
}
public async Task<bool> RunDailyPrepNow()
{
var schedule = new PrimeScheduleDto(Guid.Empty, 0, TimeSpan.Zero, true, null, null);

View File

@@ -3,6 +3,7 @@ using ClaudeDo.Data.Git;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.State;
using Microsoft.EntityFrameworkCore;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
@@ -17,6 +18,11 @@ public sealed record MergeTargets(
string DefaultBranch,
IReadOnlyList<string> LocalBranches);
public sealed record MergePreviewResult(
string Status,
IReadOnlyList<string> ConflictFiles,
int ChangedFileCount);
public sealed class TaskMergeService
{
public const string StatusMerged = "merged";
@@ -24,20 +30,27 @@ public sealed class TaskMergeService
public const string StatusBlocked = "blocked";
public const string StatusAborted = "aborted";
public const string PreviewClean = "clean";
public const string PreviewConflict = "conflict";
public const string PreviewUnavailable = "unavailable";
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly GitService _git;
private readonly HubBroadcaster _broadcaster;
private readonly ITaskStateService _state;
private readonly ILogger<TaskMergeService> _logger;
public TaskMergeService(
IDbContextFactory<ClaudeDoDbContext> dbFactory,
GitService git,
HubBroadcaster broadcaster,
ITaskStateService state,
ILogger<TaskMergeService> logger)
{
_dbFactory = dbFactory;
_git = git;
_broadcaster = broadcaster;
_state = state;
_logger = logger;
}
@@ -216,6 +229,59 @@ public sealed class TaskMergeService
return new MergeTargets(current, branches);
}
public async Task<MergePreviewResult> PreviewAsync(string taskId, string targetBranch, CancellationToken ct)
{
var (_, list, wt) = await LoadMergeContextAsync(taskId, ct);
if (wt is null || wt.State != WorktreeState.Active)
return new MergePreviewResult(PreviewUnavailable, Array.Empty<string>(), 0);
if (string.IsNullOrWhiteSpace(list.WorkingDir) || !await _git.IsGitRepoAsync(list.WorkingDir, ct))
return new MergePreviewResult(PreviewUnavailable, Array.Empty<string>(), 0);
var target = string.IsNullOrWhiteSpace(targetBranch)
? await _git.GetCurrentBranchAsync(list.WorkingDir, ct)
: targetBranch;
var preview = await _git.PreviewMergeAsync(list.WorkingDir, target, wt.BranchName, ct);
if (!preview.Supported)
return new MergePreviewResult(PreviewUnavailable, Array.Empty<string>(), 0);
if (!preview.Clean)
return new MergePreviewResult(PreviewConflict, preview.ConflictFiles, 0);
var count = await _git.CountChangedFilesAsync(list.WorkingDir, target, wt.BranchName, ct);
return new MergePreviewResult(PreviewClean, Array.Empty<string>(), count);
}
public async Task<MergeResult> ApproveAndMergeAsync(string taskId, string targetBranch, CancellationToken ct)
{
var (task, list, wt) = await LoadMergeContextAsync(taskId, ct);
if (task.Status != TaskStatus.WaitingForReview)
return Blocked("task is not waiting for review");
if (wt is null || wt.State != WorktreeState.Active)
{
var done = await _state.ApproveReviewAsync(taskId, ct);
return done.Ok
? new MergeResult(StatusMerged, Array.Empty<string>(), null)
: Blocked(done.Reason ?? "approve failed");
}
if (string.IsNullOrWhiteSpace(list.WorkingDir))
return Blocked("list has no working directory");
var target = string.IsNullOrWhiteSpace(targetBranch)
? await _git.GetCurrentBranchAsync(list.WorkingDir, ct)
: targetBranch;
var merge = await MergeAsync(taskId, target, removeWorktree: false, $"Merge {wt.BranchName}", ct);
if (merge.Status != StatusMerged)
return merge;
var approve = await _state.ApproveReviewAsync(taskId, ct);
return approve.Ok ? merge : Blocked(approve.Reason ?? "approve failed");
}
private static MergeResult Blocked(string reason) =>
new(StatusBlocked, Array.Empty<string>(), reason);
}

View File

@@ -12,6 +12,7 @@ using ClaudeDo.Worker.Queue;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.State;
using ClaudeDo.Worker.Prime;
using ClaudeDo.Worker.Refine;
using ClaudeDo.Worker.Report;
using ClaudeDo.Worker.Report.Interfaces;
using ClaudeDo.Worker.Worktrees;
@@ -108,6 +109,10 @@ builder.Services.AddSingleton(PrimeSchedulerOptions.Default);
builder.Services.AddSingleton<IPrimeBroadcaster>(sp => sp.GetRequiredService<HubBroadcaster>());
builder.Services.AddHostedService<PrimeScheduler>();
// Refine
builder.Services.AddSingleton<IRefineRunner, RefineRunner>();
builder.Services.AddSingleton<IRefineBroadcaster>(sp => sp.GetRequiredService<HubBroadcaster>());
// QueueService: singleton + hosted service (same instance).
builder.Services.AddSingleton<QueueService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<QueueService>());

View File

@@ -0,0 +1,7 @@
namespace ClaudeDo.Worker.Refine;
public interface IRefineBroadcaster
{
Task RefineStartedAsync(string taskId);
Task RefineFinishedAsync(string taskId, bool success, string? error);
}

View File

@@ -0,0 +1,8 @@
namespace ClaudeDo.Worker.Refine;
public interface IRefineRunner
{
Task<RefineRunOutcome> RefineAsync(string taskId, CancellationToken ct);
}
public sealed record RefineRunOutcome(bool Success, string Message);

View File

@@ -0,0 +1,38 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
namespace ClaudeDo.Worker.Refine;
public static class RefinePrompt
{
public const string GetTaskTool = "mcp__claudedo__get_task";
public const string UpdateTaskTool = "mcp__claudedo__update_task";
public const string AddSubtaskTool = "mcp__claudedo__add_subtask";
public static string LogPath(string taskId) =>
System.IO.Path.Combine(Paths.AppDataRoot(), "logs", $"refine-{Short(taskId)}.log");
public static string BuildArgs(int maxTurns, bool canReadRepo)
{
var tools = canReadRepo
? $"{GetTaskTool} {UpdateTaskTool} {AddSubtaskTool} Read Grep Glob"
: $"{GetTaskTool} {UpdateTaskTool} {AddSubtaskTool}";
return "-p --output-format stream-json --verbose --permission-mode acceptEdits " +
$"--max-turns {maxTurns} --allowedTools {tools}";
}
public static string BuildPrompt(TaskEntity task, IEnumerable<SubtaskEntity> subtasks)
{
var open = subtasks.Where(s => !s.Completed).Select(s => $"- {s.Title}").ToList();
var subText = open.Count == 0 ? "(none)" : string.Join("\n", open);
return PromptFiles.Render(PromptKind.Refine, new Dictionary<string, string>
{
["taskId"] = task.Id,
["title"] = task.Title,
["description"] = string.IsNullOrWhiteSpace(task.Description) ? "(empty)" : task.Description!,
["subtasks"] = subText,
});
}
private static string Short(string id) => id.Length >= 8 ? id[..8] : id;
}

View File

@@ -0,0 +1,108 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Runner;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Refine;
public sealed class RefineRunner : IRefineRunner
{
private static readonly TimeSpan RunTimeout = TimeSpan.FromMinutes(5);
private const int MaxTurns = 25;
private readonly IClaudeProcess _claude;
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly ILogger<RefineRunner> _logger;
private readonly IRefineBroadcaster _broadcaster;
private readonly object _lock = new();
private readonly HashSet<string> _inFlight = new();
public RefineRunner(
IClaudeProcess claude,
IDbContextFactory<ClaudeDoDbContext> dbFactory,
ILogger<RefineRunner> logger,
IRefineBroadcaster broadcaster)
{
_claude = claude;
_dbFactory = dbFactory;
_logger = logger;
_broadcaster = broadcaster;
}
public async Task<RefineRunOutcome> RefineAsync(string taskId, CancellationToken ct)
{
lock (_lock)
{
if (!_inFlight.Add(taskId))
return new RefineRunOutcome(false, "Already refining this task");
}
var success = false;
string? error = null;
try
{
ClaudeDo.Data.Models.TaskEntity task;
List<ClaudeDo.Data.Models.SubtaskEntity> subs;
string? workingDir;
await using (var dbCtx = await _dbFactory.CreateDbContextAsync(ct))
{
var tasks = new TaskRepository(dbCtx);
task = await tasks.GetByIdAsync(taskId, ct)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (task.Status != TaskStatus.Idle)
return new RefineRunOutcome(false, $"Task must be Idle to refine (is {task.Status}).");
subs = await new SubtaskRepository(dbCtx).GetByTaskIdAsync(taskId, ct);
var list = await new ListRepository(dbCtx).GetByIdAsync(task.ListId, ct);
workingDir = list?.WorkingDir;
}
var canReadRepo = !string.IsNullOrWhiteSpace(workingDir) && Directory.Exists(workingDir);
var cwd = canReadRepo ? workingDir! : Paths.AppDataRoot();
Directory.CreateDirectory(cwd);
var logPath = RefinePrompt.LogPath(taskId);
try { if (File.Exists(logPath)) File.Delete(logPath); } catch { }
await using var logWriter = new LogWriter(logPath);
await _broadcaster.RefineStartedAsync(taskId);
var prompt = RefinePrompt.BuildPrompt(task, subs);
var args = RefinePrompt.BuildArgs(MaxTurns, canReadRepo);
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
timeoutCts.CancelAfter(RunTimeout);
var result = await _claude.RunAsync(
arguments: args,
prompt: prompt,
workingDirectory: cwd,
onStdoutLine: async line => await logWriter.WriteLineAsync(line),
ct: timeoutCts.Token);
success = result.IsSuccess;
if (!success) error = $"exit code {result.ExitCode}";
return success
? new RefineRunOutcome(true, "Refine complete")
: new RefineRunOutcome(false, error!);
}
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
error = $"timed out after {RunTimeout.TotalMinutes:0} min";
return new RefineRunOutcome(false, error);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Refine run failed for {TaskId}", taskId);
error = ex.Message;
return new RefineRunOutcome(false, ex.Message);
}
finally
{
await _broadcaster.RefineFinishedAsync(taskId, success, error);
lock (_lock) { _inFlight.Remove(taskId); }
}
}
}

View File

@@ -101,16 +101,10 @@ public sealed class TaskRunner
await _state.StartRunningAsync(task.Id, now, ct);
await _broadcaster.TaskStarted(slot, task.Id, now);
// Build prompt.
var sb = new System.Text.StringBuilder(task.Title);
if (!string.IsNullOrWhiteSpace(task.Description)) sb.Append("\n\n").Append(task.Description.Trim());
if (subtasks.Count > 0)
{
sb.Append("\n\n## Sub-Tasks\n");
foreach (var s in subtasks)
sb.Append(s.Completed ? "- [x] " : "- [ ] ").Append(s.Title).Append('\n');
}
var prompt = sb.ToString();
// Build prompt: title + description + only the OPEN sub-tasks (resolved ones are dropped).
var prompt = TaskPromptComposer.Compose(
task.Title, task.Description,
subtasks.Select(s => (s.Title, s.Completed)));
// Run 1.
var result = await RunOnceAsync(task.Id, task.Title, slot, runDir, resolvedConfig, 1, false, prompt, ct);

View File

@@ -0,0 +1,45 @@
using ClaudeDo.Data;
using Xunit;
namespace ClaudeDo.Data.Tests;
public class TaskPromptComposerTests
{
[Fact]
public void Composes_title_description_and_open_steps()
{
var result = TaskPromptComposer.Compose(
"Refactor diff viewer",
"Share the row template.",
new (string, bool)[] { ("Done step", true), ("Open step", false) });
Assert.Equal(
"Refactor diff viewer\n\nShare the row template.\n\n## Sub-Tasks\n- [ ] Open step\n",
result);
}
[Fact]
public void Drops_resolved_steps_and_omits_section_when_none_open()
{
var result = TaskPromptComposer.Compose(
"Title",
"Desc",
new (string, bool)[] { ("a", true), ("b", true) });
Assert.Equal("Title\n\nDesc", result);
}
[Fact]
public void Omits_description_when_blank()
{
var result = TaskPromptComposer.Compose("Title", " ", new (string, bool)[] { ("open", false) });
Assert.Equal("Title\n\n## Sub-Tasks\n- [ ] open\n", result);
}
[Fact]
public void Title_only_when_no_description_or_steps()
{
Assert.Equal("Just a title", TaskPromptComposer.Compose("Just a title", null, System.Array.Empty<(string, bool)>()));
}
}

View File

@@ -25,6 +25,8 @@ public abstract class StubWorkerClient : IWorkerClient
public event Action? PrepStartedEvent;
public event Action<string>? PrepLineEvent;
public event Action<bool>? PrepFinishedEvent;
public event Action<string>? RefineStartedEvent;
public event Action<string, bool, string?>? RefineFinishedEvent;
public event Action<string, string>? PlanningMergeStartedEvent;
public event Action<string, string>? PlanningSubtaskMergedEvent;
public event Action<string, string, IReadOnlyList<string>>? PlanningMergeConflictEvent;
@@ -50,7 +52,9 @@ public abstract class StubWorkerClient : IWorkerClient
public virtual Task<ListConfigDto?> GetListConfigAsync(string listId) => Task.FromResult<ListConfigDto?>(null);
public virtual Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) => Task.CompletedTask;
public virtual Task SetTaskStatusAsync(string taskId, TaskStatus status) => Task.CompletedTask;
public virtual Task ApproveReviewAsync(string taskId) => Task.CompletedTask;
public virtual Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) => Task.FromResult<MergeResultDto?>(null);
public virtual Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch) => Task.FromResult<MergePreviewDto?>(null);
public virtual Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));
public virtual Task RejectReviewToQueueAsync(string taskId, string feedback) => Task.CompletedTask;
public virtual Task RejectReviewToIdleAsync(string taskId) => Task.CompletedTask;
public virtual Task CancelReviewAsync(string taskId) => Task.CompletedTask;
@@ -81,6 +85,7 @@ public abstract class StubWorkerClient : IWorkerClient
public virtual Task DeleteDailyNoteAsync(string id) => Task.CompletedTask;
public string LastPrepLog = "";
public virtual Task<string> GetLastPrepLogAsync() => Task.FromResult(LastPrepLog);
public virtual Task RefineTaskAsync(string taskId) => Task.CompletedTask;
protected void RaisePropertyChanged(string name) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}

View File

@@ -74,8 +74,8 @@ public class DetailsIslandPlanningTests : IDisposable
private sealed class ThrowingReviewWorkerClient : StubWorkerClient
{
public override bool IsConnected => true;
public override Task ApproveReviewAsync(string taskId) =>
Task.FromException(new InvalidOperationException("Task is not waiting for review; cannot approve."));
public override Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) =>
Task.FromException<MergeResultDto?>(new InvalidOperationException("Task is not waiting for review; cannot approve."));
}
private static SubtaskRowViewModel MakeSubtask(TaskStatus status, WorktreeState wt = WorktreeState.Active) =>

View File

@@ -0,0 +1,83 @@
using ClaudeDo.Data;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Islands;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Ui.Tests.ViewModels;
public class DetailsIslandTabsTests : IDisposable
{
private readonly string _dbPath;
public DetailsIslandTabsTests()
{
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_tabs_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 StubNotesApi : ClaudeDo.Ui.Services.Interfaces.INotesApi
{
public Task<List<DailyNoteDto>> ListAsync(DateOnly day) => Task.FromResult(new List<DailyNoteDto>());
public Task<DailyNoteDto?> AddAsync(DateOnly day, string text) => Task.FromResult<DailyNoteDto?>(null);
public Task UpdateAsync(string id, string text) => Task.CompletedTask;
public Task DeleteAsync(string id) => Task.CompletedTask;
}
private sealed class NullServiceProvider : IServiceProvider
{
public object? GetService(Type serviceType) => null;
}
// StubWorkerClient is abstract — use a concrete no-op subclass (same pattern as DetailsIslandPrepModeTests).
private sealed class DefaultStub : StubWorkerClient { }
private DetailsIslandViewModel NewVm()
{
var factory = new TestDbFactory(NewContext);
return new DetailsIslandViewModel(factory, new DefaultStub(), new NullServiceProvider(), new StubNotesApi());
}
[Fact]
public void SelectTab_git_sets_IsGitTab_and_clears_others()
{
var vm = NewVm();
vm.SelectTabCommand.Execute("git");
Assert.True(vm.IsGitTab);
Assert.False(vm.IsOutputTab);
Assert.False(vm.IsSessionTab);
}
[Fact]
public void Default_tab_is_output_not_git()
{
var vm = NewVm();
Assert.True(vm.IsOutputTab);
Assert.False(vm.IsGitTab);
}
}

View File

@@ -0,0 +1,62 @@
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Islands;
namespace ClaudeDo.Ui.Tests.ViewModels;
public class MergePreviewPresenterTests
{
[Fact]
public void Clean_Plural()
{
var (text, clean, conflict) = MergePreviewPresenter.Describe(
new MergePreviewDto("clean", System.Array.Empty<string>(), 3));
Assert.Equal("Merges cleanly · 3 files", text);
Assert.True(clean);
Assert.False(conflict);
}
[Fact]
public void Clean_Singular()
{
var (text, _, _) = MergePreviewPresenter.Describe(
new MergePreviewDto("clean", System.Array.Empty<string>(), 1));
Assert.Equal("Merges cleanly · 1 file", text);
}
[Fact]
public void Conflict_ListsUpToThree()
{
var (text, clean, conflict) = MergePreviewPresenter.Describe(
new MergePreviewDto("conflict", new[] { "a.cs", "b.cs" }, 0));
Assert.Equal("Conflicts in a.cs, b.cs", text);
Assert.False(clean);
Assert.True(conflict);
}
[Fact]
public void Conflict_TruncatesWithMore()
{
var (text, _, _) = MergePreviewPresenter.Describe(
new MergePreviewDto("conflict", new[] { "a", "b", "c", "d", "e" }, 0));
Assert.Equal("Conflicts in a, b, c (+2 more)", text);
}
[Fact]
public void Unavailable_IsMuted()
{
var (text, clean, conflict) = MergePreviewPresenter.Describe(
new MergePreviewDto("unavailable", System.Array.Empty<string>(), 0));
Assert.Equal("Mergeability unknown", text);
Assert.False(clean);
Assert.False(conflict);
}
[Fact]
public void Null_IsEmpty()
{
var (text, clean, conflict) = MergePreviewPresenter.Describe(null);
Assert.Equal("", text);
Assert.False(clean);
Assert.False(conflict);
}
}

View File

@@ -0,0 +1,118 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.External;
using ClaudeDo.Worker.Hub;
using ClaudeDo.Worker.Lifecycle;
using ClaudeDo.Worker.Queue;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Tests.Infrastructure;
using ClaudeDo.Worker.Tests.Services;
using ClaudeDo.Worker.Worktrees;
using ClaudeDo.Worker.Config;
using Microsoft.Extensions.Logging.Abstractions;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.External;
public sealed class AddSubtaskToolTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
private readonly TaskRepository _tasks;
private readonly ListRepository _lists;
public AddSubtaskToolTests()
{
_ctx = _db.CreateContext();
_tasks = new TaskRepository(_ctx);
_lists = new ListRepository(_ctx);
}
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
private async Task<string> SeedListAsync()
{
var id = Guid.NewGuid().ToString();
await _lists.AddAsync(new ListEntity { Id = id, Name = "L", CreatedAt = DateTime.UtcNow });
return id;
}
private async Task<TaskEntity> SeedTaskAsync(string listId, TaskStatus status = TaskStatus.Idle)
{
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;
}
private ExternalMcpService BuildSut()
{
var cfg = new WorkerConfig
{
SandboxRoot = Path.Combine(Path.GetTempPath(), $"cd_{Guid.NewGuid():N}"),
LogRoot = Path.Combine(Path.GetTempPath(), $"cdl_{Guid.NewGuid():N}"),
QueueBackstopIntervalMs = 50,
};
var dbFactory = _db.CreateFactory();
var hubCtx = new FakeHubContext();
var broadcaster = new HubBroadcaster(hubCtx);
var git = new ClaudeDo.Data.Git.GitService();
var wtManager = new WorktreeManager(git, dbFactory, cfg, NullLogger<WorktreeManager>.Instance);
var fake = new FakeClaudeProcess();
var argsBuilder = new ClaudeArgsBuilder();
var state = TaskStateServiceBuilder.Build(dbFactory).State;
var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, cfg,
NullLogger<TaskRunner>.Instance, state, new TaskRunTokenRegistry());
var waker = new ClaudeDo.Worker.Queue.QueueWaker();
var picker = new ClaudeDo.Worker.Queue.QueuePicker(dbFactory);
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance);
var queue = new QueueService(dbFactory, runner, cfg, NullLogger<QueueService>.Instance, waker, picker, overrideSlot, state);
var maintenance = new WorktreeMaintenanceService(dbFactory, git, NullLogger<WorktreeMaintenanceService>.Instance);
var merge = new TaskMergeService(dbFactory, git, broadcaster, TaskStateServiceBuilder.Build(dbFactory).State, NullLogger<TaskMergeService>.Instance);
return new ExternalMcpService(
_tasks, _lists, queue, broadcaster,
state,
git, dbFactory, maintenance, merge);
}
[Fact]
public async Task AddSubtask_appends_row_with_next_order()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId, TaskStatus.Idle);
var sut = BuildSut();
await sut.AddSubtask(task.Id, "First step", null, CancellationToken.None);
await sut.AddSubtask(task.Id, "Second step", null, CancellationToken.None);
await using var verifyCtx = _db.CreateContext();
var subtasks = await new SubtaskRepository(verifyCtx).GetByTaskIdAsync(task.Id);
Assert.Equal(2, subtasks.Count);
Assert.Equal("First step", subtasks[0].Title);
Assert.Equal("Second step", subtasks[1].Title);
Assert.Equal(0, subtasks[0].OrderNum);
Assert.Equal(1, subtasks[1].OrderNum);
Assert.False(subtasks[0].Completed);
Assert.False(subtasks[1].Completed);
}
[Fact]
public async Task AddSubtask_refuses_running_task()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId, TaskStatus.Running);
var sut = BuildSut();
await Assert.ThrowsAsync<InvalidOperationException>(
() => sut.AddSubtask(task.Id, "Should fail", null, CancellationToken.None));
}
}

View File

@@ -125,7 +125,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
var git = new GitService();
var factory = _db.CreateFactory();
var maintenance = new WorktreeMaintenanceService(factory, git, NullLogger<WorktreeMaintenanceService>.Instance);
var merge = new TaskMergeService(factory, git, _broadcaster, NullLogger<TaskMergeService>.Instance);
var merge = new TaskMergeService(factory, git, _broadcaster, TaskStateServiceBuilder.Build(factory).State, NullLogger<TaskMergeService>.Instance);
return new ExternalMcpService(
_tasks, _lists, queue, _broadcaster,
TaskStateServiceBuilder.Build(factory).State,

View File

@@ -19,7 +19,7 @@ public sealed class ClearMyDayHubTests : IDisposable
var broadcaster = new HubBroadcaster(new CapturingHubContext());
var hub = new WorkerHub(
null!, null!, null!, null!, broadcaster, _db.CreateFactory(),
null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!);
null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!);
hub.Clients = new FakeHubCallerClients(new RecordingClientProxy());
hub.Context = new FakeHubCallerContext();
return hub;

View File

@@ -55,7 +55,7 @@ public sealed class PlanningHubTests : IDisposable
{
var hub = new WorkerHub(
null!, null!, null!, null!, null!, null!, null!, null!, null!,
_planning, _launcher, null!, null!, null!, null!, null!, null!, null!);
_planning, _launcher, null!, null!, null!, null!, null!, null!, null!, null!);
hub.Clients = new FakeHubCallerClients(_proxy);
hub.Context = new FakeHubCallerContext();
return hub;

View File

@@ -19,7 +19,7 @@ public sealed class WorktreeStateHubTests : IDisposable
var broadcaster = new HubBroadcaster(new CapturingHubContext());
var hub = new WorkerHub(
null!, null!, null!, null!, broadcaster, _db.CreateFactory(),
null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!);
null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!);
hub.Clients = new FakeHubCallerClients(new RecordingClientProxy());
hub.Context = new FakeHubCallerContext();
return hub;

View File

@@ -254,6 +254,7 @@ public sealed class PlanningMergeOrchestratorTests : IDisposable
var factory = db.CreateFactory();
var merge = new TaskMergeService(
factory, git, broadcaster,
TaskStateServiceBuilder.Build(factory).State,
NullLogger<TaskMergeService>.Instance);
var aggregator = new PlanningAggregator(
factory, git,

View File

@@ -128,6 +128,7 @@ public sealed class TreeMergeTests : IDisposable
var factory = db.CreateFactory();
var merge = new TaskMergeService(
factory, git, broadcaster,
TaskStateServiceBuilder.Build(factory).State,
NullLogger<TaskMergeService>.Instance);
var aggregator = new PlanningAggregator(
factory, git,

View File

@@ -0,0 +1,52 @@
using ClaudeDo.Data.Models;
using ClaudeDo.Worker.Refine;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.Refine;
public sealed class RefinePromptTests
{
[Fact]
public void BuildArgs_includes_read_tools_when_repo_available()
{
var args = RefinePrompt.BuildArgs(20, canReadRepo: true);
Assert.Contains("--permission-mode acceptEdits", args);
Assert.Contains("mcp__claudedo__add_subtask", args);
Assert.Contains(" Read Grep Glob", args);
}
[Fact]
public void BuildArgs_drops_read_tools_in_text_only_mode()
{
var args = RefinePrompt.BuildArgs(20, canReadRepo: false);
Assert.DoesNotContain("Glob", args);
Assert.Contains("mcp__claudedo__update_task", args);
}
[Fact]
public void BuildPrompt_seeds_task_fields_and_open_subtasks()
{
var task = new TaskEntity
{
Id = "abc12345",
ListId = "l",
Title = "T",
Description = "D",
Status = TaskStatus.Idle,
CreatedAt = DateTime.UtcNow,
};
var subtasks = new List<SubtaskEntity>
{
new() { Id = "s1", TaskId = "abc12345", Title = "open one", Completed = false, CreatedAt = DateTime.UtcNow },
new() { Id = "s2", TaskId = "abc12345", Title = "done one", Completed = true, CreatedAt = DateTime.UtcNow },
};
var prompt = RefinePrompt.BuildPrompt(task, subtasks);
Assert.Contains("abc12345", prompt);
Assert.Contains("open one", prompt);
Assert.DoesNotContain("done one", prompt);
}
}

View File

@@ -0,0 +1,138 @@
using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Refine;
using ClaudeDo.Worker.Runner;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.Refine;
public sealed class RefineRunnerTests : IDisposable
{
private readonly DbFixture _db = new();
private readonly ClaudeDoDbContext _ctx;
public RefineRunnerTests()
{
_ctx = _db.CreateContext();
}
public void Dispose()
{
_ctx.Dispose();
_db.Dispose();
}
private async Task<string> SeedListAsync()
{
var listId = Guid.NewGuid().ToString();
await new ListRepository(_ctx).AddAsync(new ListEntity
{
Id = listId,
Name = "Test",
CreatedAt = DateTime.UtcNow,
WorkingDir = null,
});
return listId;
}
private async Task<TaskEntity> SeedTaskAsync(string listId, TaskStatus status)
{
var task = new TaskEntity
{
Id = Guid.NewGuid().ToString(),
ListId = listId,
Title = "Test task",
Status = status,
CreatedAt = DateTime.UtcNow,
};
await new TaskRepository(_ctx).AddAsync(task);
return task;
}
private RefineRunner BuildRunner(RecordingClaudeProcess claude, RecordingRefineBroadcaster broadcaster)
{
return new RefineRunner(
claude,
_db.CreateFactory(),
NullLogger<RefineRunner>.Instance,
broadcaster);
}
[Fact]
public async Task Refuses_when_task_not_idle()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId, TaskStatus.Queued);
var claude = new RecordingClaudeProcess(success: true);
var broadcaster = new RecordingRefineBroadcaster();
var runner = BuildRunner(claude, broadcaster);
var outcome = await runner.RefineAsync(task.Id, CancellationToken.None);
Assert.False(outcome.Success);
Assert.Equal(0, claude.CallCount);
}
[Fact]
public async Task Idle_task_invokes_claude_once_and_brackets_with_events()
{
var listId = await SeedListAsync();
var task = await SeedTaskAsync(listId, TaskStatus.Idle);
var claude = new RecordingClaudeProcess(success: true);
var broadcaster = new RecordingRefineBroadcaster();
var runner = BuildRunner(claude, broadcaster);
var outcome = await runner.RefineAsync(task.Id, CancellationToken.None);
Assert.True(outcome.Success);
Assert.Equal(1, claude.CallCount);
Assert.Equal(1, broadcaster.StartedCount);
Assert.Equal(1, broadcaster.FinishedCount);
}
}
internal sealed class RecordingClaudeProcess : IClaudeProcess
{
private readonly bool _success;
private int _callCount;
public int CallCount => _callCount;
public RecordingClaudeProcess(bool success) => _success = success;
public Task<RunResult> RunAsync(string arguments, string prompt, string workingDirectory,
Func<string, Task> onStdoutLine, CancellationToken ct)
{
Interlocked.Increment(ref _callCount);
var result = _success
? new RunResult { ExitCode = 0, ResultMarkdown = "ok" }
: new RunResult { ExitCode = 1, ResultMarkdown = null };
return Task.FromResult(result);
}
}
internal sealed class RecordingRefineBroadcaster : IRefineBroadcaster
{
private int _startedCount;
private int _finishedCount;
public int StartedCount => _startedCount;
public int FinishedCount => _finishedCount;
public Task RefineStartedAsync(string taskId)
{
Interlocked.Increment(ref _startedCount);
return Task.CompletedTask;
}
public Task RefineFinishedAsync(string taskId, bool success, string? error)
{
Interlocked.Increment(ref _finishedCount);
return Task.CompletedTask;
}
}

View File

@@ -0,0 +1,64 @@
using ClaudeDo.Data.Git;
using ClaudeDo.Worker.Tests.Infrastructure;
namespace ClaudeDo.Worker.Tests.Runner;
public class GitServicePreviewMergeTests : IDisposable
{
private readonly List<GitRepoFixture> _repos = new();
private GitRepoFixture NewRepo() { var r = new GitRepoFixture(); _repos.Add(r); return r; }
public void Dispose() { foreach (var r in _repos) try { r.Dispose(); } catch { } }
[Fact]
public async Task PreviewMergeAsync_NonConflicting_ReportsCleanWithChangedCount()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var git = new GitService();
var baseBranch = await git.GetCurrentBranchAsync(repo.RepoDir);
GitRepoFixture.RunGit(repo.RepoDir, "checkout", "-b", "feature");
File.WriteAllText(Path.Combine(repo.RepoDir, "newfile.txt"), "x\n");
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "feat");
GitRepoFixture.RunGit(repo.RepoDir, "checkout", baseBranch);
var preview = await git.PreviewMergeAsync(repo.RepoDir, baseBranch, "feature", CancellationToken.None);
Assert.True(preview.Supported);
Assert.True(preview.Clean);
Assert.Empty(preview.ConflictFiles);
var count = await git.CountChangedFilesAsync(repo.RepoDir, baseBranch, "feature", CancellationToken.None);
Assert.Equal(1, count);
}
[Fact]
public async Task PreviewMergeAsync_Conflicting_ReportsFilesAndDoesNotMutateTree()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var git = new GitService();
var baseBranch = await git.GetCurrentBranchAsync(repo.RepoDir);
GitRepoFixture.RunGit(repo.RepoDir, "checkout", "-b", "feature");
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# from feature\n");
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "feat readme");
GitRepoFixture.RunGit(repo.RepoDir, "checkout", baseBranch);
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# from base\n");
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "base readme");
var headBefore = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
var preview = await git.PreviewMergeAsync(repo.RepoDir, baseBranch, "feature", CancellationToken.None);
Assert.True(preview.Supported);
Assert.False(preview.Clean);
Assert.Contains("README.md", preview.ConflictFiles);
Assert.Equal(headBefore, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim());
Assert.False(await git.IsMidMergeAsync(repo.RepoDir));
}
}

View File

@@ -34,10 +34,12 @@ public class TaskMergeServiceTests : IDisposable
{
var fakeHub = new MergeRecordingHubContext();
var broadcaster = new HubBroadcaster(fakeHub);
var state = TaskStateServiceBuilder.Build(db.CreateFactory()).State;
var svc = new TaskMergeService(
db.CreateFactory(),
new GitService(),
broadcaster,
state,
NullLogger<TaskMergeService>.Instance);
return (svc, fakeHub.Proxy);
}
@@ -442,6 +444,146 @@ public class TaskMergeServiceTests : IDisposable
Assert.Equal(WorktreeState.Active, wt.State);
}
[Fact]
public async Task PreviewAsync_CleanWorktree_ReturnsClean()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
var wtMgr = BuildWorktreeManager(db);
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "x\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
var (svc, _) = BuildService(db);
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var preview = await svc.PreviewAsync(task.Id, target, CancellationToken.None);
Assert.Equal(TaskMergeService.PreviewClean, preview.Status);
Assert.True(preview.ChangedFileCount >= 1);
}
[Fact]
public async Task PreviewAsync_Conflict_ReturnsConflictFiles()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
var wtMgr = BuildWorktreeManager(db);
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "README.md"), "# from worktree\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# from main\n");
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main edit");
var (svc, _) = BuildService(db);
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var preview = await svc.PreviewAsync(task.Id, target, CancellationToken.None);
Assert.Equal(TaskMergeService.PreviewConflict, preview.Status);
Assert.Contains("README.md", preview.ConflictFiles);
}
[Fact]
public async Task PreviewAsync_NoActiveWorktree_ReturnsUnavailable()
{
var db = NewDb();
var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.WaitingForReview);
var (svc, _) = BuildService(db);
var preview = await svc.PreviewAsync(task.Id, "main", CancellationToken.None);
Assert.Equal(TaskMergeService.PreviewUnavailable, preview.Status);
}
[Fact]
public async Task ApproveAndMergeAsync_CleanWorktree_MergesAndMarksDone()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
var wtMgr = BuildWorktreeManager(db);
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "new\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
var (svc, _) = BuildService(db);
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var result = await svc.ApproveAndMergeAsync(task.Id, target, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
using var ctx = db.CreateContext();
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.Done, updated!.Status);
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
Assert.Equal(WorktreeState.Merged, wt!.State);
}
[Fact]
public async Task ApproveAndMergeAsync_Conflict_LeavesTaskWaitingForReview()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var repo = NewRepo();
var db = NewDb();
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
var wtMgr = BuildWorktreeManager(db);
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "README.md"), "# from worktree\n");
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# from main\n");
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main edit");
var headBefore = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
var (svc, _) = BuildService(db);
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
var result = await svc.ApproveAndMergeAsync(task.Id, target, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusConflict, result.Status);
Assert.Contains("README.md", result.ConflictFiles);
using var ctx = db.CreateContext();
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.WaitingForReview, updated!.Status);
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
Assert.Equal(WorktreeState.Active, wt!.State);
Assert.Equal(headBefore, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim());
Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir));
}
[Fact]
public async Task ApproveAndMergeAsync_NoWorktree_MarksDone()
{
var db = NewDb();
var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.WaitingForReview);
var (svc, _) = BuildService(db);
var result = await svc.ApproveAndMergeAsync(task.Id, "main", CancellationToken.None);
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
using var ctx = db.CreateContext();
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
Assert.Equal(TaskStatus.Done, updated!.Status);
}
[Fact]
public async Task MergeAsync_LeaveConflicts_DoesNotAbortAndReturnsConflictFiles()
{

View File

@@ -42,7 +42,9 @@ sealed class FakeWorkerClient : IWorkerClient
public Task<ListConfigDto?> GetListConfigAsync(string listId) => Task.FromResult<ListConfigDto?>(null);
public Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) => Task.CompletedTask;
public Task SetTaskStatusAsync(string taskId, TaskStatus status) => Task.CompletedTask;
public Task ApproveReviewAsync(string taskId) => Task.CompletedTask;
public Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) => Task.FromResult<MergeResultDto?>(null);
public Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch) => Task.FromResult<MergePreviewDto?>(null);
public Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));
public Task RejectReviewToQueueAsync(string taskId, string feedback) => Task.CompletedTask;
public Task RejectReviewToIdleAsync(string taskId) => Task.CompletedTask;
public Task CancelReviewAsync(string taskId) => Task.CompletedTask;
@@ -62,6 +64,8 @@ sealed class FakeWorkerClient : IWorkerClient
public event Action? PrepStartedEvent;
public event Action<string>? PrepLineEvent;
public event Action<bool>? PrepFinishedEvent;
public event Action<string>? RefineStartedEvent;
public event Action<string, bool, string?>? RefineFinishedEvent;
public event Action<string, string>? PlanningMergeStartedEvent;
public event Action<string, string>? PlanningSubtaskMergedEvent;
public event Action<string, string, IReadOnlyList<string>>? PlanningMergeConflictEvent;
@@ -86,6 +90,7 @@ sealed class FakeWorkerClient : IWorkerClient
public Task UpdateDailyNoteAsync(string id, string text) => Task.CompletedTask;
public Task DeleteDailyNoteAsync(string id) => Task.CompletedTask;
public Task<string> GetLastPrepLogAsync() => Task.FromResult(string.Empty);
public Task RefineTaskAsync(string taskId) => Task.CompletedTask;
}
// ── Helper to build VM with pre-seeded Items ──────────────────────────────────