8 Commits

147 changed files with 1488 additions and 12134 deletions

View File

@@ -1,101 +0,0 @@
name: Dependency Audit
on:
schedule:
- cron: '0 6 * * 1' # Mondays 06:00 UTC
workflow_dispatch: {}
jobs:
audit:
runs-on: ubuntu-latest
env:
DOTNET_ROOT: /home/mika/.dotnet
GITEA_API: https://git.kuns.dev/api/v1
REPO: releases/ClaudeDo
ISSUE_TITLE: 'Dependency audit: vulnerable packages detected'
steps:
- name: Checkout main
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
git clone --depth 1 --branch main \
"https://oauth2:${TOKEN}@git.kuns.dev/${REPO}.git" src
- name: Scan for vulnerable / outdated packages
run: |
set -euo pipefail
export PATH="$DOTNET_ROOT:$PATH"
cd src
: > audit.log
: > vuln.md
found=0
# .slnx tooling needs .NET 9; iterate per-project to stay on .NET 8.
while IFS= read -r proj; do
echo "==== $proj ====" | tee -a audit.log
dotnet restore "$proj" >/dev/null
vuln="$(dotnet list "$proj" package --vulnerable --include-transitive 2>&1)"
echo "$vuln" | tee -a audit.log
if echo "$vuln" | grep -qi "has the following vulnerable"; then
found=1
{
printf '#### `%s`\n\n```\n' "$proj"
echo "$vuln"
printf '```\n\n'
} >> vuln.md
fi
# Outdated is informational only — never fails the run.
dotnet list "$proj" package --outdated 2>&1 | tee -a audit.log || true
echo "" | tee -a audit.log
done < <(find . -name '*.csproj' | sort)
if [ "$found" -ne 0 ]; then
echo "::error::Vulnerable packages detected — see log above." >&2
exit 1
fi
echo "No vulnerable packages found."
- name: Report vulnerabilities to a Gitea issue
if: failure()
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
cd src
if [ -s vuln.md ]; then
DETAILS="$(cat vuln.md)"
else
DETAILS="The audit job failed before producing findings — check the run log."
fi
BODY="$(printf 'Automated weekly dependency audit found vulnerable packages.\n\n%s\n\n[View workflow run](%s)' \
"$DETAILS" "$RUN_URL")"
# Reuse an existing open issue if one is already tracking this.
EXISTING="$(curl -sS \
-H "Authorization: token ${TOKEN}" \
"${GITEA_API}/repos/${REPO}/issues?state=open&type=issues&limit=50" \
| jq -r --arg t "$ISSUE_TITLE" '.[] | select(.title==$t) | .number' | head -n1)"
if [ -n "$EXISTING" ]; then
echo "Commenting on existing issue #$EXISTING"
jq -n --arg body "$BODY" '{body:$body}' \
| curl -sS --fail-with-body -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d @- \
"${GITEA_API}/repos/${REPO}/issues/${EXISTING}/comments" >/dev/null
else
echo "Creating new issue"
jq -n --arg title "$ISSUE_TITLE" --arg body "$BODY" '{title:$title, body:$body}' \
| curl -sS --fail-with-body -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d @- \
"${GITEA_API}/repos/${REPO}/issues" >/dev/null
fi

View File

@@ -1,85 +0,0 @@
name: Changelog
on:
push:
tags:
- 'v*'
jobs:
changelog:
runs-on: ubuntu-latest
env:
REPO: releases/ClaudeDo
steps:
- name: Checkout main (full history)
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
git clone "https://oauth2:${TOKEN}@git.kuns.dev/${REPO}.git" src
cd src
git fetch --tags --force
git checkout main
- name: Regenerate CHANGELOG.md
run: |
set -euo pipefail
cd src
emit_group() {
# $1 range, $2 conventional-type, $3 heading
local range="$1" type="$2" title="$3" lines
lines="$(git log "$range" --no-merges --pretty=format:'%s|%h' \
| grep -E "^${type}(\([^)]*\))?(!)?: " || true)"
[ -z "$lines" ] && return 0
printf '### %s\n\n' "$title"
while IFS='|' read -r subject hash; do
printf -- '- %s (%s)\n' "${subject#*: }" "$hash"
done <<< "$lines"
printf '\n'
}
emit_section() {
# $1 range, $2 tag, $3 date
printf '## %s — %s\n\n' "$2" "$3"
emit_group "$1" feat "Features"
emit_group "$1" fix "Fixes"
emit_group "$1" perf "Performance"
emit_group "$1" refactor "Refactoring"
emit_group "$1" docs "Documentation"
}
# Tags ascending by semver so we can pair each with its predecessor.
mapfile -t TAGS < <(git tag --sort=v:refname | grep -E '^v' || true)
{
printf '# Changelog\n\n'
for ((i=${#TAGS[@]}-1; i>=0; i--)); do
TAG="${TAGS[$i]}"
DATE="$(git log -1 --format=%ad --date=short "$TAG")"
if (( i > 0 )); then
RANGE="${TAGS[$((i-1))]}..${TAG}"
else
RANGE="$TAG"
fi
emit_section "$RANGE" "$TAG" "$DATE"
done
} > CHANGELOG.md
cat CHANGELOG.md
- name: Commit and push if changed
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
run: |
set -euo pipefail
cd src
if git diff --quiet -- CHANGELOG.md; then
echo "CHANGELOG.md unchanged; nothing to commit."
exit 0
fi
git config user.name "ClaudeDo CI"
git config user.email "ci@kuns.dev"
git add CHANGELOG.md
git commit -m "docs(changelog): update for ${GITHUB_REF_NAME}"
git push origin main

View File

@@ -5,10 +5,6 @@ on:
tags:
- 'v*'
concurrency:
group: release-${{ github.ref_name }}
cancel-in-progress: false
jobs:
release:
runs-on: ubuntu-latest
@@ -42,52 +38,11 @@ jobs:
TAG: ${{ steps.ver.outputs.tag }}
run: |
set -euo pipefail
# Full clone (with tags) so release notes can diff against the previous tag.
git clone --branch "$TAG" \
git clone --depth 1 --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 }}
@@ -173,8 +128,7 @@ jobs:
BODY=$(jq -n \
--arg tag "$TAG" \
--arg name "$TAG" \
--rawfile body "$WORK/src/RELEASE_NOTES.md" \
'{tag_name:$tag, name:$name, body:$body, draft:true, prerelease:false, target_commitish:"main"}')
'{tag_name:$tag, name:$name, body:"", draft:false, prerelease:false, target_commitish:"main"}')
RESP=$(curl -sS -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
@@ -212,32 +166,6 @@ 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,9 +1,6 @@
# Local dev worktrees (created by using-git-worktrees skill)
.worktrees/
# Brainstorming visual companion artifacts
.superpowers/
# .NET build output
bin/
obj/

View File

@@ -1,36 +0,0 @@
# Changelog
All notable changes to ClaudeDo are documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
Versions are derived from git tags (`vX.Y.Z`) via MinVer.
## [Unreleased]
### Fixed
- Review queue is no longer permanently empty: the review virtual list now
matches tasks in `WaitingForReview` (it previously matched `Done` + active
worktree, a state successful runs never land in).
- UI ViewModels (`Details`, `Tasks`, `Lists` islands and the shell) now dispose
their `Loc.LanguageChanged`, worker-event, and timer subscriptions, fixing
long-lived subscription leaks.
- Stopping a task while the worker is offline no longer throws: `StopAsync`
guards on task/running/connection state and handles hub errors.
### Hardening
- Release-readiness audit pass across Worker, Data, UI, MCP, App and Installer
(see `docs/open.md` backlog for tracked follow-ups).
## [1.7.0]
- Agent roadblock + run-outcome surfacing in the task detail pane.
- i18n: localized task-header, task-row and prime-schedule tooltips.
- CI: dependency-audit and changelog Gitea workflows.
- Layer A/B/C git merge & conflict-resolution cockpit (multi-worktree
batch-merge, inline conflict resolver).
[Unreleased]: https://git.kuns.dev/releases/ClaudeDo/compare/v1.7.0...HEAD
[1.7.0]: https://git.kuns.dev/releases/ClaudeDo/releases/tag/v1.7.0

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 task that spawns/has children passes through WaitingForChildren first, then surfaces for review once every child is terminal — this is the single parent model for both planning and improvement parents (planning/improvement *children* themselves go straight to Done, only the parent is reviewed). From review you can approve, reject-rerun (Queued, resumes the session with feedback), reject-park (Idle), or cancel. Approve is the single review+merge action: a childless task merges its own worktree then Done (conflicts keep it in WaitingForReview); a task with children drives the unit merge (parent worktree if any + each Done child in order, with conflict continue/abort). Tasks with no active worktree (sandbox run) approve straight to Done.
- 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).
- 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

@@ -7,6 +7,7 @@
<Project Path="src/ClaudeDo.Installer/ClaudeDo.Installer.csproj" />
<Project Path="src/ClaudeDo.Releases/ClaudeDo.Releases.csproj" />
<Project Path="src/ClaudeDo.Localization/ClaudeDo.Localization.csproj" />
<Project Path="src/ClaudeDo.Logging/ClaudeDo.Logging.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj" />

View File

@@ -1,994 +0,0 @@
# 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

@@ -1,801 +0,0 @@
# 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

@@ -1,33 +0,0 @@
# 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

@@ -1,432 +0,0 @@
# Git Merge/Review — Shared Foundation + Layer A 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:** Build the shared worker conflict contract (so parallel Layer B/C sessions branch from frozen interfaces) and rework the Git tab into a single Approve+merge cockpit.
**Architecture:** Phase 0 adds the conflict-resolution contract to `IWorkerClient`/`WorkerClient` (real `_hub.InvokeAsync` bodies — the worker hub methods are implemented later by Layer C; calls simply fail at runtime until then) plus client-side DTOs and test-fake updates, then commits + pushes so B and C branch from it. Phase A reworks `WorkConsole.axaml`'s Git tab and routes single-task merge/approve conflicts into a `RequestConflictResolution` seam (wired to Layer C's resolver by the integrator at merge time).
**Tech Stack:** .NET 8, Avalonia 12 (Fluent), CommunityToolkit.Mvvm, SignalR, xUnit. Build individual csproj with `-c Release` (`.slnx` needs .NET 9; a running Worker locks `Debug`).
**Reference spec:** `docs/superpowers/specs/2026-06-05-git-merge-review-rework-design.md`
**Note on the canonical diff renderer:** the unified diff model/control already exists — `DiffFileViewModel`/`DiffLineViewModel`/`UnifiedDiffParser` (in `src/ClaudeDo.Ui/ViewModels/Modals/`) rendered by `DiffLinesView` (`src/ClaudeDo.Ui/Views/Controls/DiffLinesView.axaml`). `DiffModalView` and `PlanningDiffView` already use it. So "consolidate diff renderers" for this scope is just verifying that (Task A.3); migrating `WorktreeModalView`'s bespoke diff onto `DiffLinesView` is Layer B's job.
---
## File Structure
**Phase 0 (foundation — pushed before B/C branch):**
- Modify `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs` — 5 new method signatures.
- Modify `src/ClaudeDo.Ui/Services/WorkerClient.cs` — 5 `InvokeAsync` bodies + 3 new DTO records.
- Modify `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs` — 5 new `virtual` no-op methods.
- Modify `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs` — 5 new methods on `FakeWorkerClient`.
**Phase A (Layer A — this session, after foundation commit):**
- Modify `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs``RequestConflictResolution` seam; route Approve/Merge conflicts into it.
- Modify `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml` — fuse REVIEW + MERGE sections into one cockpit block.
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs` (or a sibling test file in the same folder).
---
## Phase 0 — Shared Foundation
### Task 0.1: Add the conflict contract (interface + client + DTOs)
**Files:**
- Modify: `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs`
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
- [ ] **Step 1: Add the 5 method signatures to `IWorkerClient`**
In `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs`, after the existing
`Task CancelReviewAsync(string taskId);` line (line 45), add:
```csharp
// ── Conflict resolution (worker hub side implemented by Layer C) ──
Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch);
Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId);
Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent);
Task<MergeResultDto> ContinueMergeAsync(string taskId);
Task AbortMergeAsync(string taskId);
```
- [ ] **Step 2: Add the 3 DTO records to `WorkerClient.cs`**
In `src/ClaudeDo.Ui/Services/WorkerClient.cs`, immediately after line 534
(`public record MergeTargetsDto(...)`), add:
```csharp
public record MergeConflictsDto(string TaskId, IReadOnlyList<ConflictFileDto> Files);
public record ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks);
public record ConflictHunkDto(string Ours, string Theirs, string? Base);
```
- [ ] **Step 3: Add the 5 client method bodies to `WorkerClient.cs`**
In `src/ClaudeDo.Ui/Services/WorkerClient.cs`, right after the `MergeTaskAsync`
method (ends at line 270), add:
```csharp
public Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch)
=> _hub.InvokeAsync<MergeResultDto>("StartConflictMerge", taskId, targetBranch);
public Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId)
=> _hub.InvokeAsync<MergeConflictsDto>("GetMergeConflicts", taskId);
public Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent)
=> _hub.InvokeAsync("WriteConflictResolution", taskId, path, resolvedContent);
public Task<MergeResultDto> ContinueMergeAsync(string taskId)
=> _hub.InvokeAsync<MergeResultDto>("ContinueMerge", taskId);
public Task AbortMergeAsync(string taskId)
=> _hub.InvokeAsync("AbortMerge", taskId);
```
- [ ] **Step 4: Build the UI project**
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj -c Release`
Expected: build FAILS — the two test projects won't compile yet, but the UI project
itself should succeed. If the UI project reports "does not implement interface member"
it means a body is missing; fix before continuing. (Test projects are fixed in 0.2.)
### Task 0.2: Update the hand-rolled test fakes
**Files:**
- Modify: `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`
- Modify: `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs`
- [ ] **Step 1: Add 5 virtual no-ops to `StubWorkerClient`**
In `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`, after the `MergeTaskAsync` override
(line 57), add:
```csharp
public virtual Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch) => Task.FromResult(new MergeResultDto("conflict", System.Array.Empty<string>(), null));
public virtual Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId) => Task.FromResult(new MergeConflictsDto(taskId, System.Array.Empty<ConflictFileDto>()));
public virtual Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent) => Task.CompletedTask;
public virtual Task<MergeResultDto> ContinueMergeAsync(string taskId) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));
public virtual Task AbortMergeAsync(string taskId) => Task.CompletedTask;
```
- [ ] **Step 2: Add 5 methods to `FakeWorkerClient`**
In `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs`, after the
`MergeTaskAsync` method (line 47), add:
```csharp
public Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch) => Task.FromResult(new MergeResultDto("conflict", System.Array.Empty<string>(), null));
public Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId) => Task.FromResult(new MergeConflictsDto(taskId, System.Array.Empty<ConflictFileDto>()));
public Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent) => Task.CompletedTask;
public Task<MergeResultDto> ContinueMergeAsync(string taskId) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));
public Task AbortMergeAsync(string taskId) => Task.CompletedTask;
```
- [ ] **Step 3: Build both test projects**
Run: `dotnet build tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release && dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release`
Expected: both BUILD succeed.
- [ ] **Step 4: Run the UI test suite to confirm green baseline**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
Expected: PASS (no behavior changed yet).
### Task 0.3: Commit and push the foundation
- [ ] **Step 1: Commit**
```bash
git add src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs src/ClaudeDo.Ui/Services/WorkerClient.cs tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs
git commit -m "feat(ui): add conflict-resolution worker contract (foundation for merge rework)"
```
- [ ] **Step 2: Push so Layer B/C can branch from this commit**
Run: `git push`
Expected: pushed to `main`. (First push to git.kuns.dev may fail auth — retry once.)
**This commit is the branch point for the Layer B and Layer C kickoff prompts.**
---
## Phase A — Layer A Review/Merge Cockpit
### Task A.1: Conflict-resolution seam + route Approve/Merge conflicts into it (TDD)
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandConflictSeamTests.cs` (new)
- [ ] **Step 1: Write the failing test**
Create `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandConflictSeamTests.cs`. Mirror
the VM-construction harness used in
`tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs` (same folder) —
construct `DetailsIslandViewModel` exactly as that file does, including its
`StubWorkerClient` subclass pattern. The test:
```csharp
[Fact]
public async Task ApproveReview_OnConflict_InvokesConflictResolutionSeam()
{
string? resolvedTaskId = null;
string? resolvedTarget = null;
// Construct the VM as in DetailsIslandPlanningTests, with a worker stub whose
// ApproveReviewAsync returns a conflict result:
// public override Task<MergeResultDto?> ApproveReviewAsync(string id, string target)
// => Task.FromResult<MergeResultDto?>(new MergeResultDto("conflict", new[]{"a.cs"}, null));
var vm = CreateVm(/* worker stub above */);
vm.RequestConflictResolution = (taskId, target) =>
{
resolvedTaskId = taskId; resolvedTarget = target;
return System.Threading.Tasks.Task.CompletedTask;
};
// assign a task in WaitingForReview + a SelectedMergeTarget = "main" via the same
// helpers DetailsIslandPlanningTests uses.
await vm.ApproveReviewCommand.ExecuteAsync(null);
Assert.Equal(/* the seeded task id */, resolvedTaskId);
Assert.Equal("main", resolvedTarget);
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter ApproveReview_OnConflict_InvokesConflictResolutionSeam`
Expected: FAIL — `RequestConflictResolution` property does not exist (compile error).
- [ ] **Step 3: Add the seam property**
In `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`, beside the other
view-wired delegates (`ShowDiffModal`, `ShowMergeModal` around line 387390), add:
```csharp
// Invoked when a single-task merge/approve hits a conflict. Wired by the
// integrator to Layer C's conflict resolver. Args: (taskId, targetBranch).
public Func<string, string, System.Threading.Tasks.Task>? RequestConflictResolution { get; set; }
```
- [ ] **Step 4: Route the Approve conflict branch into the seam**
In `ApproveReviewAsync` (around line 1453), replace the conflict branch body so it
prefers the seam, falling back to the current preview-text behavior:
```csharp
var result = await _worker.ApproveReviewAsync(Task.Id, SelectedMergeTarget ?? "");
if (result?.Status == "conflict")
{
if (RequestConflictResolution is not null)
{
await RequestConflictResolution(Task.Id, SelectedMergeTarget ?? "");
}
else
{
var (text, _, _) = MergePreviewPresenter.Describe(
new MergePreviewDto("conflict", result.ConflictFiles, 0));
MergePreviewText = text; MergeIsClean = false; MergeIsConflict = true;
}
}
```
- [ ] **Step 5: Route the manual Merge conflict branch into the seam**
In `MergeAsync` (around line 1170), apply the same pattern to its conflict branch:
```csharp
var result = await _worker.MergeTaskAsync(Task.Id, SelectedMergeTarget ?? "", false, "Merge task");
if (result.Status == "conflict")
{
if (RequestConflictResolution is not null)
{
await RequestConflictResolution(Task.Id, SelectedMergeTarget ?? "");
}
else
{
var (text, _, _) = MergePreviewPresenter.Describe(
new MergePreviewDto("conflict", result.ConflictFiles, 0));
MergePreviewText = text; MergeIsClean = false; MergeIsConflict = true;
}
}
else
{
await RefreshMergePreviewAsync();
}
```
- [ ] **Step 6: Run the test to verify it passes**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter ApproveReview_OnConflict_InvokesConflictResolutionSeam`
Expected: PASS.
- [ ] **Step 7: Run the full UI suite (no regressions)**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
Expected: PASS.
- [ ] **Step 8: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandConflictSeamTests.cs
git commit -m "feat(ui): route single-task merge conflicts into a resolution seam"
```
### Task A.2: Fuse the Git tab into one Approve+merge cockpit
**Files:**
- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml`
- [ ] **Step 1: Replace the two Git-tab sections with one cockpit block**
In `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml`, replace the entire Git
`ScrollViewer` body (lines 255313 — the `<!-- Git: ... -->` block containing the
separate `REVIEW` `StackPanel` and the `MERGE & WORKTREE` `StackPanel`) with a single
cockpit where Approve sits with the merge target/preview/actions. Keep the existing
control class names (`section-label`, `field-label`, `btn`, `btn accent`, `meta`) and
the existing bindings (`SelectedMergeTarget`, `MergeTargetBranches`, `MergePreviewText`,
`MergeIsClean`, `MergeIsConflict`, `ShowMergePreviewMuted`, `OpenDiffCommand`,
`ApproveReviewCommand`, `MergeCommand`, `ShowSingleMerge`, `OpenWorktreeCommand`,
`ReviewCombinedDiffCommand`, `MergeAllCommand`, `CanMergeAll`, `MergeAllDisabledReason`,
`MergeAllError`):
```xml
<!-- Git: one Approve + merge cockpit -->
<ScrollViewer IsVisible="{Binding IsGitTab}" Padding="14,10">
<StackPanel Spacing="12" IsVisible="{Binding ShowMergeSection}">
<TextBlock Classes="section-label" Text="MERGE" />
<StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="Target branch" />
<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>
<!-- Primary action: Approve flows straight into the merge.
Approve is the review-gated path; the plain Merge button covers
already-reviewed / kept worktrees. -->
<WrapPanel Orientation="Horizontal">
<Button Classes="btn accent" Content="Approve &amp; Merge" Margin="0,0,8,8"
Command="{Binding ApproveReviewCommand}"
IsVisible="{Binding IsWaitingForReview}" />
<Button Classes="btn accent" Content="Merge" Margin="0,0,8,8"
Command="{Binding MergeCommand}"
IsVisible="{Binding ShowSingleMerge}" />
<Button Classes="btn" Content="Open Diff" Margin="0,0,8,8"
Command="{Binding OpenDiffCommand}" />
<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>
</ScrollViewer>
```
Note: the cockpit now shows whenever `ShowMergeSection` is true. `ShowMergeSection`
(DetailsIslandViewModel line 161) must be true while `IsWaitingForReview` so the
Approve button appears. Check its expression in Step 2.
- [ ] **Step 2: Verify `ShowMergeSection` covers the review state**
Read `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` line 161. If
`ShowMergeSection` is false while `IsWaitingForReview` (e.g. it requires a non-review
state), widen it to also be true when `IsWaitingForReview && WorktreePath != null`, and
ensure `OnPropertyChanged(nameof(ShowMergeSection))` already fires on the relevant state
transitions (it is notified via `NotifySessionSections`). Make the minimal change needed
so the Approve button is visible in review state. If it already covers review, change
nothing.
- [ ] **Step 3: Build the app project**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Expected: BUILD succeeds (pulls in Ui + Data).
- [ ] **Step 4: Visual verification (manual — flag for the user)**
This is an AXAML layout change with no automated coverage. Launch the app, open a task
in `WaitingForReview`, open the Git tab, and confirm: the single MERGE block shows the
target combo, the colored preview line, an "Approve & Merge" button (review state), and
the diff/worktree/combined/merge-all actions. **Explicitly tell the user this needs a
visual pass — do not claim it works without running it.**
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs
git commit -m "feat(ui): fuse git tab into one approve+merge cockpit"
```
### Task A.3: Verify diff-renderer consolidation
**Files:** none modified (verification only).
- [ ] **Step 1: Confirm DiffModal + Planning already use the canonical renderer**
Run: `rg -l "DiffLinesView" src/ClaudeDo.Ui/Views`
Expected: matches in `Modals/DiffModalView.axaml` and `Planning/PlanningDiffView.axaml`.
If `PlanningDiffView.axaml` does NOT use `DiffLinesView`, change its diff `ItemsControl`
to a `<controls:DiffLinesView Lines="{Binding SelectedFile.Lines}" />` (matching
`DiffModalView.axaml`'s usage) and rebuild the App project. If both already use it, this
task is a no-op — record that and move on. (`WorktreeModalView`'s bespoke diff is
intentionally left for Layer B.)
---
## Self-Review
- **Spec coverage:** Foundation contract (spec §"Frozen worker conflict contract") →
Task 0.1. Test fakes (spec parallel-boundaries row) → Task 0.2. Branch point (spec
§"built & pushed this session") → Task 0.3. Layer A cockpit + Approve/merge flow
together (spec §"Layer A") → Task A.2. Single-task approve-on-conflict opens resolver
via seam (spec §"Layer A" + §"integration seams") → Task A.1. Diff consolidation
(spec §"One diff model") → Task A.3. Output-footer feedback unchanged → not touched
(correct). No spec requirement left unmapped for this session's scope.
- **Placeholder scan:** none — every code step has concrete code; the only "mirror the
existing harness" reference (Task A.1 Step 1) points at a real file with a working
pattern, not a TODO.
- **Type consistency:** `MergeConflictsDto`/`ConflictFileDto`/`ConflictHunkDto` and the
5 method names match between `IWorkerClient` (0.1 Step 1), `WorkerClient` (0.1 Steps
23), and both fakes (0.2). The seam `RequestConflictResolution` is
`Func<string,string,Task>?` everywhere (A.1 Steps 1, 35). DTO field names match the
spec.
---
## Integration notes (for the integrator merging A + B + C)
- Wire `DetailsIslandViewModel.RequestConflictResolution` and Layer B's equivalent
callback to Layer C's `ConflictResolverViewModel` factory + `ShowConflictResolver`
dialog delegate.
- Layer C implements the worker hub methods `StartConflictMerge`, `GetMergeConflicts`,
`WriteConflictResolution`, `ContinueMerge`, `AbortMerge`; the client side from Task
0.1 already calls them by name.

View File

@@ -1,139 +0,0 @@
# Git Merge/Review Rework — Parallel Kickoff Prompts (Layer B & Layer C)
These are self-contained prompts to paste into two fresh ClaudeDo sessions, each in its
own git worktree, run **in parallel** with the main session's Layer A work.
**Prerequisite — branch point:** Both sessions must branch from `main` **at or after**
the foundation commit `feat(ui): add conflict-resolution worker contract (foundation for
merge rework)` (Phase 0, Task 0.3 of
`docs/superpowers/plans/2026-06-05-git-merge-review-foundation-layerA.md`). That commit
adds the frozen `IWorkerClient` conflict contract both layers rely on. Do not start B/C
until that commit is pushed.
**Integration:** Neither session pushes to `main` or merges. Each leaves its branch/
worktree for the orchestrator (the main session) to review and merge.
Design reference for both: `docs/superpowers/specs/2026-06-05-git-merge-review-rework-design.md`
---
## Layer B — Multi-worktree merge cockpit
```
We're reworking ClaudeDo's merge/review UX. Your job is Layer B: a multi-worktree merge
cockpit. The overall design is in docs/superpowers/specs/2026-06-05-git-merge-review-rework-design.md
(read the "Layer B" section and "Parallel boundaries" table first). A shared foundation
commit ("add conflict-resolution worker contract") is already on main — branch from it.
First, create an isolated worktree for this work (use the superpowers:using-git-worktrees
skill). Then write a plan (superpowers:writing-plans) for just Layer B and implement it
with superpowers:subagent-driven-development (sonnet subagents, TDD, commit per task).
Scope:
- Rework WorktreesOverviewModalView + WorktreesOverviewModalViewModel into a batch-merge
cockpit: list mergeable worktrees, multi-select N, pick ONE target branch, "Merge all".
- Skip-and-continue: loop the EXISTING IWorkerClient.MergeTaskAsync(taskId, target,
removeWorktree:false, msg) over the selected tasks. Clean ones merge; conflicting ones
(MergeTaskAsync returns Status=="conflict", auto-aborts leaving the tree clean) are
collected into a "needs resolution" list shown with live progress.
- Each conflict row gets a "Resolve" button that invokes a seam:
public Func<string, string, Task>? RequestConflictResolution { get; set; } // (taskId, targetBranch)
Define this callback property on the cockpit VM; leave it unwired (the orchestrator
wires it to Layer C's resolver at merge time). Do NOT reference any ConflictResolver
type.
- Migrate WorktreeModalView's bespoke inline diff onto the canonical DiffLinesView
control (src/ClaudeDo.Ui/Views/Controls/DiffLinesView.axaml) using DiffFileViewModel/
DiffLineViewModel/UnifiedDiffParser (src/ClaudeDo.Ui/ViewModels/Modals/). This removes
the last duplicate diff renderer.
Reuse these existing IWorkerClient methods (already implemented): MergeTaskAsync,
GetMergeTargetsAsync, GetWorktreesOverviewAsync, SetWorktreeStateAsync,
CleanupFinishedWorktreesAsync, ForceRemoveWorktreeAsync.
Do NOT touch (other layers own them): any worker-side files (WorkerHub, TaskMergeService,
GitService), IWorkerClient.cs / WorkerClient.cs, WorkConsole.axaml,
DetailsIslandViewModel.cs, or create the ConflictResolver UI.
Build with: dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release (a running
Worker locks Debug — use Release). Keep locales/en.json and de.json keys in parity if you
add any. If you change IWorkerClient (you shouldn't need to), update the hand-rolled fakes
in tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs and
tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs. No tests that spawn
the real claude CLI.
Commit per task with Conventional Commits. Do NOT push to main and do NOT merge — leave
your worktree/branch for the orchestrator. Flag any AXAML layout for visual verification
rather than claiming it works.
```
---
## Layer C — Inline conflict resolver
```
We're reworking ClaudeDo's merge/review UX. Your job is Layer C: an in-app, VSCode-style
inline conflict resolver, plus the worker plumbing it needs. The overall design is in
docs/superpowers/specs/2026-06-05-git-merge-review-rework-design.md (read the "Layer C",
"Frozen worker conflict contract", and "Parallel boundaries" sections first). A shared
foundation commit ("add conflict-resolution worker contract") is already on main — branch
from it. That commit already wired the CLIENT side (IWorkerClient + WorkerClient call
these hub methods by name); your job includes implementing the matching WORKER hub methods.
First, create an isolated worktree (superpowers:using-git-worktrees). Then write a plan
(superpowers:writing-plans) for Layer C and implement it with
superpowers:subagent-driven-development (sonnet subagents, TDD, commit per task).
Worker side — implement these 5 hub methods in WorkerHub (names/params/returns MUST match
the client calls already shipped in the foundation):
- StartConflictMerge(string taskId, string targetBranch) -> MergeResultDto
Calls TaskMergeService.MergeAsync with leaveConflictsInTree:true (the overload/flag
already exists — used today by PlanningMergeOrchestrator). Leaves .git/MERGE_HEAD in
the list's WorkingDir, returns Status="conflict" + conflict file list.
- GetMergeConflicts(string taskId) -> MergeConflictsDto
For each conflicted file (git diff --name-only --diff-filter=U), read ours/theirs/base
via `git show :2:<path>` / `:3:<path>` / `:1:<path>`. Add GitService helpers as needed.
- WriteConflictResolution(string taskId, string path, string resolvedContent) -> void
Write resolvedContent to the file in WorkingDir and `git add` it.
- ContinueMerge(string taskId) -> MergeResultDto
Wrap the EXISTING TaskMergeService.ContinueMergeAsync (git add -A → re-check
diff --diff-filter=U → git commit). Currently service-level only; expose it on the hub.
- AbortMerge(string taskId) -> void
Wrap the EXISTING TaskMergeService.AbortMergeAsync (git merge --abort).
Define worker-side DTO records that serialize identically to the client records already in
WorkerClient.cs:
MergeConflictsDto(string TaskId, IReadOnlyList<ConflictFileDto> Files)
ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks)
ConflictHunkDto(string Ours, string Theirs, string? Base)
(place beside the other hub DTOs in WorkerHub.cs). MergeResultDto already exists.
UI side — new files only:
- ConflictResolverViewModel + ConflictResolverView. On open: StartConflictMergeAsync then
GetMergeConflictsAsync(taskId). Per conflict hunk show ours vs theirs stacked with
buttons Accept Current / Accept Incoming / Accept Both / Edit manually, plus a free-text
box for the merged result of that hunk. Use the UI conflict model from the design
(ConflictFile { Path, Hunks[] }, ConflictHunk { Ours, Theirs, Base, Resolution }) —
shape it so a future 3-way pane needs no model change.
- When every file is resolved: WriteConflictResolutionAsync per file, then
ContinueMergeAsync(taskId) (Status "merged" closes; "conflict" means not fully resolved,
stay open). AbortMergeAsync(taskId) cancels.
- Expose a factory Func<string, ConflictResolverViewModel> and a
Func<ConflictResolverViewModel, Task> ShowConflictResolver dialog delegate for the
orchestrator to wire to Layer A/B's RequestConflictResolution(taskId, target) seams.
Do NOT touch (other layers own them): WorkerClient.cs, IWorkerClient.cs (already wired),
WorkConsole.axaml, DetailsIslandViewModel.cs, WorktreesOverviewModalView/VM. You WILL need
to add the 5 worker hub methods + GitService conflict reads.
Tests: add worker tests for the conflict reads / continue / abort using real SQLite + real
git (follow existing GitService/TaskMergeService test patterns). NEVER spawn the real
claude CLI. If you change IWorkerClient (you should NOT — client is frozen), update the
fakes in both test projects.
Build with: dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release and
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release (a running Worker locks
Debug). Keep locales/en.json and de.json in parity for any new UI strings.
Commit per task with Conventional Commits. Do NOT push to main and do NOT merge — leave
your worktree/branch for the orchestrator. Flag the resolver UI for visual verification.
```

View File

@@ -1,920 +0,0 @@
# Layer C — Inline Conflict Resolver 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.
**Goal:** Build the worker-side conflict plumbing (5 frozen hub methods + GitService reads) and a VSCode-style in-app inline conflict resolver UI for ClaudeDo's merge rework.
**Architecture:** The worker performs a real merge that leaves conflicts in the list's working tree (`leaveConflictsInTree:true`), exposes ours/theirs/base per conflicted file via `git show :2:/:3:/:1:`, accepts written resolutions, and finishes via the existing `ContinueMergeAsync`/`AbortMergeAsync`. The UI presents each conflicted file's hunk with Accept Current/Incoming/Both/Edit-manually controls plus a free-text merged box, then writes resolutions and continues.
**Tech Stack:** .NET 8, ASP.NET Core SignalR (WorkerHub), EF Core/SQLite, Avalonia MVVM (CommunityToolkit), xUnit + real git/SQLite fixtures.
**Frozen client contract (already shipped in foundation commit `2dfc455`, DO NOT edit):**
- `IWorkerClient` / `WorkerClient.cs` already call hub methods by name: `StartConflictMerge`, `GetMergeConflicts`, `WriteConflictResolution`, `ContinueMerge`, `AbortMerge`.
- Client DTOs already exist in `WorkerClient.cs`: `MergeConflictsDto(string TaskId, IReadOnlyList<ConflictFileDto> Files)`, `ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks)`, `ConflictHunkDto(string Ours, string Theirs, string? Base)`, plus existing `MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage)`.
- Worker-side DTOs must serialize identically (same record shape) and live in `WorkerHub.cs`.
**Do NOT touch:** `WorkerClient.cs`, `Interfaces/IWorkerClient.cs`, `WorkConsole.axaml`, `DetailsIslandViewModel.cs`, `WorktreesOverviewModalView/VM`, `WorktreeModalView`. Test fakes for `IWorkerClient` already implement the 5 methods as no-op stubs (`StubWorkerClient` is `virtual` in Ui.Tests) — subclass/override, never edit the interface.
**Build/test commands (.NET 8 — running Worker locks `Debug`, always `-c Release`):**
```bash
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
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
```
---
## File Structure
**Worker / Data (create + modify):**
- Modify `src/ClaudeDo.Data/Git/GitService.cs` — add `ShowStageAsync` (untrimmed blob read) + `AddPathAsync`; add `trimOutput` param to `RunGitAsync`.
- Modify `src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs` — add records `MergeConflicts`/`ConflictFileContent`; add `GetConflictsAsync` + `WriteResolutionAsync`.
- Modify `src/ClaudeDo.Worker/Hub/WorkerHub.cs` — add DTOs `MergeConflictsDto`/`ConflictFileDto`/`ConflictHunkDto` + 5 hub methods.
**UI (create new only):**
- Create `src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictModels.cs``ConflictFile`, `ConflictHunk`.
- Create `src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictResolverViewModel.cs`.
- Create `src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml` + `.axaml.cs`.
**Wiring (modify):**
- Modify `src/ClaudeDo.App/Program.cs` — register `ConflictResolverViewModel` + `Func<string, ConflictResolverViewModel>`.
- Modify `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs` — additive seam (`ConflictResolverFactory`, `ShowConflictResolver`, `RequestConflictResolutionAsync`).
- Modify `src/ClaudeDo.Ui/Views/MainWindow.axaml.cs` — wire `ShowConflictResolver` dialog delegate.
- Modify `src/ClaudeDo.Localization/locales/en.json` + `de.json``conflictResolver.*` keys (parity enforced by Localization.Tests).
**Tests (create + modify):**
- Modify `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs` — conflict-read / write-resolution / round-trip tests.
- Create `tests/ClaudeDo.Ui.Tests/ViewModels/ConflictResolverViewModelTests.cs`.
---
## Task 1: GitService conflict-blob reads
**Files:**
- Modify: `src/ClaudeDo.Data/Git/GitService.cs`
- Test: `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs` (GitService exercised here via real repo; add focused tests in Task 2 round-trip)
- [ ] **Step 1: Add `trimOutput` param to `RunGitAsync`** so blob reads keep exact bytes.
In `RunGitAsync` signature add `bool trimOutput = true`, and change the return to:
```csharp
return (proc.ExitCode, trimOutput ? stdout.TrimEnd() : stdout, stderr.TrimEnd());
```
(All existing callers keep the default `true`.)
- [ ] **Step 2: Add `ShowStageAsync` + `AddPathAsync`** (place after `ListConflictedFilesAsync`):
```csharp
/// <summary>
/// Reads a conflicted file's blob at a merge stage: 1=base, 2=ours, 3=theirs.
/// Returns null when the stage doesn't exist (e.g. add/add conflict has no base).
/// Output is NOT trimmed so file content round-trips exactly.
/// </summary>
public async Task<string?> ShowStageAsync(string repoDir, int stage, string path, CancellationToken ct = default)
{
var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["show", $":{stage}:{path}"], ct, trimOutput: false);
return exitCode == 0 ? stdout : null;
}
public async Task AddPathAsync(string repoDir, string path, CancellationToken ct = default)
{
var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["add", "--", path], ct);
if (exitCode != 0)
throw new InvalidOperationException($"git add '{path}' failed (exit {exitCode}): {stderr}");
}
```
- [ ] **Step 3: Build the Data + Worker projects to verify compilation.**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`
Expected: Build succeeded, 0 errors.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Data/Git/GitService.cs
git commit -m "feat(git): add conflict-stage blob reads and single-path staging"
```
---
## Task 2: TaskMergeService conflict reads + resolution writes
**Files:**
- Modify: `src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs`
- Test: `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs`
- [ ] **Step 1: Write failing tests** (append inside `TaskMergeServiceTests`, before `#region Test doubles`). Reuse the existing helpers `SeedListAndTask`, `SeedWorktree`, `BuildService`, and the `GitRepoFixture` conflict setup pattern from `ContinueMergeAsync_AfterUserResolves...`.
```csharp
[Fact]
public async Task GetConflictsAsync_AfterConflictMerge_ReturnsOursAndTheirs()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var db = NewDb();
var repo = NewRepo();
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change");
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
_wtCleanups.Add((repo.RepoDir, wtPath));
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/c1", wtPath, repo.BaseCommit);
File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n");
GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch change");
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.WaitingForReview);
await SeedWorktree(db, task.Id, wtPath, "claudedo/c1", repo.BaseCommit);
var (svc, _) = BuildService(db);
var start = await svc.MergeAsync(task.Id, "main", false, "msg", leaveConflictsInTree: true, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusConflict, start.Status);
var conflicts = await svc.GetConflictsAsync(task.Id, CancellationToken.None);
Assert.Equal(task.Id, conflicts.TaskId);
var file = Assert.Single(conflicts.Files);
Assert.Equal("README.md", file.Path);
Assert.Contains("main change", file.Ours); // ours = target (main) side after checkout
Assert.Contains("branch change", file.Theirs); // theirs = merged-in branch
Assert.NotNull(file.Base);
GitRepoFixture.RunGit(repo.RepoDir, "merge", "--abort");
}
[Fact]
public async Task WriteResolutionAsync_ThenContinue_CompletesMerge()
{
if (!GitRepoFixture.IsGitAvailable()) return;
var db = NewDb();
var repo = NewRepo();
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n");
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change");
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
_wtCleanups.Add((repo.RepoDir, wtPath));
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/c2", wtPath, repo.BaseCommit);
File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n");
GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch change");
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.WaitingForReview);
await SeedWorktree(db, task.Id, wtPath, "claudedo/c2", repo.BaseCommit);
var (svc, _) = BuildService(db);
await svc.MergeAsync(task.Id, "main", false, "msg", leaveConflictsInTree: true, CancellationToken.None);
await svc.WriteResolutionAsync(task.Id, "README.md", "# resolved by user\n", CancellationToken.None);
var result = await svc.ContinueMergeAsync(task.Id, CancellationToken.None);
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
Assert.Equal("# resolved by user\n", File.ReadAllText(Path.Combine(repo.RepoDir, "README.md")));
Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir));
}
```
- [ ] **Step 2: Run tests to verify they fail** (no such methods).
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "GetConflictsAsync_AfterConflictMerge_ReturnsOursAndTheirs|WriteResolutionAsync_ThenContinue_CompletesMerge"`
Expected: compile error / FAIL (methods don't exist).
- [ ] **Step 3: Add records + methods to `TaskMergeService.cs`.**
Add records beside `MergeResult` (top of file, after the existing record declarations):
```csharp
public sealed record MergeConflicts(
string TaskId,
IReadOnlyList<ConflictFileContent> Files);
public sealed record ConflictFileContent(
string Path,
string Ours,
string Theirs,
string? Base);
```
Add methods inside the class (after `AbortMergeAsync`):
```csharp
public async Task<MergeConflicts> GetConflictsAsync(string taskId, CancellationToken ct)
{
var (_, list, _) = await LoadMergeContextAsync(taskId, ct);
if (string.IsNullOrWhiteSpace(list.WorkingDir))
throw new InvalidOperationException("list has no working directory");
var files = await _git.ListConflictedFilesAsync(list.WorkingDir, ct);
var result = new List<ConflictFileContent>(files.Count);
foreach (var path in files)
{
var ours = await _git.ShowStageAsync(list.WorkingDir, 2, path, ct) ?? "";
var theirs = await _git.ShowStageAsync(list.WorkingDir, 3, path, ct) ?? "";
var @base = await _git.ShowStageAsync(list.WorkingDir, 1, path, ct);
result.Add(new ConflictFileContent(path, ours, theirs, @base));
}
return new MergeConflicts(taskId, result);
}
public async Task WriteResolutionAsync(string taskId, string path, string content, CancellationToken ct)
{
var (_, list, _) = await LoadMergeContextAsync(taskId, ct);
if (string.IsNullOrWhiteSpace(list.WorkingDir))
throw new InvalidOperationException("list has no working directory");
var full = Path.Combine(list.WorkingDir, path.Replace('/', Path.DirectorySeparatorChar));
await File.WriteAllTextAsync(full, content, ct);
await _git.AddPathAsync(list.WorkingDir, path, ct);
}
```
(Note: `Path` is `System.IO.Path` — the file already uses it via other helpers; the record property `Path` does not shadow it inside these methods because it's accessed as a static type, not an instance member.)
- [ ] **Step 4: Run the tests to verify they pass.**
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "GetConflictsAsync_AfterConflictMerge_ReturnsOursAndTheirs|WriteResolutionAsync_ThenContinue_CompletesMerge"`
Expected: PASS (2 tests). If git unavailable they no-op.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs
git commit -m "feat(merge): read conflict stages and write user resolutions"
```
---
## Task 3: WorkerHub conflict methods + DTOs
**Files:**
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
- [ ] **Step 1: Add DTOs** beside the existing merge DTOs (after `public record MergeTargetsDto(...)`):
```csharp
public record MergeConflictsDto(string TaskId, IReadOnlyList<ConflictFileDto> Files);
public record ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks);
public record ConflictHunkDto(string Ours, string Theirs, string? Base);
```
- [ ] **Step 2: Add the 5 hub methods** (after `PreviewMerge`). Names/params/returns MUST match the frozen client calls.
```csharp
public Task<MergeResultDto> StartConflictMerge(string taskId, string targetBranch)
=> HubGuard(async () =>
{
var r = await _mergeService.MergeAsync(
taskId, targetBranch ?? "", removeWorktree: false, "Merge task",
leaveConflictsInTree: true, CancellationToken.None);
if (r.Status == TaskMergeService.StatusBlocked)
throw new HubException(r.ErrorMessage ?? "merge blocked");
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
});
public Task<MergeConflictsDto> GetMergeConflicts(string taskId)
=> HubGuard(async () =>
{
var c = await _mergeService.GetConflictsAsync(taskId, CancellationToken.None);
return new MergeConflictsDto(
c.TaskId,
c.Files.Select(f => new ConflictFileDto(
f.Path,
new[] { new ConflictHunkDto(f.Ours, f.Theirs, f.Base) })).ToList());
});
public Task WriteConflictResolution(string taskId, string path, string resolvedContent)
=> HubGuard(() => _mergeService.WriteResolutionAsync(
taskId, path, resolvedContent ?? "", CancellationToken.None));
public Task<MergeResultDto> ContinueMerge(string taskId)
=> HubGuard(async () =>
{
var r = await _mergeService.ContinueMergeAsync(taskId, CancellationToken.None);
if (r.Status == TaskMergeService.StatusBlocked)
throw new HubException(r.ErrorMessage ?? "continue failed");
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
});
public Task AbortMerge(string taskId)
=> HubGuard(async () =>
{
var r = await _mergeService.AbortMergeAsync(taskId, CancellationToken.None);
if (r.Status == TaskMergeService.StatusBlocked)
throw new HubException(r.ErrorMessage ?? "abort failed");
});
```
- [ ] **Step 3: Build the Worker project to verify compilation.**
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`
Expected: Build succeeded, 0 errors.
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Worker/Hub/WorkerHub.cs
git commit -m "feat(hub): expose conflict-resolution merge methods"
```
---
## Task 4: Conflict UI model
**Files:**
- Create: `src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictModels.cs`
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/ConflictResolverViewModelTests.cs` (model tests added here in Task 5; this task is build-verified)
- [ ] **Step 1: Create the model file.** Shaped so a 3-way pane needs no model change (`Base` retained per hunk).
```csharp
using System.Collections.Generic;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClaudeDo.Ui.ViewModels.Conflicts;
public sealed partial class ConflictHunk : ObservableObject
{
public string Ours { get; }
public string Theirs { get; }
public string? Base { get; }
[ObservableProperty] private string? _resolution;
public bool IsResolved => Resolution is not null;
public ConflictHunk(string ours, string theirs, string? @base)
{
Ours = ours;
Theirs = theirs;
Base = @base;
}
partial void OnResolutionChanged(string? value) => OnPropertyChanged(nameof(IsResolved));
[RelayCommand] private void AcceptCurrent() => Resolution = Ours;
[RelayCommand] private void AcceptIncoming() => Resolution = Theirs;
[RelayCommand] private void AcceptBoth() => Resolution = Ours + Theirs;
[RelayCommand] private void EditManually() => Resolution ??= Ours;
}
public sealed class ConflictFile
{
public string Path { get; }
public IReadOnlyList<ConflictHunk> Hunks { get; }
public ConflictFile(string path, IReadOnlyList<ConflictHunk> hunks)
{
Path = path;
Hunks = hunks;
}
public bool AllHunksResolved => Hunks.Count > 0 && Hunks.All(h => h.IsResolved);
/// <summary>The merged file content: concatenation of each hunk's resolution
/// (single whole-file hunk today; concatenation keeps it correct for multi-hunk later).</summary>
public string ComposeResolvedContent() => string.Concat(Hunks.Select(h => h.Resolution));
}
```
- [ ] **Step 2: Build the Ui project to verify compilation.**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Expected: Build succeeded.
- [ ] **Step 3: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictModels.cs
git commit -m "feat(ui): add inline conflict model (file/hunk with resolution)"
```
---
## Task 5: ConflictResolverViewModel
**Files:**
- Create: `src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictResolverViewModel.cs`
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/ConflictResolverViewModelTests.cs`
- [ ] **Step 1: Write failing tests.** Subclass the existing `StubWorkerClient` (its conflict methods are `virtual`).
```csharp
using System.Collections.Generic;
using System.Threading.Tasks;
using ClaudeDo.Ui.Services;
using ClaudeDo.Ui.ViewModels.Conflicts;
using Xunit;
namespace ClaudeDo.Ui.Tests.ViewModels;
public class ConflictResolverViewModelTests
{
private sealed class FakeWorker : StubWorkerClient
{
public string? WrittenPath;
public string? WrittenContent;
public bool Continued;
public bool Aborted;
public string ContinueStatus = "merged";
public override Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch)
=> Task.FromResult(new MergeResultDto("conflict", new[] { "README.md" }, null));
public override Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId)
=> Task.FromResult(new MergeConflictsDto(taskId, new[]
{
new ConflictFileDto("README.md", new[] { new ConflictHunkDto("ours\n", "theirs\n", "base\n") })
}));
public override Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent)
{
WrittenPath = path; WrittenContent = resolvedContent; return Task.CompletedTask;
}
public override Task<MergeResultDto> ContinueMergeAsync(string taskId)
{
Continued = true;
return Task.FromResult(new MergeResultDto(ContinueStatus, System.Array.Empty<string>(), null));
}
public override Task AbortMergeAsync(string taskId) { Aborted = true; return Task.CompletedTask; }
}
[Fact]
public async Task OpenAsync_LoadsConflicts_AndBlocksContinueUntilResolved()
{
var vm = new ConflictResolverViewModel(new FakeWorker(), "task-1");
var hasConflicts = await vm.OpenAsync("main");
Assert.True(hasConflicts);
var file = Assert.Single(vm.Files);
Assert.Equal("README.md", file.Path);
Assert.False(vm.CanContinue); // nothing resolved yet
file.Hunks[0].AcceptIncomingCommand.Execute(null);
Assert.True(vm.CanContinue); // every hunk resolved
}
[Fact]
public async Task Continue_WritesComposedResolution_AndClosesOnMerged()
{
var worker = new FakeWorker();
var vm = new ConflictResolverViewModel(worker, "task-1");
var closed = false;
vm.CloseRequested = () => closed = true;
await vm.OpenAsync("main");
vm.Files[0].Hunks[0].AcceptCurrentCommand.Execute(null); // resolution = "ours\n"
await vm.ContinueCommand.ExecuteAsync(null);
Assert.Equal("README.md", worker.WrittenPath);
Assert.Equal("ours\n", worker.WrittenContent);
Assert.True(worker.Continued);
Assert.True(closed);
}
[Fact]
public async Task Continue_StaysOpenAndReportsError_WhenStillConflicted()
{
var worker = new FakeWorker { ContinueStatus = "conflict" };
var vm = new ConflictResolverViewModel(worker, "task-1");
var closed = false;
vm.CloseRequested = () => closed = true;
await vm.OpenAsync("main");
vm.Files[0].Hunks[0].AcceptBothCommand.Execute(null);
await vm.ContinueCommand.ExecuteAsync(null);
Assert.False(closed);
Assert.NotNull(vm.Error);
}
[Fact]
public async Task Abort_CallsWorkerAndCloses()
{
var worker = new FakeWorker();
var vm = new ConflictResolverViewModel(worker, "task-1");
var closed = false;
vm.CloseRequested = () => closed = true;
await vm.AbortCommand.ExecuteAsync(null);
Assert.True(worker.Aborted);
Assert.True(closed);
}
}
```
- [ ] **Step 2: Run tests to verify they fail** (VM not defined).
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "ConflictResolverViewModelTests"`
Expected: compile error / FAIL.
- [ ] **Step 3: Implement the ViewModel.**
```csharp
using System;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Ui.Services;
namespace ClaudeDo.Ui.ViewModels.Conflicts;
public sealed partial class ConflictResolverViewModel : ObservableObject
{
private readonly IWorkerClient _worker;
private readonly string _taskId;
public ObservableCollection<ConflictFile> Files { get; } = new();
[ObservableProperty] private bool _isBusy;
[ObservableProperty] private string? _error;
[ObservableProperty] private bool _canContinue;
public string TaskId => _taskId;
public Action? CloseRequested { get; set; }
public ConflictResolverViewModel(IWorkerClient worker, string taskId)
{
_worker = worker;
_taskId = taskId;
}
/// <summary>Starts the conflict merge and loads ours/theirs/base per file.
/// Returns true when there are conflicts to resolve (caller should show the dialog).</summary>
public async Task<bool> OpenAsync(string targetBranch)
{
IsBusy = true;
Error = null;
try
{
var start = await _worker.StartConflictMergeAsync(_taskId, targetBranch);
if (!string.Equals(start.Status, "conflict", StringComparison.Ordinal))
{
if (string.Equals(start.Status, "blocked", StringComparison.Ordinal))
Error = start.ErrorMessage;
return false;
}
var conflicts = await _worker.GetMergeConflictsAsync(_taskId);
Files.Clear();
foreach (var f in conflicts.Files)
{
var hunks = f.Hunks.Select(h =>
{
var hk = new ConflictHunk(h.Ours, h.Theirs, h.Base);
hk.PropertyChanged += OnHunkChanged;
return hk;
}).ToList();
Files.Add(new ConflictFile(f.Path, hunks));
}
RecomputeCanContinue();
return Files.Count > 0;
}
catch (Exception ex)
{
Error = ex.Message;
return false;
}
finally { IsBusy = false; }
}
private void OnHunkChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName is nameof(ConflictHunk.IsResolved) or nameof(ConflictHunk.Resolution))
RecomputeCanContinue();
}
private void RecomputeCanContinue()
=> CanContinue = Files.Count > 0 && Files.All(f => f.AllHunksResolved);
[RelayCommand]
private async Task ContinueAsync()
{
if (!CanContinue) return;
IsBusy = true;
Error = null;
try
{
foreach (var file in Files)
await _worker.WriteConflictResolutionAsync(_taskId, file.Path, file.ComposeResolvedContent());
var result = await _worker.ContinueMergeAsync(_taskId);
if (string.Equals(result.Status, "merged", StringComparison.Ordinal))
CloseRequested?.Invoke();
else
Error = result.ErrorMessage ?? "Conflicts not fully resolved — review and retry.";
}
catch (Exception ex)
{
Error = ex.Message;
}
finally { IsBusy = false; }
}
[RelayCommand]
private async Task AbortAsync()
{
IsBusy = true;
try { await _worker.AbortMergeAsync(_taskId); }
catch (Exception ex) { Error = ex.Message; }
finally
{
IsBusy = false;
CloseRequested?.Invoke();
}
}
}
```
- [ ] **Step 4: Run tests to verify they pass.**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "ConflictResolverViewModelTests"`
Expected: PASS (4 tests).
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictResolverViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/ConflictResolverViewModelTests.cs
git commit -m "feat(ui): add inline conflict resolver view-model"
```
---
## Task 6: ConflictResolverView + localization
**Files:**
- Create: `src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml`
- Create: `src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml.cs`
- Modify: `src/ClaudeDo.Localization/locales/en.json`
- Modify: `src/ClaudeDo.Localization/locales/de.json`
- [ ] **Step 1: Add localization keys** to `en.json` as a new top-level section (sibling of `"planning"`):
```json
"conflictResolver": {
"windowTitle": "Resolve merge conflicts",
"modalTitle": "RESOLVE CONFLICTS",
"loading": "Loading conflicts…",
"current": "Current (ours)",
"incoming": "Incoming (theirs)",
"mergedResult": "Merged result",
"acceptCurrent": "Accept Current",
"acceptIncoming": "Accept Incoming",
"acceptBoth": "Accept Both",
"editManually": "Edit manually",
"continue": "Resolve & continue",
"abort": "Abort merge"
},
```
- [ ] **Step 2: Add the SAME keys to `de.json`** (German values, identical key set — parity enforced by Localization.Tests):
```json
"conflictResolver": {
"windowTitle": "Merge-Konflikte lösen",
"modalTitle": "KONFLIKTE LÖSEN",
"loading": "Konflikte werden geladen…",
"current": "Aktuell (unsere)",
"incoming": "Eingehend (ihre)",
"mergedResult": "Zusammengeführtes Ergebnis",
"acceptCurrent": "Aktuelle übernehmen",
"acceptIncoming": "Eingehende übernehmen",
"acceptBoth": "Beide übernehmen",
"editManually": "Manuell bearbeiten",
"continue": "Lösen & fortfahren",
"abort": "Merge abbrechen"
},
```
- [ ] **Step 3: Create the View** (`ConflictResolverView.axaml`). A `Window` using `ModalShell`, mirroring `ConflictResolutionView.axaml`. Two stacked read-only boxes (ours/theirs), a button row, and a two-way merged-result box per hunk.
```xml
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Conflicts"
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
xmlns:loc="using:ClaudeDo.Ui.Localization"
x:DataType="vm:ConflictResolverViewModel"
x:Class="ClaudeDo.Ui.Views.Conflicts.ConflictResolverView"
Title="{loc:Tr conflictResolver.windowTitle}"
Width="760" Height="640" MinWidth="560" MinHeight="420"
CanResize="True"
WindowDecorations="BorderOnly"
ExtendClientAreaToDecorationsHint="True"
ExtendClientAreaTitleBarHeightHint="-1"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource SurfaceBrush}">
<Window.KeyBindings>
<KeyBinding Gesture="Escape" Command="{Binding AbortCommand}"/>
</Window.KeyBindings>
<ctl:ModalShell Title="{loc:Tr conflictResolver.modalTitle}" CloseCommand="{Binding AbortCommand}">
<ctl:ModalShell.Footer>
<StackPanel Orientation="Horizontal" Spacing="8"
HorizontalAlignment="Right" VerticalAlignment="Center">
<Button Classes="btn" Content="{loc:Tr conflictResolver.continue}"
Command="{Binding ContinueCommand}" IsEnabled="{Binding CanContinue}"/>
<Button Classes="btn" Content="{loc:Tr conflictResolver.abort}" Command="{Binding AbortCommand}"/>
</StackPanel>
</ctl:ModalShell.Footer>
<Grid RowDefinitions="Auto,*" Margin="16,12">
<TextBlock Grid.Row="0" Classes="meta" Margin="0,0,0,8"
Text="{loc:Tr conflictResolver.loading}"
IsVisible="{Binding IsBusy}"/>
<TextBlock Grid.Row="0" Classes="meta" Foreground="{DynamicResource BloodBrush}"
Text="{Binding Error}" TextWrapping="Wrap"
IsVisible="{Binding Error, Converter={x:Static ObjectConverters.IsNotNull}}"/>
<ScrollViewer Grid.Row="1">
<ItemsControl ItemsSource="{Binding Files}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ConflictFile">
<StackPanel Spacing="8" Margin="0,0,0,16">
<TextBlock Classes="path-mono heading" Text="{Binding Path}"/>
<ItemsControl ItemsSource="{Binding Hunks}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ConflictHunk">
<Border BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1"
CornerRadius="6" Padding="10" Margin="0,0,0,8">
<StackPanel Spacing="6">
<TextBlock Classes="meta" Text="{loc:Tr conflictResolver.current}"/>
<TextBox Text="{Binding Ours, Mode=OneWay}" IsReadOnly="True"
TextWrapping="NoWrap" AcceptsReturn="True" MaxHeight="120"
FontFamily="{DynamicResource MonoFont}"/>
<TextBlock Classes="meta" Text="{loc:Tr conflictResolver.incoming}"/>
<TextBox Text="{Binding Theirs, Mode=OneWay}" IsReadOnly="True"
TextWrapping="NoWrap" AcceptsReturn="True" MaxHeight="120"
FontFamily="{DynamicResource MonoFont}"/>
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="btn" Content="{loc:Tr conflictResolver.acceptCurrent}"
Command="{Binding AcceptCurrentCommand}"/>
<Button Classes="btn" Content="{loc:Tr conflictResolver.acceptIncoming}"
Command="{Binding AcceptIncomingCommand}"/>
<Button Classes="btn" Content="{loc:Tr conflictResolver.acceptBoth}"
Command="{Binding AcceptBothCommand}"/>
<Button Classes="btn" Content="{loc:Tr conflictResolver.editManually}"
Command="{Binding EditManuallyCommand}"/>
</StackPanel>
<TextBlock Classes="meta" Text="{loc:Tr conflictResolver.mergedResult}"/>
<TextBox Text="{Binding Resolution, Mode=TwoWay}"
TextWrapping="NoWrap" AcceptsReturn="True" MinHeight="80" MaxHeight="200"
FontFamily="{DynamicResource MonoFont}"/>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Grid>
</ctl:ModalShell>
</Window>
```
**Note for the implementer:** if `MonoFont` / `path-mono` / `heading` / `meta` / `btn` resource keys or style classes don't resolve at build, drop the `FontFamily` attribute and unknown `Classes` (keep `btn`) — match whatever the existing `ConflictResolutionView.axaml` and app styles actually expose. Verify against `src/ClaudeDo.Ui/Views/Planning/ConflictResolutionView.axaml` and the app's style resources before finalizing.
- [ ] **Step 4: Create the code-behind** (`ConflictResolverView.axaml.cs`):
```csharp
using Avalonia.Controls;
using ClaudeDo.Ui.ViewModels.Conflicts;
namespace ClaudeDo.Ui.Views.Conflicts;
public partial class ConflictResolverView : Window
{
public ConflictResolverView()
{
InitializeComponent();
}
protected override void OnDataContextChanged(System.EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is ConflictResolverViewModel vm)
vm.CloseRequested = Close;
}
}
```
- [ ] **Step 5: Build the App + run Localization tests.**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release && dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release`
Expected: Build succeeded; localization parity tests PASS.
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml.cs src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json
git commit -m "feat(ui): add inline conflict resolver view and localization"
```
---
## Task 7: Wire factory + dialog seam for the integrator
**Files:**
- Modify: `src/ClaudeDo.App/Program.cs`
- Modify: `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`
- Modify: `src/ClaudeDo.Ui/Views/MainWindow.axaml.cs`
These are additive seams only. The integrator connects Layer A/B's `RequestConflictResolution(taskId, target)` callback to `IslandsShellViewModel.RequestConflictResolutionAsync`.
- [ ] **Step 1: Register the factory in `Program.cs`** (in the ViewModels region, near the other `Func<>` factories). Only the `Func<>` factory is needed — the VM is never resolved directly:
```csharp
sc.AddSingleton<Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>>(sp =>
taskId => new ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel(
sp.GetRequiredService<WorkerClient>(), taskId));
```
Then, after `IslandsShellViewModel` is registered, set the factory on it once resolved. Replace the existing `sc.AddSingleton<IslandsShellViewModel>();` registration with a factory that injects the conflict-resolver factory:
```csharp
sc.AddSingleton<IslandsShellViewModel>(sp =>
{
var shell = ActivatorUtilities.CreateInstance<IslandsShellViewModel>(sp);
shell.ConflictResolverFactory =
sp.GetRequiredService<Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>>();
return shell;
});
```
(`ActivatorUtilities.CreateInstance` resolves the existing big constructor + its `Func<>` deps exactly as the default registration did.)
- [ ] **Step 2: Add the additive seam to `IslandsShellViewModel`** (near the other `Show*` delegate properties):
```csharp
// Layer C seam: composition root sets the factory; MainWindow sets the dialog opener.
// The integrator connects Layer A/B's RequestConflictResolution(taskId, target) to this method.
public Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>? ConflictResolverFactory { get; set; }
public Func<ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel, Task>? ShowConflictResolver { get; set; }
public async Task RequestConflictResolutionAsync(string taskId, string targetBranch)
{
if (ConflictResolverFactory is null || ShowConflictResolver is null) return;
var vm = ConflictResolverFactory(taskId);
var hasConflicts = await vm.OpenAsync(targetBranch);
if (hasConflicts)
await ShowConflictResolver(vm);
}
```
(Add `using ClaudeDo.Ui.ViewModels.Conflicts;` or use fully-qualified names as above.)
- [ ] **Step 3: Wire the dialog opener in `MainWindow.axaml.cs`** inside `OnDataContextChanged`, alongside the other `vm.Show*` assignments:
```csharp
vm.ShowConflictResolver = async (resolverVm) =>
{
var dlg = new ClaudeDo.Ui.Views.Conflicts.ConflictResolverView { DataContext = resolverVm };
await dlg.ShowDialog(this);
};
```
- [ ] **Step 4: Build the App to verify compilation.**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Expected: Build succeeded.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.App/Program.cs src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs src/ClaudeDo.Ui/Views/MainWindow.axaml.cs
git commit -m "feat(ui): expose conflict-resolver factory and dialog seam for integrator"
```
---
## Task 8: Full verification
- [ ] **Step 1: Build both head projects.**
Run:
```bash
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
```
Expected: both Build succeeded, 0 errors/warnings.
- [ ] **Step 2: Run the full relevant test suites.**
Run:
```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 PASS.
- [ ] **Step 3: Flag visual verification.** The resolver dialog cannot be opened end-to-end until the integrator wires Layer A/B's `RequestConflictResolution(taskId, target)``IslandsShellViewModel.RequestConflictResolutionAsync`. Report this as a visual-verification gap for the user/integrator: open a real conflicting merge, confirm hunks render, Accept buttons populate the merged box, Resolve & continue closes on success, Abort restores the tree.
- [ ] **Step 4: Leave the branch for the orchestrator.** Do NOT push, do NOT merge to main.

View File

@@ -1,837 +0,0 @@
# Layer B — Multi-Worktree Merge Cockpit 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:** Turn the worktrees-overview modal into a batch-merge cockpit (multi-select N worktrees → one target branch → "Merge all" with skip-and-continue conflict collection), and migrate `WorktreeModalView`'s bespoke inline diff onto the canonical `DiffLinesView`.
**Architecture:** The cockpit VM keeps depending on the concrete `WorkerClient` (the overview/cleanup/state methods live only on `WorkerClient`, not `IWorkerClient`). The batch loop is extracted into a delegate-driven method `MergeSelectedAsync(Func<...> mergeFn)` so it is unit-testable with a fake merge function and a never-connected `WorkerClient`. Clean merges (`Status=="merged"`) update the row; conflicts (`Status=="conflict"`, which `MergeTaskAsync` already auto-aborts) are collected into a `ConflictRows` list whose rows expose a `Resolve` button wired to an inert `RequestConflictResolution(taskId, targetBranch)` seam. The diff migration replaces the right-pane `ItemsControl` in `WorktreeModalView` with `DiffLinesView`, feeding it `DiffLineViewModel`s produced by `UnifiedDiffParser`, and deletes the now-dead `WorktreeDiffLineViewModel`/`WorktreeDiffLineKind`.
**Tech Stack:** .NET 8, Avalonia 12, CommunityToolkit.Mvvm source generators, xUnit. Build UI with `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`; run `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`.
**Frozen contracts reused (do NOT modify):**
- `WorkerClient.MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage) -> Task<MergeResultDto>`
- `WorkerClient.GetMergeTargetsAsync(string taskId) -> Task<MergeTargetsDto?>` (`MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalBranches)`)
- `MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage)``Status` is `"merged" | "conflict" | "blocked" | <other>`
- `WorkerClient.GetWorktreesOverviewAsync`, `CleanupFinishedWorktreesAsync`, `SetWorktreeStateAsync`, `ForceRemoveWorktreeAsync`
- `GitService.GetFileDiffAsync(worktreePath, baseCommit?, relativePath)` returns a `git diff` blob including the `diff --git` header (so `UnifiedDiffParser.Parse` handles it)
- `DiffLinesView` (`Lines` styled property, `IEnumerable?`), `DiffLineViewModel`, `DiffFileViewModel`, `UnifiedDiffParser.Parse` / `.Flatten`
**Do NOT touch:** any worker-side files (`WorkerHub`, `TaskMergeService`, `GitService`), `IWorkerClient.cs` / `WorkerClient.cs`, `WorkConsole.axaml`, `DetailsIslandViewModel.cs`, and do not create any `ConflictResolver` UI or reference any `ConflictResolver` type.
---
## File Structure
- `src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs`**modify.** Add `BatchMergeOutcome` enum; add `IsChecked`/`MergeOutcome` (+ derived) to the row VM; add `MergeTargets`, `SelectedTarget`, `SelectedCount`, `IsMerging`, `BatchProgress`, `ConflictRows`, the `RequestConflictResolution` seam, `MergeSelectedAsync`, `MergeAllCommand`, `ResolveConflictCommand`, `ToggleSelectAllCommand`, target loading, and per-row check subscription. Keep all existing context-menu commands/wiring intact.
- `src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml`**modify.** Add a per-row checkbox + outcome badge, a target `ComboBox` + "Merge all" button + progress text in the toolbar, and a "Needs resolution" panel listing `ConflictRows` with `Resolve` buttons.
- `src/ClaudeDo.Ui/ViewModels/Modals/WorktreeModalViewModel.cs`**modify.** Replace `SelectedFileDiffLines` element type with `DiffLineViewModel` produced via `UnifiedDiffParser`; delete `WorktreeDiffLineKind` and `WorktreeDiffLineViewModel`.
- `src/ClaudeDo.Ui/Views/Modals/WorktreeModalView.axaml`**modify.** Replace the right-pane `ItemsControl` with `ctl:DiffLinesView`; drop the `DiffLineKindToBrushConverter` resource.
- `src/ClaudeDo.Localization/locales/en.json` + `de.json`**modify.** Add new `modals.worktreesOverview.*` and `vm.worktreesOverview.*` keys (keep parity).
- `tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs`**create.** Unit tests for `MergeSelectedAsync` skip-and-continue, conflict collection, progress, selection gating, and the resolve seam.
No `IWorkerClient` change → no test-fake updates needed.
---
## Task 1: Row-level batch state (outcome enum + row VM fields)
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs`
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs`
- [ ] **Step 1: Write the failing test**
Create `tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs`:
```csharp
using ClaudeDo.Data.Models;
using ClaudeDo.Ui.ViewModels.Modals;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
using Xunit;
namespace ClaudeDo.Ui.Tests.ViewModels;
public class WorktreesOverviewBatchMergeTests
{
private static WorktreeOverviewRowViewModel ActiveRow(string id) => new()
{
TaskId = id,
TaskTitle = $"Task {id}",
TaskStatus = TaskStatus.WaitingForReview,
State = WorktreeState.Active,
};
[Fact]
public void Row_outcome_helpers_reflect_state()
{
var row = ActiveRow("a");
Assert.Equal(BatchMergeOutcome.None, row.MergeOutcome);
Assert.False(row.IsConflict);
row.MergeOutcome = BatchMergeOutcome.Conflict;
Assert.True(row.IsConflict);
row.MergeOutcome = BatchMergeOutcome.Merged;
Assert.False(row.IsConflict);
}
}
```
- [ ] **Step 2: Run test to verify it fails**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter WorktreesOverviewBatchMergeTests`
Expected: FAIL — `BatchMergeOutcome` and `MergeOutcome`/`IsConflict` do not exist (compile error).
- [ ] **Step 3: Add the enum and row fields**
In `WorktreesOverviewModalViewModel.cs`, add the enum just above `WorktreeOverviewRowViewModel`:
```csharp
public enum BatchMergeOutcome { None, Merging, Merged, Conflict, Blocked, Failed }
```
Inside `WorktreeOverviewRowViewModel`, add after the existing `_isSelected` field:
```csharp
[ObservableProperty] private bool _isChecked;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsConflict))]
[NotifyPropertyChangedFor(nameof(HasOutcome))]
private BatchMergeOutcome _mergeOutcome;
public bool IsConflict => MergeOutcome == BatchMergeOutcome.Conflict;
public bool HasOutcome => MergeOutcome != BatchMergeOutcome.None;
```
- [ ] **Step 4: Run test to verify it passes**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter WorktreesOverviewBatchMergeTests`
Expected: PASS (1 test).
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs
git commit -m "feat(ui): add batch-merge row state to worktrees cockpit VM"
```
---
## Task 2: Batch orchestration (`MergeSelectedAsync` skip-and-continue)
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs`
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs`
- [ ] **Step 1: Write the failing tests**
Append to `WorktreesOverviewBatchMergeTests.cs`. The helper builds a VM with a never-connected `WorkerClient` (the loop never touches it) and seeds `Rows` directly:
```csharp
private static WorktreesOverviewModalViewModel NewVm() =>
new(new ClaudeDo.Ui.Services.WorkerClient("http://127.0.0.1:1/hub"), () => null!);
private static MergeResultDto Merged() => new("merged", System.Array.Empty<string>(), null);
private static MergeResultDto Conflict() => new("conflict", new[] { "f.cs" }, null);
private static MergeResultDto Blocked() => new("blocked", System.Array.Empty<string>(), "blocked");
[Fact]
public async System.Threading.Tasks.Task MergeSelected_only_processes_checked_active_rows()
{
var vm = NewVm();
var a = ActiveRow("a"); a.IsChecked = true;
var b = ActiveRow("b"); b.IsChecked = false; // unchecked -> skipped
var c = ActiveRow("c"); c.IsChecked = true; c.State = WorktreeState.Merged; // not active -> skipped
vm.Rows.Add(a); vm.Rows.Add(b); vm.Rows.Add(c);
vm.SelectedTarget = "main";
var seen = new System.Collections.Generic.List<string>();
await vm.MergeSelectedAsync((id, target, remove, msg) =>
{
seen.Add(id);
Assert.Equal("main", target);
Assert.False(remove); // removeWorktree must be false
return System.Threading.Tasks.Task.FromResult(Merged());
});
Assert.Equal(new[] { "a" }, seen);
Assert.Equal(BatchMergeOutcome.Merged, a.MergeOutcome);
Assert.False(a.IsChecked); // cleared after merge
}
[Fact]
public async System.Threading.Tasks.Task MergeSelected_continues_past_conflict_and_collects_it()
{
var vm = NewVm();
var a = ActiveRow("a"); a.IsChecked = true;
var b = ActiveRow("b"); b.IsChecked = true;
var c = ActiveRow("c"); c.IsChecked = true;
vm.Rows.Add(a); vm.Rows.Add(b); vm.Rows.Add(c);
vm.SelectedTarget = "main";
await vm.MergeSelectedAsync((id, target, remove, msg) =>
System.Threading.Tasks.Task.FromResult(id == "b" ? Conflict() : Merged()));
Assert.Equal(BatchMergeOutcome.Merged, a.MergeOutcome);
Assert.Equal(BatchMergeOutcome.Conflict, b.MergeOutcome);
Assert.Equal(BatchMergeOutcome.Merged, c.MergeOutcome); // continued past the conflict
Assert.Contains(b, vm.ConflictRows);
Assert.Single(vm.ConflictRows);
}
[Fact]
public async System.Threading.Tasks.Task MergeSelected_maps_blocked_and_exception_to_failure_outcomes()
{
var vm = NewVm();
var a = ActiveRow("a"); a.IsChecked = true;
var b = ActiveRow("b"); b.IsChecked = true;
vm.Rows.Add(a); vm.Rows.Add(b);
vm.SelectedTarget = "main";
await vm.MergeSelectedAsync((id, target, remove, msg) => id == "a"
? System.Threading.Tasks.Task.FromResult(Blocked())
: throw new System.InvalidOperationException("boom"));
Assert.Equal(BatchMergeOutcome.Blocked, a.MergeOutcome);
Assert.Equal(BatchMergeOutcome.Failed, b.MergeOutcome);
Assert.Empty(vm.ConflictRows);
Assert.False(vm.IsMerging);
}
[Fact]
public async System.Threading.Tasks.Task MergeSelected_noop_when_no_target()
{
var vm = NewVm();
var a = ActiveRow("a"); a.IsChecked = true;
vm.Rows.Add(a);
vm.SelectedTarget = null;
var called = false;
await vm.MergeSelectedAsync((id, t, r, m) => { called = true; return System.Threading.Tasks.Task.FromResult(Merged()); });
Assert.False(called);
Assert.Equal(BatchMergeOutcome.None, a.MergeOutcome);
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter WorktreesOverviewBatchMergeTests`
Expected: FAIL — `MergeSelectedAsync`, `ConflictRows`, `IsMerging`, `SelectedTarget` do not exist (compile error).
- [ ] **Step 3: Implement the orchestration + cockpit fields**
In `WorktreesOverviewModalViewModel.cs`, add these `using`s if missing: `using ClaudeDo.Ui.Services;` (already present). Add fields/properties to `WorktreesOverviewModalViewModel` (after the existing `_selectedRow` field):
```csharp
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(MergeAllCommand))] private string? _selectedTarget;
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(MergeAllCommand))] private int _selectedCount;
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(MergeAllCommand))] private bool _isMerging;
[ObservableProperty] private string? _batchProgress;
public ObservableCollection<string> MergeTargets { get; } = new();
public ObservableCollection<WorktreeOverviewRowViewModel> ConflictRows { get; } = new();
/// Inert seam wired by the integrator to Layer C's resolver at merge time. (taskId, targetBranch)
public Func<string, string, Task>? RequestConflictResolution { get; set; }
```
Add a helper to enumerate rows regardless of grouped/flat mode, plus the orchestration method:
```csharp
public IEnumerable<WorktreeOverviewRowViewModel> AllRows =>
IsGlobal ? Groups.SelectMany(g => g.Rows) : Rows;
public async Task MergeSelectedAsync(
Func<string, string, bool, string, Task<MergeResultDto>> mergeFn,
CancellationToken ct = default)
{
var target = SelectedTarget;
if (string.IsNullOrWhiteSpace(target)) return;
var selected = AllRows.Where(r => r.IsChecked && r.IsActive).ToList();
if (selected.Count == 0) return;
IsMerging = true;
ConflictRows.Clear();
var done = 0;
try
{
foreach (var row in selected)
{
ct.ThrowIfCancellationRequested();
row.MergeOutcome = BatchMergeOutcome.Merging;
BatchProgress = Loc.T("vm.worktreesOverview.batchProgress", ++done, selected.Count);
MergeResultDto result;
try
{
result = await mergeFn(row.TaskId, target!, false,
Loc.T("vm.merge.commitMessage", row.TaskTitle));
}
catch
{
row.MergeOutcome = BatchMergeOutcome.Failed;
continue;
}
switch (result.Status)
{
case "merged":
row.MergeOutcome = BatchMergeOutcome.Merged;
row.State = WorktreeState.Merged;
row.IsChecked = false;
break;
case "conflict":
row.MergeOutcome = BatchMergeOutcome.Conflict;
ConflictRows.Add(row);
break;
case "blocked":
row.MergeOutcome = BatchMergeOutcome.Blocked;
break;
default:
row.MergeOutcome = BatchMergeOutcome.Failed;
break;
}
}
BatchProgress = Loc.T("vm.worktreesOverview.batchDone",
selected.Count(r => r.MergeOutcome == BatchMergeOutcome.Merged), ConflictRows.Count);
}
finally
{
IsMerging = false;
}
}
```
> Note: `Loc.T` keys are added in Task 5; they resolve to the key name (harmless) until then, so tests pass now.
- [ ] **Step 4: Run tests to verify they pass**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter WorktreesOverviewBatchMergeTests`
Expected: PASS (5 tests).
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs
git commit -m "feat(ui): add skip-and-continue batch merge orchestration"
```
---
## Task 3: Selection tracking, target loading, commands + resolve seam
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs`
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs`
- [ ] **Step 1: Write the failing tests**
Append to `WorktreesOverviewBatchMergeTests.cs`:
```csharp
[Fact]
public void SelectedCount_tracks_checked_active_rows()
{
var vm = NewVm();
var a = ActiveRow("a");
var b = ActiveRow("b");
var merged = ActiveRow("c"); merged.State = WorktreeState.Merged;
vm.AddRowForTest(a); vm.AddRowForTest(b); vm.AddRowForTest(merged);
Assert.Equal(0, vm.SelectedCount);
a.IsChecked = true;
Assert.Equal(1, vm.SelectedCount);
b.IsChecked = true;
merged.IsChecked = true; // not active -> not counted
Assert.Equal(2, vm.SelectedCount);
a.IsChecked = false;
Assert.Equal(1, vm.SelectedCount);
}
[Fact]
public void ResolveConflict_invokes_seam_with_task_and_target()
{
var vm = NewVm();
vm.SelectedTarget = "release";
var row = ActiveRow("x"); row.MergeOutcome = BatchMergeOutcome.Conflict;
(string Task, string Target)? captured = null;
vm.RequestConflictResolution = (taskId, target) => { captured = (taskId, target); return System.Threading.Tasks.Task.CompletedTask; };
vm.ResolveConflictCommand.Execute(row);
Assert.Equal(("x", "release"), captured);
}
[Fact]
public void MergeAll_canExecute_requires_target_selection_and_idle()
{
var vm = NewVm();
var a = ActiveRow("a");
vm.AddRowForTest(a);
Assert.False(vm.MergeAllCommand.CanExecute(null)); // no selection, no target
a.IsChecked = true;
Assert.False(vm.MergeAllCommand.CanExecute(null)); // still no target
vm.SelectedTarget = "main";
Assert.True(vm.MergeAllCommand.CanExecute(null));
vm.IsMerging = true;
Assert.False(vm.MergeAllCommand.CanExecute(null)); // busy
}
```
- [ ] **Step 2: Run tests to verify they fail**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter WorktreesOverviewBatchMergeTests`
Expected: FAIL — `AddRowForTest`, `ResolveConflictCommand`, `MergeAllCommand` do not exist (compile error).
- [ ] **Step 3: Implement subscription, commands, target loading**
In `WorktreesOverviewModalViewModel.cs`:
(a) Add a row-hook that recomputes `SelectedCount` when a row's `IsChecked` changes, and a test seam to add a hooked row. Add these methods to the class:
```csharp
private void HookRow(WorktreeOverviewRowViewModel row)
{
row.PropertyChanged += (_, e) =>
{
if (e.PropertyName is nameof(WorktreeOverviewRowViewModel.IsChecked)
or nameof(WorktreeOverviewRowViewModel.State))
RecomputeSelected();
};
}
private void RecomputeSelected() =>
SelectedCount = AllRows.Count(r => r.IsChecked && r.IsActive);
// Test seam: adds a row to the flat list with selection tracking wired up.
internal void AddRowForTest(WorktreeOverviewRowViewModel row)
{
HookRow(row);
Rows.Add(row);
}
```
(b) In `LoadAsync`, call `HookRow(row)` everywhere a row is added. Replace the two add sites:
In the grouped branch, change `foreach (var row in grp) group.Rows.Add(row);` to:
```csharp
foreach (var row in grp) { HookRow(row); group.Rows.Add(row); }
```
In the flat branch, change `foreach (var row in ordered) Rows.Add(row);` to:
```csharp
foreach (var row in ordered) { HookRow(row); Rows.Add(row); }
```
Also, at the start of `LoadAsync` after `IsBusy = true;`, reset batch UI state and (re)load merge targets at the end of the `try`:
After `Rows.Clear(); Groups.Clear();` add:
```csharp
ConflictRows.Clear();
SelectedCount = 0;
BatchProgress = null;
```
At the very end of the `try` block (after the if/else that fills rows/groups) add:
```csharp
await LoadMergeTargetsAsync();
```
(c) Add target loading. The branch list is repo-level, so query it from the first active row:
```csharp
private async Task LoadMergeTargetsAsync()
{
var anchor = AllRows.FirstOrDefault(r => r.IsActive);
if (anchor is null) { MergeTargets.Clear(); SelectedTarget = null; return; }
try
{
var targets = await _worker.GetMergeTargetsAsync(anchor.TaskId);
MergeTargets.Clear();
if (targets is null) { SelectedTarget = null; return; }
foreach (var b in targets.LocalBranches) MergeTargets.Add(b);
SelectedTarget = MergeTargets.Contains(targets.DefaultBranch)
? targets.DefaultBranch
: MergeTargets.FirstOrDefault();
}
catch { MergeTargets.Clear(); SelectedTarget = null; }
}
```
(d) Add the commands:
```csharp
private bool CanMergeAll() => !IsMerging && SelectedCount > 0 && !string.IsNullOrWhiteSpace(SelectedTarget);
[RelayCommand(CanExecute = nameof(CanMergeAll))]
private Task MergeAll() => MergeSelectedAsync(_worker.MergeTaskAsync);
[RelayCommand]
private void ResolveConflict(WorktreeOverviewRowViewModel? row)
{
if (row is null) return;
RequestConflictResolution?.Invoke(row.TaskId, SelectedTarget ?? "");
}
[RelayCommand]
private void ToggleSelectAll()
{
var actives = AllRows.Where(r => r.IsActive).ToList();
var allChecked = actives.Count > 0 && actives.All(r => r.IsChecked);
foreach (var r in actives) r.IsChecked = !allChecked;
}
```
- [ ] **Step 4: Run tests to verify they pass**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter WorktreesOverviewBatchMergeTests`
Expected: PASS (8 tests total in this file).
- [ ] **Step 5: Build the app project to confirm the VM compiles against generated commands**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Expected: Build succeeded.
- [ ] **Step 6: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs
git commit -m "feat(ui): wire batch selection, target loading and resolve seam"
```
---
## Task 4: Cockpit view — checkboxes, target picker, Merge all, conflicts panel
**Files:**
- Modify: `src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml`
This task is AXAML only (no logic) → no new unit test; flag for visual verification.
- [ ] **Step 1: Add the batch toolbar controls**
In `WorktreesOverviewModalView.axaml`, replace the toolbar `StackPanel` (currently containing Refresh, Cleanup finished, StatusMessage) with one that adds select-all, the target picker, the Merge-all button and progress text. Replace the inner `<StackPanel Orientation="Horizontal" Spacing="8">...</StackPanel>` of the toolbar `Border` with:
```xml
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="btn" Content="{loc:Tr modals.worktreesOverview.refresh}" Command="{Binding RefreshCommand}" IsEnabled="{Binding !IsBusy}"/>
<Button Classes="btn" Content="{loc:Tr modals.worktreesOverview.cleanupFinished}" Command="{Binding CleanupFinishedCommand}" IsEnabled="{Binding !IsBusy}"/>
<Button Classes="btn" Content="{loc:Tr modals.worktreesOverview.selectAll}" Command="{Binding ToggleSelectAllCommand}"/>
<Border Width="1" Background="{DynamicResource LineBrush}" Margin="4,2"/>
<TextBlock Text="{loc:Tr modals.worktreesOverview.targetLabel}" VerticalAlignment="Center" Foreground="{DynamicResource TextDimBrush}"/>
<ComboBox MinWidth="160"
ItemsSource="{Binding MergeTargets}"
SelectedItem="{Binding SelectedTarget, Mode=TwoWay}"/>
<Button Classes="btn accent"
Content="{loc:Tr modals.worktreesOverview.mergeAll}"
Command="{Binding MergeAllCommand}"/>
<TextBlock Text="{Binding SelectedCount, StringFormat='{}{0} selected'}"
VerticalAlignment="Center" Foreground="{DynamicResource TextDimBrush}"/>
<TextBlock Text="{Binding BatchProgress}" VerticalAlignment="Center" Margin="8,0,0,0"
Foreground="{DynamicResource TextDimBrush}"/>
<TextBlock Text="{Binding StatusMessage}" VerticalAlignment="Center" Margin="8,0,0,0"
Foreground="{DynamicResource TextDimBrush}"/>
</StackPanel>
```
- [ ] **Step 2: Add a checkbox + outcome badge to the row template**
In the `WorktreeRowTemplate` `DataTemplate`, change the row `Grid` to add a leading checkbox column and a trailing outcome column. Replace the `<Grid ColumnDefinitions="*,90,80,80">...</Grid>` (the whole grid, lines for Task/State/Diff/Age) with:
```xml
<Grid ColumnDefinitions="Auto,*,90,90,80,80">
<CheckBox Grid.Column="0" VerticalAlignment="Center" Margin="0,0,8,0"
IsChecked="{Binding IsChecked, Mode=TwoWay}"
IsEnabled="{Binding IsActive}"
IsVisible="{Binding IsActive}"/>
<StackPanel Grid.Column="1" Orientation="Vertical" Spacing="2">
<TextBlock Classes="title" Text="{Binding TaskTitle}"/>
<StackPanel Orientation="Horizontal" Spacing="4">
<TextBlock Classes="meta" Text="{Binding TaskStatus}"/>
<TextBlock Classes="meta" Text="•"
IsVisible="{Binding !PathExistsOnDisk}"/>
<TextBlock Classes="meta" Text="{loc:Tr modals.worktreesOverview.phantom}" Foreground="{DynamicResource StatusErrorBrush}"
IsVisible="{Binding !PathExistsOnDisk}"
ToolTip.Tip="{loc:Tr modals.worktreesOverview.phantomTooltip}"/>
</StackPanel>
</StackPanel>
<TextBlock Grid.Column="2" Classes="meta" VerticalAlignment="Center"
Text="{Binding MergeOutcome}"
IsVisible="{Binding HasOutcome}"/>
<Border Grid.Column="3" CornerRadius="3" Padding="6,2" VerticalAlignment="Center"
Background="{Binding State, Converter={StaticResource WorktreeStateColor}}">
<TextBlock Classes="meta" Text="{Binding State}" Foreground="{DynamicResource TextBrush}"
HorizontalAlignment="Center"/>
</Border>
<TextBlock Grid.Column="4" Classes="meta" Text="{Binding DiffStat}" VerticalAlignment="Center"/>
<TextBlock Grid.Column="5" Classes="meta" Text="{Binding AgeText}" VerticalAlignment="Center"/>
</Grid>
```
Then update the column-header `Grid` (the one with `ColumnDefinitions="*,90,80,80"` near the ScrollViewer top) to match the new column layout:
```xml
<Grid ColumnDefinitions="Auto,*,90,90,80,80" Margin="12,0,12,4">
<TextBlock Grid.Column="1" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnTask}"/>
<TextBlock Grid.Column="2" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnOutcome}"/>
<TextBlock Grid.Column="3" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnState}"/>
<TextBlock Grid.Column="4" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnDiff}"/>
<TextBlock Grid.Column="5" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnAge}"/>
</Grid>
```
- [ ] **Step 3: Add the "Needs resolution" panel**
Inside the content `ScrollViewer`'s root `StackPanel`, at the very top (before the column-header `Grid`), add a conflicts panel that only shows when there are conflicts:
```xml
<Border IsVisible="{Binding ConflictRows.Count}"
Background="{DynamicResource ErrorTintBrush}"
BorderBrush="{DynamicResource StatusErrorBrush}"
BorderThickness="1" CornerRadius="6" Padding="12,8" Margin="0,0,0,12">
<StackPanel Spacing="6">
<TextBlock Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.needsResolution}"/>
<ItemsControl ItemsSource="{Binding ConflictRows}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:WorktreeOverviewRowViewModel">
<Grid ColumnDefinitions="*,Auto" Margin="0,2">
<TextBlock Grid.Column="0" Classes="meta" VerticalAlignment="Center"
Text="{Binding TaskTitle}"/>
<Button Grid.Column="1" Classes="btn"
Content="{loc:Tr modals.worktreesOverview.resolve}"
Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).ResolveConflictCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
```
> `IsVisible="{Binding ConflictRows.Count}"` uses Avalonia's int→bool coercion (0 = false). If the build flags this, change to a value converter already present, but int→bool is supported.
- [ ] **Step 4: Build the app to verify the AXAML compiles**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Expected: Build succeeded (compiled bindings resolve against the new VM members).
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml
git commit -m "feat(ui): batch-merge cockpit view with checkboxes and conflicts panel"
```
---
## Task 5: Localization keys (en + de parity)
**Files:**
- Modify: `src/ClaudeDo.Localization/locales/en.json`
- Modify: `src/ClaudeDo.Localization/locales/de.json`
- [ ] **Step 1: Add the new keys to `en.json`**
Under `modals.worktreesOverview`, add:
```json
"columnOutcome": "RESULT",
"selectAll": "Select all",
"targetLabel": "Target",
"mergeAll": "Merge all",
"needsResolution": "NEEDS RESOLUTION",
"resolve": "Resolve"
```
Under `vm.worktreesOverview`, add:
```json
"batchProgress": "Merging {0}/{1}…",
"batchDone": "Merged {0}, {1} need resolution."
```
- [ ] **Step 2: Add the matching keys to `de.json`**
Under `modals.worktreesOverview`:
```json
"columnOutcome": "ERGEBNIS",
"selectAll": "Alle auswählen",
"targetLabel": "Ziel",
"mergeAll": "Alle mergen",
"needsResolution": "ZU LÖSEN",
"resolve": "Lösen"
```
Under `vm.worktreesOverview`:
```json
"batchProgress": "Merge {0}/{1}…",
"batchDone": "{0} gemergt, {1} zu lösen."
```
- [ ] **Step 3: Run the localization parity test**
Run: `dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release`
Expected: PASS (en/de key parity holds).
- [ ] **Step 4: Commit**
```bash
git add src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json
git commit -m "feat(i18n): add batch-merge cockpit strings (en/de)"
```
---
## Task 6: Migrate `WorktreeModalView` diff onto `DiffLinesView`
**Files:**
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/WorktreeModalViewModel.cs`
- Modify: `src/ClaudeDo.Ui/Views/Modals/WorktreeModalView.axaml`
- [ ] **Step 1: Switch the VM to the canonical diff model**
In `WorktreeModalViewModel.cs`:
(a) Delete the now-dead types at the top of the file:
```csharp
public enum WorktreeDiffLineKind { Header, Hunk, Added, Removed, Context }
public sealed partial class WorktreeDiffLineViewModel : ViewModelBase
{
public required string Text { get; init; }
public required WorktreeDiffLineKind Kind { get; init; }
}
```
(b) Change the collection declaration from:
```csharp
public ObservableCollection<WorktreeDiffLineViewModel> SelectedFileDiffLines { get; } = new();
```
to:
```csharp
public ObservableCollection<DiffLineViewModel> SelectedFileDiffLines { get; } = new();
```
(c) Replace the body of `LoadFileDiffAsync` (the `foreach (var line in diff.Split('\n'))` block) so it parses via `UnifiedDiffParser`. The method becomes:
```csharp
private async Task LoadFileDiffAsync(WorktreeNodeViewModel? node)
{
SelectedFileDiffLines.Clear();
if (node is null || node.IsDirectory || string.IsNullOrEmpty(node.RelativePath))
return;
string diff;
try
{
diff = await _git.GetFileDiffAsync(WorktreePath, BaseCommit, node.RelativePath);
}
catch
{
return;
}
foreach (var line in UnifiedDiffParser.Flatten(UnifiedDiffParser.Parse(diff)))
SelectedFileDiffLines.Add(line);
}
```
(`DiffLineViewModel`, `DiffFileViewModel`, and `UnifiedDiffParser` are all in the same `ClaudeDo.Ui.ViewModels.Modals` namespace, so no new `using` is required.)
- [ ] **Step 2: Build to confirm the VM compiles and nothing else referenced the deleted types**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Expected: Build succeeded. (If a compile error names `WorktreeDiffLineViewModel`/`WorktreeDiffLineKind` outside this file or the view, that reference must be migrated too — there should be none besides `WorktreeModalView.axaml`, handled next.)
- [ ] **Step 3: Swap the view's inline diff for `DiffLinesView`**
In `WorktreeModalView.axaml`:
(a) Remove the now-unused converter resource. Delete:
```xml
<Window.Resources>
<converters:DiffLineKindToBrushConverter x:Key="DiffLineKindToBrush"/>
</Window.Resources>
```
(b) Replace the right-pane `ScrollViewer`'s `ItemsControl` (the `SelectableTextBlock` template bound to `SelectedFileDiffLines`) with the canonical control. Replace:
```xml
<ItemsControl ItemsSource="{Binding SelectedFileDiffLines}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:WorktreeDiffLineViewModel">
<SelectableTextBlock Text="{Binding Text}"
FontFamily="{DynamicResource MonoFont}"
FontSize="{StaticResource FontSizeMono}"
Foreground="{Binding Kind, Converter={StaticResource DiffLineKindToBrush}}"
TextWrapping="NoWrap"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
```
with:
```xml
<ctl:DiffLinesView Lines="{Binding SelectedFileDiffLines}"/>
```
(The `xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"` namespace is already declared at the top of this file.)
- [ ] **Step 4: Build the app to verify the AXAML compiles**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Expected: Build succeeded.
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Modals/WorktreeModalViewModel.cs src/ClaudeDo.Ui/Views/Modals/WorktreeModalView.axaml
git commit -m "refactor(ui): render worktree modal diff via canonical DiffLinesView"
```
---
## Task 7: Full build + test sweep
**Files:** none (verification only).
- [ ] **Step 1: Build the whole app**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Expected: Build succeeded, 0 errors.
- [ ] **Step 2: Run the UI + localization test projects**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
Then: `dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release`
Expected: PASS (all green, including the 8 new batch-merge tests).
- [ ] **Step 3: Flag visual-verification gaps**
The cockpit toolbar/checkbox/conflicts-panel layout and the migrated `WorktreeModalView` diff rendering are AXAML changes that cannot be verified headlessly. Report to the user that these need a visual pass (run the app, open the worktrees overview, select several worktrees, pick a target, "Merge all", and open a worktree diff).
---
## Self-Review Notes
- **Spec coverage:** batch-merge cockpit (Tasks 14), skip-and-continue + conflict collection (Task 2), single target picker (Tasks 34), Resolve → `RequestConflictResolution(taskId, targetBranch)` seam left unwired (Tasks 34), `WorktreeModalView` diff migration to `DiffLinesView` (Task 6), no worker files touched, no `IWorkerClient` change, locales in parity (Task 5). ✔
- **No ConflictResolver reference:** the seam is a bare `Func<string,string,Task>?`; no Layer C type is named. ✔
- **Type consistency:** `BatchMergeOutcome`, `MergeOutcome`, `IsConflict`, `HasOutcome`, `MergeSelectedAsync`, `ConflictRows`, `SelectedTarget`, `SelectedCount`, `IsMerging`, `BatchProgress`, `RequestConflictResolution`, `MergeAllCommand`, `ResolveConflictCommand`, `ToggleSelectAllCommand`, `AddRowForTest`, `AllRows` are used consistently across tasks. ✔

View File

@@ -1,522 +0,0 @@
# 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

@@ -1,90 +0,0 @@
# Plan — Unify the parent-task model
Spec: `docs/superpowers/specs/2026-06-09-unify-parent-task-model-design.md`
Subagents: `sonnet`. Stage files explicitly by path (never `git add -A`). TDD.
Build with `-c Release` per project. Commit per task (Conventional Commits).
## Task 1 — Single parent-advance path
- Rename `TaskStateService.TryAdvanceImprovementParentAsync``TryAdvanceParentAsync`.
- Make it advance **any** `WaitingForChildren` parent → `WaitingForReview` when all
children are terminal, and advance a parent with **zero** children straight to
`WaitingForReview`.
- In `OnChildTerminalAsync`: drop the `TryCompleteParentAsync` call; keep
`_chain.OnChildFinishedAsync`; call the renamed advance method for all parents.
- Tests: extend `WaitingForChildrenLifecycleTests` — (a) improvement parent still
advances; (b) a `WaitingForChildren` parent whose children are a *sequential chain*
advances only after the last one is terminal; (c) zero-children parent advances.
## Task 2 — Delete `TryCompleteParentAsync`
- Remove `TaskRepository.TryCompleteParentAsync` (`TaskRepository.cs:477-502`) and
any remaining references.
- Update `src/ClaudeDo.Data/CLAUDE.md` (drop it from the TaskRepository helper list).
- Build Data + Worker; fix references.
## Task 3 — Planning finalize enters `WaitingForChildren`
- `TaskStateService.FinalizePlanningAsync`: in the same `ExecuteUpdateAsync`, set
`Status = WaitingForChildren` alongside `PlanningPhase = Finalized` /
`PlanningFinalizedAt`.
- Verify `PlanningSessionManager.FinalizeAsync` ordering: finalize (→ WaitingForChildren)
**before** `SetupChainAsync` enqueues child[0]. Adjust only if ordering is wrong.
- Tests: finalizing a planning parent with N children leaves it `WaitingForChildren`;
after the chain completes it is `WaitingForReview` (not `Done`); a planning parent
with zero finalized children lands in `WaitingForReview`.
## Task 4 — Approve merges the whole unit
**Decision: full UX consolidation.** Approve becomes the single entry for reviewing
*and* merging any task; the separate planning-merge views are folded into the review
panel. The `PlanningMergeOrchestrator` (which already merges the unit + sets the
parent `Done` for both planning and improvement, with conflict continue/abort) is
reused as the engine; only its *entry/UI* moves.
Backend:
- `WorkerHub.ApproveReview`: for a parent that **has children**, drive
`PlanningMergeOrchestrator.StartAsync` (event-based: `PlanningMergeStarted` /
`PlanningSubtaskMerged` / `PlanningMergeConflict` / `PlanningMergeAborted` /
`PlanningCompleted`) instead of the one-shot `ApproveAndMergeAsync`. Childless tasks
keep `ApproveAndMergeAsync`. Conflict resolution still goes through
`ContinuePlanningMerge` / `AbortPlanningMerge`.
- Keep the orchestrator, `ContinuePlanningMerge`, `AbortPlanningMerge`,
`GetPlanningAggregate`, `BuildPlanningIntegrationBranch`. Remove the now-redundant
standalone `MergeAllPlanning` hub method (approve is the entry).
- (Optional cleanup) route the orchestrator's `FinalizeParentDoneAsync` through
`TaskStateService` so `Status` writes stay centralized; low priority.
UI (Avalonia, MVVM — visual-verification gaps, flag for user):
- The review panel (`DetailsIslandViewModel` / its view) is the single approve+merge
surface. For a child-bearing parent in `WaitingForReview`, approve shows the
unit-merge progress + per-subtask state, the aggregate/integration diff preview, and
conflict continue/abort — all inline in the review panel.
- Remove the separate planning-merge view(s)/commands and the standalone "Merge all"
button; re-wire their `PlanningMerge*` event handlers into the review panel VM.
- Sync `IWorkerClient` + hand-rolled test fakes in both UI/Worker test projects.
Tests: approving a parent with two `Done` children merges both then sets `Done`; a
conflicting second child surfaces the conflict and pauses (continue/abort) without
losing the parent's `WaitingForReview`/merge state.
## Task 5 — Cancellable `WaitingForChildren` parent
- Add `TaskStatus.WaitingForChildren` to the `CancelAsync` guard.
- Test: a parent in `WaitingForChildren` can be cancelled.
## Task 6 — Docs
- `src/ClaudeDo.Worker/CLAUDE.md`: add `WaitingForChildren` to the Status table +
transition diagram; document the unified parent flow and approve-merges-unit;
remove `MergeAllPlanning` from the Hub method list.
- `src/ClaudeDo.Data/CLAUDE.md`: add `WaitingForChildren` to the TaskEntity status list.
- Root `CLAUDE.md`: update the "Task status flow" convention line.
## Verify
- `dotnet test` for Worker.Tests + Data.Tests (`-c Release`).
- UI flows (planning finalize → review → approve-merge; improvement parent;
retired MergeAllPlanning button) are **visual-verification gaps** — flag for the
user to run the app; do not claim they work from tests alone.

View File

@@ -1,173 +0,0 @@
# 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

@@ -1,200 +0,0 @@
# 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

@@ -1,127 +0,0 @@
# 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

@@ -1,200 +0,0 @@
# 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

@@ -1,197 +0,0 @@
# Git Tab / Merge & Review Rework — Design
Date: 2026-06-05
Status: Approved
## Goal
Make handling merges and reviews as simple as possible in the Terminal component's
Git tab, and rework the diff viewers and worktree modals along the way. The work is
split into three layers built across separate sessions, with a shared foundation that
is built and pushed first so the parallel sessions branch from frozen contracts.
The user mostly trusts task output but wants the diff one click away for important
work, and wants to land several independently-queued worktrees without per-task
hopping or hand-resolving conflicts in an external editor.
## Layers
- **Layer A — Review/merge cockpit** (this session). Single-task review + merge UX in
the Git tab; consolidate the four diff renderers into one `DiffView`.
- **Layer B — Multi-worktree merge cockpit** (parallel session). Batch-merge N
worktrees into one target, skip-and-continue, conflicts collected for resolution.
- **Layer C — Inline conflict resolver** (parallel session). VSCode-style inline hunk
resolver plus the worker-side conflict plumbing it needs.
They stack: A defines the single-task flow, B reuses it for many tasks, both funnel
conflicts into C.
## Shared foundation (built & pushed this session, before B/C branch)
Everything B and C depend on lands first on `main`. B and C branch from that commit.
### 1. One diff model + one `DiffView` control
Today there are four diff renderers and two parallel diff models:
- `DiffLinesView.axaml` (used by `DiffModalView`)
- the inline diff `ItemsControl` in `WorktreeModalView.axaml`
- `PlanningDiffView.axaml`
- their backing models: `DiffFileViewModel`/`DiffLineViewModel` (+ `UnifiedDiffParser`)
vs `WorktreeNodeViewModel`/`WorktreeDiffLineViewModel`
Collapse into a single canonical diff model + parser + a `DiffView` UserControl. All
diff rendering across the app goes through `DiffView`.
- Model: `DiffFileViewModel { Path, AddCount, DelCount, Lines }`,
`DiffLineViewModel { OldNo, NewNo, Kind (Add|Del|Ctx|File|Hunk), Text }`.
- Parser: one static `UnifiedDiffParser.Parse(rawUnifiedDiff)` returning the model.
- `DiffView` exposes a `Files` styled property (file list + selected-file lines), or a
simpler `Lines` property for single-file use — Layer A decides the exact surface
while building it, but the type names above are frozen so B and C can bind to them.
### 2. Frozen worker conflict contract
Added to `IWorkerClient` (and `WorkerClient` with stub bodies that throw
`NotSupportedException`) plus new DTOs, so A and B compile against the interface while
C provides the real worker-side implementation.
```csharp
// IWorkerClient additions (signatures frozen this session)
Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch);
Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId);
Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent);
Task<MergeResultDto> ContinueMergeAsync(string taskId);
Task AbortMergeAsync(string taskId);
```
- `StartConflictMergeAsync` performs the merge with `leaveConflictsInTree: true` (the
worker already supports this flag — used today by the planning orchestrator) and
returns `MergeResultDto` with `Status="conflict"` and the conflict file list, leaving
`.git/MERGE_HEAD` in place in the list's `WorkingDir`.
- `GetMergeConflictsAsync` returns each conflicted file with ours/theirs/base content,
read via `git show :2:<path>` (ours), `:3:<path>` (theirs), `:1:<path>` (base).
- `WriteConflictResolutionAsync` writes resolved content to the file in `WorkingDir`
and `git add`s it.
- `ContinueMergeAsync` wraps the existing `TaskMergeService.ContinueMergeAsync`
(`git add -A` → re-check `git diff --name-only --diff-filter=U``git commit`).
- `AbortMergeAsync` wraps the existing `TaskMergeService.AbortMergeAsync`
(`git merge --abort`).
New DTOs (defined in the worker hub DTO file, mirrored client-side):
```csharp
public record MergeConflictsDto(string TaskId, IReadOnlyList<ConflictFileDto> Files);
public record ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks);
public record ConflictHunkDto(string Ours, string Theirs, string? Base);
```
Existing DTOs reused unchanged: `MergeResultDto(Status, ConflictFiles, ErrorMessage)`,
`MergePreviewDto`, `MergeTargetsDto`.
### 3. Conflict data model (UI)
`ConflictFile { Path, Hunks[] }`, `ConflictHunk { Ours, Theirs, Base, Resolution }`.
Shaped so a future 3-way merge pane needs no model change (Layer C is the inline
resolver now; the model leaves room for 3-way later).
### 4. Integration seams (delegates, wired by the integrator at merge)
A's and B's cockpits hold a `RequestConflictResolution(string taskId)` callback (an
`Action<string>` or `Func<string, Task>`). They never reference Layer C's resolver
types. The integrator connects these callbacks to C's `ConflictResolverViewModel`
factory when merging the three branches together.
## Parallel boundaries (verified disjoint)
| Area | A (this session) | B (parallel) | C (parallel) |
|---|---|---|---|
| `DiffView` + diff model/parser | builds | reuses | reuses |
| `WorkConsole.axaml` / `DetailsIslandViewModel` | owns | — | — |
| `DiffModalView` + `PlanningDiffView` | migrates to `DiffView` | — | — |
| `WorktreesOverviewModalView/VM` + `WorktreeModalView` | — | owns | — |
| `WorkerHub` / `TaskMergeService` / `GitService` | — | — | owns |
| New `ConflictResolverView/VM` + conflict UI model | — | — | owns |
| `IWorkerClient` / `WorkerClient` | adds frozen stubs + DTOs | reuses `MergeTaskAsync` | fills stub bodies |
| Test fakes (`IWorkerClient`) in both test projects | adds new no-op methods | — | makes them functional if needed |
The only file C and A both touch is `WorkerClient.cs` (C replaces the stub bodies A
wrote). Contained; reconciled at integration. Everything else is disjoint.
## Layer A — review/merge cockpit (this session)
- The Git tab becomes the single Approve + merge surface. `Approve` and the merge
target / preview / diff flow together as one block (no separate REVIEW vs
MERGE & WORKTREE sections).
- `Continue` (reject → requeue with feedback) and `Reset` (reject → idle) **stay** in
the Output tab footer — unchanged.
- The diff is shown via the unified `DiffView` opened as a modal from the cockpit. No
inline diff recap in the tab (the island is too small).
- On a single-task **Approve that conflicts**: instead of today's auto-abort, call
`StartConflictMergeAsync` and fire `RequestConflictResolution(taskId)`. This leaves
the main checkout mid-merge until the user resolves or aborts (behavior change,
intended). The callback is inert until Layer C is merged; the integrator wires it.
- Migrate `DiffModalView` and `PlanningDiffView` onto the new `DiffView`.
### Behavior change accepted
Today `MergeTask`/`ApproveReview` use `leaveConflictsInTree: false` (auto-abort on
conflict). Under this design, an Approve that conflicts leaves the merge in progress
and opens the resolver. The mid-merge guard (`IsMidMergeAsync`) still prevents a second
concurrent merge.
## Layer B — multi-worktree merge cockpit (parallel)
- Rework `WorktreesOverviewModalView`/`WorktreesOverviewModalViewModel` into a
batch-merge cockpit: list mergeable worktrees, select N, choose one target branch
(single target — 99% of the time everything goes to the same branch), "Merge all".
- **Skip-and-continue**: client-side loop calling the existing
`MergeTaskAsync(taskId, target, removeWorktree, msg)` per selected task. Clean merges
apply; conflicting ones are collected (existing `MergeTaskAsync` auto-aborts on
conflict, leaving the tree clean) into a "needs resolution" list with live progress.
- Each conflict row exposes a **Resolve** action → `RequestConflictResolution(taskId)`
(wired to Layer C at integration).
- Per-task diff via the shared `DiffView`; migrate `WorktreeModalView`'s inline diff
onto it.
- B touches no worker files — keeps it parallel-safe.
## Layer C — inline conflict resolver (parallel)
### Worker side
Implement the five frozen contract methods:
- Add hub methods `StartConflictMerge`, `GetMergeConflicts`, `WriteConflictResolution`,
`ContinueMerge`, `AbortMerge` in `WorkerHub`.
- `StartConflictMerge` calls the existing `TaskMergeService.MergeAsync` overload with
`leaveConflictsInTree: true`.
- `ContinueMerge` / `AbortMerge` wrap the existing `TaskMergeService.ContinueMergeAsync`
/ `AbortMergeAsync` (currently service-level only, not hub-exposed).
- `GetMergeConflicts` reads ours/theirs/base per conflicted file via
`git show :2:/:3:/:1:`; add the `GitService` helpers needed.
- `WriteConflictResolution` writes the resolved content to `WorkingDir` and stages it.
- Fill the `WorkerClient` stub bodies (real SignalR `InvokeAsync` calls).
- Update the hand-rolled `IWorkerClient` fakes in both test projects.
### UI
- New `ConflictResolverView` + `ConflictResolverViewModel`. Per conflict hunk, show
ours vs theirs stacked, with buttons **Accept Current / Accept Incoming / Accept Both
/ Edit manually** plus a free-text box for the merged result of that hunk.
- When every file's hunks are resolved → `ContinueMergeAsync(taskId)``MergeResultDto`
(`merged` closes the resolver; `conflict` means not fully resolved, stay open).
- `AbortMergeAsync(taskId)` cancels and aborts the merge.
- Expose a factory (`Func<string, ConflictResolverViewModel>`) the integrator wires to
A's and B's `RequestConflictResolution` callbacks.
## Build / test
`.slnx` needs .NET 9; on .NET 8 build individual csproj with `-c Release` (a running
Worker locks `Debug`). Run the relevant test projects. No tests that spawn the real
`claude` CLI. Keep `en.json`/`de.json` localization keys in parity.
## Out of scope
- Full 3-way synchronized merge editor (model leaves room; not built now).
- Per-task differing merge targets in the batch (single target only).
- Any CI/PR tooling (direct push-to-main workflow).

View File

@@ -1,99 +0,0 @@
# 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

@@ -1,148 +0,0 @@
# Unify the parent-task model (planning · improvement · normal)
**Date:** 2026-06-09
**Status:** Approved-pending-implementation
## Problem
ClaudeDo has three ways a task produces and waits on work, grown as separate
mechanisms that represent the *same shape* — "a task runs, may emit children,
and once it + its children are terminal it surfaces for review":
| | children authored | scheduling | parent flow today | merge of children |
|---|---|---|---|---|
| **Normal** | none | — | `Running → WaitingForReview → Done` | own worktree on approve |
| **Improvement** | autonomously *during* run (`suggest_improvement`) | parallel (no blockers) | `Running → WaitingForChildren → WaitingForReview → Done` | separate `MergeAllPlanning` |
| **Planning** | interactively *before* run (planning session) | sequential chain (`BlockedByTaskId`) | `Idle →(Active→Finalized)→ Done` (skips review) | separate `MergeAllPlanning` |
The incidental divergence we want to remove:
1. **Two "parent is waiting on children" representations** — improvement uses
`Status=WaitingForChildren`; planning uses `PlanningPhase=Finalized` with the
parent's `Status` jumping `Idle → Done`, never passing through the waiting/review
states at all.
2. **Two parent-advance methods** doing the same job —
`TaskRepository.TryCompleteParentAsync` (planning → `Done`, no review) vs
`TaskStateService.TryAdvanceImprovementParentAsync` (improvement → `WaitingForReview`).
3. **A separate merge action**`MergeAllPlanning` / `PlanningMergeOrchestrator`
merges children, decoupled from the parent's `approve`. Approving a parent and
merging its unit are two clicks.
What is **genuinely unique and kept**: `PlanningPhase.Active` — the interactive,
human-in-the-loop authoring gate where children are drafted and cannot run until
finalize. Improvement has no equivalent. The two *authoring* entry points
(`PlanningMcpService.CreateChildTask` vs `TaskRunMcpService.SuggestImprovement`)
also stay distinct — they already share `CreateChildAsync`; unifying the authoring
UX is explicitly out of scope.
## Decisions (locked)
- **All parents get review.** A planning parent now surfaces in `WaitingForReview`
after its children finish, instead of auto-completing to `Done`.
- **Approve merges the whole unit — full UX consolidation.** Approve is the single
entry for reviewing *and* merging any task. For a parent with children it drives the
existing `PlanningMergeOrchestrator` (unit merge + parent→`Done` + conflict
continue/abort, all already implemented); the standalone "Merge All" button is
removed and the orchestrator's conflict dialog + combined-diff preview are reused
in-place. Childless tasks keep `ApproveAndMergeAsync`.
- **Scope = state model + code paths.** Internal refactor; authoring UX and child
base-commit resolution are unchanged.
## Target model
**One parent-with-children lifecycle, used by every parent regardless of how its
children were authored:**
```
┌─ (no children) ──────────────┐
Idle → Queued → Running ──┤ ├→ WaitingForReview → Done
└─ (has/spawns children) ─┐ │ (approve =
│ │ merge unit)
WaitingForChildren ─┘ │
│ │
(all children terminal) ───────┘
```
Planning parent (never runs as an agent — it runs an interactive session):
```
Idle (PlanningPhase None)
→[StartPlanning] Idle (PlanningPhase Active) ← authoring gate (KEPT)
→[FinalizePlanning] WaitingForChildren (Finalized) ← children chain runs
→[all children terminal] WaitingForReview
→[approve] merge unit → Done
```
Children (planning **and** improvement) keep going straight to `Done` with no
individual review; they accumulate on their branches and merge as a unit when the
parent is approved.
### State machine after the change
- `WaitingForChildren` is the **single** "parent waiting on children" state, used by
both planning and improvement parents.
- `WaitingForReview` is reached by every parent before `Done`.
- `PlanningPhase`: `None | Active | Finalized` — unchanged; `Active` remains the
authoring gate, `Finalized` marks "was a planning parent" and is set together with
`Status=WaitingForChildren`.
## Code changes
1. **Single parent-advance path.** Rename
`TaskStateService.TryAdvanceImprovementParentAsync`
`TryAdvanceParentAsync`; it already only checks `Status==WaitingForChildren` +
"all children terminal" → `WaitingForReview` (with the failed/cancelled
annotation on `Result`). It becomes the only path for both systems.
- Handle **zero children**: a finalized planning parent with no children must go
straight to `WaitingForReview` (today `TryComplete`/`TryAdvance` both `return`
on `Count == 0`).
2. **Delete `TaskRepository.TryCompleteParentAsync`** (`TaskRepository.cs:477`) and
its invocation in `TaskStateService.OnChildTerminalAsync`. Planning parents now
advance via `TryAdvanceParentAsync` to `WaitingForReview` instead of `Done`.
- Keep `_chain.OnChildFinishedAsync` (inter-child unblock — planning-only effect).
3. **`FinalizePlanningAsync`** (`TaskStateService.cs:289`) sets the parent
`Status = WaitingForChildren` in the same update that sets
`PlanningPhase = Finalized`. This happens before `SetupChainAsync` enqueues
child[0], so the parent is in `WaitingForChildren` before any child can finish.
4. **Approve merges the unit.** `WorkerHub.ApproveReview` (and the MCP
`ReviewTask` approve path): when the approved task has children, run
`PlanningMergeOrchestrator` (parent worktree if `Active` + each `Done` child in
order), then transition the parent to `Done`. On a child merge conflict, the
parent stays in `WaitingForReview` (mirrors current single-task approve-conflict
behavior). Retire the `MergeAllPlanning` Hub method + UI button.
5. **Allow cancelling a `WaitingForChildren` parent.** Add `WaitingForChildren` to
the `CancelAsync` guard so a parent waiting on children can be cancelled (today it
cannot — minor gap).
6. **Docs.** Fix the `WaitingForChildren`-missing drift in
`src/ClaudeDo.Data/CLAUDE.md` and `src/ClaudeDo.Worker/CLAUDE.md`, and update the
transition diagram + the root `CLAUDE.md` status-flow line to the unified model.
## Out of scope (unchanged)
- Authoring UX: planning session vs `suggest_improvement` stay as two distinct
entry points (both already call `CreateChildAsync`).
- `WorktreeManager.ResolveBaseCommitAsync` base-commit divergence (planning children
branch from list HEAD; improvement children from parent head) — left as-is.
- Sequential-vs-parallel scheduling — already shared infrastructure
(`BlockedByTaskId`); planning chains, improvement doesn't. No change.
## Risks / edge cases
- **Ordering on finalize** — parent must be `WaitingForChildren` before the first
child can reach terminal. Guaranteed by setting it inside `FinalizePlanningAsync`,
which runs before `SetupChainAsync`.
- **Zero-children planning parent** — must advance to `WaitingForReview`, not stick
in `WaitingForChildren`. Explicit branch in `TryAdvanceParentAsync` /
`FinalizePlanningAsync`.
- **Failed/cancelled children** — parent still advances to `WaitingForReview` with
the existing `⚠ Children: N failed, M cancelled` annotation; no wedge.
- **Approve-merge conflict** — keep parent in `WaitingForReview`; surface the
conflicting child like the current merge-conflict path.
- **Existing rows** — planning parents currently sitting at `Idle`+`Finalized` with
live children: behavior change is forward-only (new finalizes use the new flow);
no migration needed since `Status`/`PlanningPhase` columns already exist.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -24,10 +24,12 @@
</PackageReference>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ClaudeDo.Ui\ClaudeDo.Ui.csproj" />
<ProjectReference Include="..\ClaudeDo.Logging\ClaudeDo.Logging.csproj" />
<ProjectReference Include="..\ClaudeDo.Localization\ClaudeDo.Localization.csproj" />
</ItemGroup>
<Import Project="..\ClaudeDo.Localization\Locales.targets" />

View File

@@ -13,6 +13,8 @@ using ClaudeDo.Ui.ViewModels.Modals;
using ClaudeDo.Ui.ViewModels.Modals.Settings;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Serilog;
using System;
using System.Globalization;
using System.IO;
@@ -77,6 +79,12 @@ sealed class Program
var sc = new ServiceCollection();
var logRoot = Path.Combine(Path.GetDirectoryName(dbPath)!, "logs");
var serilogLogger = ClaudeDo.Logging.LoggingSetup
.Configure(new LoggerConfiguration(), "app", logRoot)
.CreateLogger();
sc.AddLogging(b => b.AddSerilog(serilogLogger, dispose: true));
// Infrastructure
sc.AddSingleton(settings);
var localesDir = Path.Combine(AppContext.BaseDirectory, "locales");
@@ -98,7 +106,9 @@ sealed class Program
// Services
sc.AddSingleton<GitService>();
sc.AddSingleton(sp => new WorkerClient(sp.GetRequiredService<AppSettings>().SignalRUrl));
sc.AddSingleton(sp => new WorkerClient(
sp.GetRequiredService<AppSettings>().SignalRUrl,
sp.GetRequiredService<ILogger<WorkerClient>>()));
sc.AddSingleton<IWorkerClient>(sp => sp.GetRequiredService<WorkerClient>());
// Release check + installer update
@@ -132,9 +142,6 @@ sealed class Program
sc.AddTransient<Func<RepoImportModalViewModel>>(sp => () => sp.GetRequiredService<RepoImportModalViewModel>());
sc.AddTransient<WeeklyReportModalViewModel>();
sc.AddTransient<Func<WeeklyReportModalViewModel>>(sp => () => sp.GetRequiredService<WeeklyReportModalViewModel>());
sc.AddSingleton<Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>>(sp =>
taskId => new ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel(
sp.GetRequiredService<WorkerClient>(), taskId));
// Islands shell VMs
sc.AddSingleton<ListsIslandViewModel>(sp =>
@@ -152,13 +159,7 @@ sealed class Program
sp.GetRequiredService<WorkerClient>(),
sp,
sp.GetRequiredService<INotesApi>()));
sc.AddSingleton<IslandsShellViewModel>(sp =>
{
var shell = ActivatorUtilities.CreateInstance<IslandsShellViewModel>(sp);
shell.ConflictResolverFactory =
sp.GetRequiredService<Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>>();
return shell;
});
sc.AddSingleton<IslandsShellViewModel>();
return sc.BuildServiceProvider();
}

View File

@@ -4,7 +4,7 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
## Models
- **TaskEntity** — Id, ListId, Title, Description, Status (`Idle|Queued|Running|WaitingForChildren|WaitingForReview|Done|Failed|Cancelled`), PlanningPhase (`None|Active|Finalized` — parent-only), BlockedByTaskId (nullable FK to predecessor in a chain), ScheduledFor, Result, ReviewFeedback (nullable; reviewer's rejection comment, consumed and cleared by the runner on the next re-run), LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath / MaxTurns (nullable overrides), IsStarred, IsMyDay, Notes, ParentTaskId, PlanningSessionId, PlanningSessionToken, PlanningFinalizedAt, CreatedBy. Legacy values `Manual`/`Planning`/`Planned`/`Draft`/`Waiting` were retired; existing rows backfill automatically via the `RetireLegacyTaskStatus` migration.
- **TaskEntity** — Id, ListId, Title, Description, Status (`Idle|Queued|Running|WaitingForReview|Done|Failed|Cancelled`), PlanningPhase (`None|Active|Finalized` — parent-only), BlockedByTaskId (nullable FK to predecessor in a chain), ScheduledFor, Result, ReviewFeedback (nullable; reviewer's rejection comment, consumed and cleared by the runner on the next re-run), LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath / MaxTurns (nullable overrides), IsStarred, IsMyDay, Notes, ParentTaskId, PlanningSessionId, PlanningSessionToken, PlanningFinalizedAt, CreatedBy. Legacy values `Manual`/`Planning`/`Planned`/`Draft`/`Waiting` were retired; existing rows backfill automatically via the `RetireLegacyTaskStatus` migration.
- **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt
- **ListConfigEntity** — ListId (PK, 1:1 with list), Model, SystemPrompt, AgentPath, MaxTurns (all nullable)
- **WorktreeEntity** — TaskId (PK, 1:1 with task), Path, BranchName, BaseCommit, HeadCommit, DiffStat, State (Active|Merged|Discarded|Kept)
@@ -19,7 +19,7 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. The atomic `Queued -> Running` claim lives in the Worker's `QueuePicker` (uses `FromSqlRaw`), not here.
- **TaskRepository** — CRUD, planning helpers (`CreateChildAsync`, `SetPlanningStartedAsync`, `DiscardPlanningAsync`, `UpdateChildAsync`), `UpdateAgentSettingsAsync` (model / system-prompt / agent-path overrides). Status-mutation primitives `MarkRunningAsync` / `MarkDoneAsync` / `MarkFailedAsync` / `FlipAllRunningToFailedAsync` are `internal` and called only by `TaskStateService` in the worker. `CreateChildAsync` produces children with `Status=Idle, PlanningPhase=None`; once their parent's `PlanningPhase` becomes `Finalized`, the chain coordinator queues them.
- **TaskRepository** — CRUD, planning helpers (`CreateChildAsync`, `SetPlanningStartedAsync`, `DiscardPlanningAsync`, `TryCompleteParentAsync`, `UpdateChildAsync`), `UpdateAgentSettingsAsync` (model / system-prompt / agent-path overrides). Status-mutation primitives `MarkRunningAsync` / `MarkDoneAsync` / `MarkFailedAsync` / `FlipAllRunningToFailedAsync` are `internal` and called only by `TaskStateService` in the worker. `CreateChildAsync` produces children with `Status=Idle, PlanningPhase=None`; once their parent's `PlanningPhase` becomes `Finalized`, the chain coordinator queues them.
- **ListRepository** — CRUD, `GetConfigAsync` / `SetConfigAsync` (upsert) / `DeleteConfigAsync` for `list_config`
- **WorktreeRepository** — CRUD, `UpdateHeadAsync`, `SetStateAsync`
- **TaskRunRepository**, **SubtaskRepository**, **AppSettingsRepository**
@@ -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, `PreviewMergeAsync` (non-destructive mergeability check via `git merge-tree --write-tree`), `CountChangedFilesAsync`
- **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
## Schema

View File

@@ -1,8 +1,6 @@
using System.Data.Common;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Seeding;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
@@ -11,35 +9,8 @@ namespace ClaudeDo.Data;
public class ClaudeDoDbContext : DbContext
{
// Runs PRAGMA foreign_keys=ON on every EF-managed connection open so FK
// enforcement is active for all IDbContextFactory-created contexts, not
// just the single context used in MigrateAndConfigure.
private sealed class SqliteForeignKeyInterceptor : DbConnectionInterceptor
{
internal static readonly SqliteForeignKeyInterceptor Instance = new();
public override void ConnectionOpened(DbConnection connection, ConnectionEndEventData eventData)
=> Apply(connection);
public override Task ConnectionOpenedAsync(DbConnection connection, ConnectionEndEventData eventData, CancellationToken cancellationToken = default)
{
Apply(connection);
return Task.CompletedTask;
}
private static void Apply(DbConnection connection)
{
using var cmd = connection.CreateCommand();
cmd.CommandText = "PRAGMA foreign_keys=ON;";
cmd.ExecuteNonQuery();
}
}
public ClaudeDoDbContext(DbContextOptions<ClaudeDoDbContext> options) : base(options) { }
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder.AddInterceptors(SqliteForeignKeyInterceptor.Instance);
public DbSet<TaskEntity> Tasks => Set<TaskEntity>();
public DbSet<ListEntity> Lists => Set<ListEntity>();
public DbSet<ListConfigEntity> ListConfigs => Set<ListConfigEntity>();

View File

@@ -7,7 +7,8 @@ public sealed class ReviewFilter : ITaskListFilter
{
public string Id => "virtual:review";
public bool Matches(TaskEntity t) =>
t.Status == TaskStatus.WaitingForReview;
t.Status == TaskStatus.Done &&
t.Worktree is { State: WorktreeState.Active };
public bool ShouldCount(TaskEntity t) => Matches(t);
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
}

View File

@@ -3,15 +3,8 @@ using System.Text;
namespace ClaudeDo.Data.Git;
public sealed record MergePreview(bool Supported, bool Clean, IReadOnlyList<string> ConflictFiles);
public sealed class GitService
{
// git mutates shared .git/worktrees/ metadata during `worktree add`; concurrent adds
// race and fail with "failed to read .git/worktrees/<other>/commondir". Serialize them
// process-wide so parallel task starts don't collide.
private static readonly SemaphoreSlim WorktreeAddGate = new(1, 1);
public async Task<bool> IsGitRepoAsync(string dir, CancellationToken ct = default)
{
var (exitCode, _, _) = await RunGitAsync(dir, ["rev-parse", "--git-dir"], ct);
@@ -28,30 +21,10 @@ public sealed class GitService
public async Task WorktreeAddAsync(string repoDir, string branchName, string worktreePath, string baseCommit, CancellationToken ct = default)
{
await WorktreeAddGate.WaitAsync(ct);
try
{
const int maxAttempts = 3;
for (var attempt = 1; ; attempt++)
{
var (exitCode, _, stderr) = await RunGitAsync(repoDir,
["worktree", "add", "-b", branchName, worktreePath, baseCommit], ct);
if (exitCode == 0)
return;
// Transient races leave a half-written worktree metadata dir; retry briefly.
var transient = stderr.Contains("commondir", StringComparison.OrdinalIgnoreCase)
|| stderr.Contains("failed to read", StringComparison.OrdinalIgnoreCase);
if (!transient || attempt >= maxAttempts)
throw new InvalidOperationException($"git worktree add failed (exit {exitCode}): {stderr}");
await Task.Delay(150 * attempt, ct);
}
}
finally
{
WorktreeAddGate.Release();
}
var (exitCode, _, stderr) = await RunGitAsync(repoDir,
["worktree", "add", "-b", branchName, worktreePath, baseCommit], ct);
if (exitCode != 0)
throw new InvalidOperationException($"git worktree add failed (exit {exitCode}): {stderr}");
}
public async Task<string> GetStatusPorcelainAsync(string workingDirectory, CancellationToken ct = default)
@@ -124,20 +97,6 @@ public sealed class GitService
return await GetDiffAsync(worktreePath, ct);
}
/// <summary>
/// Diff between two commits, run in any repo that can reach them. Used to view a
/// task's changes after its worktree has been merged away (the commits survive on
/// the target branch even though the worktree directory and branch ref are gone).
/// </summary>
public async Task<string> GetCommitRangeDiffAsync(string repoDir, string baseCommit, string headCommit, CancellationToken ct = default)
{
var (exitCode, stdout, stderr) = await RunGitAsync(repoDir,
["diff", $"{baseCommit}..{headCommit}"], ct);
if (exitCode != 0)
throw new InvalidOperationException($"git diff {baseCommit}..{headCommit} failed (exit {exitCode}): {stderr}");
return stdout;
}
public async Task<string> DiffStatAsync(string worktreePath, string baseCommit, string headCommit, CancellationToken ct = default)
{
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath,
@@ -277,67 +236,6 @@ public sealed class GitService
.ToList();
}
/// <summary>
/// Reads a conflicted file's blob at a merge stage: 1=base, 2=ours, 3=theirs.
/// Returns null when the stage doesn't exist (e.g. add/add conflict has no base).
/// Output is NOT trimmed so file content round-trips exactly.
/// </summary>
public async Task<string?> ShowStageAsync(string repoDir, int stage, string path, CancellationToken ct = default)
{
var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["show", $":{stage}:{path}"], ct, trimOutput: false);
return exitCode == 0 ? stdout : null;
}
public async Task AddPathAsync(string repoDir, string path, CancellationToken ct = default)
{
var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["add", "--", path], ct);
if (exitCode != 0)
throw new InvalidOperationException($"git add '{path}' failed (exit {exitCode}): {stderr}");
}
/// <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);
@@ -346,7 +244,7 @@ public sealed class GitService
}
private static async Task<(int ExitCode, string Stdout, string Stderr)> RunGitAsync(
string workDir, IEnumerable<string> args, CancellationToken ct, string? stdinData = null, bool trimOutput = true)
string workDir, IEnumerable<string> args, CancellationToken ct, string? stdinData = null)
{
var psi = new ProcessStartInfo
{
@@ -395,6 +293,6 @@ public sealed class GitService
ct.ThrowIfCancellationRequested();
return (proc.ExitCode, trimOutput ? stdout.TrimEnd() : stdout, stderr.TrimEnd());
return (proc.ExitCode, stdout.TrimEnd(), stderr.TrimEnd());
}
}

View File

@@ -1,696 +0,0 @@
// <auto-generated />
using System;
using ClaudeDo.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
[DbContext(typeof(ClaudeDoDbContext))]
[Migration("20260609000000_UniqueListName")]
partial class UniqueListName
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
{
b.Property<int>("Id")
.HasColumnType("INTEGER")
.HasColumnName("id");
b.Property<string>("CentralWorktreeRoot")
.HasColumnType("TEXT")
.HasColumnName("central_worktree_root");
b.Property<int>("DailyPrepMaxTasks")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(5)
.HasColumnName("daily_prep_max_tasks");
b.Property<string>("DefaultClaudeInstructions")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("")
.HasColumnName("default_claude_instructions");
b.Property<int>("DefaultMaxTurns")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(30)
.HasColumnName("default_max_turns");
b.Property<string>("DefaultModel")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sonnet")
.HasColumnName("default_model");
b.Property<string>("DefaultPermissionMode")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("bypassPermissions")
.HasColumnName("default_permission_mode");
b.Property<int>("MaxParallelExecutions")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1)
.HasColumnName("max_parallel_executions");
b.Property<string>("RepoImportFolders")
.HasColumnType("TEXT")
.HasColumnName("repo_import_folders");
b.Property<string>("ReportExcludedPaths")
.HasColumnType("TEXT")
.HasColumnName("report_excluded_paths");
b.Property<int>("StandupWeekday")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(3)
.HasColumnName("standup_weekday");
b.Property<int>("WorktreeAutoCleanupDays")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(7)
.HasColumnName("worktree_auto_cleanup_days");
b.Property<bool>("WorktreeAutoCleanupEnabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("worktree_auto_cleanup_enabled");
b.Property<string>("WorktreeStrategy")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("sibling")
.HasColumnName("worktree_strategy");
b.HasKey("Id");
b.ToTable("app_settings", (string)null);
b.HasData(
new
{
Id = 1,
DailyPrepMaxTasks = 5,
DefaultClaudeInstructions = "",
DefaultMaxTurns = 100,
DefaultModel = "sonnet",
DefaultPermissionMode = "auto",
MaxParallelExecutions = 1,
StandupWeekday = 3,
WorktreeAutoCleanupDays = 7,
WorktreeAutoCleanupEnabled = false,
WorktreeStrategy = "sibling"
});
});
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<DateOnly>("Date")
.HasColumnType("TEXT")
.HasColumnName("note_date");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER")
.HasColumnName("sort_order");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("text");
b.HasKey("Id");
b.HasIndex("Date");
b.ToTable("daily_notes", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.Property<string>("ListId")
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.HasKey("ListId");
b.ToTable("list_config", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DefaultCommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("default_commit_type");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("name");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<string>("WorkingDir")
.HasColumnType("TEXT")
.HasColumnName("working_dir");
b.HasKey("Id");
b.HasIndex("SortOrder")
.HasDatabaseName("idx_lists_sort");
b.ToTable("lists", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
{
b.Property<Guid>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("Days")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(31)
.HasColumnName("days_of_week");
b.Property<bool>("Enabled")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(true)
.HasColumnName("enabled");
b.Property<DateTimeOffset?>("LastRunAt")
.HasColumnType("TEXT")
.HasColumnName("last_run_at");
b.Property<string>("PromptOverride")
.HasColumnType("TEXT")
.HasColumnName("prompt_override");
b.Property<TimeSpan>("TimeOfDay")
.HasColumnType("TEXT")
.HasColumnName("time_of_day");
b.HasKey("Id");
b.ToTable("prime_schedules", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<bool>("Completed")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("completed");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<int>("OrderNum")
.HasColumnType("INTEGER")
.HasColumnName("order_num");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_subtasks_task_id");
b.ToTable("subtasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("AgentPath")
.HasColumnType("TEXT")
.HasColumnName("agent_path");
b.Property<string>("BlockedByTaskId")
.HasColumnType("TEXT")
.HasColumnName("blocked_by_task_id");
b.Property<string>("CommitType")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("chore")
.HasColumnName("commit_type");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("CreatedBy")
.HasColumnType("TEXT")
.HasColumnName("created_by");
b.Property<string>("Description")
.HasColumnType("TEXT")
.HasColumnName("description");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsMyDay")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_my_day");
b.Property<bool>("IsStarred")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_starred");
b.Property<string>("ListId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("list_id");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<int?>("MaxTurns")
.HasColumnType("INTEGER")
.HasColumnName("max_turns");
b.Property<string>("Model")
.HasColumnType("TEXT")
.HasColumnName("model");
b.Property<string>("Notes")
.HasColumnType("TEXT")
.HasColumnName("notes");
b.Property<string>("ParentTaskId")
.HasColumnType("TEXT")
.HasColumnName("parent_task_id");
b.Property<DateTime?>("PlanningFinalizedAt")
.HasColumnType("TEXT")
.HasColumnName("planning_finalized_at");
b.Property<string>("PlanningPhase")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("none")
.HasColumnName("planning_phase");
b.Property<string>("PlanningSessionId")
.HasColumnType("TEXT")
.HasColumnName("planning_session_id");
b.Property<string>("PlanningSessionToken")
.HasColumnType("TEXT")
.HasColumnName("planning_session_token");
b.Property<string>("Result")
.HasColumnType("TEXT")
.HasColumnName("result");
b.Property<string>("ReviewFeedback")
.HasColumnType("TEXT")
.HasColumnName("review_feedback");
b.Property<int>("RoadblockCount")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("roadblock_count");
b.Property<DateTime?>("ScheduledFor")
.HasColumnType("TEXT")
.HasColumnName("scheduled_for");
b.Property<int>("SortOrder")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0)
.HasColumnName("sort_order");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("Status")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("status");
b.Property<string>("SystemPrompt")
.HasColumnType("TEXT")
.HasColumnName("system_prompt");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("title");
b.HasKey("Id");
b.HasIndex("BlockedByTaskId")
.HasDatabaseName("idx_tasks_blocked_by");
b.HasIndex("ListId")
.HasDatabaseName("idx_tasks_list_id");
b.HasIndex("ParentTaskId")
.HasDatabaseName("idx_tasks_parent_task_id");
b.HasIndex("Status")
.HasDatabaseName("idx_tasks_status");
b.HasIndex("ListId", "SortOrder")
.HasDatabaseName("idx_tasks_list_sort");
b.ToTable("tasks", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<string>("ErrorMarkdown")
.HasColumnType("TEXT")
.HasColumnName("error_markdown");
b.Property<int?>("ExitCode")
.HasColumnType("INTEGER")
.HasColumnName("exit_code");
b.Property<DateTime?>("FinishedAt")
.HasColumnType("TEXT")
.HasColumnName("finished_at");
b.Property<bool>("IsRetry")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false)
.HasColumnName("is_retry");
b.Property<string>("LogPath")
.HasColumnType("TEXT")
.HasColumnName("log_path");
b.Property<string>("Prompt")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("prompt");
b.Property<string>("ResultMarkdown")
.HasColumnType("TEXT")
.HasColumnName("result_markdown");
b.Property<int>("RunNumber")
.HasColumnType("INTEGER")
.HasColumnName("run_number");
b.Property<string>("SessionId")
.HasColumnType("TEXT")
.HasColumnName("session_id");
b.Property<DateTime?>("StartedAt")
.HasColumnType("TEXT")
.HasColumnName("started_at");
b.Property<string>("StructuredOutputJson")
.HasColumnType("TEXT")
.HasColumnName("structured_output");
b.Property<string>("TaskId")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<int?>("TokensIn")
.HasColumnType("INTEGER")
.HasColumnName("tokens_in");
b.Property<int?>("TokensOut")
.HasColumnType("INTEGER")
.HasColumnName("tokens_out");
b.Property<int?>("TurnCount")
.HasColumnType("INTEGER")
.HasColumnName("turn_count");
b.HasKey("Id");
b.HasIndex("TaskId")
.HasDatabaseName("idx_task_runs_task_id");
b.ToTable("task_runs", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT")
.HasColumnName("id");
b.Property<DateOnly>("EndDate")
.HasColumnType("TEXT")
.HasColumnName("end_date");
b.Property<DateTime>("GeneratedAt")
.HasColumnType("TEXT")
.HasColumnName("generated_at");
b.Property<string>("Markdown")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("markdown");
b.Property<DateOnly>("StartDate")
.HasColumnType("TEXT")
.HasColumnName("start_date");
b.HasKey("Id");
b.HasIndex("StartDate", "EndDate")
.IsUnique();
b.ToTable("week_reports", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.Property<string>("TaskId")
.HasColumnType("TEXT")
.HasColumnName("task_id");
b.Property<string>("BaseCommit")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("base_commit");
b.Property<string>("BranchName")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("branch_name");
b.Property<DateTime>("CreatedAt")
.HasColumnType("TEXT")
.HasColumnName("created_at");
b.Property<string>("DiffStat")
.HasColumnType("TEXT")
.HasColumnName("diff_stat");
b.Property<string>("HeadCommit")
.HasColumnType("TEXT")
.HasColumnName("head_commit");
b.Property<string>("Path")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("path");
b.Property<string>("State")
.IsRequired()
.ValueGeneratedOnAdd()
.HasColumnType("TEXT")
.HasDefaultValue("active")
.HasColumnName("state");
b.HasKey("TaskId");
b.ToTable("worktrees", (string)null);
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithOne("Config")
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("List");
});
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Subtasks")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
.WithMany()
.HasForeignKey("BlockedByTaskId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
.WithMany("Tasks")
.HasForeignKey("ListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
.WithMany("Children")
.HasForeignKey("ParentTaskId")
.OnDelete(DeleteBehavior.Restrict);
b.Navigation("List");
b.Navigation("Parent");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithMany("Runs")
.HasForeignKey("TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
{
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
.WithOne("Worktree")
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Task");
});
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
{
b.Navigation("Config");
b.Navigation("Tasks");
});
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
{
b.Navigation("Children");
b.Navigation("Runs");
b.Navigation("Subtasks");
b.Navigation("Worktree");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -1,30 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace ClaudeDo.Data.Migrations
{
/// <inheritdoc />
public partial class UniqueListName : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Remove duplicate list rows that have no tasks — keep the oldest rowid.
// This handles the startup-race case where both App and Worker seeded
// the same default list names concurrently.
migrationBuilder.Sql("""
DELETE FROM lists
WHERE (SELECT COUNT(*) FROM tasks WHERE list_id = lists.id) = 0
AND rowid NOT IN (
SELECT MIN(l2.rowid) FROM lists l2 WHERE l2.name = lists.name
)
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}

View File

@@ -2,7 +2,7 @@ using System.Text;
namespace ClaudeDo.Data;
public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport, ImprovementChild, Refine }
public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport, ImprovementChild }
public static class PromptFiles
{
@@ -17,7 +17,6 @@ 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))
};
@@ -62,7 +61,6 @@ public static class PromptFiles
PromptKind.DailyPrep => DailyPrepDefault,
PromptKind.WeeklyReport => WeeklyReportDefault,
PromptKind.ImprovementChild => ImprovementChildDefault,
PromptKind.Refine => RefineDefault,
_ => ""
};
@@ -183,33 +181,6 @@ 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

@@ -18,18 +18,8 @@ public sealed class AppSettingsRepository
row = new AppSettingsEntity { Id = AppSettingsEntity.SingletonId };
_context.AppSettings.Add(row);
try
{
await _context.SaveChangesAsync(ct);
_context.Entry(row).State = EntityState.Detached;
}
catch (DbUpdateException)
{
// Concurrent process already inserted the singleton — discard our attempt and re-read.
_context.Entry(row).State = EntityState.Detached;
row = await _context.AppSettings.AsNoTracking()
.FirstAsync(s => s.Id == AppSettingsEntity.SingletonId, ct);
}
await _context.SaveChangesAsync(ct);
_context.Entry(row).State = EntityState.Detached;
return row;
}

View File

@@ -474,5 +474,32 @@ public sealed class TaskRepository
return chainIds.Count;
}
public async Task TryCompleteParentAsync(
string parentId,
CancellationToken ct = default)
{
var parent = await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == parentId, ct);
if (parent is null || parent.PlanningPhase != PlanningPhase.Finalized) return;
var children = await _context.Tasks
.Where(t => t.ParentTaskId == parentId)
.Select(t => t.Status)
.ToListAsync(ct);
if (children.Count == 0) return;
bool allTerminal = children.All(s => s == TaskStatus.Done || s == TaskStatus.Failed);
if (!allTerminal) return;
bool anyFailed = children.Any(s => s == TaskStatus.Failed);
var finalStatus = anyFailed ? TaskStatus.Failed : TaskStatus.Done;
var finishedAt = DateTime.UtcNow;
await _context.Tasks
.Where(t => t.Id == parentId)
.ExecuteUpdateAsync(s => s
.SetProperty(t => t.Status, finalStatus)
.SetProperty(t => t.FinishedAt, finishedAt), ct);
}
#endregion
}

View File

@@ -1,3 +1,4 @@
using ClaudeDo.Data.Models;
using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Data.Seeding;
@@ -8,18 +9,17 @@ public static class DefaultListsSeeder
public static async Task SeedAsync(ClaudeDoDbContext ctx, CancellationToken ct = default)
{
var existing = await ctx.Lists.Select(l => l.Name).ToListAsync(ct);
var now = DateTime.UtcNow;
foreach (var name in Defaults)
foreach (var name in Defaults.Where(n => !existing.Contains(n)))
{
var id = Guid.NewGuid().ToString();
// Atomic conditional insert: the SELECT ... WHERE NOT EXISTS is a single
// SQLite statement and cannot race — only one writer holds the lock.
await ctx.Database.ExecuteSqlAsync(
$"""
INSERT INTO lists (id, name, created_at, default_commit_type, sort_order)
SELECT {id}, {name}, {now}, 'chore', 0
WHERE NOT EXISTS (SELECT 1 FROM lists WHERE name = {name})
""", ct);
ctx.Lists.Add(new ListEntity
{
Id = Guid.NewGuid().ToString(),
Name = name,
CreatedAt = now,
});
}
await ctx.SaveChangesAsync(ct);
}
}

View File

@@ -1,29 +0,0 @@
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();
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 374 B

View File

@@ -53,7 +53,6 @@
"prime": {
"description": "Bereite dein Claude-Nutzungsfenster vor, indem an den von dir gewählten Tagen zu einer bestimmten Zeit ein einzelner nicht-interaktiver Ping ausgelöst wird. Läuft nur, solange ClaudeDo geöffnet ist. Wenn die App innerhalb von 30 Minuten vor der Zielzeit startet, wird der Ping sofort ausgelöst.",
"addSchedule": "+ Zeitplan hinzufügen",
"removeScheduleTip": "Zeitplan entfernen",
"dailyPrepMaxTasks": "Max. Aufgaben pro Tag",
"dayMo": "Mo",
"dayTu": "Di",
@@ -105,8 +104,6 @@
"cancel": "Abbrechen",
"cancelTip": "Diese Aufgabe abbrechen",
"removeFromQueueTip": "Aus Warteschlange entfernen",
"toggleSubtasksTip": "Unteraufgaben ein-/ausklappen",
"agentSuggestedTip": "Vom Agenten vorgeschlagen",
"scheduleTitle": "Aufgabe planen",
"scheduleWhen": "WANN",
"scheduleConfirm": "Planen",
@@ -114,8 +111,7 @@
"reviewTitle": "Review",
"feedbackLabel": "FEEDBACK FÜR DEN AGENTEN",
"feedbackPlaceholder": "Was soll der Agent korrigieren?",
"rerun": "Erneut ausführen",
"refineTip": "Aufgabe mit Claude verfeinern"
"rerun": "Erneut ausführen"
},
"lists": {
"heading": "Listen",
@@ -133,7 +129,6 @@
},
"details": {
"deleteTaskTip": "Aufgabe löschen",
"killSessionTip": "Laufende Sitzung beenden",
"closeTip": "Schließen",
"copyTaskIdTip": "Aufgaben-ID kopieren",
"starTip": "Favorit",
@@ -153,7 +148,6 @@
"addStepPlaceholder": "Schritt hinzufügen...",
"detailsLabel": "DETAILS",
"copyDescriptionTip": "Beschreibung in die Zwischenablage kopieren",
"copyFormattedTip": "Titel, Beschreibung und offene Schritte kopieren",
"toggleEditPreviewTip": "Bearbeiten/Vorschau umschalten",
"previewBtn": "Vorschau",
"editBtn": "Bearbeiten",
@@ -189,9 +183,7 @@
"session": {
"chipLive": "LIVE",
"chipDone": "FERTIG",
"chipFailed": "FEHLGESCHLAGEN",
"reviewContinueTip": "Dieses Feedback senden und die Aufgabe erneut ausführen",
"reviewResetTip": "Alle Änderungen verwerfen und die Aufgabe auf Leerlauf zurücksetzen"
"chipFailed": "FEHLGESCHLAGEN"
},
"modals": {
"about": {
@@ -238,10 +230,7 @@
"diff": {
"title": "DIFF",
"windowTitle": "Diff",
"merge": "Mergen…",
"filesHeader": "Dateien",
"binary": "Binärdatei — kein Text-Diff",
"empty": "Kein Inhalt"
"merge": "Mergen…"
},
"worktree": {
"title": "Worktree"
@@ -253,12 +242,6 @@
"columnState": "STATUS",
"columnDiff": "DIFF",
"columnAge": "ALTER",
"columnOutcome": "ERGEBNIS",
"selectAll": "Alle auswählen",
"targetLabel": "Ziel",
"mergeAll": "Alle mergen",
"needsResolution": "ZU LÖSEN",
"resolve": "Lösen",
"phantom": "Phantom",
"phantomTooltip": "Verzeichnis fehlt auf der Festplatte",
"ctxShowDiff": "Diff anzeigen",
@@ -374,20 +357,6 @@
"loading": "Wird geladen…"
}
},
"conflictResolver": {
"windowTitle": "Merge-Konflikte lösen",
"modalTitle": "KONFLIKTE LÖSEN",
"loading": "Konflikte werden geladen…",
"current": "Aktuell (unsere)",
"incoming": "Eingehend (ihre)",
"mergedResult": "Zusammengeführtes Ergebnis",
"acceptCurrent": "Aktuelle übernehmen",
"acceptIncoming": "Eingehende übernehmen",
"acceptBoth": "Beide übernehmen",
"editManually": "Manuell bearbeiten",
"continue": "Lösen & fortfahren",
"abort": "Merge abbrechen"
},
"controls": {
"datePicker": {
"today": "Heute",
@@ -430,7 +399,7 @@
"weeklyReport": { "invalidRange": "Ungültiger Datumsbereich.", "generating": "Bericht wird erstellt…", "error": "Fehler: {0}" },
"filesTab": { "workerOffline": "Worker offline.", "noneBundled": "Keine Standard-Agenten mitgeliefert.", "allPresent": "Alle Standard-Agenten bereits vorhanden.", "restored": "{0} Standard-Agent(en) wiederhergestellt.", "restoreFailed": "Wiederherstellung fehlgeschlagen: {0}", "openFailed": "Öffnen fehlgeschlagen: {0}" },
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "{0} Worktree(s) entfernt.", "blocked": "Zwangsentfernung nicht möglich: {0} Aufgabe(n) laufen noch. Brich sie zuerst ab.", "removedFrom": "{0} Worktree(s) von {1} Aufgabe(n) entfernt." },
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "Liste", "cleanupFailed": "Aufräumen fehlgeschlagen.", "removed": "{0} Worktree(s) entfernt.", "discardFailed": "Worktree konnte nicht verworfen werden.", "keepFailed": "Worktree konnte nicht behalten werden.", "cannotForceRunning": "Eine laufende Aufgabe kann nicht zwangsweise entfernt werden.", "forceRemoveFailed": "Zwangsentfernung fehlgeschlagen.", "batchProgress": "Merge {0}/{1}…", "batchDone": "{0} gemergt, {1} zu lösen." },
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "Liste", "cleanupFailed": "Aufräumen fehlgeschlagen.", "removed": "{0} Worktree(s) entfernt.", "discardFailed": "Worktree konnte nicht verworfen werden.", "keepFailed": "Worktree konnte nicht behalten werden.", "cannotForceRunning": "Eine laufende Aufgabe kann nicht zwangsweise entfernt werden.", "forceRemoveFailed": "Zwangsentfernung fehlgeschlagen." },
"listSettings": { "untitled": "Unbenannt" },
"lists": { "localSuffix": "{0} / lokal", "smartMyDay": "Mein Tag", "smartImportant": "Wichtig", "smartPlanned": "Geplant", "virtualQueue": "Warteschlange", "virtualRunning": "Läuft", "virtualReview": "Prüfung", "newList": "Neue Liste" }
}

View File

@@ -53,7 +53,6 @@
"prime": {
"description": "Prime your Claude usage window by firing a single non-interactive ping on the days you choose, at a chosen time. Only runs while ClaudeDo is open. If the app starts within 30 minutes of the target time, the ping fires immediately.",
"addSchedule": "+ Add schedule",
"removeScheduleTip": "Remove schedule",
"dailyPrepMaxTasks": "Max tasks per day",
"dayMo": "Mo",
"dayTu": "Tu",
@@ -105,8 +104,6 @@
"cancel": "Cancel",
"cancelTip": "Cancel this task",
"removeFromQueueTip": "Remove from queue",
"toggleSubtasksTip": "Expand / collapse subtasks",
"agentSuggestedTip": "Suggested by the agent",
"scheduleTitle": "Schedule task",
"scheduleWhen": "WHEN",
"scheduleConfirm": "Schedule",
@@ -114,8 +111,7 @@
"reviewTitle": "Review",
"feedbackLabel": "FEEDBACK FOR THE AGENT",
"feedbackPlaceholder": "What should the agent fix?",
"rerun": "Re-run",
"refineTip": "Refine this task with Claude"
"rerun": "Re-run"
},
"lists": {
"heading": "Lists",
@@ -133,7 +129,6 @@
},
"details": {
"deleteTaskTip": "Delete task",
"killSessionTip": "Kill the running session",
"closeTip": "Close",
"copyTaskIdTip": "Copy task ID",
"starTip": "Star",
@@ -153,7 +148,6 @@
"addStepPlaceholder": "Add a step...",
"detailsLabel": "DETAILS",
"copyDescriptionTip": "Copy description to clipboard",
"copyFormattedTip": "Copy title, description and open steps",
"toggleEditPreviewTip": "Toggle edit/preview",
"previewBtn": "Preview",
"editBtn": "Edit",
@@ -189,9 +183,7 @@
"session": {
"chipLive": "LIVE",
"chipDone": "DONE",
"chipFailed": "FAILED",
"reviewContinueTip": "Send this feedback and re-run the task",
"reviewResetTip": "Discard all changes and reset the task to Idle"
"chipFailed": "FAILED"
},
"modals": {
"about": {
@@ -238,10 +230,7 @@
"diff": {
"title": "DIFF",
"windowTitle": "Diff",
"merge": "Merge…",
"filesHeader": "Files",
"binary": "Binary file — no text diff",
"empty": "No content"
"merge": "Merge…"
},
"worktree": {
"title": "Worktree"
@@ -253,12 +242,6 @@
"columnState": "STATE",
"columnDiff": "DIFF",
"columnAge": "AGE",
"columnOutcome": "RESULT",
"selectAll": "Select all",
"targetLabel": "Target",
"mergeAll": "Merge all",
"needsResolution": "NEEDS RESOLUTION",
"resolve": "Resolve",
"phantom": "phantom",
"phantomTooltip": "Directory missing on disk",
"ctxShowDiff": "Show diff",
@@ -374,20 +357,6 @@
"loading": "Loading…"
}
},
"conflictResolver": {
"windowTitle": "Resolve merge conflicts",
"modalTitle": "RESOLVE CONFLICTS",
"loading": "Loading conflicts…",
"current": "Current (ours)",
"incoming": "Incoming (theirs)",
"mergedResult": "Merged result",
"acceptCurrent": "Accept Current",
"acceptIncoming": "Accept Incoming",
"acceptBoth": "Accept Both",
"editManually": "Edit manually",
"continue": "Resolve & continue",
"abort": "Abort merge"
},
"controls": {
"datePicker": {
"today": "Today",
@@ -430,7 +399,7 @@
"weeklyReport": { "invalidRange": "Invalid date range.", "generating": "Generating report…", "error": "Error: {0}" },
"filesTab": { "workerOffline": "Worker offline.", "noneBundled": "No default agents bundled.", "allPresent": "All default agents already present.", "restored": "Restored {0} default agent(s).", "restoreFailed": "Restore failed: {0}", "openFailed": "Open failed: {0}" },
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "Removed {0} worktree(s).", "blocked": "Cannot force-remove: {0} task(s) still running. Cancel them first.", "removedFrom": "Removed {0} worktree(s) from {1} task(s)." },
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "list", "cleanupFailed": "Cleanup failed.", "removed": "Removed {0} worktree(s).", "discardFailed": "Failed to discard worktree.", "keepFailed": "Failed to keep worktree.", "cannotForceRunning": "Cannot force-remove a running task.", "forceRemoveFailed": "Force remove failed.", "batchProgress": "Merging {0}/{1}…", "batchDone": "Merged {0}, {1} need resolution." },
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "list", "cleanupFailed": "Cleanup failed.", "removed": "Removed {0} worktree(s).", "discardFailed": "Failed to discard worktree.", "keepFailed": "Failed to keep worktree.", "cannotForceRunning": "Cannot force-remove a running task.", "forceRemoveFailed": "Force remove failed." },
"listSettings": { "untitled": "Untitled" },
"lists": { "localSuffix": "{0} / local", "smartMyDay": "My Day", "smartImportant": "Important", "smartPlanned": "Planned", "virtualQueue": "Queue", "virtualRunning": "Running", "virtualReview": "Review", "newList": "New list" }
}

View File

@@ -0,0 +1,14 @@
using System.Diagnostics;
using System.Reflection;
namespace ClaudeDo.Logging;
/// <summary>Runtime build-configuration detection — the replacement for #if DEBUG.
/// Debug builds compile with the JIT optimizer disabled; Release builds enable it.</summary>
public static class BuildConfig
{
public static bool IsDebug { get; } =
Assembly.GetEntryAssembly()
?.GetCustomAttribute<DebuggableAttribute>()
?.IsJITOptimizerDisabled ?? false;
}

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Serilog" Version="4.1.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ClaudeDo.Worker.Tests" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,15 @@
using Serilog.Core;
using Serilog.Events;
namespace ClaudeDo.Logging;
/// <summary>Ensures every log event carries a TaskId property (defaulting to "-")
/// so the output template's [{TaskId}] column never renders the raw token.</summary>
public sealed class DefaultTaskIdEnricher : ILogEventEnricher
{
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
{
if (!logEvent.Properties.ContainsKey("TaskId"))
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("TaskId", "-"));
}
}

View File

@@ -0,0 +1,44 @@
using Serilog;
using Serilog.Events;
namespace ClaudeDo.Logging;
public static class LoggingSetup
{
private const string OutputTemplate =
"[{Timestamp:HH:mm:ss.fff} {Level:u3}] {Process}/{SourceContext} [{TaskId}] {Message:lj}{NewLine}{Exception}";
public static LoggerConfiguration Configure(LoggerConfiguration cfg, string processTag, string logRoot)
{
Directory.CreateDirectory(logRoot);
var logFile = Path.Combine(logRoot, "claudedo-.log");
cfg.Enrich.FromLogContext()
.Enrich.WithProperty("Process", processTag)
.Enrich.With(new DefaultTaskIdEnricher());
if (BuildConfig.IsDebug)
{
cfg.MinimumLevel.Debug()
.WriteTo.Console(outputTemplate: OutputTemplate)
.WriteTo.File(
logFile,
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 2,
shared: true,
outputTemplate: OutputTemplate);
}
else
{
cfg.MinimumLevel.Warning()
.WriteTo.File(
logFile,
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 2,
shared: true,
outputTemplate: OutputTemplate);
}
return cfg;
}
}

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`. 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.
- **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`.
- **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, `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`
- **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`
- **INotesApi** / **WorkerNotesApi** — thin wrapper over the daily-note WorkerClient methods (mirrors `IPrimeScheduleApi`). UI DTO: `DailyNoteDto(Id, Date, Text, SortOrder)`.
## Converters

View File

@@ -11,7 +11,9 @@
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.2" />
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
<PackageReference Include="Serilog" Version="4.1.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,30 @@
using System.Globalization;
using Avalonia.Data.Converters;
using Avalonia.Media;
using ClaudeDo.Ui.ViewModels.Modals;
namespace ClaudeDo.Ui.Converters;
public sealed class DiffLineKindToBrushConverter : IValueConverter
{
private static readonly ISolidColorBrush Added = new SolidColorBrush(Color.Parse("#66BB6A"));
private static readonly ISolidColorBrush Removed = new SolidColorBrush(Color.Parse("#EF5350"));
private static readonly ISolidColorBrush Hunk = new SolidColorBrush(Color.Parse("#42A5F5"));
private static readonly ISolidColorBrush Header = new SolidColorBrush(Color.Parse("#9E9E9E"));
private static readonly ISolidColorBrush Default = new SolidColorBrush(Color.Parse("#CFD8DC"));
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
value is WorktreeDiffLineKind kind
? kind switch
{
WorktreeDiffLineKind.Added => Added,
WorktreeDiffLineKind.Removed => Removed,
WorktreeDiffLineKind.Hunk => Hunk,
WorktreeDiffLineKind.Header => Header,
_ => Default,
}
: Default;
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
=> throw new NotSupportedException();
}

View File

@@ -76,9 +76,6 @@
<!-- 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>
@@ -88,9 +85,6 @@
<!-- 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>
@@ -100,9 +94,6 @@
<!-- 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}"/>
@@ -574,13 +565,6 @@
<Style Selector="Border[Tag=?] > TextBlock">
<Setter Property="Foreground" Value="{StaticResource TextFaintBrush}"/>
</Style>
<!-- R → rename (sage) -->
<Style Selector="Border[Tag=R]">
<Setter Property="Background" Value="#268B9D7A"/>
</Style>
<Style Selector="Border[Tag=R] > TextBlock">
<Setter Property="Foreground" Value="{StaticResource SageBrush}"/>
</Style>
<!-- ============================================================ -->
<!-- LIST NAV ITEM -->

View File

@@ -37,19 +37,10 @@ public interface IWorkerClient : INotifyPropertyChanged
Task<ListConfigDto?> GetListConfigAsync(string listId);
Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto);
Task SetTaskStatusAsync(string taskId, TaskStatus status);
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 ApproveReviewAsync(string taskId);
Task RejectReviewToQueueAsync(string taskId, string feedback);
Task RejectReviewToIdleAsync(string taskId);
Task CancelReviewAsync(string taskId);
// ── Conflict resolution (worker hub side implemented by Layer C) ──
Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch);
Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId);
Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent);
Task<MergeResultDto> ContinueMergeAsync(string taskId);
Task AbortMergeAsync(string taskId);
Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default);
Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default);
Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default);
@@ -59,16 +50,13 @@ public interface IWorkerClient : INotifyPropertyChanged
Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId);
Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregateAsync(string planningTaskId);
Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch);
Task MergeAllPlanningAsync(string planningTaskId, string targetBranch);
Task ContinuePlanningMergeAsync(string planningTaskId);
Task AbortPlanningMergeAsync(string planningTaskId);
Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default);
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

@@ -6,6 +6,8 @@ using CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.AspNetCore.SignalR;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Serilog.Context;
namespace ClaudeDo.Ui.Services;
@@ -30,6 +32,7 @@ sealed class IndefiniteRetryPolicy : IRetryPolicy
public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerClient
{
private readonly HubConnection _hub;
private readonly ILogger<WorkerClient> _logger;
private CancellationTokenSource? _startCts;
private Task _retryLoopTask = Task.CompletedTask;
private readonly object _startLock = new();
@@ -55,9 +58,6 @@ 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;
@@ -66,10 +66,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public event Action<PrimeFiredEvent>? PrimeFired;
public string? LastApproveTarget { get; private set; }
public string? LastMergeAllTarget { get; private set; }
public WorkerClient(string signalRUrl)
public WorkerClient(string signalRUrl, ILogger<WorkerClient> logger)
{
_logger = logger;
_hub = new HubConnectionBuilder()
.WithUrl(signalRUrl)
.WithAutomaticReconnect(new IndefiniteRetryPolicy())
@@ -182,11 +183,6 @@ 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()
@@ -248,20 +244,24 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
catch { return default; }
}
public async Task RunNowAsync(string taskId)
/// <summary>Invoke a task-targeted hub method under a TaskId log scope, emitting a debug trace line.</summary>
private async Task InvokeForTaskAsync(string taskId, string method, params object?[] args)
{
await _hub.InvokeAsync("RunNow", taskId);
using (LogContext.PushProperty("TaskId", taskId))
{
_logger.LogDebug("UI invoking {Method} for task {TaskId}", method, taskId);
await _hub.InvokeCoreAsync(method, args);
}
}
public async Task ContinueTaskAsync(string taskId, string followUpPrompt)
{
await _hub.InvokeAsync("ContinueTask", taskId, followUpPrompt);
}
public Task RunNowAsync(string taskId)
=> InvokeForTaskAsync(taskId, "RunNow", taskId);
public async Task ResetTaskAsync(string taskId)
{
await _hub.InvokeAsync("ResetTask", taskId);
}
public Task ContinueTaskAsync(string taskId, string followUpPrompt)
=> InvokeForTaskAsync(taskId, "ContinueTask", taskId, followUpPrompt);
public Task ResetTaskAsync(string taskId)
=> InvokeForTaskAsync(taskId, "ResetTask", taskId);
public async Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage)
{
@@ -269,28 +269,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
"MergeTask", taskId, targetBranch, removeWorktree, commitMessage);
}
public Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch)
=> _hub.InvokeAsync<MergeResultDto>("StartConflictMerge", taskId, targetBranch);
public Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId)
=> _hub.InvokeAsync<MergeConflictsDto>("GetMergeConflicts", taskId);
public Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent)
=> _hub.InvokeAsync("WriteConflictResolution", taskId, path, resolvedContent);
public Task<MergeResultDto> ContinueMergeAsync(string taskId)
=> _hub.InvokeAsync<MergeResultDto>("ContinueMerge", taskId);
public Task AbortMergeAsync(string taskId)
=> _hub.InvokeAsync("AbortMerge", taskId);
public Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId)
=> TryInvokeAsync<MergeTargetsDto>("GetMergeTargets", taskId);
public async Task CancelTaskAsync(string taskId)
{
await _hub.InvokeAsync("CancelTask", taskId);
}
public Task CancelTaskAsync(string taskId)
=> InvokeForTaskAsync(taskId, "CancelTask", taskId);
public async Task WakeQueueAsync()
{
@@ -368,8 +351,6 @@ 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");
@@ -411,29 +392,17 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
await _hub.InvokeAsync("SetTaskStatus", taskId, status.ToString());
}
public Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch)
{
LastApproveTarget = targetBranch;
return TryInvokeAsync<MergeResultDto>("ApproveReview", taskId, targetBranch);
}
public Task ApproveReviewAsync(string taskId)
=> InvokeForTaskAsync(taskId, "ApproveReview", taskId);
public Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch)
=> TryInvokeAsync<MergePreviewDto>("PreviewMerge", taskId, targetBranch);
public Task RejectReviewToQueueAsync(string taskId, string feedback)
=> InvokeForTaskAsync(taskId, "RejectReviewToQueue", taskId, feedback);
public async Task RejectReviewToQueueAsync(string taskId, string feedback)
{
await _hub.InvokeAsync("RejectReviewToQueue", taskId, feedback);
}
public Task RejectReviewToIdleAsync(string taskId)
=> InvokeForTaskAsync(taskId, "RejectReviewToIdle", taskId);
public async Task RejectReviewToIdleAsync(string taskId)
{
await _hub.InvokeAsync("RejectReviewToIdle", taskId);
}
public async Task CancelReviewAsync(string taskId)
{
await _hub.InvokeAsync("CancelReview", taskId);
}
public Task CancelReviewAsync(string taskId)
=> InvokeForTaskAsync(taskId, "CancelReview", taskId);
public Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync(string? listId = null)
=> TryInvokeAsync<WorktreeCleanupDto>("CleanupFinishedWorktrees", listId);
@@ -489,6 +458,12 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
public Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch)
=> TryInvokeAsync<CombinedDiffResultDto>("BuildPlanningIntegrationBranch", planningTaskId, targetBranch);
public async Task MergeAllPlanningAsync(string planningTaskId, string targetBranch)
{
LastMergeAllTarget = targetBranch;
await _hub.InvokeAsync("MergeAllPlanning", planningTaskId, targetBranch);
}
public async Task ContinuePlanningMergeAsync(string planningTaskId)
{
await _hub.InvokeAsync("ContinuePlanningMerge", planningTaskId);
@@ -542,11 +517,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 record MergeConflictsDto(string TaskId, IReadOnlyList<ConflictFileDto> Files);
public record ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks);
public record ConflictHunkDto(string Ours, string Theirs, string? Base);
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);
public sealed record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);

View File

@@ -1,49 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace ClaudeDo.Ui.ViewModels.Conflicts;
public sealed partial class ConflictHunk : ObservableObject
{
public string Ours { get; }
public string Theirs { get; }
public string? Base { get; }
[ObservableProperty] private string? _resolution;
public bool IsResolved => Resolution is not null;
public ConflictHunk(string ours, string theirs, string? @base)
{
Ours = ours;
Theirs = theirs;
Base = @base;
}
partial void OnResolutionChanged(string? value) => OnPropertyChanged(nameof(IsResolved));
[RelayCommand] private void AcceptCurrent() => Resolution = Ours;
[RelayCommand] private void AcceptIncoming() => Resolution = Theirs;
[RelayCommand] private void AcceptBoth() => Resolution = Ours + Theirs;
[RelayCommand] private void EditManually() => Resolution ??= Ours;
}
public sealed class ConflictFile
{
public string Path { get; }
public IReadOnlyList<ConflictHunk> Hunks { get; }
public ConflictFile(string path, IReadOnlyList<ConflictHunk> hunks)
{
Path = path;
Hunks = hunks;
}
public bool AllHunksResolved => Hunks.Count > 0 && Hunks.All(h => h.IsResolved);
/// <summary>Merged file content: concatenation of each hunk's resolution
/// (single whole-file hunk today; concatenation stays correct for multi-hunk later).</summary>
public string ComposeResolvedContent() => string.Concat(Hunks.Select(h => h.Resolution));
}

View File

@@ -1,116 +0,0 @@
using System;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ClaudeDo.Ui.Services;
namespace ClaudeDo.Ui.ViewModels.Conflicts;
public sealed partial class ConflictResolverViewModel : ObservableObject
{
private readonly IWorkerClient _worker;
private readonly string _taskId;
public ObservableCollection<ConflictFile> Files { get; } = new();
[ObservableProperty] private bool _isBusy;
[ObservableProperty] private string? _error;
[ObservableProperty] private bool _canContinue;
public string TaskId => _taskId;
public Action? CloseRequested { get; set; }
public ConflictResolverViewModel(IWorkerClient worker, string taskId)
{
_worker = worker;
_taskId = taskId;
}
/// <summary>Starts the conflict merge and loads ours/theirs/base per file.
/// Returns true when there are conflicts to resolve (caller should show the dialog).</summary>
public async Task<bool> OpenAsync(string targetBranch)
{
IsBusy = true;
Error = null;
try
{
var start = await _worker.StartConflictMergeAsync(_taskId, targetBranch);
if (!string.Equals(start.Status, "conflict", StringComparison.Ordinal))
{
if (string.Equals(start.Status, "blocked", StringComparison.Ordinal))
Error = start.ErrorMessage;
return false;
}
var conflicts = await _worker.GetMergeConflictsAsync(_taskId);
Files.Clear();
foreach (var f in conflicts.Files)
{
var hunks = f.Hunks.Select(h =>
{
var hk = new ConflictHunk(h.Ours, h.Theirs, h.Base);
hk.PropertyChanged += OnHunkChanged;
return hk;
}).ToList();
Files.Add(new ConflictFile(f.Path, hunks));
}
RecomputeCanContinue();
return Files.Count > 0;
}
catch (Exception ex)
{
Error = ex.Message;
return false;
}
finally { IsBusy = false; }
}
private void OnHunkChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName is nameof(ConflictHunk.IsResolved) or nameof(ConflictHunk.Resolution))
RecomputeCanContinue();
}
private void RecomputeCanContinue()
=> CanContinue = Files.Count > 0 && Files.All(f => f.AllHunksResolved);
[RelayCommand]
private async Task ContinueAsync()
{
if (!CanContinue) return;
IsBusy = true;
Error = null;
try
{
foreach (var file in Files)
await _worker.WriteConflictResolutionAsync(_taskId, file.Path, file.ComposeResolvedContent());
var result = await _worker.ContinueMergeAsync(_taskId);
if (string.Equals(result.Status, "merged", StringComparison.Ordinal))
CloseRequested?.Invoke();
else
Error = result.ErrorMessage ?? "Conflicts not fully resolved — review and retry.";
}
catch (Exception ex)
{
Error = ex.Message;
}
finally { IsBusy = false; }
}
[RelayCommand]
private async Task AbortAsync()
{
IsBusy = true;
try { await _worker.AbortMergeAsync(_taskId); }
catch (Exception ex) { Error = ex.Message; }
finally
{
IsBusy = false;
CloseRequested?.Invoke();
}
}
}

View File

@@ -46,21 +46,13 @@ public sealed class LogLineViewModel
};
}
public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
public sealed partial class DetailsIslandViewModel : ViewModelBase
{
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly IWorkerClient _worker;
private readonly IServiceProvider _services;
private readonly INotesApi _notesApi;
// Captured handler delegates for disposal
private readonly EventHandler _langChangedHandler;
private readonly System.ComponentModel.PropertyChangedEventHandler _workerPropertyChangedHandler;
private readonly Action<string, string, DateTime> _workerTaskStartedHandler;
private readonly Action<string, string, string, DateTime> _workerTaskFinishedHandler;
private readonly Action<string> _workerWorktreeUpdatedHandler;
private readonly Action<string> _workerTaskUpdatedHandler;
[ObservableProperty] private bool _isNotesMode;
[ObservableProperty] private bool _isPrepMode;
[ObservableProperty] private bool _isPrepRunning;
@@ -82,12 +74,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
private TaskRowViewModel? _task;
// Editable fields
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ComposedPreview))]
private string _editableTitle = "";
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ComposedPreview))]
private string _editableDescription = "";
[ObservableProperty] private string _editableTitle = "";
[ObservableProperty] private string _editableDescription = "";
[ObservableProperty] private bool _isEditingDescription;
[ObservableProperty] private bool _isDescriptionExpanded = true;
@@ -112,129 +100,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
[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;
private void NotifySessionSections()
{
OnPropertyChanged(nameof(HasChildOutcomes));
OnPropertyChanged(nameof(ShowMergeSection));
NotifyAttention();
// The Session tab is only visible when it has outcomes; if it just
// emptied while selected, fall back to Output so the body isn't blank.
if (!HasChildOutcomes && SelectedTab == "session")
SelectedTab = "output";
}
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." : "";
// The session's outcome summary — the task's Result minus any roadblock
// section (those get their own card), falling back to the run's
// ErrorMarkdown for hard failures. Shown once a run has finished.
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowSessionOutcome))]
private string? _sessionOutcome;
public bool ShowSessionOutcome =>
!string.IsNullOrWhiteSpace(SessionOutcome)
&& (IsWaitingForReview || IsDone || IsFailed || IsCancelled);
// The roadblocks the agent emitted (CLAUDEDO_BLOCKED), parsed out of the
// run result so they can surface as a distinct colored card.
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ShowRoadblockCard))]
private string? _roadblocks;
public bool ShowRoadblockCard =>
!string.IsNullOrWhiteSpace(Roadblocks)
&& (IsWaitingForReview || IsDone || IsFailed || IsCancelled);
// Worker writes roadblocks into the result under this header
// (TaskRunner.ComposeReviewResult). Split it back out for display.
private const string RoadblockMarker = "Roadblocks reported during the run:";
private void ApplyOutcome(string? result, string? errorFallback)
{
if (string.IsNullOrWhiteSpace(result))
{
SessionOutcome = errorFallback;
Roadblocks = null;
return;
}
var idx = result.IndexOf(RoadblockMarker, StringComparison.Ordinal);
if (idx < 0)
{
SessionOutcome = result;
Roadblocks = null;
return;
}
var summary = result[..idx].TrimEnd().TrimEnd('⚠').TrimEnd();
SessionOutcome = string.IsNullOrWhiteSpace(summary) ? null : summary;
Roadblocks = result[(idx + RoadblockMarker.Length)..].Trim();
}
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()}" : "";
@@ -277,11 +142,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
OnPropertyChanged(nameof(ShowContinue));
OnPropertyChanged(nameof(ShowResetAndRetry));
OnPropertyChanged(nameof(IsAgentSectionEnabled));
OnPropertyChanged(nameof(ShowRoadblock));
OnPropertyChanged(nameof(RoadblockMessage));
OnPropertyChanged(nameof(ShowSessionOutcome));
OnPropertyChanged(nameof(ShowRoadblockCard));
NotifySessionSections();
}
[ObservableProperty] private string? _model;
@@ -304,13 +164,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
private string? _listAgentName;
partial void OnTaskModelSelectionChanged(string? value) { RecomputeModelBadge(); QueueAgentSave(); }
partial void OnTaskMaxTurnsChanged(decimal? value)
{
RecomputeTurnsBadge();
OnPropertyChanged(nameof(EffectiveMaxTurns));
OnPropertyChanged(nameof(TurnsText));
QueueAgentSave();
}
partial void OnTaskMaxTurnsChanged(decimal? value) { RecomputeTurnsBadge(); QueueAgentSave(); }
private void RecomputeModelBadge()
{
@@ -355,11 +209,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
[ObservableProperty] private string? _worktreePath;
[ObservableProperty] private string? _worktreeBaseCommit;
[ObservableProperty] private string? _worktreeHeadCommit;
[ObservableProperty] private string? _worktreeStateLabel;
// Repo working dir of the selected task's list — used to diff a merged task's
// commit range after its worktree directory is gone.
private string? _listWorkingDir;
[ObservableProperty] private string? _branchLine;
[ObservableProperty] private int _turns;
[ObservableProperty] private int _tokens;
@@ -371,9 +221,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
public string ElapsedFormatted => ""; // placeholder — no start-time stored yet
partial void OnTokensChanged(int value) => OnPropertyChanged(nameof(TokensFormatted));
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)); }
partial void OnDiffAdditionsChanged(int value) => OnPropertyChanged(nameof(DiffMeterRatio));
partial void OnDiffDeletionsChanged(int value) => OnPropertyChanged(nameof(DiffMeterRatio));
// 0.01.0 additions share for the diff meter
public double DiffMeterRatio
@@ -393,47 +242,16 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
public ObservableCollection<ChildOutcomeRowViewModel> ChildOutcomes { get; } = new();
public bool HasChildOutcomes => ChildOutcomes.Count > 0;
// Children that need the user's attention before the parent can be approved:
// failed, cancelled, still awaiting their own review, or that reported roadblocks.
// The parent deliberately stays in WaitingForChildren until these are resolved;
// this surfaces a flag so the roadblock is visible on the parent.
public int ChildrenNeedingAttention => ChildOutcomes.Count(c =>
c.Status == ClaudeDo.Data.Models.TaskStatus.Failed
|| c.Status == ClaudeDo.Data.Models.TaskStatus.Cancelled
|| c.Status == ClaudeDo.Data.Models.TaskStatus.WaitingForReview
|| c.RoadblockCount > 0);
public bool HasChildrenNeedingAttention => ChildrenNeedingAttention > 0;
public string ChildrenAttentionText => ChildrenNeedingAttention == 1
? "1 child needs attention"
: $"{ChildrenNeedingAttention} children need attention";
private void NotifyAttention()
{
OnPropertyChanged(nameof(ChildrenNeedingAttention));
OnPropertyChanged(nameof(HasChildrenNeedingAttention));
OnPropertyChanged(nameof(ChildrenAttentionText));
}
[ObservableProperty] private string _newSubtaskTitle = "";
// Planning merge controls
[ObservableProperty] private ObservableCollection<string> _mergeTargetBranches = new();
[ObservableProperty] private string? _selectedMergeTarget;
[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);
[NotifyCanExecuteChangedFor(nameof(MergeAllCommand))]
private bool _canMergeAll;
[ObservableProperty] private string? _mergeAllDisabledReason;
[ObservableProperty] private string? _mergeAllError;
// Claude CLI stream-json parser + buffer for partial text deltas
private readonly StreamLineFormatter _formatter = new();
@@ -466,10 +284,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
// Set by the view so DeleteTaskCommand can show an error message
public Func<string, System.Threading.Tasks.Task>? ShowErrorAsync { get; set; }
// Invoked when a single-task merge/approve hits a conflict. Wired by the
// integrator to Layer C's conflict resolver. Args: (taskId, targetBranch).
public Func<string, string, System.Threading.Tasks.Task>? RequestConflictResolution { get; set; }
private static string StatusToStateKey(ClaudeDo.Data.Models.TaskStatus status) => status switch
{
ClaudeDo.Data.Models.TaskStatus.Queued => "queued",
@@ -507,21 +321,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
catch { }
}
// Reload the session outcome (task Result incl. roadblocks, or the run's
// error) so it appears as soon as a run finishes.
private async System.Threading.Tasks.Task RefreshOutcomeAsync(string taskId)
{
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
var entity = await ctx.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId);
var latestRun = await new TaskRunRepository(ctx).GetLatestByTaskIdAsync(taskId);
if (Task?.Id != taskId) return;
ApplyOutcome(entity?.Result, latestRun?.ErrorMarkdown);
}
catch { }
}
public DetailsIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient worker, IServiceProvider services, INotesApi notesApi)
{
_dbFactory = dbFactory;
@@ -529,15 +328,13 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
_services = services;
_notesApi = notesApi;
Notes = new NotesEditorViewModel(_notesApi);
Subtasks.CollectionChanged += (_, _) => NotifyStepsChanged();
_langChangedHandler = (_, _) =>
Loc.LanguageChanged += (_, _) =>
{
OnPropertyChanged(nameof(AgentStatusLabel));
RecomputeModelBadge();
RecomputeTurnsBadge();
RecomputeAgentBadge();
};
Loc.LanguageChanged += _langChangedHandler;
// Subscribe once; filter by current task id inside the handler
_worker.TaskMessageEvent += OnTaskMessage;
@@ -546,7 +343,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
_worker.PrepFinishedEvent += OnPrepFinished;
// Re-evaluate CanExecute when worker connection flips.
_workerPropertyChangedHandler = (_, e) =>
_worker.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(WorkerClient.IsConnected))
{
@@ -556,17 +353,14 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
ContinueCommand.NotifyCanExecuteChanged();
}
};
_worker.PropertyChanged += _workerPropertyChangedHandler;
// If the task row's live status changes (e.g. TaskStarted/Finished), mirror it.
_workerTaskStartedHandler = (slot, taskId, startedAt) =>
_worker.TaskStartedEvent += (slot, taskId, startedAt) =>
{
if (Task?.Id == taskId) AgentState = "running";
_ = RefreshChildOutcomeAsync(taskId);
};
_worker.TaskStartedEvent += _workerTaskStartedHandler;
_workerTaskFinishedHandler = (slot, taskId, status, finishedAt) =>
_worker.TaskFinishedEvent += (slot, taskId, status, finishedAt) =>
{
if (Task?.Id != taskId) return;
FlushClaudeBuffer();
@@ -579,54 +373,37 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
// Re-query to pick up worktree created during the run.
_ = RefreshWorktreeAsync(taskId);
_ = RefreshChildOutcomeAsync(taskId);
_ = RefreshOutcomeAsync(taskId);
};
_worker.TaskFinishedEvent += _workerTaskFinishedHandler;
_workerWorktreeUpdatedHandler = taskId =>
_worker.WorktreeUpdatedEvent += taskId =>
{
if (Task?.Id == taskId) _ = RefreshWorktreeAsync(taskId);
if (Task?.IsPlanningParent == true) _ = RefreshPlanningChildAsync(taskId);
_ = RefreshChildOutcomeAsync(taskId);
};
_worker.WorktreeUpdatedEvent += _workerWorktreeUpdatedHandler;
_workerTaskUpdatedHandler = taskId =>
_worker.TaskUpdatedEvent += taskId =>
{
if (Task?.Id == taskId) _ = RefreshStatusAsync(taskId);
if (Task?.IsPlanningParent == true) _ = RefreshPlanningChildAsync(taskId);
_ = RefreshChildOutcomeAsync(taskId);
};
_worker.TaskUpdatedEvent += _workerTaskUpdatedHandler;
Subtasks.CollectionChanged += (_, _) =>
{
RecomputeCanMergeAll();
ReviewCombinedDiffCommand.NotifyCanExecuteChanged();
};
ChildOutcomes.CollectionChanged += (_, _) =>
{
RecomputeCanMergeAll();
ReviewCombinedDiffCommand.NotifyCanExecuteChanged();
NotifySessionSections();
};
PrepLog.CollectionChanged += (_, _) => OnPropertyChanged(nameof(ShowPrepEmptyState));
}
public void Dispose()
{
Loc.LanguageChanged -= _langChangedHandler;
_worker.PropertyChanged -= _workerPropertyChangedHandler;
_worker.TaskStartedEvent -= _workerTaskStartedHandler;
_worker.TaskFinishedEvent -= _workerTaskFinishedHandler;
_worker.WorktreeUpdatedEvent -= _workerWorktreeUpdatedHandler;
_worker.TaskUpdatedEvent -= _workerTaskUpdatedHandler;
_worker.TaskMessageEvent -= OnTaskMessage;
_worker.PrepStartedEvent -= OnPrepStarted;
_worker.PrepLineEvent -= OnPrepLine;
_worker.PrepFinishedEvent -= OnPrepFinished;
}
private void OnTaskMessage(string taskId, string line)
{
if (taskId != _subscribedTaskId) return;
@@ -799,8 +576,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
RecomputeModelBadge();
RecomputeTurnsBadge();
RecomputeAgentBadge();
OnPropertyChanged(nameof(EffectiveMaxTurns));
OnPropertyChanged(nameof(TurnsText));
}
finally
{
@@ -855,8 +630,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
OnPropertyChanged(nameof(HasChildOutcomes));
MergeTargetBranches.Clear();
SelectedMergeTarget = null;
SessionOutcome = null;
Roadblocks = null;
CanMergeAll = false;
MergeAllDisabledReason = null;
MergeAllError = null;
_claudeBuf.Clear();
if (row == null)
@@ -866,8 +642,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
EditableDescription = "";
Model = null;
WorktreePath = null;
WorktreeHeadCommit = null;
_listWorkingDir = null;
WorktreeStateLabel = null;
BranchLine = null;
DiffAdditions = 0;
@@ -904,7 +678,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
var entity = await ctx.Tasks
.AsNoTracking()
.Include(t => t.Worktree)
.Include(t => t.List)
.FirstOrDefaultAsync(t => t.Id == row.Id, ct);
ct.ThrowIfCancellationRequested();
if (entity == null) return;
@@ -914,10 +687,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
try { EditableDescription = entity.Description ?? ""; }
finally { _suppressDescSave = false; }
Model = entity.Model;
_listWorkingDir = entity.List?.WorkingDir;
WorktreePath = entity.Worktree?.Path;
WorktreeBaseCommit = entity.Worktree?.BaseCommit;
WorktreeHeadCommit = entity.Worktree?.HeadCommit;
WorktreeStateLabel = entity.Worktree?.State.ToString();
BranchLine = entity.Worktree is { } w ? $"{w.BranchName} \u2190 main" : null;
var (add, del) = ParseDiffStat(entity.Worktree?.DiffStat);
@@ -931,7 +702,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
var latestRun = await runRepo.GetLatestByTaskIdAsync(row.Id, ct);
ct.ThrowIfCancellationRequested();
LatestRunSessionId = latestRun?.SessionId;
ApplyOutcome(entity.Result, latestRun?.ErrorMarkdown);
// Subscribe only after DB load confirms the task exists
_subscribedTaskId = row.Id;
@@ -945,26 +715,13 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
Subtasks.Add(new SubtaskRowViewModel { Id = s.Id, Title = s.Title, Done = s.Completed });
if (entity.PlanningPhase != ClaudeDo.Data.Models.PlanningPhase.None)
await LoadPlanningChildrenAsync(row.Id, ct);
// Surface every parent's children — planning or improvement — in the
// Session tab with their live status + roadblock count. This is what
// makes the Session tab appear for planning parents and lets a child's
// roadblock register on the parent.
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 LoadPlanningChildrenAsync(row.Id, ct);
}
else
{
await LoadChildOutcomesAsync(row.Id, ct);
}
await RefreshMergePreviewAsync();
}
catch (OperationCanceledException) { }
}
@@ -1013,6 +770,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
}
}
RecomputeCanMergeAll();
}
catch (OperationCanceledException) { }
catch { /* best-effort */ }
@@ -1109,6 +867,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
}
}
RecomputeCanMergeAll();
}
catch (OperationCanceledException) { }
catch { /* best-effort */ }
@@ -1133,6 +892,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
existing.WorktreeState = child.Worktree?.State ?? ClaudeDo.Data.Models.WorktreeState.Active;
}
RecomputeCanMergeAll();
}
catch { /* best-effort */ }
}
@@ -1154,12 +914,54 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
row.Status = child.Status;
row.RoadblockCount = child.RoadblockCount;
row.WorktreeState = child.Worktree?.State ?? ClaudeDo.Data.Models.WorktreeState.Active;
RecomputeCanMergeAll();
MergeAllCommand.NotifyCanExecuteChanged();
ReviewCombinedDiffCommand.NotifyCanExecuteChanged();
NotifyAttention();
}
catch { /* best-effort */ }
}
internal void RecomputeCanMergeAll()
{
// Improvement parent: merge is allowed once every child is terminal. The
// orchestrator folds the parent's own branch and skips failed/cancelled children.
if (ChildOutcomes.Count > 0)
{
var unfinished = ChildOutcomes.Count(c =>
c.Status != ClaudeDo.Data.Models.TaskStatus.Done
&& c.Status != ClaudeDo.Data.Models.TaskStatus.Failed
&& c.Status != ClaudeDo.Data.Models.TaskStatus.Cancelled);
if (unfinished > 0)
{
CanMergeAll = false;
MergeAllDisabledReason = $"{unfinished} improvement(s) not finished";
return;
}
CanMergeAll = true;
MergeAllDisabledReason = null;
return;
}
var notDone = Subtasks.Count(c => c.Status != ClaudeDo.Data.Models.TaskStatus.Done);
if (notDone > 0)
{
CanMergeAll = false;
MergeAllDisabledReason = $"{notDone} subtask(s) not done";
return;
}
var badWt = Subtasks.FirstOrDefault(c =>
c.WorktreeState == ClaudeDo.Data.Models.WorktreeState.Discarded ||
c.WorktreeState == ClaudeDo.Data.Models.WorktreeState.Kept);
if (badWt is not null)
{
CanMergeAll = false;
MergeAllDisabledReason = "at least one worktree was discarded/kept";
return;
}
CanMergeAll = true;
MergeAllDisabledReason = null;
}
[RelayCommand(CanExecute = nameof(CanReviewDiff))]
private async System.Threading.Tasks.Task ReviewCombinedDiffAsync()
{
@@ -1171,6 +973,20 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
private bool CanReviewDiff() => (Task?.IsPlanningParent == true && Subtasks.Any()) || HasChildOutcomes;
[RelayCommand(CanExecute = nameof(CanMergeAll))]
private async System.Threading.Tasks.Task MergeAllAsync()
{
MergeAllError = null;
try
{
await _worker.MergeAllPlanningAsync(Task!.Id, SelectedMergeTarget ?? "main");
}
catch (Exception ex)
{
MergeAllError = ex.Message;
}
}
private async System.Threading.Tasks.Task RefreshWorktreeAsync(string taskId)
{
try
@@ -1179,14 +995,11 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
var entity = await ctx.Tasks
.AsNoTracking()
.Include(t => t.Worktree)
.Include(t => t.List)
.FirstOrDefaultAsync(t => t.Id == taskId);
if (entity == null || Task?.Id != taskId) return;
_listWorkingDir = entity.List?.WorkingDir;
WorktreePath = entity.Worktree?.Path;
WorktreeBaseCommit = entity.Worktree?.BaseCommit;
WorktreeHeadCommit = entity.Worktree?.HeadCommit;
WorktreeStateLabel = entity.Worktree?.State.ToString();
BranchLine = entity.Worktree is { } w ? $"{w.BranchName} ← main" : null;
AgentState = StatusToStateKey(entity.Status);
@@ -1199,78 +1012,24 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
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(CanExecute = nameof(CanOpenDiff))]
private async System.Threading.Tasks.Task OpenDiffAsync()
{
if (ShowDiffModal == null) return;
var git = _services.GetRequiredService<ClaudeDo.Data.Git.GitService>();
// Active worktree on disk → diff the worktree live (and allow merging from it).
var hasLiveWorktree =
WorktreePath != null
&& WorktreeStateLabel == "Active"
&& System.IO.Directory.Exists(WorktreePath);
DiffModalViewModel diffVm;
if (hasLiveWorktree)
if (WorktreePath == null || ShowDiffModal == null) return;
var diffVm = new DiffModalViewModel(_services.GetRequiredService<ClaudeDo.Data.Git.GitService>())
{
diffVm = new DiffModalViewModel(git)
{
WorktreePath = WorktreePath!,
BaseRef = WorktreeBaseCommit,
TaskId = Task?.Id,
TaskTitle = Task?.Title ?? "",
ShowMergeModal = ShowMergeModal,
ResolveMergeVm = () => _services.GetRequiredService<MergeModalViewModel>(),
};
}
else if (CanDiffMergedRange)
{
// Worktree is gone (merged/discarded) but the commits survive on the
// target branch — diff the captured base..head range in the repo. No
// merge action: the work is already integrated.
diffVm = new DiffModalViewModel(git)
{
WorktreePath = _listWorkingDir!,
BaseRef = WorktreeBaseCommit,
HeadCommit = WorktreeHeadCommit,
FromCommitRange = true,
TaskId = Task?.Id,
TaskTitle = Task?.Title ?? "",
};
}
else return;
WorktreePath = WorktreePath,
BaseRef = WorktreeBaseCommit,
TaskId = Task?.Id,
TaskTitle = Task?.Title ?? "",
ShowMergeModal = ShowMergeModal,
ResolveMergeVm = () => _services.GetRequiredService<MergeModalViewModel>(),
};
await diffVm.LoadAsync();
await ShowDiffModal(diffVm);
}
private bool CanDiffMergedRange =>
WorktreeBaseCommit != null && WorktreeHeadCommit != null && _listWorkingDir != null;
private bool CanOpenDiff() => WorktreePath != null || CanDiffMergedRange;
private bool CanOpenDiff() => WorktreePath != null;
[RelayCommand(CanExecute = nameof(CanOpenWorktree))]
private void OpenWorktree()
@@ -1289,23 +1048,12 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
private bool CanOpenWorktree() => WorktreePath != null;
partial void OnSelectedMergeTargetChanged(string? value)
{
_ = RefreshMergePreviewAsync();
}
partial void OnWorktreePathChanged(string? value)
{
OpenDiffCommand.NotifyCanExecuteChanged();
OpenWorktreeCommand.NotifyCanExecuteChanged();
NotifySessionSections();
}
partial void OnWorktreeHeadCommitChanged(string? value) =>
OpenDiffCommand.NotifyCanExecuteChanged();
partial void OnTaskChanged(TaskRowViewModel? value) => NotifySessionSections();
[RelayCommand]
private void CloseDetails() => CloseDetail?.Invoke();
@@ -1344,7 +1092,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
{
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 ?? "");
@@ -1410,7 +1157,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
await repo.UpdateAsync(entity);
}
row.Title = title;
OnPropertyChanged(nameof(ComposedPreview));
}
[RelayCommand]
@@ -1440,10 +1186,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
[RelayCommand]
private async System.Threading.Tasks.Task StopAsync()
{
if (Task == null || !IsRunning) return;
if (!_worker.IsConnected) return;
try { await _worker.CancelTaskAsync(Task.Id); }
catch { /* offline */ }
if (Task == null) return;
await _worker.CancelTaskAsync(Task.Id);
}
[RelayCommand(CanExecute = nameof(CanEnqueue))]
@@ -1523,32 +1267,11 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
private async System.Threading.Tasks.Task ApproveReviewAsync()
{
if (Task is null || !_worker.IsConnected) return;
try
{
var hasChildren = Subtasks.Count > 0 || ChildOutcomes.Count > 0;
var result = await _worker.ApproveReviewAsync(Task.Id, SelectedMergeTarget ?? "");
if (!hasChildren && result?.Status == "conflict")
{
if (RequestConflictResolution is not null)
{
await RequestConflictResolution(Task.Id, SelectedMergeTarget ?? "");
}
else
{
var (text, _, _) = MergePreviewPresenter.Describe(
new MergePreviewDto("conflict", result.ConflictFiles, 0));
MergePreviewText = text; MergeIsClean = false; MergeIsConflict = true;
}
}
// hasChildren: conflicts arrive via PlanningMergeConflictEvent → conflict dialog
}
catch (Exception ex)
{
// A real failure (e.g. a child still needs attention, so the unit can't
// be approved yet) must not vanish — tell the user why nothing happened.
if (ShowErrorAsync != null)
await ShowErrorAsync(ex.Message);
}
// 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); }
catch { /* stale review action; broadcast reconciles */ }
}
[RelayCommand]
@@ -1563,14 +1286,10 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
}
[RelayCommand]
private async System.Threading.Tasks.Task ResetReviewAsync()
private async System.Threading.Tasks.Task ParkReviewAsync()
{
if (Task is null || !_worker.IsConnected || ConfirmAsync is null) return;
var branchName = $"claudedo/{Task.Id.Replace("-", "")}";
var ok = await ConfirmAsync(
$"Reset working tree?\nThis discards branch {branchName} (and all changes) and returns the task to Idle.");
if (!ok) return;
try { await _worker.ResetTaskAsync(Task.Id); }
if (Task is null || !_worker.IsConnected) return;
try { await _worker.RejectReviewToIdleAsync(Task.Id); }
catch { /* stale review action; broadcast reconciles */ }
}

View File

@@ -16,7 +16,7 @@ namespace ClaudeDo.Ui.ViewModels.Islands;
public enum ListKind { Smart, Virtual, User }
public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
public sealed partial class ListsIslandViewModel : ViewModelBase
{
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly IServiceProvider? _services;
@@ -141,8 +141,6 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
public string MachineNameLocal => Loc.T("vm.lists.localSuffix", MachineName);
public string UserInitials { get; }
private readonly EventHandler _langChangedHandler;
public ListsIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IServiceProvider? services = null, WorkerClient? worker = null)
{
_dbFactory = dbFactory;
@@ -165,13 +163,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
_worker.ConnectionRestoredEvent += () => _ = RefreshCountsAsync();
}
_langChangedHandler = (_, _) => RefreshLocalizedLabels();
Loc.LanguageChanged += _langChangedHandler;
}
public void Dispose()
{
Loc.LanguageChanged -= _langChangedHandler;
Loc.LanguageChanged += (_, _) => RefreshLocalizedLabels();
}
private static string? SmartListNameKey(string id) => id switch

View File

@@ -1,28 +0,0 @@
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,9 +32,6 @@ 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"));
@@ -128,7 +125,6 @@ public sealed partial class TaskRowViewModel : ViewModelBase
OnPropertyChanged(nameof(CanOpenPlanningSession));
OnPropertyChanged(nameof(CanRemoveFromQueue));
OnPropertyChanged(nameof(CanSendToQueue));
OnPropertyChanged(nameof(CanRefine));
}
partial void OnParentTaskIdChanged(string? value)
@@ -159,11 +155,8 @@ 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

@@ -14,7 +14,7 @@ using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Ui.ViewModels.Islands;
public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
public sealed partial class TasksIslandViewModel : ViewModelBase
{
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
private readonly IWorkerClient? _worker;
@@ -71,8 +71,6 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
public Func<UnfinishedPlanningModalViewModel, Task>? ShowUnfinishedPlanningModal { get; set; }
private readonly EventHandler _langChangedHandler;
public TasksIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient? worker = null)
{
_dbFactory = dbFactory;
@@ -84,16 +82,8 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
_worker.WorktreeUpdatedEvent += OnWorkerTaskUpdated;
_worker.ListUpdatedEvent += OnWorkerListUpdated;
_worker.ConnectionRestoredEvent += () => LoadForList(_currentList);
_worker.RefineStartedEvent += OnRefineStarted;
_worker.RefineFinishedEvent += OnRefineFinished;
}
_langChangedHandler = (_, _) => RefreshLocalizedText();
Loc.LanguageChanged += _langChangedHandler;
}
public void Dispose()
{
Loc.LanguageChanged -= _langChangedHandler;
Loc.LanguageChanged += (_, _) => RefreshLocalizedText();
}
private void RefreshLocalizedText()
@@ -186,7 +176,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
ListKind.Smart when list.Id == "smart:my-day" => t.IsMyDay,
ListKind.Smart when list.Id == "smart:important" => t.IsStarred,
ListKind.Smart when list.Id == "smart:planned" => t.ScheduledFor != null,
ListKind.Virtual when list.Id == "virtual:review" => t.Status == TaskStatus.WaitingForReview,
ListKind.Virtual when list.Id == "virtual:review" => t.Status == TaskStatus.Done && t.Worktree?.State == WorktreeState.Active && t.ParentTaskId == null,
ListKind.User => $"user:{t.ListId}" == list.Id,
_ => false,
};
@@ -655,7 +645,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
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 */ }
}
@@ -840,27 +830,6 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
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

@@ -15,7 +15,7 @@ using Microsoft.EntityFrameworkCore;
namespace ClaudeDo.Ui.ViewModels;
public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
public sealed partial class IslandsShellViewModel : ViewModelBase
{
public ListsIslandViewModel? Lists { get; }
public TasksIslandViewModel? Tasks { get; }
@@ -44,20 +44,6 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
// Set by MainWindow to open the conflict resolution dialog.
public Func<ConflictResolutionViewModel, Task>? ShowConflictDialog { get; set; }
// Layer C seam: composition root sets the factory; MainWindow sets the dialog opener.
// The integrator connects Layer A/B's RequestConflictResolution(taskId, target) to this method.
public Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>? ConflictResolverFactory { get; set; }
public Func<ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel, Task>? ShowConflictResolver { get; set; }
public async Task RequestConflictResolutionAsync(string taskId, string targetBranch)
{
if (ConflictResolverFactory is null || ShowConflictResolver is null) return;
var vm = ConflictResolverFactory(taskId);
var hasConflicts = await vm.OpenAsync(targetBranch);
if (hasConflicts)
await ShowConflictResolver(vm);
}
// Set by MainWindow to open the About dialog.
public Func<AboutModalViewModel, Task>? ShowAboutModal { get; set; }
@@ -154,23 +140,21 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
if (ShowConflictDialog == null || _dbFactory == null) return;
string subtaskTitle = subtaskId;
// The conflict lives in the list's working dir (the repo being merged into),
// not the subtask worktree. VS Code must open this folder to show the merge UI.
string repoDirectory = System.Environment.CurrentDirectory;
string targetBranch = Worker?.LastApproveTarget ?? "main";
string worktreePath = System.Environment.CurrentDirectory;
string targetBranch = Worker?.LastMergeAllTarget ?? "main";
try
{
await using var ctx = await _dbFactory.CreateDbContextAsync();
var entity = await ctx.Tasks
.Include(t => t.List)
.Include(t => t.Worktree)
.AsNoTracking()
.FirstOrDefaultAsync(t => t.Id == subtaskId);
if (entity != null)
{
subtaskTitle = entity.Title;
if (entity.List?.WorkingDir is { } dir && !string.IsNullOrWhiteSpace(dir))
repoDirectory = dir;
if (entity.Worktree?.Path is { } p)
worktreePath = p;
}
}
catch { /* Non-fatal: fall back to subtaskId and cwd */ }
@@ -181,7 +165,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
subtaskTitle,
targetBranch,
conflictedFiles,
repoDirectory);
worktreePath);
await ShowConflictDialog(vm);
}
@@ -229,7 +213,6 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
_ = Lists.RefreshCountsAsync();
return System.Threading.Tasks.Task.CompletedTask;
};
Details.RequestConflictResolution = RequestConflictResolutionAsync;
Worker.PropertyChanged += (_, e) =>
{
if (e.PropertyName is nameof(WorkerClient.IsConnected) or nameof(WorkerClient.IsReconnecting))
@@ -270,16 +253,6 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
});
}
public void Dispose()
{
_clearTimer.Stop();
_clearTimer.Dispose();
_connectTimer.Stop();
_connectTimer.Dispose();
_primeStatusTimer.Stop();
_primeStatusTimer.Dispose();
}
private void RefreshBannerFromStatus()
{
switch (_updateCheck.LastCheckStatus)

View File

@@ -8,8 +8,6 @@ namespace ClaudeDo.Ui.ViewModels.Modals;
public enum DiffLineKind { Add, Del, Ctx, File }
public enum DiffFileStatus { Modified, Added, Deleted, Renamed }
public sealed class DiffLineViewModel
{
public required DiffLineKind Kind { get; init; }
@@ -34,27 +32,10 @@ public sealed class DiffLineViewModel
public sealed class DiffFileViewModel
{
public required string Path { get; set; }
public string? OldPath { get; set; }
public DiffFileStatus Status { get; set; } = DiffFileStatus.Modified;
public bool IsBinary { get; set; }
public required string Path { get; init; }
public int Additions { get; set; }
public int Deletions { get; set; }
public ObservableCollection<DiffLineViewModel> Lines { get; } = new();
/// Single-letter badge for the file's change kind (A/M/D/R).
public string StatusCode => Status switch
{
DiffFileStatus.Added => "A",
DiffFileStatus.Deleted => "D",
DiffFileStatus.Renamed => "R",
_ => "M",
};
public bool HasLines => Lines.Count > 0;
/// A text file that produced no diff hunks (e.g. a newly added empty file).
public bool IsEmptyContent => !IsBinary && Lines.Count == 0;
}
public sealed partial class DiffModalViewModel : ViewModelBase
@@ -63,11 +44,6 @@ public sealed partial class DiffModalViewModel : ViewModelBase
public required string WorktreePath { get; init; }
public string? BaseRef { get; init; }
/// When set together with <see cref="FromCommitRange"/>, the diff is computed as
/// <c>BaseRef..HeadCommit</c> inside <see cref="WorktreePath"/> (used as the repo
/// dir) — lets a merged task's diff be viewed after its worktree is gone.
public string? HeadCommit { get; init; }
public bool FromCommitRange { get; init; }
public string? TaskId { get; init; }
public string TaskTitle { get; init; } = "";
public Func<MergeModalViewModel, Task>? ShowMergeModal { get; set; }
@@ -101,8 +77,6 @@ public sealed partial class DiffModalViewModel : ViewModelBase
var vm = ResolveMergeVm();
await vm.InitializeAsync(TaskId, TaskTitle);
await ShowMergeModal(vm);
// The diff is stale once the worktree has been merged away — close it too.
if (vm.Merged) CloseAction?.Invoke();
}
public async Task LoadAsync(CancellationToken ct = default)
@@ -113,11 +87,9 @@ public sealed partial class DiffModalViewModel : ViewModelBase
string raw;
try
{
raw = FromCommitRange && BaseRef is not null && HeadCommit is not null
? await _git.GetCommitRangeDiffAsync(WorktreePath, BaseRef, HeadCommit, ct)
: BaseRef is not null
? await _git.GetBranchDiffAsync(WorktreePath, BaseRef, ct)
: await _git.GetDiffAsync(WorktreePath, ct);
raw = BaseRef is not null
? await _git.GetBranchDiffAsync(WorktreePath, BaseRef, ct)
: await _git.GetDiffAsync(WorktreePath, ct);
}
catch (Exception ex)
{

View File

@@ -28,10 +28,6 @@ public sealed partial class MergeModalViewModel : ViewModelBase
public Action? CloseAction { get; set; }
/// True once a merge has succeeded — lets the caller (e.g. the diff window)
/// close itself after this modal closes.
public bool Merged { get; private set; }
public MergeModalViewModel(WorkerClient worker)
{
_worker = worker;
@@ -84,7 +80,6 @@ public sealed partial class MergeModalViewModel : ViewModelBase
switch (result.Status)
{
case "merged":
Merged = true;
SuccessMessage = result.ErrorMessage is not null
? $"Merged with warning: {result.ErrorMessage}"
: Loc.T("vm.merge.merged");

View File

@@ -27,36 +27,6 @@ public static class UnifiedDiffParser
if (current == null) continue;
// File-level metadata that carries the change kind.
if (line.StartsWith("new file", StringComparison.Ordinal))
{
current.Status = DiffFileStatus.Added;
continue;
}
if (line.StartsWith("deleted file", StringComparison.Ordinal))
{
current.Status = DiffFileStatus.Deleted;
continue;
}
if (line.StartsWith("rename from ", StringComparison.Ordinal))
{
current.Status = DiffFileStatus.Renamed;
current.OldPath = line["rename from ".Length..];
continue;
}
if (line.StartsWith("rename to ", StringComparison.Ordinal))
{
current.Status = DiffFileStatus.Renamed;
current.Path = line["rename to ".Length..];
continue;
}
if (line.StartsWith("Binary files", StringComparison.Ordinal) ||
line.StartsWith("GIT binary patch", StringComparison.Ordinal))
{
current.IsBinary = true;
continue;
}
if (line.StartsWith("@@ ", StringComparison.Ordinal))
{
// e.g. "@@ -10,7 +10,9 @@"
@@ -64,15 +34,13 @@ public static class UnifiedDiffParser
continue;
}
// Skip remaining diff metadata lines
// Skip diff metadata lines
if (line.StartsWith("--- ", StringComparison.Ordinal) ||
line.StartsWith("+++ ", StringComparison.Ordinal) ||
line.StartsWith("index ", StringComparison.Ordinal) ||
line.StartsWith("old mode", StringComparison.Ordinal) ||
line.StartsWith("new mode", StringComparison.Ordinal) ||
line.StartsWith("similarity index", StringComparison.Ordinal) ||
line.StartsWith("copy from", StringComparison.Ordinal) ||
line.StartsWith("copy to", StringComparison.Ordinal))
line.StartsWith("new file", StringComparison.Ordinal) ||
line.StartsWith("deleted file", StringComparison.Ordinal) ||
line.StartsWith("Binary ", StringComparison.Ordinal))
continue;
if (line.StartsWith('+'))

View File

@@ -5,6 +5,14 @@ using ClaudeDo.Data.Git;
namespace ClaudeDo.Ui.ViewModels.Modals;
public enum WorktreeDiffLineKind { Header, Hunk, Added, Removed, Context }
public sealed partial class WorktreeDiffLineViewModel : ViewModelBase
{
public required string Text { get; init; }
public required WorktreeDiffLineKind Kind { get; init; }
}
public sealed partial class WorktreeNodeViewModel : ViewModelBase
{
public required string Name { get; init; }
@@ -20,7 +28,7 @@ public sealed partial class WorktreeModalViewModel : ViewModelBase
private readonly GitService _git;
public ObservableCollection<WorktreeNodeViewModel> Root { get; } = new();
public ObservableCollection<DiffLineViewModel> SelectedFileDiffLines { get; } = new();
public ObservableCollection<WorktreeDiffLineViewModel> SelectedFileDiffLines { get; } = new();
[ObservableProperty] private string _worktreePath = "";
[ObservableProperty] private string? _baseCommit;
@@ -56,8 +64,19 @@ public sealed partial class WorktreeModalViewModel : ViewModelBase
return;
}
foreach (var line in UnifiedDiffParser.Flatten(UnifiedDiffParser.Parse(diff)))
SelectedFileDiffLines.Add(line);
foreach (var line in diff.Split('\n'))
{
var kind = line switch
{
_ when line.StartsWith("+++") || line.StartsWith("---") => WorktreeDiffLineKind.Header,
_ when line.StartsWith("@@") => WorktreeDiffLineKind.Hunk,
_ when line.StartsWith('+') => WorktreeDiffLineKind.Added,
_ when line.StartsWith('-') => WorktreeDiffLineKind.Removed,
_ when line.StartsWith("diff ") || line.StartsWith("index ") || line.StartsWith("\\ ") => WorktreeDiffLineKind.Header,
_ => WorktreeDiffLineKind.Context,
};
SelectedFileDiffLines.Add(new WorktreeDiffLineViewModel { Text = line, Kind = kind });
}
}
[RelayCommand]

View File

@@ -12,8 +12,6 @@ using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Ui.ViewModels.Modals;
public enum BatchMergeOutcome { None, Merging, Merged, Conflict, Blocked, Failed }
public sealed partial class WorktreeOverviewRowViewModel : ViewModelBase
{
[ObservableProperty] private string _taskId = "";
@@ -29,14 +27,6 @@ public sealed partial class WorktreeOverviewRowViewModel : ViewModelBase
[ObservableProperty][NotifyPropertyChangedFor(nameof(AgeText))] private DateTime _createdAt;
[ObservableProperty] private bool _pathExistsOnDisk;
[ObservableProperty] private bool _isSelected;
[ObservableProperty] private bool _isChecked;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsConflict))]
[NotifyPropertyChangedFor(nameof(HasOutcome))]
private BatchMergeOutcome _mergeOutcome;
public bool IsConflict => MergeOutcome == BatchMergeOutcome.Conflict;
public bool HasOutcome => MergeOutcome != BatchMergeOutcome.None;
public string AgeText => FormatAge(DateTime.UtcNow - CreatedAt);
public bool IsActive => State == WorktreeState.Active;
@@ -69,18 +59,9 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
[ObservableProperty] private bool _isBusy;
[ObservableProperty] private string? _statusMessage;
[ObservableProperty] private WorktreeOverviewRowViewModel? _selectedRow;
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(MergeAllCommand))] private string? _selectedTarget;
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(MergeAllCommand))] private int _selectedCount;
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(MergeAllCommand))] private bool _isMerging;
[ObservableProperty] private string? _batchProgress;
public ObservableCollection<WorktreeOverviewRowViewModel> Rows { get; } = new();
public ObservableCollection<WorktreesGroupViewModel> Groups { get; } = new();
public ObservableCollection<string> MergeTargets { get; } = new();
public ObservableCollection<WorktreeOverviewRowViewModel> ConflictRows { get; } = new();
/// Inert seam wired by the integrator to Layer C's resolver at merge time. (taskId, targetBranch)
public Func<string, string, Task>? RequestConflictResolution { get; set; }
public Action? CloseAction { get; set; }
public Action<WorktreeModalViewModel>? ShowDiffAction { get; set; }
@@ -125,24 +106,20 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
Rows.Clear();
Groups.Clear();
ConflictRows.Clear();
SelectedCount = 0;
BatchProgress = null;
if (IsGlobal)
{
foreach (var grp in ordered.GroupBy(r => (r.ListId, r.ListName))
.OrderBy(g => g.Key.ListName, StringComparer.OrdinalIgnoreCase))
{
var group = new WorktreesGroupViewModel { ListId = grp.Key.ListId, ListName = grp.Key.ListName };
foreach (var row in grp) { HookRow(row); group.Rows.Add(row); }
foreach (var row in grp) group.Rows.Add(row);
Groups.Add(group);
}
}
else
{
foreach (var row in ordered) { HookRow(row); Rows.Add(row); }
foreach (var row in ordered) Rows.Add(row);
}
await LoadMergeTargetsAsync();
}
finally
{
@@ -278,125 +255,4 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
Path = d.Path, BranchName = d.BranchName, BaseCommit = d.BaseCommit, State = d.State,
DiffStat = d.DiffStat, CreatedAt = d.CreatedAt, PathExistsOnDisk = d.PathExistsOnDisk,
};
public IEnumerable<WorktreeOverviewRowViewModel> AllRows =>
IsGlobal ? Groups.SelectMany(g => g.Rows) : Rows;
private void HookRow(WorktreeOverviewRowViewModel row)
{
row.PropertyChanged += (_, e) =>
{
if (e.PropertyName is nameof(WorktreeOverviewRowViewModel.IsChecked)
or nameof(WorktreeOverviewRowViewModel.State))
RecomputeSelected();
};
}
private void RecomputeSelected() =>
SelectedCount = AllRows.Count(r => r.IsChecked && r.IsActive);
// Test seam: adds a row to the flat list with selection tracking wired up.
internal void AddRowForTest(WorktreeOverviewRowViewModel row)
{
HookRow(row);
Rows.Add(row);
}
private async Task LoadMergeTargetsAsync()
{
var anchor = AllRows.FirstOrDefault(r => r.IsActive);
if (anchor is null) { MergeTargets.Clear(); SelectedTarget = null; return; }
try
{
var targets = await _worker.GetMergeTargetsAsync(anchor.TaskId);
MergeTargets.Clear();
if (targets is null) { SelectedTarget = null; return; }
foreach (var b in targets.LocalBranches) MergeTargets.Add(b);
SelectedTarget = MergeTargets.Contains(targets.DefaultBranch)
? targets.DefaultBranch
: MergeTargets.FirstOrDefault();
}
catch { MergeTargets.Clear(); SelectedTarget = null; }
}
private bool CanMergeAll() => !IsMerging && SelectedCount > 0 && !string.IsNullOrWhiteSpace(SelectedTarget);
[RelayCommand(CanExecute = nameof(CanMergeAll))]
private Task MergeAll() => MergeSelectedAsync(_worker.MergeTaskAsync);
[RelayCommand]
private void ResolveConflict(WorktreeOverviewRowViewModel? row)
{
if (row is null) return;
RequestConflictResolution?.Invoke(row.TaskId, SelectedTarget ?? "");
}
[RelayCommand]
private void ToggleSelectAll()
{
var actives = AllRows.Where(r => r.IsActive).ToList();
var allChecked = actives.Count > 0 && actives.All(r => r.IsChecked);
foreach (var r in actives) r.IsChecked = !allChecked;
}
public async Task MergeSelectedAsync(
Func<string, string, bool, string, Task<MergeResultDto>> mergeFn,
CancellationToken ct = default)
{
var target = SelectedTarget;
if (string.IsNullOrWhiteSpace(target)) return;
var selected = AllRows.Where(r => r.IsChecked && r.IsActive).ToList();
if (selected.Count == 0) return;
IsMerging = true;
ConflictRows.Clear();
var done = 0;
try
{
foreach (var row in selected)
{
ct.ThrowIfCancellationRequested();
row.MergeOutcome = BatchMergeOutcome.Merging;
BatchProgress = Loc.T("vm.worktreesOverview.batchProgress", ++done, selected.Count);
MergeResultDto result;
try
{
result = await mergeFn(row.TaskId, target!, false,
Loc.T("vm.merge.commitMessage", row.TaskTitle));
}
catch
{
row.MergeOutcome = BatchMergeOutcome.Failed;
continue;
}
switch (result.Status)
{
case "merged":
row.MergeOutcome = BatchMergeOutcome.Merged;
row.State = WorktreeState.Merged;
row.IsChecked = false;
break;
case "conflict":
row.MergeOutcome = BatchMergeOutcome.Conflict;
ConflictRows.Add(row);
break;
case "blocked":
row.MergeOutcome = BatchMergeOutcome.Blocked;
break;
default:
row.MergeOutcome = BatchMergeOutcome.Failed;
break;
}
}
BatchProgress = Loc.T("vm.worktreesOverview.batchDone",
selected.Count(r => r.MergeOutcome == BatchMergeOutcome.Merged), ConflictRows.Count);
}
finally
{
IsMerging = false;
}
}
}

View File

@@ -10,10 +10,7 @@ public sealed partial class ConflictResolutionViewModel : ObservableObject
{
private readonly IWorkerClient _worker;
private readonly string _planningTaskId;
// The repository directory that is currently mid-merge (the list's working dir),
// NOT the subtask worktree. Opening this folder is what makes VS Code show its
// merge-conflict resolution UI.
private readonly string _repoDirectory;
private readonly string _worktreePath;
public string SubtaskTitle { get; }
public string TargetBranch { get; }
@@ -32,11 +29,11 @@ public sealed partial class ConflictResolutionViewModel : ObservableObject
string subtaskTitle,
string targetBranch,
IReadOnlyList<string> conflictedFiles,
string repoDirectory)
string worktreePath)
{
_worker = worker;
_planningTaskId = planningTaskId;
_repoDirectory = repoDirectory;
_worktreePath = worktreePath;
SubtaskTitle = subtaskTitle;
TargetBranch = targetBranch;
ConflictedFiles = conflictedFiles;
@@ -47,13 +44,12 @@ public sealed partial class ConflictResolutionViewModel : ObservableObject
{
try
{
// Open the folder that is mid-merge so VS Code shows the Source Control
// merge-conflict UI for every conflicted file. Opening individual files
// gives only a plain editor with no conflict resolution affordances.
var args = string.Join(" ", ConflictedFiles.Select(f => $"\"{f}\""));
Process.Start(new ProcessStartInfo
{
FileName = "code",
Arguments = $"\"{_repoDirectory}\"",
Arguments = args,
WorkingDirectory = _worktreePath,
UseShellExecute = true,
});
VsCodeError = null;

View File

@@ -1,82 +0,0 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Conflicts"
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
xmlns:loc="using:ClaudeDo.Ui.Localization"
x:DataType="vm:ConflictResolverViewModel"
x:Class="ClaudeDo.Ui.Views.Conflicts.ConflictResolverView"
Title="{loc:Tr conflictResolver.windowTitle}"
Width="760" Height="640" MinWidth="560" MinHeight="420"
CanResize="True"
WindowDecorations="BorderOnly"
ExtendClientAreaToDecorationsHint="True"
ExtendClientAreaTitleBarHeightHint="-1"
WindowStartupLocation="CenterOwner"
Background="{DynamicResource SurfaceBrush}">
<Window.KeyBindings>
<KeyBinding Gesture="Escape" Command="{Binding AbortCommand}"/>
</Window.KeyBindings>
<ctl:ModalShell Title="{loc:Tr conflictResolver.modalTitle}" CloseCommand="{Binding AbortCommand}">
<ctl:ModalShell.Footer>
<StackPanel Orientation="Horizontal" Spacing="8"
HorizontalAlignment="Right" VerticalAlignment="Center">
<Button Classes="btn" Content="{loc:Tr conflictResolver.continue}"
Command="{Binding ContinueCommand}" IsEnabled="{Binding CanContinue}"/>
<Button Classes="btn" Content="{loc:Tr conflictResolver.abort}" Command="{Binding AbortCommand}"/>
</StackPanel>
</ctl:ModalShell.Footer>
<Grid RowDefinitions="Auto,*" Margin="16,12">
<TextBlock Grid.Row="0" Classes="meta" Margin="0,0,0,8"
Text="{loc:Tr conflictResolver.loading}"
IsVisible="{Binding IsBusy}"/>
<TextBlock Grid.Row="0" Classes="meta" Foreground="{DynamicResource BloodBrush}"
Text="{Binding Error}" TextWrapping="Wrap"
IsVisible="{Binding Error, Converter={x:Static ObjectConverters.IsNotNull}}"/>
<ScrollViewer Grid.Row="1">
<ItemsControl ItemsSource="{Binding Files}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ConflictFile">
<StackPanel Spacing="8" Margin="0,0,0,16">
<TextBlock Classes="path-mono heading" Text="{Binding Path}"/>
<ItemsControl ItemsSource="{Binding Hunks}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:ConflictHunk">
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="1"
CornerRadius="6" Padding="10" Margin="0,0,0,8">
<StackPanel Spacing="6">
<TextBlock Classes="meta" Text="{loc:Tr conflictResolver.current}"/>
<TextBox Text="{Binding Ours, Mode=OneWay}" IsReadOnly="True"
AcceptsReturn="True" MaxHeight="120"/>
<TextBlock Classes="meta" Text="{loc:Tr conflictResolver.incoming}"/>
<TextBox Text="{Binding Theirs, Mode=OneWay}" IsReadOnly="True"
AcceptsReturn="True" MaxHeight="120"/>
<StackPanel Orientation="Horizontal" Spacing="6">
<Button Classes="btn" Content="{loc:Tr conflictResolver.acceptCurrent}"
Command="{Binding AcceptCurrentCommand}"/>
<Button Classes="btn" Content="{loc:Tr conflictResolver.acceptIncoming}"
Command="{Binding AcceptIncomingCommand}"/>
<Button Classes="btn" Content="{loc:Tr conflictResolver.acceptBoth}"
Command="{Binding AcceptBothCommand}"/>
<Button Classes="btn" Content="{loc:Tr conflictResolver.editManually}"
Command="{Binding EditManuallyCommand}"/>
</StackPanel>
<TextBlock Classes="meta" Text="{loc:Tr conflictResolver.mergedResult}"/>
<TextBox Text="{Binding Resolution, Mode=TwoWay}"
AcceptsReturn="True" MinHeight="80" MaxHeight="200"/>
</StackPanel>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Grid>
</ctl:ModalShell>
</Window>

View File

@@ -1,19 +0,0 @@
using Avalonia.Controls;
using ClaudeDo.Ui.ViewModels.Conflicts;
namespace ClaudeDo.Ui.Views.Conflicts;
public partial class ConflictResolverView : Window
{
public ConflictResolverView()
{
InitializeComponent();
}
protected override void OnDataContextChanged(System.EventArgs e)
{
base.OnDataContextChanged(e);
if (DataContext is ConflictResolverViewModel vm)
vm.CloseRequested = Close;
}
}

View File

@@ -1,169 +0,0 @@
<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.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="{loc:Tr details.copyFormattedTip}"
Click="OnCopyClick">
<PathIcon Data="{StaticResource Icon.Copy}" Width="11" Height="11"/>
</Button>
<!-- Preview/Edit toggle -->
<Button Grid.Column="3"
Classes="btn"
Padding="8,3"
ToolTip.Tip="{loc:Tr details.toggleEditPreviewTip}"
Command="{Binding ToggleEditDescriptionCommand}">
<Panel>
<TextBlock Text="Preview" IsVisible="{Binding IsEditingDescription}"/>
<TextBlock Text="Edit" IsVisible="{Binding !IsEditingDescription}"/>
</Panel>
</Button>
</Grid>
</Border>
<!-- Body (scrolls inside the card so the card fills its row to the divider) -->
<ScrollViewer VerticalScrollBarVisibility="Auto">
<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>
</ScrollViewer>
</DockPanel>
</Border>
</UserControl>

View File

@@ -1,35 +0,0 @@
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

@@ -1,125 +0,0 @@
<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="{loc:Tr details.deleteTaskTip}"
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="{loc:Tr details.killSessionTip}"
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

@@ -1,22 +0,0 @@
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

@@ -1,360 +0,0 @@
<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>
<!-- Traffic-light dot button: no chrome, just the ellipse -->
<Style Selector="Button.dot-btn">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Padding" Value="0" />
<Setter Property="CornerRadius" Value="0" />
<Setter Property="Cursor" Value="Hand" />
</Style>
<Style Selector="Button.dot-btn /template/ ContentPresenter">
<Setter Property="Background" Value="Transparent" />
</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" />
<Setter Property="TextElement.Foreground" Value="{StaticResource TextMuteBrush}" />
</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 /template/ ContentPresenter">
<Setter Property="TextElement.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 (decorative) -->
<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"
IsVisible="{Binding HasChildOutcomes}"
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">
<!-- Session outcome: the run's result summary, incl. any roadblocks
reported (or the error for a hard failure). -->
<Border DockPanel.Dock="Top"
Margin="12,8,12,4" Padding="10,8"
IsVisible="{Binding ShowSessionOutcome}"
Background="{DynamicResource Surface2Brush}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="1" CornerRadius="8">
<StackPanel Spacing="6">
<TextBlock Classes="section-label" Text="OUTCOME" />
<ScrollViewer MaxHeight="160" VerticalScrollBarVisibility="Auto">
<SelectableTextBlock Text="{Binding SessionOutcome}"
TextWrapping="Wrap"
Foreground="{DynamicResource TextDimBrush}"
FontFamily="{StaticResource MonoFont}"
FontSize="{StaticResource FontSizeMono}" />
</ScrollViewer>
</StackPanel>
</Border>
<!-- 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="[Continue]"
ToolTip.Tip="{loc:Tr session.reviewContinueTip}"
Command="{Binding RejectReviewCommand}" />
<Button Classes="prompt-action" Content="[Reset]"
ToolTip.Tip="{loc:Tr session.reviewResetTip}"
Command="{Binding ResetReviewCommand}" />
</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: one Approve + merge cockpit -->
<ScrollViewer IsVisible="{Binding IsGitTab}" Padding="14,10">
<StackPanel Spacing="12" IsVisible="{Binding ShowMergeSection}">
<TextBlock Classes="section-label" Text="MERGE" />
<StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="Target branch" />
<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>
<!-- Primary action: Approve flows straight into the merge. -->
<WrapPanel Orientation="Horizontal">
<Button Classes="btn accent" Content="Approve &amp; Merge" Margin="0,0,8,8"
Command="{Binding ApproveReviewCommand}"
IsVisible="{Binding IsWaitingForReview}" />
<Button Classes="btn" Content="Open Diff" Margin="0,0,8,8"
Command="{Binding OpenDiffCommand}" />
<Button Classes="btn" Margin="0,0,8,8"
ToolTip.Tip="{loc:Tr agent.openWorktreeTip}"
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}" />
</WrapPanel>
</StackPanel>
</ScrollViewer>
<!-- Session: subtask outcomes (review lives in Output, merge in Git) -->
<ScrollViewer IsVisible="{Binding IsSessionTab}" Padding="14,10">
<StackPanel Spacing="14">
<!-- Attention band: a child failed, was cancelled, still needs its own
review, or reported roadblocks. The parent stays waiting until resolved. -->
<Border IsVisible="{Binding HasChildrenNeedingAttention}"
Background="{DynamicResource ErrorTintBrush}"
BorderBrush="{DynamicResource BloodBrush}"
BorderThickness="1" CornerRadius="8" Padding="10,8">
<StackPanel Orientation="Horizontal" Spacing="8">
<PathIcon Data="{StaticResource Icon.Warning}"
Foreground="{DynamicResource BloodBrush}"
Width="14" Height="14" VerticalAlignment="Center" />
<TextBlock Classes="meta" Text="{Binding ChildrenAttentionText}"
Foreground="{DynamicResource BloodBrush}"
VerticalAlignment="Center" />
</StackPanel>
</Border>
<!-- 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>
</StackPanel>
</ScrollViewer>
</Grid>
</DockPanel>
</Border>
</UserControl>

View File

@@ -1,55 +0,0 @@
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,25 +2,32 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
xmlns:islands="using:ClaudeDo.Ui.Views.Islands"
xmlns:detail="using:ClaudeDo.Ui.Views.Islands.Detail"
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
xmlns:loc="using:ClaudeDo.Ui.Localization"
x:Class="ClaudeDo.Ui.Views.Islands.DetailsIslandView"
x:DataType="vm:DetailsIslandViewModel">
<DockPanel>
<!-- ── Metadata footer (sticky bottom) — created-at + close — task detail only ── -->
<!-- ── Metadata footer (sticky bottom) — task detail only ── -->
<Border DockPanel.Dock="Bottom"
IsVisible="{Binding IsTaskDetailVisible}"
BorderBrush="{DynamicResource LineBrush}"
BorderThickness="0,1,0,0"
Padding="14,8">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0"
<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"
Classes="meta"
Text="{Binding Task.CreatedAtFormatted}"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
<Button Grid.Column="1" Classes="icon-btn"
<Button Grid.Column="2" Classes="icon-btn"
Command="{Binding CloseDetailsCommand}"
ToolTip.Tip="{loc:Tr details.closeTip}"
VerticalAlignment="Center">
@@ -29,98 +36,377 @@
</Grid>
</Border>
<!-- ── Header (sticky top): id · title · trash/skull · gear — task detail only ── -->
<!-- ── Header (sticky top): check · eyebrow · title · status · star · gear — task detail only ── -->
<Border DockPanel.Dock="Top" Classes="island-header"
IsVisible="{Binding IsTaskDetailVisible}">
<detail:TaskHeaderBar/>
<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>
</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">
<!-- Task detail: description/steps card (upper) + pinned work console (lower) -->
<Grid x:Name="DetailBodyGrid"
IsVisible="{Binding IsTaskDetailVisible}"
Margin="14,12,14,12">
<Grid.RowDefinitions>
<!-- Auto: the description sizes to its content so the console takes
every spare pixel when it's short. Row limits are proportional
and set in code-behind (UpdateRowLimits): the description row is
capped at 2/3 of the island and the console row floored at 1/3,
so the console can be dragged down to (but not below) 1/3 and a
long description never spills over the footer. -->
<RowDefinition Height="Auto" MinHeight="90"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<detail:DescriptionStepsCard x:Name="DescriptionCard" Grid.Row="0"/>
<!-- Console row also hosts the roadblock card (docked above the console)
so it surfaces at a glance between Details and Output. Keeping it
inside row 1 leaves the desc/console resize model untouched. -->
<DockPanel Grid.Row="1" Margin="0,10,0,0">
<Border DockPanel.Dock="Top"
IsVisible="{Binding ShowRoadblockCard}"
Margin="0,0,0,10" Padding="12,10"
Background="{DynamicResource ReviewTintBrush}"
BorderBrush="{DynamicResource ReviewTintBorderBrush}"
BorderThickness="1" CornerRadius="10">
<StackPanel Spacing="6">
<StackPanel Orientation="Horizontal" Spacing="8">
<PathIcon Data="{StaticResource Icon.Warning}"
Foreground="{DynamicResource StatusReviewBrush}"
Width="14" Height="14" VerticalAlignment="Center"/>
<TextBlock Classes="section-label" Text="ROADBLOCK"
Foreground="{DynamicResource StatusReviewBrush}"
VerticalAlignment="Center"/>
</StackPanel>
<SelectableTextBlock Text="{Binding Roadblocks}"
TextWrapping="Wrap"
Foreground="{DynamicResource TextDimBrush}"/>
<!-- 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>
</Border>
<detail:WorkConsole/>
</DockPanel>
<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>
<!-- Resize by dragging the console's top edge — a transparent splitter
over the gap above the console; no standalone separator bar.
Stays draggable while maximized. -->
<GridSplitter x:Name="DetailSplitter" Grid.Row="1"
VerticalAlignment="Top"
Height="10"
HorizontalAlignment="Stretch"
ResizeDirection="Rows"
Background="Transparent"
DragStarted="OnSplitterDragStarted"
DragCompleted="OnSplitterDragCompleted"/>
</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,9 +1,13 @@
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.Reactive;
using Avalonia.Threading;
using Avalonia.VisualTree;
using ClaudeDo.Ui.ViewModels.Islands;
using ClaudeDo.Ui.Views.Modals;
using ClaudeDo.Ui.Views.Planning;
@@ -12,42 +16,16 @@ namespace ClaudeDo.Ui.Views.Islands;
public partial class DetailsIslandView : UserControl
{
// Per-task description height (pixels) once the user drags the splitter.
// Keyed by task id so each task keeps its own resize; tasks that were
// never dragged stay dynamic (Auto-sized description).
private readonly Dictionary<string, double> _descriptionHeights = new();
private DetailsIslandViewModel? _vm;
public DetailsIslandView()
{
InitializeComponent();
DataContextChanged += OnDataContextChanged;
// Keep the row limits proportional to the island height: description
// capped at 2/3, console floored at 1/3. The GridSplitter honours these
// row Min/Max during a drag, so the console stops shrinking at 1/3.
DetailBodyGrid.GetObservable(BoundsProperty)
.Subscribe(new AnonymousObserver<Rect>(_ => UpdateRowLimits()));
}
private void UpdateRowLimits()
{
var h = DetailBodyGrid.Bounds.Height;
if (h <= 0) return;
DetailBodyGrid.RowDefinitions[0].MaxHeight = h * 2.0 / 3.0;
DetailBodyGrid.RowDefinitions[1].MinHeight = h / 3.0;
}
private void OnDataContextChanged(object? sender, EventArgs e)
{
if (_vm != null)
_vm.PropertyChanged -= OnViewModelPropertyChanged;
if (DataContext is DetailsIslandViewModel vm)
{
_vm = vm;
vm.PropertyChanged += OnViewModelPropertyChanged;
ApplyResizeStateForCurrentTask();
vm.ShowDiffModal = async (diffVm) =>
{
var owner = TopLevel.GetTopLevel(this) as Window;
@@ -77,41 +55,6 @@ public partial class DetailsIslandView : UserControl
}
}
// Restores the resize state for the currently-selected task: a task the
// user has dragged before gets its pinned pixel height (cap lifted); a task
// never dragged falls back to dynamic sizing (Auto row + the bound cap).
private void OnViewModelPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(DetailsIslandViewModel.Task))
ApplyResizeStateForCurrentTask();
}
private void ApplyResizeStateForCurrentTask()
{
// A task dragged before keeps its pixel height (clamped by the row's
// 2/3 MaxHeight); a task never dragged stays Auto-sized.
DetailBodyGrid.RowDefinitions[0].Height = _vm?.Task?.Id is string id && _descriptionHeights.TryGetValue(id, out var h)
? new GridLength(h, GridUnitType.Pixel)
: GridLength.Auto;
}
// Pin the (until now Auto-sized) description row to its current pixel
// height so the splitter resizes smoothly from there.
private void OnSplitterDragStarted(object? sender, VectorEventArgs e)
{
var descRow = DetailBodyGrid.RowDefinitions[0];
if (descRow.Height.IsAuto)
descRow.Height = new GridLength(DescriptionCard.Bounds.Height, GridUnitType.Pixel);
}
// Remember the dragged height for this task so switching tasks keeps each
// task's resize independent.
private void OnSplitterDragCompleted(object? sender, VectorEventArgs e)
{
if (_vm?.Task?.Id is string id)
_descriptionHeights[id] = DetailBodyGrid.RowDefinitions[0].Height.Value;
}
private async System.Threading.Tasks.Task ShowErrorDialogAsync(string message)
{
var owner = TopLevel.GetTopLevel(this) as Window;
@@ -195,4 +138,37 @@ 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="0">
<Border Classes="terminal" Margin="18,8,18,0">
<DockPanel LastChildFill="True">
<!-- ── Terminal header bar ── -->

View File

@@ -69,7 +69,7 @@
Click="OnClearScheduleClick"/>
</ContextMenu>
</Border.ContextMenu>
<Grid ColumnDefinitions="0,18,32,*,Auto,Auto,32" Margin="6,8,10,8">
<Grid ColumnDefinitions="0,18,32,*,Auto,32" Margin="6,8,10,8">
<!-- Chevron toggle (only for planning parent tasks) -->
<Button Grid.Column="1"
@@ -78,8 +78,7 @@
CommandParameter="{Binding}"
Classes="icon-btn"
Width="18" Height="18"
VerticalAlignment="Center"
ToolTip.Tip="{loc:Tr tasks.toggleSubtasksTip}">
VerticalAlignment="Center">
<Panel>
<TextBlock Classes="meta" Text="▾" IsVisible="{Binding IsExpanded}"
VerticalAlignment="Center" HorizontalAlignment="Center"/>
@@ -142,7 +141,7 @@
Data="{StaticResource Icon.AgentSuggested}"
Foreground="#5C8FA8"
IsVisible="{Binding IsAgentSuggested}"
ToolTip.Tip="{loc:Tr tasks.agentSuggestedTip}"/>
ToolTip.Tip="Suggested by the agent"/>
<!-- Status chip -->
<Border Classes="chip"
@@ -195,25 +194,12 @@
</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="6" Classes="icon-btn star-btn"
<Button Grid.Column="5" Classes="icon-btn star-btn"
Classes.on="{Binding IsStarred}"
VerticalAlignment="Top" Margin="0,2,0,0"
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).ToggleStarCommand}"
CommandParameter="{Binding}"
ToolTip.Tip="{loc:Tr details.starTip}">
CommandParameter="{Binding}">
<PathIcon Width="14" Height="14" Data="{StaticResource Icon.Star}"/>
</Button>
</Grid>

View File

@@ -81,10 +81,6 @@ public partial class MainWindow : Window
var mergeDlg = new MergeModalView { DataContext = mergeVm };
await mergeDlg.ShowDialog(this);
};
modal.RequestConflictResolution = (taskId, target) =>
DataContext is IslandsShellViewModel s
? s.RequestConflictResolutionAsync(taskId, target)
: System.Threading.Tasks.Task.CompletedTask;
await dlg.ShowDialog(this);
};
vm.ShowRepoImportModal = async (modal) =>
@@ -99,11 +95,6 @@ public partial class MainWindow : Window
connVm.CloseAction = () => dlg.Close();
await dlg.ShowDialog(this);
};
vm.ShowConflictResolver = async (resolverVm) =>
{
var dlg = new ClaudeDo.Ui.Views.Conflicts.ConflictResolverView { DataContext = resolverVm };
await dlg.ShowDialog(this);
};
}
}

View File

@@ -26,100 +26,51 @@
</StackPanel>
</ctl:ModalShell.Footer>
<!-- Body: two islands — file list | diff content -->
<Grid ColumnDefinitions="280,12,*" Margin="16">
<!-- Body: sidebar + diff content -->
<Grid ColumnDefinitions="240,*">
<!-- Files island -->
<Border Grid.Column="0" Classes="island">
<DockPanel>
<Border DockPanel.Dock="Top" Classes="island-header">
<TextBlock Classes="eyebrow" Text="{loc:Tr modals.diff.filesHeader}"/>
</Border>
<ListBox ItemsSource="{Binding Files}"
SelectedItem="{Binding SelectedFile, Mode=TwoWay}"
Background="Transparent"
BorderThickness="0"
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:DiffFileViewModel">
<Border Padding="10,8" Background="Transparent">
<StackPanel Spacing="4">
<Grid ColumnDefinitions="Auto,*">
<Border Grid.Column="0" Tag="{Binding StatusCode}"
CornerRadius="3" Padding="4,0" Margin="0,0,8,0"
VerticalAlignment="Center">
<TextBlock Text="{Binding StatusCode}"
FontFamily="{DynamicResource MonoFont}"
FontSize="{StaticResource FontSizeEyebrow}"
Foreground="{DynamicResource TextBrush}"/>
</Border>
<TextBlock Grid.Column="1" Classes="path-mono" Text="{Binding Path}"
VerticalAlignment="Center"
TextTrimming="PrefixCharacterEllipsis"/>
</Grid>
<StackPanel Orientation="Horizontal" Spacing="6"
IsVisible="{Binding !IsBinary}">
<Border Classes="chip" Padding="5,2">
<TextBlock Foreground="{DynamicResource MossBrightBrush}"
Text="{Binding Additions, StringFormat='+{0}'}"/>
</Border>
<Border Classes="chip" Padding="5,2">
<TextBlock Foreground="{DynamicResource BloodBrush}"
Text="{Binding Deletions, StringFormat='{0}'}"/>
</Border>
</StackPanel>
<!-- File sidebar -->
<Border Grid.Column="0"
Classes="sidebar-pane">
<ListBox ItemsSource="{Binding Files}"
SelectedItem="{Binding SelectedFile, Mode=TwoWay}"
Background="Transparent"
BorderThickness="0"
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:DiffFileViewModel">
<Border Padding="10,8" Background="Transparent">
<StackPanel Spacing="4">
<TextBlock Classes="path-mono" Text="{Binding Path}"
TextTrimming="PrefixCharacterEllipsis"/>
<StackPanel Orientation="Horizontal" Spacing="6">
<Border Classes="chip" Padding="5,2">
<TextBlock Foreground="{DynamicResource MossBrightBrush}"
Text="{Binding Additions, StringFormat='+{0}'}"/>
</Border>
<Border Classes="chip" Padding="5,2">
<TextBlock Foreground="{DynamicResource BloodBrush}"
Text="{Binding Deletions, StringFormat='{0}'}"/>
</Border>
</StackPanel>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
</Border>
<!-- Diff content island -->
<Border Grid.Column="2" Classes="island">
<DockPanel>
<Border DockPanel.Dock="Top" Classes="island-header"
IsVisible="{Binding SelectedFile, Converter={x:Static ObjectConverters.IsNotNull}}">
<Grid ColumnDefinitions="Auto,*">
<Border Grid.Column="0" Tag="{Binding SelectedFile.StatusCode}"
CornerRadius="3" Padding="4,0" Margin="0,0,8,0"
VerticalAlignment="Center">
<TextBlock Text="{Binding SelectedFile.StatusCode}"
FontFamily="{DynamicResource MonoFont}"
FontSize="{StaticResource FontSizeEyebrow}"
Foreground="{DynamicResource TextBrush}"/>
</StackPanel>
</Border>
<TextBlock Grid.Column="1" Classes="path-mono" Text="{Binding SelectedFile.Path}"
VerticalAlignment="Center"
TextTrimming="PrefixCharacterEllipsis"/>
</Grid>
</Border>
<Grid Background="{DynamicResource VoidBrush}">
<!-- Load / no-changes message -->
<TextBlock Classes="body" Text="{Binding StatusMessage}"
IsVisible="{Binding StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
<!-- Binary file -->
<TextBlock Classes="body" Text="{loc:Tr modals.diff.binary}"
Foreground="{DynamicResource TextMuteBrush}"
IsVisible="{Binding SelectedFile.IsBinary}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
<!-- Empty / no-content file -->
<TextBlock Classes="body" Text="{loc:Tr modals.diff.empty}"
Foreground="{DynamicResource TextMuteBrush}"
IsVisible="{Binding SelectedFile.IsEmptyContent}"
HorizontalAlignment="Center" VerticalAlignment="Center"/>
<!-- Diff content -->
<ScrollViewer HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Auto"
IsVisible="{Binding SelectedFile.HasLines}">
<ctl:DiffLinesView Lines="{Binding SelectedFile.Lines}"/>
</ScrollViewer>
</Grid>
</DockPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Border>
<!-- Diff content -->
<Grid Grid.Column="1" Background="{DynamicResource VoidBrush}">
<TextBlock Classes="body" Text="{Binding StatusMessage}"
IsVisible="{Binding StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
HorizontalAlignment="Center"
VerticalAlignment="Center"/>
<ScrollViewer HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Auto">
<ctl:DiffLinesView Lines="{Binding SelectedFile.Lines}"/>
</ScrollViewer>
</Grid>
</Grid>
</ctl:ModalShell>
</Window>

View File

@@ -243,7 +243,6 @@
<TextBlock Classes="meta" Grid.Column="3" Text="{Binding LastRunLabel}" VerticalAlignment="Center"
MinWidth="80"/>
<Button Classes="icon-btn" Grid.Column="4" Content="✕"
ToolTip.Tip="{loc:Tr settings.prime.removeScheduleTip}"
Command="{Binding $parent[ItemsControl].((vm:SettingsModalViewModel)DataContext).Prime.RemoveScheduleCommand}"
CommandParameter="{Binding}"/>
</Grid>

View File

@@ -2,7 +2,6 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
xmlns:converters="using:ClaudeDo.Ui.Converters"
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
xmlns:loc="using:ClaudeDo.Ui.Localization"
x:Class="ClaudeDo.Ui.Views.Modals.WorktreeModalView"
x:DataType="vm:WorktreeModalViewModel"
@@ -17,6 +16,10 @@
CanResize="True"
TransparencyLevelHint="AcrylicBlur">
<Window.Resources>
<converters:DiffLineKindToBrushConverter x:Key="DiffLineKindToBrush"/>
</Window.Resources>
<Window.KeyBindings>
<KeyBinding Gesture="Escape" Command="{Binding CloseCommand}"/>
</Window.KeyBindings>
@@ -86,7 +89,17 @@
HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Auto"
Margin="4,0,8,8">
<ctl:DiffLinesView Lines="{Binding SelectedFileDiffLines}"/>
<ItemsControl ItemsSource="{Binding SelectedFileDiffLines}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="vm:WorktreeDiffLineViewModel">
<SelectableTextBlock Text="{Binding Text}"
FontFamily="{DynamicResource MonoFont}"
FontSize="{StaticResource FontSizeMono}"
Foreground="{Binding Kind, Converter={StaticResource DiffLineKindToBrush}}"
TextWrapping="NoWrap"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Grid>

View File

@@ -60,12 +60,8 @@
CommandParameter="{Binding}"/>
</ContextMenu>
</Border.ContextMenu>
<Grid ColumnDefinitions="Auto,*,90,90,80,80">
<CheckBox Grid.Column="0" VerticalAlignment="Center" Margin="0,0,8,0"
IsChecked="{Binding IsChecked, Mode=TwoWay}"
IsEnabled="{Binding IsActive}"
IsVisible="{Binding IsActive}"/>
<StackPanel Grid.Column="1" Orientation="Vertical" Spacing="2">
<Grid ColumnDefinitions="*,90,80,80">
<StackPanel Grid.Column="0" Orientation="Vertical" Spacing="2">
<TextBlock Classes="title" Text="{Binding TaskTitle}"/>
<StackPanel Orientation="Horizontal" Spacing="4">
<TextBlock Classes="meta" Text="{Binding TaskStatus}"/>
@@ -76,16 +72,13 @@
ToolTip.Tip="{loc:Tr modals.worktreesOverview.phantomTooltip}"/>
</StackPanel>
</StackPanel>
<TextBlock Grid.Column="2" Classes="meta" VerticalAlignment="Center"
Text="{Binding MergeOutcome}"
IsVisible="{Binding HasOutcome}"/>
<Border Grid.Column="3" CornerRadius="3" Padding="6,2" VerticalAlignment="Center"
<Border Grid.Column="1" CornerRadius="3" Padding="6,2" VerticalAlignment="Center"
Background="{Binding State, Converter={StaticResource WorktreeStateColor}}">
<TextBlock Classes="meta" Text="{Binding State}" Foreground="{DynamicResource TextBrush}"
HorizontalAlignment="Center"/>
</Border>
<TextBlock Grid.Column="4" Classes="meta" Text="{Binding DiffStat}" VerticalAlignment="Center"/>
<TextBlock Grid.Column="5" Classes="meta" Text="{Binding AgeText}" VerticalAlignment="Center"/>
<TextBlock Grid.Column="2" Classes="meta" Text="{Binding DiffStat}" VerticalAlignment="Center"/>
<TextBlock Grid.Column="3" Classes="meta" Text="{Binding AgeText}" VerticalAlignment="Center"/>
</Grid>
</Border>
</DataTemplate>
@@ -105,20 +98,7 @@
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Classes="btn" Content="{loc:Tr modals.worktreesOverview.refresh}" Command="{Binding RefreshCommand}" IsEnabled="{Binding !IsBusy}"/>
<Button Classes="btn" Content="{loc:Tr modals.worktreesOverview.cleanupFinished}" Command="{Binding CleanupFinishedCommand}" IsEnabled="{Binding !IsBusy}"/>
<Button Classes="btn" Content="{loc:Tr modals.worktreesOverview.selectAll}" Command="{Binding ToggleSelectAllCommand}"/>
<Border Width="1" Background="{DynamicResource LineBrush}" Margin="4,2"/>
<TextBlock Text="{loc:Tr modals.worktreesOverview.targetLabel}" VerticalAlignment="Center" Foreground="{DynamicResource TextDimBrush}"/>
<ComboBox MinWidth="160"
ItemsSource="{Binding MergeTargets}"
SelectedItem="{Binding SelectedTarget, Mode=TwoWay}"/>
<Button Classes="btn accent"
Content="{loc:Tr modals.worktreesOverview.mergeAll}"
Command="{Binding MergeAllCommand}"/>
<TextBlock Text="{Binding SelectedCount, StringFormat='{}{0} selected'}"
VerticalAlignment="Center" Foreground="{DynamicResource TextDimBrush}"/>
<TextBlock Text="{Binding BatchProgress}" VerticalAlignment="Center" Margin="8,0,0,0"
Foreground="{DynamicResource TextDimBrush}"/>
<TextBlock Text="{Binding StatusMessage}" VerticalAlignment="Center" Margin="8,0,0,0"
<TextBlock Text="{Binding StatusMessage}" VerticalAlignment="Center" Margin="12,0,0,0"
Foreground="{DynamicResource TextDimBrush}"/>
</StackPanel>
</Border>
@@ -126,35 +106,12 @@
<!-- Content -->
<ScrollViewer Padding="20,16">
<StackPanel>
<Border IsVisible="{Binding ConflictRows.Count}"
Background="{DynamicResource ErrorTintBrush}"
BorderBrush="{DynamicResource StatusErrorBrush}"
BorderThickness="1" CornerRadius="6" Padding="12,8" Margin="0,0,0,12">
<StackPanel Spacing="6">
<TextBlock Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.needsResolution}"/>
<ItemsControl ItemsSource="{Binding ConflictRows}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="vm:WorktreeOverviewRowViewModel">
<Grid ColumnDefinitions="*,Auto" Margin="0,2">
<TextBlock Grid.Column="0" Classes="meta" VerticalAlignment="Center"
Text="{Binding TaskTitle}"/>
<Button Grid.Column="1" Classes="btn"
Content="{loc:Tr modals.worktreesOverview.resolve}"
Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).ResolveConflictCommand}"
CommandParameter="{Binding}"/>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<!-- Column headers -->
<Grid ColumnDefinitions="Auto,*,90,90,80,80" Margin="12,0,12,4">
<TextBlock Grid.Column="1" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnTask}"/>
<TextBlock Grid.Column="2" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnOutcome}"/>
<TextBlock Grid.Column="3" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnState}"/>
<TextBlock Grid.Column="4" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnDiff}"/>
<TextBlock Grid.Column="5" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnAge}"/>
<Grid ColumnDefinitions="*,90,80,80" Margin="12,0,12,4">
<TextBlock Grid.Column="0" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnTask}"/>
<TextBlock Grid.Column="1" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnState}"/>
<TextBlock Grid.Column="2" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnDiff}"/>
<TextBlock Grid.Column="3" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnAge}"/>
</Grid>
<Border Height="1" Background="{DynamicResource LineBrush}" Margin="0,0,0,8"/>

View File

@@ -58,7 +58,7 @@ Interfaces (e.g. `IQueueWaker`, `IPrimeClock`, `ITaskStateService`) live in an `
| Field | Values | Meaning |
|---|---|---|
| `Status` | `Idle`, `Queued`, `Running`, `WaitingForChildren`, `WaitingForReview`, `Done`, `Failed`, `Cancelled` | Lifecycle only. `WaitingForChildren` = parent's own work is done, waiting on its children. |
| `Status` | `Idle`, `Queued`, `Running`, `WaitingForReview`, `Done`, `Failed`, `Cancelled` | Lifecycle only. |
| `PlanningPhase` | `None`, `Active`, `Finalized` | Parent-only marker. `Active` ≈ legacy `Planning`; `Finalized` ≈ legacy `Planned`. |
| `BlockedByTaskId` | nullable FK | Replaces legacy `Waiting`. A queued row with `BlockedByTaskId != NULL` is skipped by the picker. |
| `ReviewFeedback` | nullable string | Reviewer's rejection comment. Set by `RejectToQueueAsync`; consumed and cleared by `QueueService` on the next re-run (resumes the Claude session with it as the next-turn prompt). |
@@ -66,46 +66,24 @@ Interfaces (e.g. `IQueueWaker`, `IPrimeClock`, `ITaskStateService`) live in an `
Allowed transitions (enforced by `TaskStateService`):
```
Idle → Queued | Running (RunNow)
Queued → Running | Cancelled | Idle
Running → WaitingForReview (standalone success, no children)
| WaitingForChildren (parent with pending children)
| Done (planning/improvement child success) | Failed | Cancelled
WaitingForChildren → WaitingForReview (all children terminal) | Cancelled
WaitingForReview → Done (approve) | Queued (reject-rerun, +feedback) | Idle (reject-park) | Cancelled
Done → Idle (re-run)
Failed → Idle | Queued
Cancelled → Idle | Queued
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
Done → Idle (re-run)
Failed → Idle | Queued
Cancelled → Idle | Queued
```
**Unified parent model.** Every parent — planning *or* improvement — flows
`… → WaitingForChildren → WaitingForReview → Done`, advanced by the single
`TaskStateService.TryAdvanceParentAsync` (surfaces any `WaitingForChildren` parent for
review once all children are terminal; failed/cancelled children are annotated on the
result, not wedged). A planning parent enters `WaitingForChildren` at
`FinalizePlanningAsync` (or `WaitingForReview` directly if it has no children); an
improvement parent enters it from `TaskRunner.HandleSuccess` when its run spawned
children. Planning/improvement **children** still go straight to `Done` (no individual
review) — only the parent is reviewed.
**Approve = merge the whole unit.** `ApproveReview`/`review_task` approve, for a task
that has children, drives `PlanningMergeOrchestrator` (merges the parent worktree if
Active + each `Done` child in order, sets the parent `Done`, and on a mid-merge
conflict pauses for `ContinuePlanningMerge`/`AbortPlanningMerge`). Childless tasks use
`TaskMergeService.ApproveAndMergeAsync`. There is no separate "Merge all" entry —
approve is the single review+merge action. Review transitions live in `TaskStateService`
(`SubmitForReviewAsync`, `SubmitForChildrenAsync`, `ApproveReviewAsync`,
`RejectToQueueAsync`, `RejectToIdleAsync`, `ClearReviewFeedbackAsync`).
Only standalone tasks (`ParentTaskId == null`) route to `WaitingForReview` on success. Planning children go straight to `Done` so the sequential chain (which advances on terminal states) is unaffected. `TaskRunner.HandleSuccess` makes this choice; review transitions live in `TaskStateService` (`SubmitForReviewAsync`, `ApproveReviewAsync`, `RejectToQueueAsync`, `RejectToIdleAsync`, `ClearReviewFeedbackAsync`).
## Planning Flow
`PlanningSessionManager.FinalizeAsync` is the single path:
1. `_state.FinalizePlanningAsync(parent)` flips parent `PlanningPhase` to `Finalized` and sets `Status` to `WaitingForChildren` (or `WaitingForReview` if the parent has no children).
2. `PlanningChainCoordinator.SetupChainAsync(parent, enqueue: false)` establishes the blocked-by chain (`BlockOn`s child[i] → child[i-1]) but **leaves children `Idle`** — finalize never auto-queues. Queueing is a deliberate user action: `QueuePlanAsync` (hub `QueuePlanningSubtasksAsync`, the "Queue plan" button) calls `SetupChainAsync(parent, enqueue: true)`, which sets every non-terminal child `Queued` and re-applies the chain.
3. Once queued, the first child is woken automatically; successors unblock as their predecessor reaches a terminal state via `OnChildFinishedAsync`.
A child that hits a roadblock (fails, or reports `CLAUDEDO_BLOCKED` roadblocks) does **not** advance the parent — the parent stays in `WaitingForChildren` until every child is terminal. The UI surfaces blocked children on the parent's Session tab (`ChildOutcomes` + a "children need attention" band) so the roadblock is visible without forcing a transition.
1. `_state.FinalizePlanningAsync(parent)` flips parent `PlanningPhase` to `Finalized`.
2. `PlanningChainCoordinator.SetupChainAsync` attaches the `agent` tag to every child, enqueues child[0], and `BlockOn`s child[i] → child[i-1].
3. The first child is woken automatically; successors unblock as their predecessor reaches a terminal state via `OnChildFinishedAsync`.
`TaskRepository.FinalizePlanningAsync` no longer exists. The `Mark*Async` repository helpers are `internal` — only `TaskStateService` calls them.
@@ -143,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(taskId, targetBranch) -> MergeResultDto` (childless task: merges its worktree then Done, conflict stays WaitingForReview; task with children: drives `PlanningMergeOrchestrator` to merge the whole unit), `ContinuePlanningMerge` / `AbortPlanningMerge` (resolve a unit-merge conflict), `PreviewMerge(taskId, targetBranch) -> MergePreviewDto` (non-destructive mergeability check), `RejectReviewToQueue`, `RejectReviewToIdle`, `CancelReview`, `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`, `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

@@ -2,6 +2,7 @@
<ItemGroup>
<ProjectReference Include="..\ClaudeDo.Data\ClaudeDo.Data.csproj" />
<ProjectReference Include="..\ClaudeDo.Logging\ClaudeDo.Logging.csproj" />
</ItemGroup>
<ItemGroup>
@@ -27,7 +28,6 @@
<OutputType>WinExe</OutputType>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<ApplicationIcon>ClaudeTaskWorker.ico</ApplicationIcon>
</PropertyGroup>
<ItemGroup>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -61,14 +61,4 @@ public sealed class ConfigMcpTools
await _tasks.UpdateAgentSettingsAsync(taskId, model.NullIfBlank(), systemPrompt.NullIfBlank(), agentPath.NullIfBlank(), maxTurns, cancellationToken);
await _broadcaster.TaskUpdated(taskId);
}
[McpServerTool, Description("Get per-task config overrides (model/system prompt/agent path/max turns). Returns null if no override is set on this task.")]
public async Task<TaskConfigDto?> GetTaskConfig(string taskId, CancellationToken cancellationToken)
{
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
if (task.Model is null && task.SystemPrompt is null && task.AgentPath is null && task.MaxTurns is null)
return null;
return new TaskConfigDto(task.Model, task.SystemPrompt, task.AgentPath, task.MaxTurns);
}
}

View File

@@ -104,7 +104,7 @@ public sealed class ExternalMcpService
[McpServerTool, Description(
"List tasks in a given list. Optionally filter by creator (createdBy) and/or status. " +
"Valid status values: Idle, Queued, Running, WaitingForReview, WaitingForChildren, Done, Failed, Cancelled.")]
"Valid status values: Idle, Queued, Running, WaitingForReview, Done, Failed, Cancelled.")]
public async Task<IReadOnlyList<TaskDto>> ListTasks(
string listId,
string? createdBy,
@@ -116,7 +116,7 @@ public sealed class ExternalMcpService
{
if (!Enum.TryParse<TaskStatus>(status, ignoreCase: true, out var parsed))
throw new InvalidOperationException(
$"Unknown status '{status}'. Valid values: Idle, Queued, Running, WaitingForReview, WaitingForChildren, Done, Failed, Cancelled.");
$"Unknown status '{status}'. Valid values: Idle, Queued, Running, WaitingForReview, Done, Failed, Cancelled.");
statusFilter = parsed;
}
@@ -207,44 +207,6 @@ 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. " +
@@ -360,14 +322,13 @@ public sealed class ExternalMcpService
[McpServerTool, Description("Returns all valid task status values and their meanings. Use before filtering by status or interpreting task state.")]
public Task<IReadOnlyList<StatusValueDto>> GetTaskStatusValues() =>
Task.FromResult<IReadOnlyList<StatusValueDto>>([
new("Idle", "Not yet queued; task is editable and will not run until enqueued."),
new("Queued", "Waiting for an agent execution slot. Tasks with a blocker (BlockedByTaskId) are skipped by the queue picker until their predecessor finishes."),
new("Running", "Agent is actively executing the task; cannot be edited or deleted until cancelled."),
new("WaitingForReview", "Run finished successfully and awaits review. Use review_task: approve (→ Done), reject_rerun (→ Queued, resumes the session with feedback), reject_park (→ Idle), or cancel (→ Cancelled)."),
new("WaitingForChildren", "Planning parent whose child tasks are still running. The parent resumes once all children reach a terminal state."),
new("Done", "Completed successfully and approved; result text is available in the result field. Can be reset to Idle for re-execution."),
new("Failed", "Execution ended with an error; task can be reset to Idle or re-queued directly."),
new("Cancelled", "Cancelled by the user; task can be reset to Idle or re-queued directly."),
new("Idle", "Not yet queued; task is editable and will not run until enqueued."),
new("Queued", "Waiting for an agent execution slot. Tasks with a blocker (BlockedByTaskId) are skipped by the queue picker until their predecessor finishes."),
new("Running", "Agent is actively executing the task; cannot be edited or deleted until cancelled."),
new("WaitingForReview", "Run finished successfully and awaits review. Use review_task: approve (→ Done), reject_rerun (→ Queued, resumes the session with feedback), reject_park (→ Idle), or cancel (→ Cancelled)."),
new("Done", "Completed successfully and approved; result text is available in the result field. Can be reset to Idle for re-execution."),
new("Failed", "Execution ended with an error; task can be reset to Idle or re-queued directly."),
new("Cancelled", "Cancelled by the user; task can be reset to Idle or re-queued directly."),
]);
// ── Worktree / git tools ──────────────────────────────────────────────────
@@ -429,7 +390,7 @@ public sealed class ExternalMcpService
"Merge a task's worktree branch into targetBranch (default: main). " +
"noFf=true (default): always creates a merge commit (--no-ff). " +
"dryRun=true: validates preconditions only, does not perform the merge; merged=false in the result means 'not actually merged'. " +
"allowWaitingForReview=true: also allows merging a task in WaitingForReview (default false, which only allows Done). " +
"Refuses if task status is not Done (status values: Idle, Queued, Running, Done, Failed, Cancelled). " +
"On success: merged=true, mergeCommit contains the new merge commit SHA. " +
"On conflict: the merge is cleanly aborted (no half-merged state left); merged=false and conflicts lists the affected files.")]
public async Task<MergeTaskResultDto> MergeTask(
@@ -437,17 +398,14 @@ public sealed class ExternalMcpService
string targetBranch = "main",
bool noFf = true,
bool dryRun = false,
bool allowWaitingForReview = false,
CancellationToken cancellationToken = default)
{
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
?? throw new InvalidOperationException($"Task {taskId} not found.");
var canMerge = task.Status == TaskStatus.Done ||
(allowWaitingForReview && task.Status == TaskStatus.WaitingForReview);
if (!canMerge)
if (task.Status != TaskStatus.Done)
throw new InvalidOperationException(
$"Task must be Done to merge (current status: {task.Status}). " +
"Pass allowWaitingForReview=true to also merge a WaitingForReview task.");
"Valid statuses for merge: Done.");
var list = await _lists.GetByIdAsync(task.ListId, cancellationToken);
@@ -532,37 +490,7 @@ public sealed class ExternalMcpService
var path = wt.Path;
var result = await _maintenance.ForceRemoveAsync(taskId, cancellationToken);
return new CleanupWorktreeResult(result.Removed, path, result.BranchDeleted);
}
[McpServerTool, Description(
"Send a follow-up prompt to an existing Claude session (multi-turn continuation). " +
"The agent resumes using --resume with the session ID from the task's last run. " +
"Runs in the override execution slot; throws if the slot is busy — try again later. " +
"Returns a status string from the execution slot.")]
public async Task<string> ContinueTask(
string taskId,
string followUpPrompt,
CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(followUpPrompt))
throw new InvalidOperationException("followUpPrompt is required.");
string result;
try
{
result = await _queue.ContinueTask(taskId, followUpPrompt);
}
catch (InvalidOperationException)
{
throw new InvalidOperationException("Override slot busy. Try again later.");
}
catch (KeyNotFoundException)
{
throw new InvalidOperationException($"Task {taskId} not found.");
}
await _broadcaster.TaskUpdated(taskId);
return result;
return new CleanupWorktreeResult(result.Removed, path, result.Removed);
}
// ── Daily prep ───────────────────────────────────────────────────────────

View File

@@ -1,11 +1,10 @@
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, IRefineBroadcaster
public sealed class HubBroadcaster : IPrimeBroadcaster
{
private readonly IHubContext<WorkerHub> _hub;
@@ -63,12 +62,4 @@ public sealed class HubBroadcaster : IPrimeBroadcaster, IRefineBroadcaster
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,7 +8,6 @@ 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;
@@ -54,11 +53,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 MergeConflictsDto(string TaskId, IReadOnlyList<ConflictFileDto> Files);
public record ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks);
public record ConflictHunkDto(string Ours, string Theirs, string? Base);
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);
public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
@@ -88,7 +83,6 @@ 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,
@@ -108,8 +102,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
IPrimeScheduleSignal primeSignal,
IPrimeRunner primeRunner,
ITaskStateService state,
IWeekReportService report,
IRefineRunner refineRunner)
IWeekReportService report)
{
_queue = queue;
_waker = waker;
@@ -129,7 +122,6 @@ 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:
@@ -324,56 +316,6 @@ 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 Task<MergeResultDto> StartConflictMerge(string taskId, string targetBranch)
=> HubGuard(async () =>
{
var r = await _mergeService.MergeAsync(
taskId, targetBranch ?? "", removeWorktree: false, "Merge task",
leaveConflictsInTree: true, CancellationToken.None);
if (r.Status == TaskMergeService.StatusBlocked)
throw new HubException(r.ErrorMessage ?? "merge blocked");
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
});
public Task<MergeConflictsDto> GetMergeConflicts(string taskId)
=> HubGuard(async () =>
{
var c = await _mergeService.GetConflictsAsync(taskId, CancellationToken.None);
return new MergeConflictsDto(
c.TaskId,
c.Files.Select(f => new ConflictFileDto(
f.Path,
new[] { new ConflictHunkDto(f.Ours, f.Theirs, f.Base) })).ToList());
});
public Task WriteConflictResolution(string taskId, string path, string resolvedContent)
=> HubGuard(() => _mergeService.WriteResolutionAsync(
taskId, path, resolvedContent ?? "", CancellationToken.None));
public Task<MergeResultDto> ContinueMerge(string taskId)
=> HubGuard(async () =>
{
var r = await _mergeService.ContinueMergeAsync(taskId, CancellationToken.None);
if (r.Status == TaskMergeService.StatusBlocked)
throw new HubException(r.ErrorMessage ?? "continue failed");
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
});
public Task AbortMerge(string taskId)
=> HubGuard(async () =>
{
var r = await _mergeService.AbortMergeAsync(taskId, CancellationToken.None);
if (r.Status == TaskMergeService.StatusBlocked)
throw new HubException(r.ErrorMessage ?? "abort failed");
});
public async Task UpdateList(UpdateListDto dto)
{
using var ctx = _dbFactory.CreateDbContext();
@@ -438,24 +380,11 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
if (!result.Ok) throw new HubException(result.Reason ?? "set status failed");
}
public Task<MergeResultDto> ApproveReview(string taskId, string targetBranch)
=> HubGuard(async () =>
{
bool hasChildren;
await using (var ctx = await _dbFactory.CreateDbContextAsync(CancellationToken.None))
hasChildren = await ctx.Tasks.AnyAsync(t => t.ParentTaskId == taskId, CancellationToken.None);
if (hasChildren)
{
await _planningMergeOrchestrator.StartAsync(taskId, targetBranch ?? "", CancellationToken.None);
return new MergeResultDto(TaskMergeService.StatusMerged, Array.Empty<string>(), null);
}
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 ApproveReview(string taskId)
{
var result = await _state.ApproveReviewAsync(taskId, Context.ConnectionAborted);
if (!result.Ok) throw new HubException(result.Reason ?? "approve failed");
}
public async Task RejectReviewToQueue(string taskId, string feedback)
{
@@ -560,6 +489,10 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
};
}, "planning task not found");
public Task MergeAllPlanning(string planningTaskId, string targetBranch)
=> HubGuard(() => _planningMergeOrchestrator.StartAsync(planningTaskId, targetBranch ?? "", CancellationToken.None),
"planning task not found");
public async Task ContinuePlanningMerge(string planningTaskId)
{
try { await _planningMergeOrchestrator.ContinueAsync(planningTaskId, CancellationToken.None); }
@@ -608,12 +541,6 @@ 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,7 +3,6 @@ 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;
@@ -18,21 +17,6 @@ public sealed record MergeTargets(
string DefaultBranch,
IReadOnlyList<string> LocalBranches);
public sealed record MergePreviewResult(
string Status,
IReadOnlyList<string> ConflictFiles,
int ChangedFileCount);
public sealed record MergeConflicts(
string TaskId,
IReadOnlyList<ConflictFileContent> Files);
public sealed record ConflictFileContent(
string Path,
string Ours,
string Theirs,
string? Base);
public sealed class TaskMergeService
{
public const string StatusMerged = "merged";
@@ -40,27 +24,20 @@ 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;
}
@@ -85,15 +62,6 @@ public sealed class TaskMergeService
await _broadcaster.WorktreeUpdated(taskId);
}
private async Task ApproveIfWaitingForReviewAsync(TaskEntity task, CancellationToken ct)
{
// A merged worktree means the work is integrated, so the task must reach Done.
// MarkWorktreeMergedAsync only flips the worktree state; transition the task
// itself when it was still awaiting review (a Done task is already terminal).
if (task.Status == TaskStatus.WaitingForReview)
await _state.ApproveReviewAsync(task.Id, ct);
}
public async Task<MergeResult> MergeAsync(
string taskId,
string targetBranch,
@@ -177,7 +145,6 @@ public sealed class TaskMergeService
}
await MarkWorktreeMergedAsync(taskId, ct);
await ApproveIfWaitingForReviewAsync(task, ct);
_logger.LogInformation(
"Merged task {TaskId} branch {Branch} into {Target} (remove worktree: {Remove})",
@@ -197,7 +164,7 @@ public sealed class TaskMergeService
public async Task<MergeResult> ContinueMergeAsync(string taskId, CancellationToken ct)
{
var (task, list, wt) = await LoadMergeContextAsync(taskId, ct);
var (_, list, wt) = await LoadMergeContextAsync(taskId, ct);
if (wt is null) return Blocked("task has no worktree");
if (wt.State != WorktreeState.Active) return Blocked($"worktree state is {wt.State}");
@@ -215,7 +182,6 @@ public sealed class TaskMergeService
catch (Exception ex) { return Blocked($"commit failed: {ex.Message}"); }
await MarkWorktreeMergedAsync(taskId, ct);
await ApproveIfWaitingForReviewAsync(task, ct);
_logger.LogInformation("Continued merge of task {TaskId} branch {Branch}", taskId, wt.BranchName);
return new MergeResult(StatusMerged, Array.Empty<string>(), null);
@@ -238,35 +204,6 @@ public sealed class TaskMergeService
return new MergeResult(StatusAborted, Array.Empty<string>(), null);
}
public async Task<MergeConflicts> GetConflictsAsync(string taskId, CancellationToken ct)
{
var (_, list, _) = await LoadMergeContextAsync(taskId, ct);
if (string.IsNullOrWhiteSpace(list.WorkingDir))
throw new InvalidOperationException("list has no working directory");
var files = await _git.ListConflictedFilesAsync(list.WorkingDir, ct);
var result = new List<ConflictFileContent>(files.Count);
foreach (var path in files)
{
var ours = await _git.ShowStageAsync(list.WorkingDir, 2, path, ct) ?? "";
var theirs = await _git.ShowStageAsync(list.WorkingDir, 3, path, ct) ?? "";
var @base = await _git.ShowStageAsync(list.WorkingDir, 1, path, ct);
result.Add(new ConflictFileContent(path, ours, theirs, @base));
}
return new MergeConflicts(taskId, result);
}
public async Task WriteResolutionAsync(string taskId, string path, string content, CancellationToken ct)
{
var (_, list, _) = await LoadMergeContextAsync(taskId, ct);
if (string.IsNullOrWhiteSpace(list.WorkingDir))
throw new InvalidOperationException("list has no working directory");
var full = Path.Combine(list.WorkingDir, path.Replace('/', Path.DirectorySeparatorChar));
await File.WriteAllTextAsync(full, content, ct);
await _git.AddPathAsync(list.WorkingDir, path, ct);
}
public async Task<MergeTargets> GetTargetsAsync(string taskId, CancellationToken ct)
{
var (_, list, _) = await LoadMergeContextAsync(taskId, ct);
@@ -279,57 +216,6 @@ 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;
// MergeAsync transitions the task WaitingForReview -> Done on a successful merge.
// Remove the worktree on approve (matching the unit-merge path) so merged
// worktrees don't pile up; the merge commit on the target branch is the record.
return await MergeAsync(taskId, target, removeWorktree: true, $"Merge {wt.BranchName}", ct);
}
private static MergeResult Blocked(string reason) =>
new(StatusBlocked, Array.Empty<string>(), reason);
}

View File

@@ -19,21 +19,17 @@ public sealed class PlanningChainCoordinator
_state = state;
}
// Sets up a sequential chain over a planning parent's children.
// - First non-terminal child gets BlockedByTaskId=null.
// - Each subsequent non-terminal child gets BlockedByTaskId=<predecessor>,
// Sets up a sequential queue chain over a planning parent's children.
// - First non-terminal child gets Status=Queued, BlockedByTaskId=null.
// - Each subsequent non-terminal child gets Status=Queued + BlockedByTaskId=<predecessor>,
// so the picker skips them until the predecessor finishes.
// - When enqueue is true, each non-terminal child is also set to Status=Queued
// (the user-driven "Queue plan"). When false (finalize), children are left
// Idle and only the blocked-by links are established, so nothing runs until
// the user queues the plan.
// - Terminal children (Done/Failed/Cancelled) are left untouched; they are
// skipped when computing predecessors so a re-run on a partially executed
// chain leaves history alone but still reshapes the tail.
// - Running children abort the operation — the chain cannot be reshaped while
// one of its members is mid-flight.
// Returns the number of children placed in the chain.
internal async Task<int> SetupChainAsync(string parentTaskId, bool enqueue, CancellationToken ct = default)
internal async Task<int> SetupChainAsync(string parentTaskId, CancellationToken ct = default)
{
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
var parent = await ctx.Tasks.FirstOrDefaultAsync(t => t.Id == parentTaskId, ct)
@@ -60,8 +56,7 @@ public sealed class PlanningChainCoordinator
var state = _state();
for (int i = 0; i < sequenceable.Count; i++)
{
if (enqueue)
await state.EnqueueAsync(sequenceable[i].Id, ct);
await state.EnqueueAsync(sequenceable[i].Id, ct);
if (i == 0)
await state.UnblockAsync(sequenceable[i].Id, ct);
else
@@ -86,14 +81,18 @@ public sealed class PlanningChainCoordinator
if (phase != PlanningPhase.Finalized)
throw new InvalidOperationException("Plan must be finalized before it can be queued.");
return await SetupChainAsync(parentTaskId, enqueue: true, ct);
return await SetupChainAsync(parentTaskId, ct);
}
public async Task<string?> OnChildFinishedAsync(
string childTaskId, TaskStatus finalStatus, CancellationToken ct = default)
{
if (finalStatus != TaskStatus.Done) return null;
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
// The successor is whichever sibling explicitly blocks on this child.
// No status check — UnblockAsync flips legacy Waiting to Queued and is a no-op
// for already-Queued rows in the new layout.
var nextId = await ctx.Tasks
.AsNoTracking()
.Where(t => t.BlockedByTaskId == childTaskId)
@@ -102,16 +101,7 @@ public sealed class PlanningChainCoordinator
.FirstOrDefaultAsync(ct);
if (nextId is null) return null;
if (finalStatus == TaskStatus.Done)
{
await _state().UnblockAsync(nextId, ct);
return nextId;
}
// Child failed or was cancelled: cancel the immediate successor so the chain
// is not left wedged. CancelAsync triggers OnChildTerminalAsync → OnChildFinishedAsync
// for that successor, cascading cancellation through the rest of the chain.
await _state().CancelAsync(nextId, DateTime.UtcNow, ct);
return null;
await _state().UnblockAsync(nextId, ct);
return nextId;
}
}

View File

@@ -135,7 +135,7 @@ public sealed class PlanningMcpService
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
}
[McpServerTool, Description("Finalize the planning session. Child tasks are left idle and chain-linked (each blocked by its predecessor); they are NOT queued automatically — the user queues the plan from the app when ready. The queueAgentTasks argument is accepted for compatibility but ignored.")]
[McpServerTool, Description("Finalize the planning session, promoting all draft child tasks to queued or manual status.")]
public async Task<int> Finalize(
bool queueAgentTasks,
CancellationToken cancellationToken)
@@ -149,10 +149,8 @@ public sealed class PlanningMcpService
var children = await _tasks.GetChildrenAsync(ctx.ParentTaskId, cancellationToken);
int count = children.Count;
// Establish the blocked-by chain but leave children Idle; queueing is a
// deliberate user action ("Queue plan"), never an automatic finalize step.
if (children.Count > 0)
count = await _chain.SetupChainAsync(ctx.ParentTaskId, enqueue: false, cancellationToken);
if (queueAgentTasks && children.Count > 0)
count = await _chain.SetupChainAsync(ctx.ParentTaskId, cancellationToken);
foreach (var c in children)
await BroadcastTaskUpdatedAsync(c.Id, cancellationToken);

View File

@@ -199,10 +199,6 @@ public sealed class PlanningMergeOrchestrator
parent.FinishedAt = DateTime.UtcNow;
await ctx.SaveChangesAsync(ct);
// Surface the Done transition to the UI. Without this the parent row stays
// visibly stuck in WaitingForReview even though the unit merge completed.
await _broadcaster.TaskUpdated(parentTaskId);
// Only planning builds an integration branch via the aggregator; skip cleanup otherwise.
if (isPlanning)
{

View File

@@ -209,13 +209,12 @@ public sealed class PlanningSessionManager
throw new InvalidOperationException(
finalizeResult.Reason ?? $"Could not finalize planning for task {taskId}.");
// Establish the blocked-by chain but leave children Idle; queueing is a
// deliberate user action ("Queue plan"), never an automatic finalize step.
// queueAgentTasks is accepted for compatibility but no longer auto-queues.
int count = 0;
var children = await tasks.GetChildrenAsync(taskId, ct);
int count = children.Count;
if (children.Count > 0)
count = await _chain.SetupChainAsync(taskId, enqueue: false, ct);
if (queueAgentTasks && children.Count > 0)
count = await _chain.SetupChainAsync(taskId, ct);
else
count = children.Count;
// Best-effort cleanup — don't block finalization on git state.
await TryCleanupWorktreeAsync(taskId, lists, settings, ct);

View File

@@ -8,13 +8,10 @@ public static class DailyPrepPrompt
public static string LogPath() =>
System.IO.Path.Combine(ClaudeDo.Data.Paths.AppDataRoot(), "logs", "daily-prep.log");
public static IReadOnlyList<string> BuildArgs(int maxTurns) =>
[
"-p", "--output-format", "stream-json", "--verbose",
"--permission-mode", "acceptEdits",
"--max-turns", maxTurns.ToString(),
"--allowedTools", CandidatesTool, SetMyDayTool,
];
public static string BuildArgs(int maxTurns) =>
"-p --output-format stream-json --verbose --permission-mode acceptEdits " +
$"--max-turns {maxTurns} " +
$"--allowedTools {CandidatesTool} {SetMyDayTool}";
public static string BuildPrompt(int maxTasks, DateOnly today) =>
ClaudeDo.Data.PromptFiles.Render(

View File

@@ -12,7 +12,6 @@ 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;
@@ -32,13 +31,8 @@ var builder = WebApplication.CreateBuilder(args);
var logRoot = cfg.LogRoot;
Directory.CreateDirectory(logRoot);
builder.Host.UseSerilog((ctx, lc) => lc
.MinimumLevel.Information()
.WriteTo.File(
System.IO.Path.Combine(logRoot, "worker-.log"),
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 7,
shared: true));
builder.Host.UseSerilog((ctx, lc) =>
ClaudeDo.Logging.LoggingSetup.Configure(lc, "worker", logRoot));
builder.Services.AddDbContextFactory<ClaudeDoDbContext>(opt =>
opt.UseSqlite($"Data Source={cfg.DbPath}"));
@@ -109,10 +103,6 @@ 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

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

View File

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

View File

@@ -1,47 +0,0 @@
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 IReadOnlyList<string> BuildArgs(int maxTurns, bool canReadRepo)
{
var args = new List<string>
{
"-p", "--output-format", "stream-json", "--verbose",
"--permission-mode", "acceptEdits",
"--max-turns", maxTurns.ToString(),
"--allowedTools", GetTaskTool, UpdateTaskTool, AddSubtaskTool,
};
if (canReadRepo)
{
args.Add("Read");
args.Add("Grep");
args.Add("Glob");
}
return args;
}
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

@@ -1,108 +0,0 @@
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 = 5;
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

@@ -69,12 +69,7 @@ public sealed class WeekReportService : IWeekReportService
// alphanumerics, dashes and dots only.
var safeModel = new string(model.Where(c => char.IsLetterOrDigit(c) || c is '-' or '.').ToArray());
if (safeModel.Length == 0) safeModel = "sonnet";
IReadOnlyList<string> args =
[
"-p", "--output-format", "stream-json", "--verbose",
"--permission-mode", "auto",
"--model", safeModel,
];
var args = $"-p --output-format stream-json --verbose --permission-mode auto --model {safeModel}";
var result = await _claude.RunAsync(args, prompt, Path.GetTempPath(), _ => Task.CompletedTask, ct);
if (!result.IsSuccess)
throw new InvalidOperationException(result.ErrorMarkdown ?? "Claude could not generate the report.");

Some files were not shown because too many files have changed in this diff Show More