Compare commits
42 Commits
feature/de
...
07a9d07cf6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07a9d07cf6 | ||
|
|
19435b2d48 | ||
|
|
e22a3267fe | ||
|
|
9c5872eb27 | ||
|
|
8819a56496 | ||
|
|
6c65158be8 | ||
|
|
096519b978 | ||
|
|
266e6d191b | ||
|
|
cb4c396a53 | ||
|
|
6e3f90d289 | ||
|
|
de01579e84 | ||
|
|
0d8999dc20 | ||
|
|
3202c76674 | ||
|
|
43f8f7f7d8 | ||
|
|
f1cf29b58d | ||
|
|
98b0d58e03 | ||
|
|
b817c87656 | ||
|
|
2a6781f80f | ||
|
|
4098f7f341 | ||
|
|
82390047d2 | ||
|
|
75ad7b1735 | ||
|
|
e523ed85eb | ||
|
|
0460d7bea5 | ||
|
|
66a7b2377f | ||
|
|
eca6813cdb | ||
|
|
22830d3ea8 | ||
|
|
3573548348 | ||
|
|
0867bc8296 | ||
|
|
1603be0c78 | ||
|
|
71a3765c07 | ||
|
|
b840655163 | ||
|
|
ac9bae9546 | ||
|
|
99c6bf4478 | ||
|
|
3e848710b8 | ||
|
|
a2c339cd87 | ||
|
|
c71026d125 | ||
|
|
ce50f9fcce | ||
|
|
c323953f8c | ||
|
|
9f95942dd1 | ||
|
|
299867d8df | ||
|
|
8f7e2898fe | ||
|
|
9f37b1e21e |
@@ -5,6 +5,10 @@ on:
|
||||
tags:
|
||||
- 'v*'
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref_name }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -38,11 +42,52 @@ jobs:
|
||||
TAG: ${{ steps.ver.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git clone --depth 1 --branch "$TAG" \
|
||||
# Full clone (with tags) so release notes can diff against the previous tag.
|
||||
git clone --branch "$TAG" \
|
||||
"https://oauth2:${TOKEN}@git.kuns.dev/${REPO}.git" \
|
||||
"$WORK/src"
|
||||
git -C "$WORK/src" log -1 --oneline
|
||||
|
||||
- name: Generate release notes
|
||||
env:
|
||||
WORK: ${{ steps.ws.outputs.dir }}
|
||||
TAG: ${{ steps.ver.outputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd "$WORK/src"
|
||||
|
||||
PREV="$(git tag --sort=v:refname | grep -E '^v' \
|
||||
| awk -v t="$TAG" '$0==t{print prev} {prev=$0}')"
|
||||
if [ -n "$PREV" ]; then
|
||||
RANGE="${PREV}..${TAG}"
|
||||
else
|
||||
RANGE="$TAG"
|
||||
fi
|
||||
|
||||
emit_group() {
|
||||
# $1 conventional-type, $2 heading
|
||||
local lines
|
||||
lines="$(git log "$RANGE" --no-merges --pretty=format:'%s|%h' \
|
||||
| grep -E "^${1}(\([^)]*\))?(!)?: " || true)"
|
||||
[ -z "$lines" ] && return 0
|
||||
printf '### %s\n\n' "$2"
|
||||
while IFS='|' read -r subject hash; do
|
||||
printf -- '- %s (%s)\n' "${subject#*: }" "$hash"
|
||||
done <<< "$lines"
|
||||
printf '\n'
|
||||
}
|
||||
|
||||
{
|
||||
emit_group feat "Features"
|
||||
emit_group fix "Fixes"
|
||||
emit_group perf "Performance"
|
||||
emit_group refactor "Refactoring"
|
||||
emit_group docs "Documentation"
|
||||
} > RELEASE_NOTES.md
|
||||
|
||||
echo "--- release notes ---"
|
||||
cat RELEASE_NOTES.md
|
||||
|
||||
- name: Publish ClaudeDo.App (win-x64, self-contained)
|
||||
env:
|
||||
WORK: ${{ steps.ws.outputs.dir }}
|
||||
@@ -128,7 +173,8 @@ jobs:
|
||||
BODY=$(jq -n \
|
||||
--arg tag "$TAG" \
|
||||
--arg name "$TAG" \
|
||||
'{tag_name:$tag, name:$name, body:"", draft:false, prerelease:false, target_commitish:"main"}')
|
||||
--rawfile body "$WORK/src/RELEASE_NOTES.md" \
|
||||
'{tag_name:$tag, name:$name, body:$body, draft:true, prerelease:false, target_commitish:"main"}')
|
||||
RESP=$(curl -sS -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
@@ -166,6 +212,32 @@ jobs:
|
||||
done
|
||||
echo "All assets uploaded."
|
||||
|
||||
- name: Publish release
|
||||
env:
|
||||
RELEASE_ID: ${{ steps.release.outputs.release_id }}
|
||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -sS --fail-with-body -X PATCH \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"draft":false}' \
|
||||
"${GITEA_API}/repos/${REPO}/releases/${RELEASE_ID}" \
|
||||
> /dev/null
|
||||
echo "Release ${RELEASE_ID} published."
|
||||
|
||||
- name: Delete draft release on failure
|
||||
if: failure() && steps.release.outputs.release_id != ''
|
||||
env:
|
||||
RELEASE_ID: ${{ steps.release.outputs.release_id }}
|
||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
run: |
|
||||
curl -sS -X DELETE \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
"${GITEA_API}/repos/${REPO}/releases/${RELEASE_ID}" \
|
||||
> /dev/null || true
|
||||
echo "Cleaned up draft release ${RELEASE_ID}."
|
||||
|
||||
- name: Cleanup workspace
|
||||
if: always()
|
||||
env:
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,6 +1,9 @@
|
||||
# Local dev worktrees (created by using-git-worktrees skill)
|
||||
.worktrees/
|
||||
|
||||
# Brainstorming visual companion artifacts
|
||||
.superpowers/
|
||||
|
||||
# .NET build output
|
||||
bin/
|
||||
obj/
|
||||
|
||||
@@ -35,7 +35,7 @@ Two-process system communicating over SignalR (`127.0.0.1:47821`):
|
||||
- EF Core migrations manage schema (Migrations/ folder in ClaudeDo.Data)
|
||||
- `IDbContextFactory<ClaudeDoDbContext>` used by singleton consumers (e.g. Worker)
|
||||
- Entity configuration via `IEntityTypeConfiguration<T>` in Configuration/ folder
|
||||
- Task status flow: Idle | Queued -> Running -> WaitingForReview -> Done | Failed | Cancelled. A standalone task's successful run lands in WaitingForReview (planning children go straight to Done); from review you can approve (Done), reject-rerun (Queued, resumes the session with feedback), reject-park (Idle), or cancel (Cancelled).
|
||||
- Task status flow: Idle | Queued -> Running -> WaitingForReview -> Done | Failed | Cancelled. A standalone task's successful run lands in WaitingForReview (planning children go straight to Done); from review you can approve (merges the worktree into the target branch, then Done; conflicts keep it in WaitingForReview), reject-rerun (Queued, resumes the session with feedback), reject-park (Idle), or cancel (Cancelled). Tasks with no active worktree (sandbox run / improvement parent) are approved straight to Done.
|
||||
- Worktree state flow: Active -> Merged | Discarded | Kept
|
||||
- The queue picker claims tasks by `Status=Queued` (with `BlockedByTaskId IS NULL`); the legacy tag system was removed
|
||||
- Interfaces live in an `Interfaces/` subfolder beside their consumers (namespace unchanged)
|
||||
|
||||
@@ -0,0 +1,994 @@
|
||||
# Approve = Merge → Done + Conflict Preview — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Approving a `WaitingForReview` task merges its worktree into the target branch first and only marks the task `Done` on a clean merge; conflicts keep it in review and are surfaced. Add a non-destructive "merges cleanly / conflicts" indicator and a direct single-task Merge button.
|
||||
|
||||
**Architecture:** A new `GitService.PreviewMergeAsync` probes mergeability via `git merge-tree --write-tree` (no working-tree mutation). `TaskMergeService` gains `PreviewAsync` and `ApproveAndMergeAsync` (merge first, then delegate the `Done` flip to `ITaskStateService`). `WorkerHub` exposes `PreviewMerge` and a result-returning `ApproveReview(taskId, targetBranch)`. The UI loads merge targets whenever a worktree exists, shows the preview, and reacts to conflict results.
|
||||
|
||||
**Tech Stack:** .NET 8, Avalonia, EF Core/SQLite, SignalR, xUnit with real git (`GitRepoFixture`) and real SQLite (`DbFixture`).
|
||||
|
||||
**Conventions for the implementer:**
|
||||
- Use the **sonnet** model.
|
||||
- **Stage files explicitly by path** — never `git add -A` (parallel sessions leave unrelated WIP).
|
||||
- Build with `-c Release` (a running Worker locks `Debug` output).
|
||||
- Conventional Commit messages: `type(scope): description`.
|
||||
- New UI strings use **plain English literals** to match the surrounding merge controls (no `loc:Tr`) — this avoids Localization.Tests parity churn.
|
||||
- Ignore anything under `.claude/worktrees/` — those are stale worktrees, not the build tree.
|
||||
|
||||
---
|
||||
|
||||
## File map
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `src/ClaudeDo.Data/Git/GitService.cs` | Add `MergePreview` record + `PreviewMergeAsync` + `CountChangedFilesAsync` |
|
||||
| `src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs` | Inject `ITaskStateService`; add `MergePreviewResult` + `PreviewAsync` + `ApproveAndMergeAsync` |
|
||||
| `src/ClaudeDo.Worker/Hub/WorkerHub.cs` | Add `MergePreviewDto` + `PreviewMerge`; change `ApproveReview` to `(taskId, targetBranch) → MergeResultDto` |
|
||||
| `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs` | Change `ApproveReviewAsync`; add `PreviewMergeAsync`, `MergeTaskAsync` |
|
||||
| `src/ClaudeDo.Ui/Services/WorkerClient.cs` | Implement the above; add UI `MergePreviewDto` record |
|
||||
| `src/ClaudeDo.Ui/ViewModels/Islands/MergePreviewPresenter.cs` | New pure presenter (text + color flags) |
|
||||
| `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` | Load targets for worktree tasks; preview props; approve conflict handling; `MergeCommand` |
|
||||
| `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` | Update the list-level approve call to new signature |
|
||||
| `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml` | Mergeability status line + Merge button |
|
||||
| `tests/ClaudeDo.Worker.Tests/Runner/GitServicePreviewMergeTests.cs` | New — git-backed preview tests |
|
||||
| `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs` | Update `BuildService`; add preview + approve-merge tests |
|
||||
| `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs` | Update `FakeWorkerClient` |
|
||||
| `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs` | Update fake |
|
||||
| `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs` | Update the `ApproveReviewAsync` override |
|
||||
| `tests/ClaudeDo.Ui.Tests/ViewModels/MergePreviewPresenterTests.cs` | New — presenter unit tests |
|
||||
|
||||
---
|
||||
|
||||
## Task 1: GitService non-destructive merge probe
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Data/Git/GitService.cs`
|
||||
- Test: `tests/ClaudeDo.Worker.Tests/Runner/GitServicePreviewMergeTests.cs` (create)
|
||||
|
||||
Behaviour verified on git 2.50: `git merge-tree --write-tree --name-only <target> <source>` exits `0` when clean (stdout = a single tree-OID line) and `1` on conflict (stdout = tree-OID line, then conflicted file names, then a blank line, then informational messages). It writes only loose objects — the working tree, index, and refs are untouched.
|
||||
|
||||
- [ ] **Step 1: Write the failing tests**
|
||||
|
||||
Create `tests/ClaudeDo.Worker.Tests/Runner/GitServicePreviewMergeTests.cs`:
|
||||
|
||||
```csharp
|
||||
using ClaudeDo.Data.Git;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.Runner;
|
||||
|
||||
public class GitServicePreviewMergeTests : IDisposable
|
||||
{
|
||||
private readonly List<GitRepoFixture> _repos = new();
|
||||
private GitRepoFixture NewRepo() { var r = new GitRepoFixture(); _repos.Add(r); return r; }
|
||||
public void Dispose() { foreach (var r in _repos) try { r.Dispose(); } catch { } }
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewMergeAsync_NonConflicting_ReportsCleanWithChangedCount()
|
||||
{
|
||||
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||
var repo = NewRepo();
|
||||
var git = new GitService();
|
||||
var baseBranch = await git.GetCurrentBranchAsync(repo.RepoDir);
|
||||
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "checkout", "-b", "feature");
|
||||
File.WriteAllText(Path.Combine(repo.RepoDir, "newfile.txt"), "x\n");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "feat");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "checkout", baseBranch);
|
||||
|
||||
var preview = await git.PreviewMergeAsync(repo.RepoDir, baseBranch, "feature", CancellationToken.None);
|
||||
|
||||
Assert.True(preview.Supported);
|
||||
Assert.True(preview.Clean);
|
||||
Assert.Empty(preview.ConflictFiles);
|
||||
|
||||
var count = await git.CountChangedFilesAsync(repo.RepoDir, baseBranch, "feature", CancellationToken.None);
|
||||
Assert.Equal(1, count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewMergeAsync_Conflicting_ReportsFilesAndDoesNotMutateTree()
|
||||
{
|
||||
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||
var repo = NewRepo();
|
||||
var git = new GitService();
|
||||
var baseBranch = await git.GetCurrentBranchAsync(repo.RepoDir);
|
||||
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "checkout", "-b", "feature");
|
||||
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# from feature\n");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "feat readme");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "checkout", baseBranch);
|
||||
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# from base\n");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "base readme");
|
||||
|
||||
var headBefore = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
|
||||
|
||||
var preview = await git.PreviewMergeAsync(repo.RepoDir, baseBranch, "feature", CancellationToken.None);
|
||||
|
||||
Assert.True(preview.Supported);
|
||||
Assert.False(preview.Clean);
|
||||
Assert.Contains("README.md", preview.ConflictFiles);
|
||||
|
||||
// Non-destructive: HEAD unchanged, no mid-merge state.
|
||||
Assert.Equal(headBefore, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim());
|
||||
Assert.False(await git.IsMidMergeAsync(repo.RepoDir));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests, verify they fail to compile**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~GitServicePreviewMergeTests`
|
||||
Expected: build error — `PreviewMergeAsync`/`CountChangedFilesAsync` do not exist.
|
||||
|
||||
- [ ] **Step 3: Implement the probe**
|
||||
|
||||
In `src/ClaudeDo.Data/Git/GitService.cs`, add this record just under `namespace ClaudeDo.Data.Git;`:
|
||||
|
||||
```csharp
|
||||
public sealed record MergePreview(bool Supported, bool Clean, IReadOnlyList<string> ConflictFiles);
|
||||
```
|
||||
|
||||
Add these methods inside the `GitService` class (e.g. after `ListConflictedFilesAsync`):
|
||||
|
||||
```csharp
|
||||
/// <summary>
|
||||
/// Non-destructive mergeability probe via `git merge-tree --write-tree`. Writes only
|
||||
/// loose objects — the working tree, index, and refs are left untouched.
|
||||
/// </summary>
|
||||
public async Task<MergePreview> PreviewMergeAsync(
|
||||
string repoDir, string targetBranch, string sourceBranch, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, stdout, _) = await RunGitAsync(repoDir,
|
||||
["merge-tree", "--write-tree", "--name-only", targetBranch, sourceBranch], ct);
|
||||
|
||||
if (exitCode == 0)
|
||||
return new MergePreview(true, true, Array.Empty<string>());
|
||||
|
||||
if (exitCode == 1)
|
||||
{
|
||||
// stdout: <tree-oid>\n<file>\n...\n\n<informational messages>
|
||||
var lines = stdout.Split('\n');
|
||||
var files = new List<string>();
|
||||
for (int i = 1; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i].TrimEnd('\r');
|
||||
if (string.IsNullOrWhiteSpace(line)) break;
|
||||
files.Add(line.Trim());
|
||||
}
|
||||
return new MergePreview(true, false, files);
|
||||
}
|
||||
|
||||
// Any other exit (e.g. git too old: "unknown option --write-tree").
|
||||
return new MergePreview(false, false, Array.Empty<string>());
|
||||
}
|
||||
|
||||
/// <summary>Count of files that differ on <paramref name="sourceBranch"/> since its merge base with the target.</summary>
|
||||
public async Task<int> CountChangedFilesAsync(
|
||||
string repoDir, string targetBranch, string sourceBranch, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, stdout, _) = await RunGitAsync(repoDir,
|
||||
["diff", "--name-only", $"{targetBranch}...{sourceBranch}"], ct);
|
||||
if (exitCode != 0) return 0;
|
||||
return stdout
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Count(s => s.Length > 0);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the tests, verify they pass**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~GitServicePreviewMergeTests`
|
||||
Expected: PASS (2 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Data/Git/GitService.cs tests/ClaudeDo.Worker.Tests/Runner/GitServicePreviewMergeTests.cs
|
||||
git commit -m "feat(git): add non-destructive merge-tree conflict probe"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: TaskMergeService preview + approve-merge orchestration
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs`
|
||||
- Test: `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs`
|
||||
|
||||
`ApproveAndMergeAsync` merges first (reusing `MergeAsync`, `removeWorktree:false`) and only then delegates the `Done` flip to `ITaskStateService.ApproveReviewAsync` (the sole owner of Status writes). Conflicts/blocks return without flipping status. No DI cycle: `TaskStateService` and `PlanningChainCoordinator` do not depend on `TaskMergeService`.
|
||||
|
||||
- [ ] **Step 1: Update `BuildService` and add failing tests**
|
||||
|
||||
In `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs`, replace the `BuildService` helper so it also constructs a real `TaskStateService` (existing merge tests still pass — they only inspect the merge service's own broadcaster proxy):
|
||||
|
||||
```csharp
|
||||
private static (TaskMergeService svc, MergeRecordingClientProxy proxy) BuildService(DbFixture db)
|
||||
{
|
||||
var fakeHub = new MergeRecordingHubContext();
|
||||
var broadcaster = new HubBroadcaster(fakeHub);
|
||||
var state = TaskStateServiceBuilder.Build(db.CreateFactory()).State;
|
||||
var svc = new TaskMergeService(
|
||||
db.CreateFactory(),
|
||||
new GitService(),
|
||||
broadcaster,
|
||||
state,
|
||||
NullLogger<TaskMergeService>.Instance);
|
||||
return (svc, fakeHub.Proxy);
|
||||
}
|
||||
```
|
||||
|
||||
Add these tests to the class:
|
||||
|
||||
```csharp
|
||||
[Fact]
|
||||
public async Task PreviewAsync_CleanWorktree_ReturnsClean()
|
||||
{
|
||||
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||
var repo = NewRepo();
|
||||
var db = NewDb();
|
||||
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
|
||||
|
||||
var wtMgr = BuildWorktreeManager(db);
|
||||
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
|
||||
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
|
||||
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "x\n");
|
||||
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
||||
|
||||
var (svc, _) = BuildService(db);
|
||||
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
||||
|
||||
var preview = await svc.PreviewAsync(task.Id, target, CancellationToken.None);
|
||||
|
||||
Assert.Equal(TaskMergeService.PreviewClean, preview.Status);
|
||||
Assert.True(preview.ChangedFileCount >= 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewAsync_Conflict_ReturnsConflictFiles()
|
||||
{
|
||||
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||
var repo = NewRepo();
|
||||
var db = NewDb();
|
||||
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
|
||||
|
||||
var wtMgr = BuildWorktreeManager(db);
|
||||
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
|
||||
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
|
||||
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "README.md"), "# from worktree\n");
|
||||
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
||||
|
||||
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# from main\n");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main edit");
|
||||
|
||||
var (svc, _) = BuildService(db);
|
||||
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
||||
|
||||
var preview = await svc.PreviewAsync(task.Id, target, CancellationToken.None);
|
||||
|
||||
Assert.Equal(TaskMergeService.PreviewConflict, preview.Status);
|
||||
Assert.Contains("README.md", preview.ConflictFiles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewAsync_NoActiveWorktree_ReturnsUnavailable()
|
||||
{
|
||||
var db = NewDb();
|
||||
var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.WaitingForReview);
|
||||
var (svc, _) = BuildService(db);
|
||||
|
||||
var preview = await svc.PreviewAsync(task.Id, "main", CancellationToken.None);
|
||||
|
||||
Assert.Equal(TaskMergeService.PreviewUnavailable, preview.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApproveAndMergeAsync_CleanWorktree_MergesAndMarksDone()
|
||||
{
|
||||
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||
var repo = NewRepo();
|
||||
var db = NewDb();
|
||||
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
|
||||
|
||||
var wtMgr = BuildWorktreeManager(db);
|
||||
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
|
||||
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
|
||||
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "new\n");
|
||||
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
||||
|
||||
var (svc, _) = BuildService(db);
|
||||
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
||||
|
||||
var result = await svc.ApproveAndMergeAsync(task.Id, target, CancellationToken.None);
|
||||
|
||||
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
|
||||
using var ctx = db.CreateContext();
|
||||
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
|
||||
Assert.Equal(TaskStatus.Done, updated!.Status);
|
||||
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
|
||||
Assert.Equal(WorktreeState.Merged, wt!.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApproveAndMergeAsync_Conflict_LeavesTaskWaitingForReview()
|
||||
{
|
||||
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||
var repo = NewRepo();
|
||||
var db = NewDb();
|
||||
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
|
||||
|
||||
var wtMgr = BuildWorktreeManager(db);
|
||||
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
|
||||
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
|
||||
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "README.md"), "# from worktree\n");
|
||||
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
||||
|
||||
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# from main\n");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main edit");
|
||||
var headBefore = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
|
||||
|
||||
var (svc, _) = BuildService(db);
|
||||
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
||||
|
||||
var result = await svc.ApproveAndMergeAsync(task.Id, target, CancellationToken.None);
|
||||
|
||||
Assert.Equal(TaskMergeService.StatusConflict, result.Status);
|
||||
Assert.Contains("README.md", result.ConflictFiles);
|
||||
|
||||
using var ctx = db.CreateContext();
|
||||
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
|
||||
Assert.Equal(TaskStatus.WaitingForReview, updated!.Status);
|
||||
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
|
||||
Assert.Equal(WorktreeState.Active, wt!.State);
|
||||
Assert.Equal(headBefore, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim());
|
||||
Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApproveAndMergeAsync_NoWorktree_MarksDone()
|
||||
{
|
||||
var db = NewDb();
|
||||
var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.WaitingForReview);
|
||||
var (svc, _) = BuildService(db);
|
||||
|
||||
var result = await svc.ApproveAndMergeAsync(task.Id, "main", CancellationToken.None);
|
||||
|
||||
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
|
||||
using var ctx = db.CreateContext();
|
||||
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
|
||||
Assert.Equal(TaskStatus.Done, updated!.Status);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run the tests, verify they fail**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~TaskMergeServiceTests`
|
||||
Expected: build error — `ITaskStateService` ctor arg, `PreviewAsync`, `ApproveAndMergeAsync`, `PreviewClean/PreviewConflict/PreviewUnavailable` do not exist.
|
||||
|
||||
- [ ] **Step 3: Implement in TaskMergeService**
|
||||
|
||||
In `src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs`:
|
||||
|
||||
Add `using ClaudeDo.Worker.State;` to the usings.
|
||||
|
||||
Add the preview-result record beside `MergeTargets`:
|
||||
|
||||
```csharp
|
||||
public sealed record MergePreviewResult(
|
||||
string Status,
|
||||
IReadOnlyList<string> ConflictFiles,
|
||||
int ChangedFileCount);
|
||||
```
|
||||
|
||||
Add the status constants beside the existing `StatusMerged` etc.:
|
||||
|
||||
```csharp
|
||||
public const string PreviewClean = "clean";
|
||||
public const string PreviewConflict = "conflict";
|
||||
public const string PreviewUnavailable = "unavailable";
|
||||
```
|
||||
|
||||
Add the field and constructor param (inject `ITaskStateService`):
|
||||
|
||||
```csharp
|
||||
private readonly ITaskStateService _state;
|
||||
|
||||
public TaskMergeService(
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
GitService git,
|
||||
HubBroadcaster broadcaster,
|
||||
ITaskStateService state,
|
||||
ILogger<TaskMergeService> logger)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_git = git;
|
||||
_broadcaster = broadcaster;
|
||||
_state = state;
|
||||
_logger = logger;
|
||||
}
|
||||
```
|
||||
|
||||
Add the two methods (e.g. after `GetTargetsAsync`):
|
||||
|
||||
```csharp
|
||||
public async Task<MergePreviewResult> PreviewAsync(string taskId, string targetBranch, CancellationToken ct)
|
||||
{
|
||||
var (_, list, wt) = await LoadMergeContextAsync(taskId, ct);
|
||||
|
||||
if (wt is null || wt.State != WorktreeState.Active)
|
||||
return new MergePreviewResult(PreviewUnavailable, Array.Empty<string>(), 0);
|
||||
if (string.IsNullOrWhiteSpace(list.WorkingDir) || !await _git.IsGitRepoAsync(list.WorkingDir, ct))
|
||||
return new MergePreviewResult(PreviewUnavailable, Array.Empty<string>(), 0);
|
||||
|
||||
var target = string.IsNullOrWhiteSpace(targetBranch)
|
||||
? await _git.GetCurrentBranchAsync(list.WorkingDir, ct)
|
||||
: targetBranch;
|
||||
|
||||
var preview = await _git.PreviewMergeAsync(list.WorkingDir, target, wt.BranchName, ct);
|
||||
if (!preview.Supported)
|
||||
return new MergePreviewResult(PreviewUnavailable, Array.Empty<string>(), 0);
|
||||
if (!preview.Clean)
|
||||
return new MergePreviewResult(PreviewConflict, preview.ConflictFiles, 0);
|
||||
|
||||
var count = await _git.CountChangedFilesAsync(list.WorkingDir, target, wt.BranchName, ct);
|
||||
return new MergePreviewResult(PreviewClean, Array.Empty<string>(), count);
|
||||
}
|
||||
|
||||
public async Task<MergeResult> ApproveAndMergeAsync(string taskId, string targetBranch, CancellationToken ct)
|
||||
{
|
||||
var (task, list, wt) = await LoadMergeContextAsync(taskId, ct);
|
||||
|
||||
if (task.Status != TaskStatus.WaitingForReview)
|
||||
return Blocked("task is not waiting for review");
|
||||
|
||||
// No worktree to merge (sandbox run, or an improvement parent whose children own
|
||||
// the worktrees) — approve straight to Done.
|
||||
if (wt is null || wt.State != WorktreeState.Active)
|
||||
{
|
||||
var done = await _state.ApproveReviewAsync(taskId, ct);
|
||||
return done.Ok
|
||||
? new MergeResult(StatusMerged, Array.Empty<string>(), null)
|
||||
: Blocked(done.Reason ?? "approve failed");
|
||||
}
|
||||
|
||||
var target = string.IsNullOrWhiteSpace(targetBranch)
|
||||
? await _git.GetCurrentBranchAsync(list.WorkingDir, ct)
|
||||
: targetBranch;
|
||||
|
||||
var merge = await MergeAsync(taskId, target, removeWorktree: false, $"Merge {wt.BranchName}", ct);
|
||||
if (merge.Status != StatusMerged)
|
||||
return merge; // conflict or blocked — leave the task in WaitingForReview
|
||||
|
||||
var approve = await _state.ApproveReviewAsync(taskId, ct);
|
||||
return approve.Ok ? merge : Blocked(approve.Reason ?? "approve failed");
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run the tests, verify they pass**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~TaskMergeServiceTests`
|
||||
Expected: PASS (all existing + 6 new).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs
|
||||
git commit -m "feat(worker): approve merges worktree before marking task done"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: WorkerHub — PreviewMerge + result-returning ApproveReview
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
|
||||
|
||||
This is SignalR wiring (no unit test); verify by building the Worker.
|
||||
|
||||
- [ ] **Step 1: Add the DTO**
|
||||
|
||||
Beside the existing `MergeResultDto`/`MergeTargetsDto` records (around line 56):
|
||||
|
||||
```csharp
|
||||
public record MergePreviewDto(string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `PreviewMerge` and replace `ApproveReview`**
|
||||
|
||||
Add a `PreviewMerge` method beside `GetMergeTargets`:
|
||||
|
||||
```csharp
|
||||
public Task<MergePreviewDto> PreviewMerge(string taskId, string targetBranch)
|
||||
=> HubGuard(async () =>
|
||||
{
|
||||
var p = await _mergeService.PreviewAsync(taskId, targetBranch ?? "", CancellationToken.None);
|
||||
return new MergePreviewDto(p.Status, p.ConflictFiles, p.ChangedFileCount);
|
||||
});
|
||||
```
|
||||
|
||||
Replace the existing `ApproveReview` method (currently lines ~383-387, delegating to `_state.ApproveReviewAsync`) with:
|
||||
|
||||
```csharp
|
||||
public Task<MergeResultDto> ApproveReview(string taskId, string targetBranch)
|
||||
=> HubGuard(async () =>
|
||||
{
|
||||
var r = await _mergeService.ApproveAndMergeAsync(taskId, targetBranch ?? "", CancellationToken.None);
|
||||
if (r.Status == TaskMergeService.StatusBlocked)
|
||||
throw new HubException(r.ErrorMessage ?? "approve failed");
|
||||
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
|
||||
});
|
||||
```
|
||||
|
||||
(Conflicts are returned, not thrown, so the UI can display the conflicting files; only hard blocks throw.)
|
||||
|
||||
- [ ] **Step 3: Build the Worker, verify green**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`
|
||||
Expected: Build succeeded. (DI resolves the new `ITaskStateService` dependency of `TaskMergeService` automatically — it is already registered.)
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/Hub/WorkerHub.cs
|
||||
git commit -m "feat(worker): expose PreviewMerge hub method and merge-on-approve"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: UI client + interface + test fakes
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs`
|
||||
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
|
||||
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` (caller at line 648)
|
||||
- Modify: `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs` (`FakeWorkerClient`)
|
||||
- Modify: `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`
|
||||
- Modify: `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs` (override)
|
||||
|
||||
Note: `DetailsIslandViewModel.ApproveReviewAsync` (line 1368) is updated in Task 5, not here — but the interface change forces it to compile, so Task 5 must follow before the Ui project builds. To keep this task self-contained and green on its own, update that call site here too (the conflict-handling logic lands in Task 5).
|
||||
|
||||
- [ ] **Step 1: Add the UI DTO**
|
||||
|
||||
In `src/ClaudeDo.Ui/Services/WorkerClient.cs`, beside the existing `MergeResultDto`/`MergeTargetsDto` records (lines 521-522):
|
||||
|
||||
```csharp
|
||||
public record MergePreviewDto(string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update the interface**
|
||||
|
||||
In `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs`, replace `Task ApproveReviewAsync(string taskId);` (line 40) with:
|
||||
|
||||
```csharp
|
||||
Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch);
|
||||
Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch);
|
||||
Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage);
|
||||
```
|
||||
|
||||
(`MergeTaskAsync` already exists on the concrete `WorkerClient` — this only adds it to the interface.)
|
||||
|
||||
- [ ] **Step 3: Update the concrete client**
|
||||
|
||||
In `src/ClaudeDo.Ui/Services/WorkerClient.cs`, replace the existing `ApproveReviewAsync` (line ~389) and add `PreviewMergeAsync`. Mirror the existing `GetMergeTargetsAsync` pattern (it uses the `TryInvokeAsync<T>` helper which returns `null` when disconnected):
|
||||
|
||||
```csharp
|
||||
public Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch)
|
||||
=> TryInvokeAsync<MergeResultDto>("ApproveReview", taskId, targetBranch);
|
||||
|
||||
public Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch)
|
||||
=> TryInvokeAsync<MergePreviewDto>("PreviewMerge", taskId, targetBranch);
|
||||
```
|
||||
|
||||
Ensure the existing `public async Task<MergeResultDto> MergeTaskAsync(...)` signature matches the interface exactly (params: `string taskId, string targetBranch, bool removeWorktree, string commitMessage`). Leave its body as-is.
|
||||
|
||||
- [ ] **Step 4: Update the two callers**
|
||||
|
||||
`src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` line 648 — the list-level quick approve has no merge-target selector, so it merges into the repo's current branch (empty string resolves server-side):
|
||||
|
||||
```csharp
|
||||
try { await _worker.ApproveReviewAsync(row.Id, ""); }
|
||||
```
|
||||
|
||||
`src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` line 1368 — update to the new signature for now (full conflict handling is added in Task 5):
|
||||
|
||||
```csharp
|
||||
try { await _worker.ApproveReviewAsync(Task.Id, SelectedMergeTarget ?? ""); }
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Update the three test fakes**
|
||||
|
||||
`tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs` line 53 — replace and add:
|
||||
|
||||
```csharp
|
||||
public virtual Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) => Task.FromResult<MergeResultDto?>(null);
|
||||
public virtual Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch) => Task.FromResult<MergePreviewDto?>(null);
|
||||
public virtual Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));
|
||||
```
|
||||
|
||||
`tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs` line 45 (`FakeWorkerClient`) — replace and add:
|
||||
|
||||
```csharp
|
||||
public Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) => Task.FromResult<MergeResultDto?>(null);
|
||||
public Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch) => Task.FromResult<MergePreviewDto?>(null);
|
||||
public Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));
|
||||
```
|
||||
|
||||
(Confirm whether `FakeWorkerClient` already implements `MergeTaskAsync`; if so, only change `ApproveReviewAsync` and add `PreviewMergeAsync`. Add `using` for the DTO namespace if needed — same namespace as `IWorkerClient`.)
|
||||
|
||||
`tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs` line 77 — update the override signature:
|
||||
|
||||
```csharp
|
||||
public override Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) =>
|
||||
/* keep whatever recording/behavior this override had, now returning Task<MergeResultDto?> */
|
||||
Task.FromResult<MergeResultDto?>(null);
|
||||
```
|
||||
|
||||
(Preserve any side effect the existing override performed — e.g. recording the call — just change the signature and return type.)
|
||||
|
||||
- [ ] **Step 6: Build UI + run both UI-touching test projects**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
|
||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~TasksIslandViewModelPlanning`
|
||||
Expected: all green.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs src/ClaudeDo.Ui/Services/WorkerClient.cs src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs
|
||||
git commit -m "feat(ui): wire merge-aware approve and preview into the worker client"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Mergeability presenter + DetailsIslandViewModel wiring
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ClaudeDo.Ui/ViewModels/Islands/MergePreviewPresenter.cs`
|
||||
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`
|
||||
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/MergePreviewPresenterTests.cs` (create)
|
||||
|
||||
- [ ] **Step 1: Write the failing presenter tests**
|
||||
|
||||
Create `tests/ClaudeDo.Ui.Tests/ViewModels/MergePreviewPresenterTests.cs`:
|
||||
|
||||
```csharp
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||
|
||||
public class MergePreviewPresenterTests
|
||||
{
|
||||
[Fact]
|
||||
public void Clean_Plural()
|
||||
{
|
||||
var (text, clean, conflict) = MergePreviewPresenter.Describe(
|
||||
new MergePreviewDto("clean", System.Array.Empty<string>(), 3));
|
||||
Assert.Equal("Merges cleanly · 3 files", text);
|
||||
Assert.True(clean);
|
||||
Assert.False(conflict);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clean_Singular()
|
||||
{
|
||||
var (text, _, _) = MergePreviewPresenter.Describe(
|
||||
new MergePreviewDto("clean", System.Array.Empty<string>(), 1));
|
||||
Assert.Equal("Merges cleanly · 1 file", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Conflict_ListsUpToThree()
|
||||
{
|
||||
var (text, clean, conflict) = MergePreviewPresenter.Describe(
|
||||
new MergePreviewDto("conflict", new[] { "a.cs", "b.cs" }, 0));
|
||||
Assert.Equal("Conflicts in a.cs, b.cs", text);
|
||||
Assert.False(clean);
|
||||
Assert.True(conflict);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Conflict_TruncatesWithMore()
|
||||
{
|
||||
var (text, _, _) = MergePreviewPresenter.Describe(
|
||||
new MergePreviewDto("conflict", new[] { "a", "b", "c", "d", "e" }, 0));
|
||||
Assert.Equal("Conflicts in a, b, c (+2 more)", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unavailable_IsMuted()
|
||||
{
|
||||
var (text, clean, conflict) = MergePreviewPresenter.Describe(
|
||||
new MergePreviewDto("unavailable", System.Array.Empty<string>(), 0));
|
||||
Assert.Equal("Mergeability unknown", text);
|
||||
Assert.False(clean);
|
||||
Assert.False(conflict);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_IsEmpty()
|
||||
{
|
||||
var (text, clean, conflict) = MergePreviewPresenter.Describe(null);
|
||||
Assert.Equal("", text);
|
||||
Assert.False(clean);
|
||||
Assert.False(conflict);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run, verify it fails to compile**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter FullyQualifiedName~MergePreviewPresenterTests`
|
||||
Expected: build error — `MergePreviewPresenter` does not exist.
|
||||
|
||||
- [ ] **Step 3: Create the presenter**
|
||||
|
||||
Create `src/ClaudeDo.Ui/ViewModels/Islands/MergePreviewPresenter.cs`:
|
||||
|
||||
```csharp
|
||||
using System.Linq;
|
||||
using ClaudeDo.Ui.Services;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||
|
||||
/// Pure mapping from a merge-preview DTO to display text + color flags.
|
||||
public static class MergePreviewPresenter
|
||||
{
|
||||
public static (string Text, bool IsClean, bool IsConflict) Describe(MergePreviewDto? dto)
|
||||
{
|
||||
if (dto is null) return ("", false, false);
|
||||
|
||||
switch (dto.Status)
|
||||
{
|
||||
case "clean":
|
||||
var unit = dto.ChangedFileCount == 1 ? "file" : "files";
|
||||
return ($"Merges cleanly · {dto.ChangedFileCount} {unit}", true, false);
|
||||
|
||||
case "conflict":
|
||||
var names = string.Join(", ", dto.ConflictFiles.Take(3));
|
||||
var more = dto.ConflictFiles.Count > 3 ? $" (+{dto.ConflictFiles.Count - 3} more)" : "";
|
||||
return ($"Conflicts in {names}{more}", false, true);
|
||||
|
||||
default:
|
||||
return ("Mergeability unknown", false, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run, verify the presenter tests pass**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter FullyQualifiedName~MergePreviewPresenterTests`
|
||||
Expected: PASS (6 tests).
|
||||
|
||||
- [ ] **Step 5: Wire the presenter into DetailsIslandViewModel**
|
||||
|
||||
In `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`:
|
||||
|
||||
(a) Add observable properties (near the other merge properties, ~line 334):
|
||||
|
||||
```csharp
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ShowMergePreviewMuted))]
|
||||
private string _mergePreviewText = "";
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ShowMergePreviewMuted))]
|
||||
private bool _mergeIsClean;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ShowMergePreviewMuted))]
|
||||
private bool _mergeIsConflict;
|
||||
|
||||
public bool ShowMergePreviewMuted =>
|
||||
!MergeIsClean && !MergeIsConflict && !string.IsNullOrEmpty(MergePreviewText);
|
||||
|
||||
public bool ShowSingleMerge =>
|
||||
WorktreePath != null && Task?.IsPlanningParent != true;
|
||||
```
|
||||
|
||||
(b) Add the refresh method:
|
||||
|
||||
```csharp
|
||||
private async System.Threading.Tasks.Task RefreshMergePreviewAsync()
|
||||
{
|
||||
if (Task is null || WorktreePath is null)
|
||||
{
|
||||
MergePreviewText = ""; MergeIsClean = false; MergeIsConflict = false;
|
||||
return;
|
||||
}
|
||||
// Only probe Active worktrees; terminal states show their label instead.
|
||||
if (WorktreeStateLabel is { } label && label != "Active")
|
||||
{
|
||||
MergePreviewText = label; MergeIsClean = false; MergeIsConflict = false;
|
||||
return;
|
||||
}
|
||||
var dto = await _worker.PreviewMergeAsync(Task.Id, SelectedMergeTarget ?? "");
|
||||
var (text, clean, conflict) = MergePreviewPresenter.Describe(dto);
|
||||
MergePreviewText = text; MergeIsClean = clean; MergeIsConflict = conflict;
|
||||
}
|
||||
```
|
||||
|
||||
(c) Recompute when the merge target changes — add (or extend) the generated partial:
|
||||
|
||||
```csharp
|
||||
partial void OnSelectedMergeTargetChanged(string? value)
|
||||
{
|
||||
_ = RefreshMergePreviewAsync();
|
||||
}
|
||||
```
|
||||
|
||||
(d) Notify `ShowSingleMerge` when the worktree path changes. In the existing `OnWorktreePathChanged` (line ~1141) add:
|
||||
|
||||
```csharp
|
||||
OnPropertyChanged(nameof(ShowSingleMerge));
|
||||
```
|
||||
|
||||
(e) Load merge targets for standalone worktree tasks. In `BindAsync`, after the `if (entity.PlanningPhase != None) {...} else {...}` block (~line 814), add:
|
||||
|
||||
```csharp
|
||||
if (entity.Worktree != null
|
||||
&& entity.PlanningPhase == ClaudeDo.Data.Models.PlanningPhase.None
|
||||
&& MergeTargetBranches.Count == 0)
|
||||
{
|
||||
var targets = await _worker.GetMergeTargetsAsync(row.Id);
|
||||
if (targets != null)
|
||||
{
|
||||
MergeTargetBranches.Clear();
|
||||
foreach (var b in targets.LocalBranches) MergeTargetBranches.Add(b);
|
||||
SelectedMergeTarget = targets.DefaultBranch; // triggers OnSelectedMergeTargetChanged → preview
|
||||
}
|
||||
}
|
||||
await RefreshMergePreviewAsync();
|
||||
```
|
||||
|
||||
(f) Replace the body of `ApproveReviewAsync` (line ~1362) to surface conflicts:
|
||||
|
||||
```csharp
|
||||
[RelayCommand]
|
||||
private async System.Threading.Tasks.Task ApproveReviewAsync()
|
||||
{
|
||||
if (Task is null || !_worker.IsConnected) return;
|
||||
try
|
||||
{
|
||||
var result = await _worker.ApproveReviewAsync(Task.Id, SelectedMergeTarget ?? "");
|
||||
if (result?.Status == "conflict")
|
||||
{
|
||||
var (text, _, _) = MergePreviewPresenter.Describe(
|
||||
new MergePreviewDto("conflict", result.ConflictFiles, 0));
|
||||
MergePreviewText = text; MergeIsClean = false; MergeIsConflict = true;
|
||||
}
|
||||
}
|
||||
catch { /* stale review action; broadcast reconciles */ }
|
||||
}
|
||||
```
|
||||
|
||||
(g) Add the single-task `MergeCommand` (place near `OpenDiffAsync`):
|
||||
|
||||
```csharp
|
||||
[RelayCommand]
|
||||
private async System.Threading.Tasks.Task MergeAsync()
|
||||
{
|
||||
if (Task is null || WorktreePath is null || !_worker.IsConnected) return;
|
||||
try
|
||||
{
|
||||
var result = await _worker.MergeTaskAsync(Task.Id, SelectedMergeTarget ?? "", false, "Merge task");
|
||||
if (result.Status == "conflict")
|
||||
{
|
||||
var (text, _, _) = MergePreviewPresenter.Describe(
|
||||
new MergePreviewDto("conflict", result.ConflictFiles, 0));
|
||||
MergePreviewText = text; MergeIsClean = false; MergeIsConflict = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
await RefreshMergePreviewAsync();
|
||||
}
|
||||
}
|
||||
catch { /* broadcast reconciles */ }
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Build UI + run the UI tests**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
|
||||
Expected: green. (If `OnSelectedMergeTargetChanged` already exists, merge the new line into it instead of duplicating.)
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/ViewModels/Islands/MergePreviewPresenter.cs src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/MergePreviewPresenterTests.cs
|
||||
git commit -m "feat(ui): show mergeability and surface approve conflicts in the work console"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: WorkConsole — status line + Merge button
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml`
|
||||
|
||||
No unit test (XAML); verified by build + manual visual check in Task 7.
|
||||
|
||||
- [ ] **Step 1: Add the mergeability status line and the Merge button**
|
||||
|
||||
In the `MERGE & WORKTREE` `StackPanel` (starts line 196), insert the status line **between** the merge-target `StackPanel` (ends line 203) and the `<WrapPanel>` (line 204). Three single-line `TextBlock`s, one visible at a time by color:
|
||||
|
||||
```xml
|
||||
<StackPanel Spacing="0">
|
||||
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource MossBrush}"
|
||||
IsVisible="{Binding MergeIsClean}" />
|
||||
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource BloodBrush}"
|
||||
IsVisible="{Binding MergeIsConflict}" />
|
||||
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextMuteBrush}"
|
||||
IsVisible="{Binding ShowMergePreviewMuted}" />
|
||||
</StackPanel>
|
||||
```
|
||||
|
||||
In the `<WrapPanel>` (line 204), add a **Merge** button immediately after the "Open Diff" button (line 206):
|
||||
|
||||
```xml
|
||||
<Button Classes="btn accent" Content="Merge" Margin="0,0,8,8"
|
||||
Command="{Binding MergeCommand}"
|
||||
IsVisible="{Binding ShowSingleMerge}" />
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build UI, verify green**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||
Expected: Build succeeded (XAML compiles; all bound members exist from Task 5).
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml
|
||||
git commit -m "feat(ui): add mergeability indicator and Merge button to work console"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Full build, full test, manual verification
|
||||
|
||||
**Files:** none (verification only)
|
||||
|
||||
- [ ] **Step 1: Build the whole app + worker**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`
|
||||
Expected: both succeed.
|
||||
|
||||
- [ ] **Step 2: Run all touched test projects**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release`
|
||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
|
||||
Expected: all green.
|
||||
|
||||
- [ ] **Step 3: Manual verification (cannot be automated — no real Claude in tests)**
|
||||
|
||||
Start the Worker, then the App. Pick a list whose `WorkingDir` is a real git repo and use a task that already has an Active worktree (or create one).
|
||||
|
||||
Verify each acceptance criterion:
|
||||
1. **Clean approve:** Open a `WaitingForReview` task whose worktree merges cleanly → the Session tab shows green "Merges cleanly · N files". Click **Approve** → the worktree merges into the target, the task becomes **Done**, and the worktree state becomes **Merged** (check the worktree overview).
|
||||
2. **Conflicting approve:** Open a task whose worktree conflicts with the target → the Session tab shows red "Conflicts in …". Click **Approve** → the task stays **WaitingForReview** (NOT Done), the conflict line remains, and the target branch is unchanged.
|
||||
3. **Done task preview:** Open a previously-Done task that was never merged (worktree still Active) → the merge/conflict status appears without any tree mutation; the **Merge** button merges it on demand.
|
||||
|
||||
Report the result of each check explicitly. If any visual issue appears (colors, layout, missing controls), note it for the user — do not claim the UI works without running it.
|
||||
|
||||
---
|
||||
|
||||
## Self-review notes
|
||||
|
||||
- **Spec coverage:** Approve-merge (Task 2/3/5), conflict-keeps-review (Task 2 test + Task 5 surfacing), non-destructive preview (Task 1/2 + indicator in Task 5/6), real single-task Merge button (Task 5/6), standalone target-loading gap (Task 5e). All spec sections map to a task.
|
||||
- **Type consistency:** `MergePreview` (Data) → `MergePreviewResult` (Worker service) → `MergePreviewDto` (hub + UI). Status strings `clean`/`conflict`/`unavailable` and merge statuses `merged`/`conflict`/`blocked` are used consistently across worker, client, presenter, and VM.
|
||||
- **No new statuses, no DB migration, no localization keys** (literals match the surrounding controls).
|
||||
- **External MCP unchanged:** `ExternalMcpService.ReviewTask` keeps calling `TaskStateService.ApproveReviewAsync` directly (its documented scope excludes merges); that method's signature is unchanged.
|
||||
801
docs/superpowers/plans/2026-06-04-refine-task.md
Normal file
801
docs/superpowers/plans/2026-06-04-refine-task.md
Normal file
@@ -0,0 +1,801 @@
|
||||
# Refine Task Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Subagents use the `sonnet` model and stage files explicitly by path (never `git add -A`).
|
||||
|
||||
**Goal:** Add a one-click "Refine Task" button to each Idle task card that spawns a headless Claude session which rewrites the task's description and adds subtasks (steps), then updates the task live in the UI.
|
||||
|
||||
**Architecture:** A new headless `RefineRunner` (modeled on `PrimeRunner`) runs `claude -p` read-only in the list's working dir, using the globally-registered `claudedo` MCP. Claude calls `update_task` (existing) and a new `add_subtask` tool. The task stays `Idle`; refine only mutates Title/Description/subtasks. UI shows a busy state via new `RefineStarted`/`RefineFinished` SignalR events; content updates arrive via the existing `TaskUpdated` events.
|
||||
|
||||
**Tech Stack:** .NET 8, ASP.NET Core + SignalR, EF Core (SQLite), Avalonia 12 (CommunityToolkit.Mvvm), ModelContextProtocol server tools, xUnit.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-06-04-refine-task-design.md`
|
||||
|
||||
**Build/test reminders:** Build individual csproj with `-c Release` (a running Worker locks Debug). `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`, `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`, `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release`. Keep `locales/en.json` and `locales/de.json` keys in parity.
|
||||
|
||||
---
|
||||
|
||||
## File structure
|
||||
|
||||
**Create:**
|
||||
- `src/ClaudeDo.Worker/Refine/RefineRunner.cs` — headless refine run orchestrator
|
||||
- `src/ClaudeDo.Worker/Refine/RefinePrompt.cs` — prompt + CLI args + log path helper
|
||||
- `src/ClaudeDo.Worker/Refine/Interfaces/IRefineRunner.cs` — interface + `RefineRunOutcome`
|
||||
- `src/ClaudeDo.Worker/Refine/Interfaces/IRefineBroadcaster.cs` — `RefineStartedAsync`/`RefineFinishedAsync`
|
||||
|
||||
**Modify:**
|
||||
- `src/ClaudeDo.Data/PromptFiles.cs` — add `Refine` to `PromptKind`, path, default
|
||||
- `src/ClaudeDo.Worker/External/ExternalMcpService.cs` — add `add_subtask` tool
|
||||
- `src/ClaudeDo.Worker/Hub/HubBroadcaster.cs` — implement `RefineStarted`/`RefineFinished` + `IRefineBroadcaster`
|
||||
- `src/ClaudeDo.Worker/Hub/WorkerHub.cs` — add `RefineTask(string taskId)` method
|
||||
- `src/ClaudeDo.Worker/Program.cs` — register `IRefineRunner`/`IRefineBroadcaster`
|
||||
- `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs` — `RefineTaskAsync` + `RefineStartedEvent`/`RefineFinishedEvent`
|
||||
- `src/ClaudeDo.Ui/Services/WorkerClient.cs` — implement call + subscribe events
|
||||
- `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs` — `IsRefining` + `CanRefine`
|
||||
- `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` — `RefineTaskCommand` + event wiring
|
||||
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml` — `Icon.Refine` geometry
|
||||
- `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml` — refine button
|
||||
- `locales/en.json`, `locales/de.json` — tooltip key
|
||||
- Test fakes implementing `IWorkerClient` in `tests/ClaudeDo.Ui.Tests` (and any other project that hand-rolls it)
|
||||
|
||||
**Test:**
|
||||
- `tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs`
|
||||
- `tests/ClaudeDo.Worker.Tests/Refine/RefinePromptTests.cs`
|
||||
- `tests/ClaudeDo.Worker.Tests/Refine/RefineRunnerTests.cs`
|
||||
|
||||
---
|
||||
|
||||
## Task 1: `add_subtask` MCP tool
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Worker/External/ExternalMcpService.cs`
|
||||
- Test: `tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs`
|
||||
|
||||
The `ExternalMcpService` already injects `IDbContextFactory<ClaudeDoDbContext> _dbFactory`, `TaskRepository _tasks`, and `HubBroadcaster _broadcaster`. Reuse them; new up a `SubtaskRepository` from a fresh context (matching the `SetMyDay`/`GetDailyPrepCandidates` pattern in the same file).
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs`. Follow the existing External tool test setup in that test project (look at a sibling test, e.g. an `ExternalMcpService`/`UpdateTask` test, for the in-memory-real-SQLite fixture + broadcaster fake construction; reuse that exact fixture pattern).
|
||||
|
||||
```csharp
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
public class AddSubtaskToolTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task AddSubtask_appends_row_with_next_order()
|
||||
{
|
||||
await using var f = new ExternalMcpServiceFixture(); // reuse the project's existing fixture helper
|
||||
var list = await f.SeedListAsync();
|
||||
var task = await f.SeedTaskAsync(list.Id, status: TaskStatus.Idle);
|
||||
|
||||
await f.Service.AddSubtask(task.Id, "First step", orderNum: null, CancellationToken.None);
|
||||
await f.Service.AddSubtask(task.Id, "Second step", orderNum: null, CancellationToken.None);
|
||||
|
||||
await using var ctx = f.CreateContext();
|
||||
var subs = await new SubtaskRepository(ctx).GetByTaskIdAsync(task.Id);
|
||||
Assert.Equal(new[] { "First step", "Second step" }, subs.Select(s => s.Title));
|
||||
Assert.Equal(new[] { 0, 1 }, subs.Select(s => s.OrderNum));
|
||||
Assert.All(subs, s => Assert.False(s.Completed));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddSubtask_refuses_running_task()
|
||||
{
|
||||
await using var f = new ExternalMcpServiceFixture();
|
||||
var list = await f.SeedListAsync();
|
||||
var task = await f.SeedTaskAsync(list.Id, status: TaskStatus.Running);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => f.Service.AddSubtask(task.Id, "x", null, CancellationToken.None));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> If the test project has no reusable `ExternalMcpServiceFixture`, mirror the construction already used by the nearest existing `ExternalMcpService` test (same ctor args, real SQLite via `IDbContextFactory`, a no-op/recording broadcaster). Do not invent a new pattern.
|
||||
|
||||
- [ ] **Step 2: Run the test to verify it fails** (compile error / method missing)
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter AddSubtaskToolTests`
|
||||
Expected: FAIL — `AddSubtask` not defined.
|
||||
|
||||
- [ ] **Step 3: Implement `add_subtask`**
|
||||
|
||||
Add to `ExternalMcpService` (near `UpdateTask`):
|
||||
|
||||
```csharp
|
||||
[McpServerTool, Description(
|
||||
"Append a subtask (step) to a task. orderNum defaults to the end. " +
|
||||
"Refuses if the task is currently Running. Subtasks are surfaced to the agent at run time and shown in the task's Steps list.")]
|
||||
public async Task<TaskDto> AddSubtask(
|
||||
string taskId,
|
||||
string title,
|
||||
int? orderNum,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
throw new InvalidOperationException("title is required.");
|
||||
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
|
||||
var tasks = new TaskRepository(ctx);
|
||||
var subtasks = new SubtaskRepository(ctx);
|
||||
|
||||
var task = await tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
if (task.Status == TaskStatus.Running)
|
||||
throw new InvalidOperationException("Cannot add a subtask to a running task. Cancel it first.");
|
||||
|
||||
var existing = await subtasks.GetByTaskIdAsync(taskId, cancellationToken);
|
||||
var order = orderNum ?? (existing.Count == 0 ? 0 : existing.Max(s => s.OrderNum) + 1);
|
||||
|
||||
await subtasks.AddAsync(new SubtaskEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
TaskId = taskId,
|
||||
Title = title.Trim(),
|
||||
Completed = false,
|
||||
OrderNum = order,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
}, cancellationToken);
|
||||
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
return ToDto(task);
|
||||
}
|
||||
```
|
||||
|
||||
Add `using ClaudeDo.Data.Repositories;` if not present (it is). `SubtaskEntity` is in `ClaudeDo.Data.Models` (already imported).
|
||||
|
||||
- [ ] **Step 4: Run the test to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter AddSubtaskToolTests`
|
||||
Expected: PASS (2 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/External/ExternalMcpService.cs tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs
|
||||
git commit -m "feat(mcp): add add_subtask tool to claudedo MCP"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Refine prompt (`PromptKind.Refine`)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Data/PromptFiles.cs`
|
||||
|
||||
- [ ] **Step 1: Add the enum value**
|
||||
|
||||
Change the enum line in `PromptFiles.cs`:
|
||||
|
||||
```csharp
|
||||
public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport, ImprovementChild, Refine }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add the path mapping**
|
||||
|
||||
In `PathFor`, add before the `_ => throw`:
|
||||
|
||||
```csharp
|
||||
PromptKind.Refine => Path.Combine(Root, "refine.md"),
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Add the default mapping**
|
||||
|
||||
In `DefaultFor`, add:
|
||||
|
||||
```csharp
|
||||
PromptKind.Refine => RefineDefault,
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the default prompt constant**
|
||||
|
||||
Add near the other `private const string ...Default` blocks:
|
||||
|
||||
```csharp
|
||||
private const string RefineDefault = """
|
||||
You are refining ONE ClaudeDo task so it is ready to run autonomously later.
|
||||
You are NOT executing the task — only improving its specification.
|
||||
|
||||
The task you are refining:
|
||||
- id: {taskId}
|
||||
- title: {title}
|
||||
- description: {description}
|
||||
- current subtasks (steps):
|
||||
{subtasks}
|
||||
|
||||
What to do:
|
||||
1. If a repository is available, read the relevant code (read-only) to ground your
|
||||
understanding. Do NOT edit, create, or delete any files. Do NOT run commands.
|
||||
2. Rewrite the description so it is clear, specific, and self-contained: what to change,
|
||||
where, and what "done" looks like. Keep scope tight — do not invent adjacent work.
|
||||
3. Call mcp__claudedo__update_task to save the improved title (only if it genuinely
|
||||
helps) and description.
|
||||
4. If the work is clearer as discrete steps, add them as subtasks with
|
||||
mcp__claudedo__add_subtask (one call per step, in order). Only add steps that are
|
||||
not already present in the current subtasks above.
|
||||
|
||||
Use ONLY these tools: mcp__claudedo__get_task, mcp__claudedo__update_task,
|
||||
mcp__claudedo__add_subtask, and read-only Read/Grep/Glob. When you have updated the
|
||||
task, stop.
|
||||
""";
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Build to verify it compiles**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj -c Release`
|
||||
Expected: Build succeeded.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Data/PromptFiles.cs
|
||||
git commit -m "feat(prompts): add Refine prompt kind and default"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: RefineRunner, interfaces, prompt/args helper
|
||||
|
||||
**Files:**
|
||||
- Create: `src/ClaudeDo.Worker/Refine/Interfaces/IRefineRunner.cs`
|
||||
- Create: `src/ClaudeDo.Worker/Refine/Interfaces/IRefineBroadcaster.cs`
|
||||
- Create: `src/ClaudeDo.Worker/Refine/RefinePrompt.cs`
|
||||
- Create: `src/ClaudeDo.Worker/Refine/RefineRunner.cs`
|
||||
- Test: `tests/ClaudeDo.Worker.Tests/Refine/RefinePromptTests.cs`
|
||||
- Test: `tests/ClaudeDo.Worker.Tests/Refine/RefineRunnerTests.cs`
|
||||
|
||||
- [ ] **Step 1: Create `IRefineRunner.cs`**
|
||||
|
||||
```csharp
|
||||
namespace ClaudeDo.Worker.Refine;
|
||||
|
||||
public interface IRefineRunner
|
||||
{
|
||||
Task<RefineRunOutcome> RefineAsync(string taskId, CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed record RefineRunOutcome(bool Success, string Message);
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create `IRefineBroadcaster.cs`**
|
||||
|
||||
```csharp
|
||||
namespace ClaudeDo.Worker.Refine;
|
||||
|
||||
public interface IRefineBroadcaster
|
||||
{
|
||||
Task RefineStartedAsync(string taskId);
|
||||
Task RefineFinishedAsync(string taskId, bool success, string? error);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Create `RefinePrompt.cs`**
|
||||
|
||||
```csharp
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
|
||||
namespace ClaudeDo.Worker.Refine;
|
||||
|
||||
public static class RefinePrompt
|
||||
{
|
||||
public const string GetTaskTool = "mcp__claudedo__get_task";
|
||||
public const string UpdateTaskTool = "mcp__claudedo__update_task";
|
||||
public const string AddSubtaskTool = "mcp__claudedo__add_subtask";
|
||||
|
||||
public static string LogPath(string taskId) =>
|
||||
System.IO.Path.Combine(Paths.AppDataRoot(), "logs", $"refine-{Short(taskId)}.log");
|
||||
|
||||
// canReadRepo=false drops the read-only filesystem tools (text-only fallback).
|
||||
public static string BuildArgs(int maxTurns, bool canReadRepo)
|
||||
{
|
||||
var tools = canReadRepo
|
||||
? $"{GetTaskTool} {UpdateTaskTool} {AddSubtaskTool} Read Grep Glob"
|
||||
: $"{GetTaskTool} {UpdateTaskTool} {AddSubtaskTool}";
|
||||
return "-p --output-format stream-json --verbose --permission-mode acceptEdits " +
|
||||
$"--max-turns {maxTurns} --allowedTools {tools}";
|
||||
}
|
||||
|
||||
public static string BuildPrompt(TaskEntity task, IEnumerable<SubtaskEntity> subtasks)
|
||||
{
|
||||
var open = subtasks.Where(s => !s.Completed).Select(s => $"- {s.Title}").ToList();
|
||||
var subText = open.Count == 0 ? "(none)" : string.Join("\n", open);
|
||||
return PromptFiles.Render(PromptKind.Refine, new Dictionary<string, string>
|
||||
{
|
||||
["taskId"] = task.Id,
|
||||
["title"] = task.Title,
|
||||
["description"] = string.IsNullOrWhiteSpace(task.Description) ? "(empty)" : task.Description!,
|
||||
["subtasks"] = subText,
|
||||
});
|
||||
}
|
||||
|
||||
private static string Short(string id) => id.Length >= 8 ? id[..8] : id;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Write `RefinePromptTests.cs`**
|
||||
|
||||
```csharp
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Worker.Refine;
|
||||
|
||||
public class RefinePromptTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildArgs_includes_read_tools_when_repo_available()
|
||||
{
|
||||
var args = RefinePrompt.BuildArgs(20, canReadRepo: true);
|
||||
Assert.Contains("--permission-mode acceptEdits", args);
|
||||
Assert.Contains("mcp__claudedo__add_subtask", args);
|
||||
Assert.Contains(" Read Grep Glob", args);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildArgs_drops_read_tools_in_text_only_mode()
|
||||
{
|
||||
var args = RefinePrompt.BuildArgs(20, canReadRepo: false);
|
||||
Assert.DoesNotContain("Glob", args);
|
||||
Assert.Contains("mcp__claudedo__update_task", args);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPrompt_seeds_task_fields_and_open_subtasks()
|
||||
{
|
||||
var task = new TaskEntity { Id = "abc12345", ListId = "l", Title = "T", Description = "D",
|
||||
Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow };
|
||||
var subs = new[]
|
||||
{
|
||||
new SubtaskEntity { Id="1", TaskId="abc12345", Title="open one", Completed=false, OrderNum=0, CreatedAt=DateTime.UtcNow },
|
||||
new SubtaskEntity { Id="2", TaskId="abc12345", Title="done one", Completed=true, OrderNum=1, CreatedAt=DateTime.UtcNow },
|
||||
};
|
||||
var prompt = RefinePrompt.BuildPrompt(task, subs);
|
||||
Assert.Contains("abc12345", prompt);
|
||||
Assert.Contains("open one", prompt);
|
||||
Assert.DoesNotContain("done one", prompt);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter RefinePromptTests`
|
||||
Expected: PASS (3 tests).
|
||||
|
||||
- [ ] **Step 5: Create `RefineRunner.cs`**
|
||||
|
||||
`IClaudeProcess.RunAsync(arguments, prompt, workingDirectory, onStdoutLine, ct)` returns a result with `.IsSuccess` and `.ExitCode` (same as used by `PrimeRunner`). Resolve the working dir from the task's list; fall back to a sandbox dir + text-only when missing/invalid. Per-task single-flight via a guarded `HashSet<string>`.
|
||||
|
||||
```csharp
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Runner;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Worker.Refine;
|
||||
|
||||
public sealed class RefineRunner : IRefineRunner
|
||||
{
|
||||
private static readonly TimeSpan RunTimeout = TimeSpan.FromMinutes(5);
|
||||
private const int MaxTurns = 25;
|
||||
|
||||
private readonly IClaudeProcess _claude;
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly ILogger<RefineRunner> _logger;
|
||||
private readonly IRefineBroadcaster _broadcaster;
|
||||
|
||||
private readonly object _lock = new();
|
||||
private readonly HashSet<string> _inFlight = new();
|
||||
|
||||
public RefineRunner(
|
||||
IClaudeProcess claude,
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
ILogger<RefineRunner> logger,
|
||||
IRefineBroadcaster broadcaster)
|
||||
{
|
||||
_claude = claude;
|
||||
_dbFactory = dbFactory;
|
||||
_logger = logger;
|
||||
_broadcaster = broadcaster;
|
||||
}
|
||||
|
||||
public async Task<RefineRunOutcome> RefineAsync(string taskId, CancellationToken ct)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_inFlight.Add(taskId))
|
||||
return new RefineRunOutcome(false, "Already refining this task");
|
||||
}
|
||||
|
||||
var success = false;
|
||||
string? error = null;
|
||||
try
|
||||
{
|
||||
ClaudeDo.Data.Models.TaskEntity task;
|
||||
List<ClaudeDo.Data.Models.SubtaskEntity> subs;
|
||||
string? workingDir;
|
||||
await using (var dbCtx = await _dbFactory.CreateDbContextAsync(ct))
|
||||
{
|
||||
var tasks = new TaskRepository(dbCtx);
|
||||
task = await tasks.GetByIdAsync(taskId, ct)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
if (task.Status != TaskStatus.Idle)
|
||||
return new RefineRunOutcome(false, $"Task must be Idle to refine (is {task.Status}).");
|
||||
subs = await new SubtaskRepository(dbCtx).GetByTaskIdAsync(taskId, ct);
|
||||
var list = await new ListRepository(dbCtx).GetByIdAsync(task.ListId, ct);
|
||||
workingDir = list?.WorkingDir;
|
||||
}
|
||||
|
||||
var canReadRepo = !string.IsNullOrWhiteSpace(workingDir) && Directory.Exists(workingDir);
|
||||
var cwd = canReadRepo ? workingDir! : Paths.AppDataRoot();
|
||||
Directory.CreateDirectory(cwd);
|
||||
|
||||
var logPath = RefinePrompt.LogPath(taskId);
|
||||
try { if (File.Exists(logPath)) File.Delete(logPath); } catch { }
|
||||
await using var logWriter = new LogWriter(logPath);
|
||||
|
||||
await _broadcaster.RefineStartedAsync(taskId);
|
||||
|
||||
var prompt = RefinePrompt.BuildPrompt(task, subs);
|
||||
var args = RefinePrompt.BuildArgs(MaxTurns, canReadRepo);
|
||||
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeoutCts.CancelAfter(RunTimeout);
|
||||
|
||||
var result = await _claude.RunAsync(
|
||||
arguments: args,
|
||||
prompt: prompt,
|
||||
workingDirectory: cwd,
|
||||
onStdoutLine: async line => await logWriter.WriteLineAsync(line),
|
||||
ct: timeoutCts.Token);
|
||||
|
||||
success = result.IsSuccess;
|
||||
if (!success) error = $"exit code {result.ExitCode}";
|
||||
return success
|
||||
? new RefineRunOutcome(true, "Refine complete")
|
||||
: new RefineRunOutcome(false, error!);
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
error = $"timed out after {RunTimeout.TotalMinutes:0} min";
|
||||
return new RefineRunOutcome(false, error);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Refine run failed for {TaskId}", taskId);
|
||||
error = ex.Message;
|
||||
return new RefineRunOutcome(false, ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _broadcaster.RefineFinishedAsync(taskId, success, error);
|
||||
lock (_lock) { _inFlight.Remove(taskId); }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Write `RefineRunnerTests.cs` (guards, with a fake IClaudeProcess)**
|
||||
|
||||
The test project already has a fake/stub for `IClaudeProcess` used by Prime tests — reuse it (recording invocation + returning a configurable success result). Do NOT spawn the real CLI.
|
||||
|
||||
```csharp
|
||||
public class RefineRunnerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Refuses_when_task_not_idle()
|
||||
{
|
||||
await using var f = new RefineRunnerFixture(); // mirror Prime test fixture wiring
|
||||
var task = await f.SeedTaskAsync(status: TaskStatus.Queued);
|
||||
var outcome = await f.Runner.RefineAsync(task.Id, CancellationToken.None);
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(0, f.Claude.RunCount); // never invoked the CLI
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Idle_task_invokes_claude_once_and_brackets_with_events()
|
||||
{
|
||||
await using var f = new RefineRunnerFixture();
|
||||
var task = await f.SeedTaskAsync(status: TaskStatus.Idle);
|
||||
var outcome = await f.Runner.RefineAsync(task.Id, CancellationToken.None);
|
||||
Assert.True(outcome.Success);
|
||||
Assert.Equal(1, f.Claude.RunCount);
|
||||
Assert.Equal(1, f.Broadcaster.Started);
|
||||
Assert.Equal(1, f.Broadcaster.Finished);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> Build the `RefineRunnerFixture`/fakes by copying the Prime test's `IClaudeProcess` stub + real-SQLite `IDbContextFactory` setup and a recording `IRefineBroadcaster`. If a Prime fixture exists, mirror it; otherwise construct inline.
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter RefineRunnerTests`
|
||||
Expected: PASS (2 tests).
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/Refine tests/ClaudeDo.Worker.Tests/Refine
|
||||
git commit -m "feat(refine): add RefineRunner, prompt/args helper, and interfaces"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Worker wiring — broadcaster, hub, DI
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Worker/Hub/HubBroadcaster.cs`
|
||||
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
|
||||
- Modify: `src/ClaudeDo.Worker/Program.cs`
|
||||
|
||||
- [ ] **Step 1: Implement events on `HubBroadcaster`**
|
||||
|
||||
Add `IRefineBroadcaster` to the class's interface list (`public sealed class HubBroadcaster : ..., IRefineBroadcaster`) and add (mirroring the `Prep*` block):
|
||||
|
||||
```csharp
|
||||
public Task RefineStarted(string taskId) => _hub.Clients.All.SendAsync("RefineStarted", taskId);
|
||||
public Task RefineFinished(string taskId, bool success, string? error) =>
|
||||
_hub.Clients.All.SendAsync("RefineFinished", taskId, success, error);
|
||||
|
||||
Task IRefineBroadcaster.RefineStartedAsync(string taskId) => RefineStarted(taskId);
|
||||
Task IRefineBroadcaster.RefineFinishedAsync(string taskId, bool success, string? error) =>
|
||||
RefineFinished(taskId, success, error);
|
||||
```
|
||||
|
||||
Add `using ClaudeDo.Worker.Refine;`.
|
||||
|
||||
- [ ] **Step 2: Add `RefineTask` to `WorkerHub`**
|
||||
|
||||
`WorkerHub` injects services via its constructor. Add a `private readonly IRefineRunner _refineRunner;` field, add the parameter to the constructor and assign it. Add the method (fire-and-forget; the runner brackets with its own events):
|
||||
|
||||
```csharp
|
||||
public Task RefineTask(string taskId)
|
||||
{
|
||||
_ = _refineRunner.RefineAsync(taskId, CancellationToken.None);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
```
|
||||
|
||||
Add `using ClaudeDo.Worker.Refine;`.
|
||||
|
||||
- [ ] **Step 3: Register DI in `Program.cs`**
|
||||
|
||||
Near the Prime registrations:
|
||||
|
||||
```csharp
|
||||
builder.Services.AddSingleton<IRefineRunner, RefineRunner>();
|
||||
builder.Services.AddSingleton<IRefineBroadcaster>(sp => sp.GetRequiredService<HubBroadcaster>());
|
||||
```
|
||||
|
||||
Add `using ClaudeDo.Worker.Refine;` if needed. (`HubBroadcaster` is already registered as a singleton — confirm and reuse that registration; do not double-register it.)
|
||||
|
||||
- [ ] **Step 4: Build the worker**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`
|
||||
Expected: Build succeeded.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Worker/Hub/HubBroadcaster.cs src/ClaudeDo.Worker/Hub/WorkerHub.cs src/ClaudeDo.Worker/Program.cs
|
||||
git commit -m "feat(refine): wire RefineTask hub method, broadcaster events, and DI"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: UI worker client — call + events + fakes
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs`
|
||||
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
|
||||
- Modify: test fakes implementing `IWorkerClient`
|
||||
|
||||
- [ ] **Step 1: Extend the interface**
|
||||
|
||||
In `IWorkerClient.cs` add (near `RunDailyPrepNowAsync` and the `Prep*` events):
|
||||
|
||||
```csharp
|
||||
Task RefineTaskAsync(string taskId);
|
||||
|
||||
event Action<string>? RefineStartedEvent;
|
||||
event Action<string, bool, string?>? RefineFinishedEvent;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Implement in `WorkerClient`**
|
||||
|
||||
Add the method (mirror `RunDailyPrepNowAsync`):
|
||||
|
||||
```csharp
|
||||
public Task RefineTaskAsync(string taskId) => _hub.InvokeAsync("RefineTask", taskId);
|
||||
```
|
||||
|
||||
Declare the events:
|
||||
|
||||
```csharp
|
||||
public event Action<string>? RefineStartedEvent;
|
||||
public event Action<string, bool, string?>? RefineFinishedEvent;
|
||||
```
|
||||
|
||||
Subscribe in the constructor (mirror the `Prep*` subscriptions block):
|
||||
|
||||
```csharp
|
||||
_hub.On<string>("RefineStarted", id =>
|
||||
Dispatcher.UIThread.Post(() => RefineStartedEvent?.Invoke(id)));
|
||||
_hub.On<string, bool, string?>("RefineFinished", (id, ok, err) =>
|
||||
Dispatcher.UIThread.Post(() => RefineFinishedEvent?.Invoke(id, ok, err)));
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update test fakes**
|
||||
|
||||
Find every hand-rolled `IWorkerClient` implementation (search the test projects) and add `RefineTaskAsync` (return `Task.CompletedTask`) plus the two events (`= delegate {}` or `add{}remove{}` no-ops as the fake convention dictates). Build each affected test project.
|
||||
|
||||
- [ ] **Step 4: Build UI + test projects**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||
Then build the UI test project(s). Expected: Build succeeded.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs src/ClaudeDo.Ui/Services/WorkerClient.cs <fake files>
|
||||
git commit -m "feat(ui): add RefineTask client call and refine events"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: UI — icon, button, view model, command
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Ui/Design/IslandStyles.axaml`
|
||||
- Modify: `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`
|
||||
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs`
|
||||
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs`
|
||||
- Modify: `locales/en.json`, `locales/de.json`
|
||||
|
||||
- [ ] **Step 1: Add the `Icon.Refine` geometry**
|
||||
|
||||
In `IslandStyles.axaml`, near the other `Icon.*` `StreamGeometry` resources, add the supplied SVG converted to path data (line-art, rendered stroked via `plan-icon`):
|
||||
|
||||
```xml
|
||||
<StreamGeometry x:Key="Icon.Refine">M3,5 L11,5 M3,9 L9,9 M3,13 L7,13 M19,1.8 L19.7,3.9 L21.7,4.6 L19.7,5.3 L19,7.4 L18.3,5.3 L16.3,4.6 L18.3,3.9 Z M18,10.5 L12.2,16.3 M16.6,9.1 L19.4,11.9 M12.2,16.3 L11,18.5 L13.2,17.5 Z</StreamGeometry>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add `IsRefining`/`CanRefine` to `TaskRowViewModel`**
|
||||
|
||||
Add the observable property (with the other `[ObservableProperty]` fields):
|
||||
|
||||
```csharp
|
||||
[ObservableProperty] private bool _isRefining;
|
||||
```
|
||||
|
||||
Add a computed gate (refine is only offered for Idle, non-parent tasks). Place near other `Can*` getters:
|
||||
|
||||
```csharp
|
||||
public bool CanRefine => Status == TaskStatus.Idle && PlanningPhase == PlanningPhase.None && !IsRefining;
|
||||
```
|
||||
|
||||
If `Status`/`PlanningPhase`/`IsRefining` are `[ObservableProperty]`, raise `CanRefine` change notifications via partial `On<Prop>Changed` hooks:
|
||||
|
||||
```csharp
|
||||
partial void OnStatusChanged(TaskStatus value) => OnPropertyChanged(nameof(CanRefine));
|
||||
partial void OnPlanningPhaseChanged(PlanningPhase value) => OnPropertyChanged(nameof(CanRefine));
|
||||
partial void OnIsRefiningChanged(bool value) => OnPropertyChanged(nameof(CanRefine));
|
||||
```
|
||||
|
||||
> If `On...Changed` partials already exist for `Status`/`PlanningPhase`, add the `OnPropertyChanged(nameof(CanRefine))` line inside them instead of redeclaring.
|
||||
|
||||
- [ ] **Step 3: Add `RefineTaskCommand` + event wiring to `TasksIslandViewModel`**
|
||||
|
||||
Add the command (mirror an existing per-row command like `ToggleStarCommand`, which takes a `TaskRowViewModel`):
|
||||
|
||||
```csharp
|
||||
[RelayCommand]
|
||||
private async Task RefineTask(TaskRowViewModel row)
|
||||
{
|
||||
if (row is null || !row.CanRefine) return;
|
||||
row.IsRefining = true;
|
||||
try { await _worker.RefineTaskAsync(row.Id); }
|
||||
catch { row.IsRefining = false; }
|
||||
}
|
||||
```
|
||||
|
||||
> Use the same injected worker-client field name this VM already uses (e.g. `_worker`/`_client`). Match it.
|
||||
|
||||
Subscribe to the refine events where the VM wires other worker events (where `OnWorkerTaskUpdated` is subscribed). Add handlers that flip the row flag:
|
||||
|
||||
```csharp
|
||||
private void OnRefineStarted(string taskId)
|
||||
{
|
||||
var row = Items.FirstOrDefault(r => r.Id == taskId);
|
||||
if (row is not null) row.IsRefining = true;
|
||||
}
|
||||
|
||||
private void OnRefineFinished(string taskId, bool ok, string? error)
|
||||
{
|
||||
var row = Items.FirstOrDefault(r => r.Id == taskId);
|
||||
if (row is not null) row.IsRefining = false;
|
||||
}
|
||||
```
|
||||
|
||||
Wire them next to the existing subscriptions (and unsubscribe in the same place the VM unsubscribes others, if it does):
|
||||
|
||||
```csharp
|
||||
_worker.RefineStartedEvent += OnRefineStarted;
|
||||
_worker.RefineFinishedEvent += OnRefineFinished;
|
||||
```
|
||||
|
||||
(Content changes—new description/subtasks—arrive through the existing `TaskUpdated` → `OnWorkerTaskUpdated` path; no extra work needed.)
|
||||
|
||||
- [ ] **Step 4: Add the button to `TaskRowView.axaml`**
|
||||
|
||||
Mirror the star button (`Grid.Column="5"` area). Add a refine `icon-btn` (e.g. as a new column or beside the star) bound to the parent ItemsControl's command, passing the row as parameter. Use the `plan-icon` stroked `Path` inside a `Viewbox` (matching the Plan-day button), gate visibility on `CanRefine`, and disable/spin on `IsRefining`:
|
||||
|
||||
```xml
|
||||
<Button Classes="icon-btn refine-btn"
|
||||
IsVisible="{Binding CanRefine}"
|
||||
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).RefineTaskCommand}"
|
||||
CommandParameter="{Binding}"
|
||||
ToolTip.Tip="{loc:Tr tasks.refineTip}">
|
||||
<Viewbox Width="16" Height="16">
|
||||
<Path Classes="plan-icon" Data="{StaticResource Icon.Refine}"/>
|
||||
</Viewbox>
|
||||
</Button>
|
||||
```
|
||||
|
||||
> Match the column layout already in `TaskRowView.axaml`. If a new grid column is needed, widen `ColumnDefinitions` accordingly and place the refine button left of the star (`Grid.Column`). Keep the existing `vm:` / `loc:` xmlns aliases the file already declares.
|
||||
|
||||
Optionally show a spinning/dimmed state while `IsRefining` (e.g. a style `Selector="Button.refine-btn:disabled"` or bind opacity to `IsRefining`). Keep it simple; a disabled look is enough.
|
||||
|
||||
- [ ] **Step 5: Add localization keys**
|
||||
|
||||
Add to both `locales/en.json` and `locales/de.json` under the `tasks` group (keys must stay in parity):
|
||||
|
||||
- en: `"tasks.refineTip": "Refine this task with Claude"`
|
||||
- de: `"tasks.refineTip": "Aufgabe mit Claude verfeinern"`
|
||||
|
||||
> Match the file's actual key structure (flat `"tasks.x"` vs nested `tasks: { x }`)—look at an existing `tasks.*` tooltip key (e.g. the plan-day tip) and follow it exactly.
|
||||
|
||||
- [ ] **Step 6: Build UI**
|
||||
|
||||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||
Then run the Localization parity tests: `dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release`
|
||||
Expected: Build succeeded; locale parity passes.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/Design/IslandStyles.axaml src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs locales/en.json locales/de.json
|
||||
git commit -m "feat(ui): add Refine button, icon, and command to task card"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Full build + test sweep, manual smoke
|
||||
|
||||
- [ ] **Step 1: Build all main projects**
|
||||
|
||||
```bash
|
||||
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
|
||||
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
|
||||
```
|
||||
Expected: Build succeeded for both.
|
||||
|
||||
- [ ] **Step 2: Run the worker + UI test suites**
|
||||
|
||||
```bash
|
||||
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
|
||||
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
|
||||
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
|
||||
```
|
||||
Expected: all green.
|
||||
|
||||
- [ ] **Step 3: Manual smoke (visual + real CLI — flag to user)**
|
||||
|
||||
Cannot be automated (no real-Claude in tests). Verify by hand: start Worker + UI, on an Idle task click the refine icon → button shows busy → after the run the description improves and steps appear in the Steps card → task stays Idle. Confirm the refine icon is hidden for Queued/Running/Done tasks and for planning parents. **Report this as a visual-verification gap for the user to confirm.**
|
||||
|
||||
---
|
||||
|
||||
## Notes on parallelism / execution
|
||||
|
||||
- Tasks 1–4 are backend and largely sequential (4 depends on 3). Tasks 1 and 2 are independent and could be done first in either order.
|
||||
- Tasks 5–6 (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.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Task Detail Redesign — Component Build Prompts
|
||||
|
||||
Three isolated build tasks (one per component). Each runs in its own worktree off
|
||||
`main`, with the project CLAUDE.md auto-loaded. Full design context lives in
|
||||
`docs/superpowers/specs/2026-06-04-task-detail-redesign-design.md` — every task
|
||||
must read it first.
|
||||
|
||||
Shared rules (all three):
|
||||
- Build a **standalone** `UserControl` + dedicated `ViewModel` that renders fully
|
||||
in the Avalonia previewer via **design-time sample data** (parameterless ctor
|
||||
populating realistic values). Do **not** bind to `DetailsIslandViewModel`.
|
||||
- New files under `src/ClaudeDo.Ui/Views/Islands/Detail/` and
|
||||
`src/ClaudeDo.Ui/ViewModels/Islands/Detail/`.
|
||||
- Use **only** tokens from `Design/Tokens.axaml` and classes from
|
||||
`Design/IslandStyles.axaml`. No inline hex, no magic numbers where a token
|
||||
exists. `PathIcon` fills geometry — stroke-only art is invisible.
|
||||
- Compiled bindings (`x:DataType`). MVVM via CommunityToolkit
|
||||
(`[ObservableProperty]`, `[RelayCommand]`); VM inherits `ViewModelBase`.
|
||||
- **Do NOT modify** `DetailsIslandView.axaml`, `DetailsIslandViewModel.cs`,
|
||||
`AgentStripView`, `SessionTerminalView`, or `TaskRunner.cs`.
|
||||
- Verify: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj -c Release` is green.
|
||||
Stage files explicitly by path (never `git add -A`). Commit with a conventional
|
||||
message.
|
||||
|
||||
---
|
||||
|
||||
## TASK 1 — TaskHeaderBar
|
||||
|
||||
(prompt text = task description; see below)
|
||||
|
||||
## TASK 2 — DescriptionStepsCard
|
||||
|
||||
## TASK 3 — WorkConsole
|
||||
522
docs/superpowers/plans/2026-06-05-terminal-review.md
Normal file
522
docs/superpowers/plans/2026-06-05-terminal-review.md
Normal file
@@ -0,0 +1,522 @@
|
||||
# Terminal-style Review Controls Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Move review feedback into the Output (terminal) tab as a prompt-style input with `[Retry]`/`[Reset]` actions, and relocate Approve + all merge/worktree controls to a new **Git** tab.
|
||||
|
||||
**Architecture:** Pure UI-layer change in `ClaudeDo.Ui`. Add an `IsGitTab` computed flag to `DetailsIslandViewModel`, re-home existing XAML blocks across three tabs (Output · Git · Session) in `WorkConsole.axaml`, add a bottom-docked review footer to the Output tab, and intercept Enter in `WorkConsole.axaml.cs`. No worker-side or `IWorkerClient` changes; no ViewModel command renames.
|
||||
|
||||
**Tech Stack:** .NET 8, Avalonia 12 (Fluent), CommunityToolkit.Mvvm, xUnit (ClaudeDo.Ui.Tests).
|
||||
|
||||
**Reference spec:** `docs/superpowers/specs/2026-06-05-terminal-review-design.md`
|
||||
|
||||
**Build/test note (from CLAUDE.md):** A running Worker locks `Debug` output — build UI in `-c Release`:
|
||||
`dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||
`dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` — add `IsGitTab`, wire notifications.
|
||||
- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml` — add Git tab button; split tab bodies; add Output-tab review footer; update Session empty-state text.
|
||||
- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml.cs` — Enter-to-Retry key handling.
|
||||
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandTabsTests.cs` (create) — `IsGitTab` behavior.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add `IsGitTab` tab flag to the ViewModel
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs:139-147`
|
||||
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandTabsTests.cs` (create)
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandTabsTests.cs`. Mirror the
|
||||
construction pattern from `DetailsIslandPrepModeTests.cs` (temp SQLite db,
|
||||
`TestDbFactory`, `StubWorkerClient`, `NullServiceProvider`, `StubNotesApi`).
|
||||
|
||||
```csharp
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||
|
||||
public class DetailsIslandTabsTests : IDisposable
|
||||
{
|
||||
private readonly string _dbPath;
|
||||
|
||||
public DetailsIslandTabsTests()
|
||||
{
|
||||
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_tabs_test_{Guid.NewGuid():N}.db");
|
||||
using var ctx = NewContext();
|
||||
ctx.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { File.Delete(_dbPath); } catch { }
|
||||
try { File.Delete(_dbPath + "-wal"); } catch { }
|
||||
try { File.Delete(_dbPath + "-shm"); } catch { }
|
||||
}
|
||||
|
||||
private ClaudeDoDbContext NewContext()
|
||||
{
|
||||
var opts = new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
||||
.UseSqlite($"Data Source={_dbPath}")
|
||||
.Options;
|
||||
return new ClaudeDoDbContext(opts);
|
||||
}
|
||||
|
||||
private sealed class TestDbFactory : IDbContextFactory<ClaudeDoDbContext>
|
||||
{
|
||||
private readonly Func<ClaudeDoDbContext> _create;
|
||||
public TestDbFactory(Func<ClaudeDoDbContext> create) => _create = create;
|
||||
public ClaudeDoDbContext CreateDbContext() => _create();
|
||||
}
|
||||
|
||||
private sealed class StubNotesApi : ClaudeDo.Ui.Services.Interfaces.INotesApi
|
||||
{
|
||||
public Task<List<DailyNoteDto>> ListAsync(DateOnly day) => Task.FromResult(new List<DailyNoteDto>());
|
||||
public Task<DailyNoteDto?> AddAsync(DateOnly day, string text) => Task.FromResult<DailyNoteDto?>(null);
|
||||
public Task UpdateAsync(string id, string text) => Task.CompletedTask;
|
||||
public Task DeleteAsync(string id) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class NullServiceProvider : IServiceProvider
|
||||
{
|
||||
public object? GetService(Type serviceType) => null;
|
||||
}
|
||||
|
||||
// StubWorkerClient is abstract — use a concrete no-op subclass (same pattern as DetailsIslandPrepModeTests).
|
||||
private sealed class DefaultStub : StubWorkerClient { }
|
||||
|
||||
private DetailsIslandViewModel NewVm()
|
||||
{
|
||||
var factory = new TestDbFactory(NewContext);
|
||||
return new DetailsIslandViewModel(factory, new DefaultStub(), new NullServiceProvider(), new StubNotesApi());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectTab_git_sets_IsGitTab_and_clears_others()
|
||||
{
|
||||
var vm = NewVm();
|
||||
|
||||
vm.SelectTabCommand.Execute("git");
|
||||
|
||||
Assert.True(vm.IsGitTab);
|
||||
Assert.False(vm.IsOutputTab);
|
||||
Assert.False(vm.IsSessionTab);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_tab_is_output_not_git()
|
||||
{
|
||||
var vm = NewVm();
|
||||
|
||||
Assert.True(vm.IsOutputTab);
|
||||
Assert.False(vm.IsGitTab);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run test to verify it fails**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter DetailsIslandTabsTests`
|
||||
Expected: FAIL — compile error, `DetailsIslandViewModel` has no `IsGitTab`.
|
||||
|
||||
- [ ] **Step 3: Add `IsGitTab` to the ViewModel**
|
||||
|
||||
In `DetailsIslandViewModel.cs`, find the `SelectedTab` property notifications and the
|
||||
tab getters (around lines 139-147). Add the `IsGitTab` notification and getter:
|
||||
|
||||
```csharp
|
||||
[NotifyPropertyChangedFor(nameof(IsOutputTab))]
|
||||
[NotifyPropertyChangedFor(nameof(IsSessionTab))]
|
||||
[NotifyPropertyChangedFor(nameof(IsGitTab))]
|
||||
```
|
||||
|
||||
```csharp
|
||||
public bool IsOutputTab => SelectedTab == "output";
|
||||
public bool IsGitTab => SelectedTab == "git";
|
||||
public bool IsSessionTab => SelectedTab == "session";
|
||||
```
|
||||
|
||||
(Leave `SelectTab` unchanged — it already accepts any string and defaults to `"output"`.)
|
||||
|
||||
- [ ] **Step 4: Run test to verify it passes**
|
||||
|
||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter DetailsIslandTabsTests`
|
||||
Expected: PASS (2 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandTabsTests.cs
|
||||
git commit -m "feat(ui): add IsGitTab flag to work console view model"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Add the Git tab button and move the merge/worktree block onto it
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml:124-135` (tab strip)
|
||||
- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml:164-273` (tab body)
|
||||
|
||||
- [ ] **Step 1: Add the Git tab button**
|
||||
|
||||
In the tab strip `StackPanel` (lines 124-135), insert a Git button between the Output
|
||||
and Session buttons:
|
||||
|
||||
```xml
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button Classes="tab-btn"
|
||||
Classes.active="{Binding IsOutputTab}"
|
||||
Content="Output"
|
||||
Command="{Binding SelectTabCommand}"
|
||||
CommandParameter="output" />
|
||||
<Button Classes="tab-btn"
|
||||
Classes.active="{Binding IsGitTab}"
|
||||
Content="Git"
|
||||
Command="{Binding SelectTabCommand}"
|
||||
CommandParameter="git" />
|
||||
<Button Classes="tab-btn"
|
||||
Classes.active="{Binding IsSessionTab}"
|
||||
Content="Session"
|
||||
Command="{Binding SelectTabCommand}"
|
||||
CommandParameter="session" />
|
||||
</StackPanel>
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Move the "Merge & worktree" block to a new Git-tab ScrollViewer**
|
||||
|
||||
In the tab body `Grid` (starts line 139), the body currently holds the Output
|
||||
`ScrollViewer` (`IsVisible="{Binding IsOutputTab}"`, lines 142-162) and the Session
|
||||
`ScrollViewer` (`IsVisible="{Binding IsSessionTab}"`, lines 165-273).
|
||||
|
||||
Cut the **entire "Merge & worktree management" `StackPanel`** — the block currently at
|
||||
lines 195-241, beginning with the comment `<!-- Merge & worktree management -->` and the
|
||||
`<StackPanel Spacing="10" IsVisible="{Binding ShowMergeSection}">` and ending at its
|
||||
matching `</StackPanel>` after the `MergeAllError` `TextBlock` (line 241).
|
||||
|
||||
Add a new Git-tab `ScrollViewer` between the Output and Session `ScrollViewer`s, and
|
||||
paste the cut block inside it:
|
||||
|
||||
```xml
|
||||
<!-- Git: merge target, approve, diff, worktree -->
|
||||
<ScrollViewer IsVisible="{Binding IsGitTab}" Padding="14,10">
|
||||
<StackPanel Spacing="14">
|
||||
|
||||
<!-- Approve (review-gated) -->
|
||||
<StackPanel Spacing="8" IsVisible="{Binding IsWaitingForReview}">
|
||||
<TextBlock Classes="section-label" Text="REVIEW" />
|
||||
<Button Classes="btn accent" Content="Approve"
|
||||
Command="{Binding ApproveReviewCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Merge & worktree management (moved from Session tab) -->
|
||||
<StackPanel Spacing="10" IsVisible="{Binding ShowMergeSection}">
|
||||
<TextBlock Classes="section-label" Text="MERGE & 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="❯"
|
||||
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).
|
||||
@@ -0,0 +1,173 @@
|
||||
# Approve = Merge → Done, plus Conflict Preview — Design
|
||||
|
||||
**Date:** 2026-06-04
|
||||
**Status:** Approved (autonomous — user on break, authorized to continue)
|
||||
**Author:** brainstormed from issue "Make merge/diff real"
|
||||
|
||||
## Problem
|
||||
|
||||
Approving a `WaitingForReview` task flips it straight to `Done`
|
||||
(`TaskStateService.ApproveReviewAsync`) and **never merges** its worktree — the
|
||||
worktree stays `Active`. The user approved three component tasks expecting them
|
||||
to merge; none did. Separately, there is **no way to see whether a task's
|
||||
worktree merges cleanly** before acting, and a standalone task has no direct
|
||||
**Merge** button (single-task merge is only reachable from inside the Diff
|
||||
modal).
|
||||
|
||||
What is already real (verified): `WorkerHub.MergeTask → TaskMergeService.MergeAsync`
|
||||
performs a real `git merge --no-ff`, aborts on conflict, and marks the worktree
|
||||
`Merged`. **Open Diff** opens a real in-app diff. **Merge All Subtasks**
|
||||
(planning) is real. So the gaps are narrow.
|
||||
|
||||
## Scope decisions (autonomous)
|
||||
|
||||
- **Tab location:** keep the **single "Session" tab** that the recent commit
|
||||
`ac9bae9` deliberately consolidated. All new controls go in its existing
|
||||
`MERGE & WORKTREE` block (`WorkConsole.axaml:196`). Do **not** re-introduce a
|
||||
separate "Actions" tab.
|
||||
- **Approve target:** Approve merges into the UI-selected merge target
|
||||
(`SelectedMergeTarget`); when blank, the worker resolves to the repo's current
|
||||
branch.
|
||||
- **On conflict:** task stays in `WaitingForReview` (no new status). The conflict
|
||||
is surfaced inline. No automatic state change to a "blocked" status.
|
||||
- **Worktree removal on approve:** do **not** remove — merge marks the worktree
|
||||
`Merged` and existing auto-cleanup handles disposal (matches the single-task
|
||||
merge default `removeWorktree:false`).
|
||||
- **Applies to:** standalone leaf tasks with an active worktree. A
|
||||
`WaitingForReview` task with **no** active worktree (e.g. ran in a sandbox, or
|
||||
an improvement parent whose children own the worktrees) is just marked `Done`
|
||||
— current behavior preserved. Planning parents keep "Merge All Subtasks".
|
||||
|
||||
## Acceptance (restated)
|
||||
|
||||
1. Approve a clean-merging task → worktree merged into target, worktree `Merged`,
|
||||
task `Done`.
|
||||
2. Approve a conflicting task → task **not** `Done`, conflict surfaced.
|
||||
3. Opening a Done/WaitingForReview task shows clean/conflict status **without
|
||||
mutating** the tree (use `git merge-tree`, not a real merge).
|
||||
|
||||
## Architecture
|
||||
|
||||
Three layers, each single-purpose; the only new cross-dependency is
|
||||
`TaskMergeService → ITaskStateService` (one-way; verify no DI cycle).
|
||||
|
||||
### 1. GitService — non-destructive conflict probe (`ClaudeDo.Data`)
|
||||
|
||||
New method:
|
||||
|
||||
```csharp
|
||||
public sealed record MergePreview(bool Supported, bool Clean, IReadOnlyList<string> ConflictFiles);
|
||||
|
||||
public async Task<MergePreview> PreviewMergeAsync(
|
||||
string repoDir, string targetBranch, string sourceBranch, CancellationToken ct = default)
|
||||
```
|
||||
|
||||
- Runs `git merge-tree --write-tree --name-only <target> <source>` from `repoDir`.
|
||||
`merge-tree` computes the merge base itself and writes only loose objects — it
|
||||
does **not** touch the working tree, index, or refs.
|
||||
- Exit code `0` → `Clean = true`, no conflict files.
|
||||
- Exit code `1` → `Clean = false`; conflicted paths are the lines after the
|
||||
first (tree-OID) line, up to the first blank line.
|
||||
- Any other outcome (e.g. git too old → "unknown option") → `Supported = false`
|
||||
(UI shows "mergeability unknown").
|
||||
|
||||
New helper for the "· N files" count (clean case):
|
||||
`git diff --name-only <target>...<source>` (three-dot = changes on source since
|
||||
the merge base); count non-empty lines. May reuse/extend existing diff helpers.
|
||||
|
||||
### 2. TaskMergeService — preview + approve orchestration (`ClaudeDo.Worker`)
|
||||
|
||||
Inject `ITaskStateService` (verify `PlanningChainCoordinator` has no back-edge to
|
||||
`TaskMergeService`; if a cycle exists, fall back to orchestrating in the hub).
|
||||
|
||||
```csharp
|
||||
public sealed record MergePreviewResult(string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount);
|
||||
// Status: "clean" | "conflict" | "unavailable"
|
||||
public Task<MergePreviewResult> PreviewAsync(string taskId, string targetBranch, CancellationToken ct);
|
||||
|
||||
public Task<MergeResult> ApproveAndMergeAsync(string taskId, string targetBranch, CancellationToken ct);
|
||||
```
|
||||
|
||||
**PreviewAsync:** load context. If no active worktree → `"unavailable"`. Resolve
|
||||
`targetBranch` (blank → current branch). Call `GitService.PreviewMergeAsync`; map
|
||||
`Supported=false` → `"unavailable"`, else clean/conflict (+ ChangedFileCount on
|
||||
clean).
|
||||
|
||||
**ApproveAndMergeAsync:** load context; require `task.Status == WaitingForReview`
|
||||
(else `Blocked`). Resolve target (blank → current branch).
|
||||
- **No active worktree** → `_state.ApproveReviewAsync(taskId)` → return
|
||||
`MergeResult(StatusMerged, [], null)` ("approved, nothing to merge").
|
||||
- **Active worktree** → `MergeAsync(taskId, target, removeWorktree:false,
|
||||
"Merge {branch}", ct)`. On `StatusMerged` → `_state.ApproveReviewAsync(taskId)`
|
||||
then return the merged result. On `StatusConflict`/`StatusBlocked` → return as-is;
|
||||
**do not** flip status (task stays `WaitingForReview`).
|
||||
|
||||
`TaskStateService.ApproveReviewAsync` is unchanged (still the sole Status writer;
|
||||
still runs `OnChildTerminalAsync`).
|
||||
|
||||
### 3. WorkerHub — signatures (`ClaudeDo.Worker`)
|
||||
|
||||
```csharp
|
||||
public record MergePreviewDto(string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount);
|
||||
|
||||
public Task<MergePreviewDto> PreviewMerge(string taskId, string targetBranch); // new
|
||||
public Task<MergeResultDto> ApproveReview(string taskId, string targetBranch); // CHANGED: was void(taskId)
|
||||
```
|
||||
|
||||
`ApproveReview` returns the orchestration result so the UI can react to conflicts.
|
||||
`MergeTask` / `GetMergeTargets` unchanged.
|
||||
|
||||
### 4. UI (`ClaudeDo.Ui`)
|
||||
|
||||
`IWorkerClient` (+ `WorkerClient` + **both test-project fakes** — see memory:
|
||||
changing `IWorkerClient` breaks hand-rolled fakes):
|
||||
- Change `Task ApproveReviewAsync(string)` → `Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch)`.
|
||||
- Add `Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch)`.
|
||||
- Add `Task<MergeResultDto> MergeTaskAsync(...)` to the **interface** (already on
|
||||
the concrete client) so the single-task Merge button can use `_worker`.
|
||||
|
||||
`DetailsIslandViewModel`:
|
||||
- **Load merge targets whenever a worktree exists.** In `BindAsync`, when
|
||||
`entity.Worktree != null` and the task is not a planning parent, call
|
||||
`GetMergeTargetsAsync(taskId)` and set `SelectedMergeTarget = DefaultBranch`
|
||||
(fixes the standalone-task gap where targets were never loaded).
|
||||
- **Mergeability indicator** properties: `MergePreviewText` (string),
|
||||
`MergeIsClean` / `MergeIsConflict` (bool, for color). Compute via
|
||||
`PreviewMergeAsync` when the merge section is shown for an **Active** worktree;
|
||||
recompute on `SelectedMergeTarget` change. If worktree state is
|
||||
`Merged/Discarded/Kept`, show that label instead of probing. Text examples:
|
||||
"Merges cleanly · 7 files" / "Conflicts in a.cs, b.cs" / "Mergeability unknown".
|
||||
- **Approve** (`ApproveReviewAsync`): pass `SelectedMergeTarget ?? ""`; inspect
|
||||
result — on `"conflict"` set the conflict indicator + a short notice
|
||||
("Approve blocked — resolve conflicts first"); success path relies on the
|
||||
existing `TaskUpdated` broadcast to refresh.
|
||||
- **Single-task Merge** (`MergeCommand`): `MergeTaskAsync(taskId,
|
||||
SelectedMergeTarget ?? "", removeWorktree:false, "Merge task")`; on `"conflict"`
|
||||
show the conflict indicator. Shown for non-planning tasks with an active
|
||||
worktree (planning parents keep "Merge All Subtasks").
|
||||
|
||||
`WorkConsole.axaml` (Session tab, `MERGE & WORKTREE` block):
|
||||
- Add a status line above the button row bound to `MergePreviewText`, colored
|
||||
green (`MossBrush`) when `MergeIsClean`, red (`BloodBrush`) when
|
||||
`MergeIsConflict`, muted otherwise. Use existing tokens/classes only.
|
||||
- Add a **Merge** button (`MergeCommand`) beside **Open Diff** for the
|
||||
single-task path.
|
||||
|
||||
## Testing (git-backed, no real Claude)
|
||||
|
||||
In `ClaudeDo.Worker.Tests` (real temp git repos + real SQLite), and/or
|
||||
`ClaudeDo.Data.Tests` for the pure git probe:
|
||||
- `GitService.PreviewMergeAsync`: clean branches → `Clean=true`; a real
|
||||
edit-conflict on the same lines → `Clean=false` with the expected file in
|
||||
`ConflictFiles`.
|
||||
- `ApproveAndMergeAsync`: clean worktree → returns `merged`, task is `Done`,
|
||||
worktree state `Merged`. Conflicting worktree → returns `conflict`, task still
|
||||
`WaitingForReview`, worktree still `Active`, target branch unmodified
|
||||
(HEAD unchanged, no `MERGE_HEAD`).
|
||||
- No-worktree `WaitingForReview` task → returns `merged`, task `Done`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
External difftools, new task statuses, auto-removing worktrees on approve,
|
||||
re-splitting the console into separate tabs, conflict resolution UI (the existing
|
||||
`ContinueMerge`/`AbortMerge` paths remain as-is for mid-merge cases).
|
||||
200
docs/superpowers/specs/2026-06-04-marketing-website-design.md
Normal file
200
docs/superpowers/specs/2026-06-04-marketing-website-design.md
Normal file
@@ -0,0 +1,200 @@
|
||||
# ClaudeDo distribution website — design
|
||||
|
||||
**Date:** 2026-06-04
|
||||
**Status:** Approved (design), ready for implementation planning
|
||||
**Repo:** new standalone repo `claudedo-web` (not part of the ClaudeDo app solution)
|
||||
**Domain:** `claudedo.kuns.dev` (Coolify on the user's VPS)
|
||||
|
||||
## Purpose
|
||||
|
||||
Give friends a public place to download ClaudeDo and learn what it does, without
|
||||
sending them to the Gitea repo — so the source repo can be made more private. The
|
||||
site also fronts the app's self-updater so the Gitea URL is never exposed in the
|
||||
app or on the page.
|
||||
|
||||
## Goals / non-goals
|
||||
|
||||
**Goals**
|
||||
- Public, no-auth landing page at `claudedo.kuns.dev` that matches the app's visual identity.
|
||||
- A primary download (installer `.exe`) plus the portable `.zip` and checksums.
|
||||
- A release proxy that (a) feeds the page the current version and (b) serves the
|
||||
app's self-updater the same JSON shape Gitea returns, with download URLs rewritten
|
||||
to route through `claudedo.kuns.dev` — hiding Gitea entirely.
|
||||
|
||||
**Non-goals**
|
||||
- No docs site / getting-started page (the app ships an installer that handles setup).
|
||||
- No changelog page (release notes already live on Gitea releases).
|
||||
- No auth, accounts, analytics, or CMS.
|
||||
- No CI/PR tooling for this repo beyond what Coolify needs to deploy.
|
||||
|
||||
## Access & distribution decisions
|
||||
|
||||
- **Access:** fully public. No password/login. Relies on the unadvertised URL.
|
||||
- **Download source:** build-time fetch of the latest Gitea release for the displayed
|
||||
version; actual download links route through the proxy and resolve the latest asset
|
||||
at request time (so a stale page still downloads the current build).
|
||||
- **Self-updater proxy:** in scope for v1 (not deferred).
|
||||
|
||||
## Tech stack
|
||||
|
||||
- **Nuxt 3** (Vue 3) — single framework, single repo, single Coolify deploy.
|
||||
- **Nitro** server routes for the release proxy + asset streaming.
|
||||
- **No DB, no auth, no secrets** (the `releases/ClaudeDo` repo is public).
|
||||
- Fonts: **Inter Tight** (display/body) + **JetBrains Mono** (mono), self-hosted or via
|
||||
Google Fonts. Design tokens ported from `docs/UI Rewrite/design_handoff_claudedo/Tokens.axaml`
|
||||
and `styles.css` (moss/sage/peat palette, dark-first, 14px island radius, grain texture).
|
||||
|
||||
## Concept: "the page IS the app"
|
||||
|
||||
The landing page is a faithful, in-browser rendering of the ClaudeDo desktop — the
|
||||
window chrome + the three islands — rather than a conventional marketing page. This
|
||||
is the chosen direction (over a cinematic single-window scroll and a worklog feed).
|
||||
|
||||
### Layout — three islands on the app "desktop"
|
||||
|
||||
Desktop background = the app's layered moss gradients + 3px grain overlay. A centered
|
||||
app `window` (titlebar + body) holds a 3-column island grid:
|
||||
|
||||
1. **Lists island (left)** — repurposed as page nav.
|
||||
- Header "Lists" + a decorative search box (`Ctrl K` kbd chip).
|
||||
- "Pages" group: Overview · Features (6) · How it works · Screenshots (3) · Download,
|
||||
each with a colored swatch dot; active item gets the accent left-bar.
|
||||
- Footer styled like the app's user footer: avatar, "For friends", `claudedo.kuns.dev`.
|
||||
|
||||
2. **Tasks island (middle)** — features rendered as **task cards**.
|
||||
- Header: date eyebrow, big title (the hero line "Queue the work. Claude does it."),
|
||||
a `running · review` badge, eye/gear icon buttons, and a subtitle.
|
||||
- A decorative "Add a task…" row.
|
||||
- Six **feature cards** (circle check — done cards filled; title; a status chip
|
||||
`done`/`running`/`waiting for review`; a star). The features:
|
||||
1. Isolated worktrees
|
||||
2. The task queue
|
||||
3. Review & merge
|
||||
4. Live session log
|
||||
5. Per-list & per-task config
|
||||
6. Self-updating
|
||||
- A "Ready" group with the final **"↓ Download ClaudeDo"** card.
|
||||
|
||||
3. **Detail island (right)** — faithful to the reworked Task-Detail island.
|
||||
- **Source of truth for the detail visuals:** `docs/superpowers/specs/2026-06-04-task-detail-redesign-design.md`
|
||||
(the app's in-progress rework). The website's detail pane must track that design.
|
||||
- Three-zone stack:
|
||||
- **Task header** — mono id (`#F01…`), title, trash/gear action icons.
|
||||
- **DETAILS bar** — `DETAILS` eyebrow + `Edit` / copy / `⋯`, then a **markdown body**
|
||||
(headings, paragraphs, inline `code`, ordered/unordered lists) describing the feature.
|
||||
- **WorkConsole** docked at the bottom — traffic-light dots, `· N turns · +x −y`,
|
||||
tabs **Output / Actions / Session**, and a `Created …` footer.
|
||||
- Per-feature mapping:
|
||||
- Feature panels: markdown writeup in DETAILS + a short relevant **Output** log.
|
||||
- "Review & merge": opens on the **Actions** tab with `Merge target` + `Open Diff` /
|
||||
`Approve & merge`.
|
||||
- **Download**: DETAILS shows requirements (`.NET 8 Desktop Runtime`, `Claude CLI`,
|
||||
`Git`); the **Actions** tab holds the install controls, with `Merge target`
|
||||
repurposed as a **Build** selector and buttons `↓ Download installer` /
|
||||
`Portable .zip` / `checksums.txt`.
|
||||
|
||||
4. **Statusbar (bottom)** — `● Online · claudedo.kuns.dev · private build`.
|
||||
|
||||
### Interaction
|
||||
|
||||
- Clicking a task card selects it (accent bar + card highlight) and swaps the active
|
||||
detail panel; the WorkConsole tabs are clickable within a panel.
|
||||
- **All panels are server-rendered and present in the DOM**, toggled by class — the
|
||||
page is fully readable and downloadable **without JavaScript** (progressive
|
||||
enhancement). Vue handles the selection state on the client.
|
||||
|
||||
### Responsive
|
||||
|
||||
- ≤ ~1100px: drop the Detail island; show Lists + Tasks.
|
||||
- ≤ ~780px: single column — the Tasks list; tapping a feature pushes to a full-screen
|
||||
Detail view (mirrors the app's narrow-window behavior) with a back affordance.
|
||||
|
||||
## Server: release proxy (Nitro)
|
||||
|
||||
The app's `ReleaseClient` (`src/ClaudeDo.Releases/ReleaseClient.cs`) calls
|
||||
`{apiBase}/releases/latest` and reads `tag_name`, `name`, and
|
||||
`assets[].browser_download_url`; `DownloadAsync` GETs an asset URL directly.
|
||||
|
||||
- **`GET /api/releases/latest`** — fetches `https://git.kuns.dev/api/v1/repos/releases/ClaudeDo/releases/latest`,
|
||||
returns the **same JSON shape**, but every `assets[].browser_download_url` is rewritten
|
||||
from the Gitea URL to `https://claudedo.kuns.dev/api/download/<encoded-asset-path>`.
|
||||
Cached briefly (e.g. 5 min) server-side.
|
||||
- **`GET /api/download/[...path]`** — reconstructs the Gitea asset URL from the path and
|
||||
**streams** the binary back (no redirect to Gitea, so the URL stays hidden). Sets
|
||||
appropriate `Content-Type`/`Content-Disposition`.
|
||||
- **`server/utils/gitea.ts`** — shared base URL (`GITEA_API`, `REPO` from env), fetch
|
||||
helper, and the URL-rewrite/asset-path round-trip.
|
||||
- The page's download buttons point at the same `/api/download/...` routes (with a
|
||||
stable "latest installer" path), so links never go stale between deploys.
|
||||
|
||||
### App-side coordinating change (separate, in the ClaudeDo repo)
|
||||
|
||||
Point `ReleaseClient`'s `apiBase` at `https://claudedo.kuns.dev/api` instead of the
|
||||
Gitea default (one-line DI change where `ReleaseClient`/`UpdateCheckService` are
|
||||
constructed). Tracked as a follow-up; not part of the `claudedo-web` repo. The proxy
|
||||
path (`/api/releases/latest`) is chosen to match the existing
|
||||
`{apiBase}/releases/latest` call so the parser is untouched.
|
||||
|
||||
## Content / assets
|
||||
|
||||
- **Screenshots (provided):** main 3-column view (hero), diff review modal, worktrees
|
||||
panel. Stored in `public/screenshots/`. Placeholders sized for them until dropped in.
|
||||
- Copy: hero "Queue the work. Claude does it."; six feature writeups as above (final
|
||||
wording during implementation).
|
||||
|
||||
## Error handling
|
||||
|
||||
- **Build-time release fetch fails:** render the page with a last-known/placeholder
|
||||
version label; download buttons still work because they resolve via the runtime
|
||||
proxy route.
|
||||
- **Proxy `/api/releases/latest` upstream failure:** return a 502/`null`-equivalent the
|
||||
way Gitea would on miss; the app's `UpdateCheckService` already treats null/exception
|
||||
as `CheckFailed` and degrades gracefully.
|
||||
- **`/api/download` upstream failure:** surface a 502; the button shows an error state.
|
||||
- No retries beyond a single upstream attempt for v1 (low traffic, friends-only).
|
||||
|
||||
## Testing
|
||||
|
||||
- **Vitest** unit tests for `server/utils/gitea.ts`: URL rewrite (Gitea → proxy) and the
|
||||
asset-path round-trip (proxy path → Gitea URL), and release-JSON shape preservation.
|
||||
- A light component smoke test that the page renders the islands and the download
|
||||
controls without JS errors.
|
||||
- No real-network/Gitea calls in tests — mock the upstream fetch.
|
||||
|
||||
## Deployment (Coolify)
|
||||
|
||||
- **Dockerfile**: `node:20-alpine` build → `nuxt build` → run `.output/server/index.mjs`.
|
||||
- Coolify app bound to `claudedo.kuns.dev` with TLS via its reverse proxy.
|
||||
- Env: `GITEA_API` (default `https://git.kuns.dev/api/v1`), `REPO` (`releases/ClaudeDo`),
|
||||
`PUBLIC_BASE_URL` (`https://claudedo.kuns.dev`) for URL rewriting.
|
||||
- Deploy on push to `main`; re-deploy (or a periodic rebuild) refreshes the displayed
|
||||
version. No PR/CI tooling beyond Coolify's build.
|
||||
|
||||
## Open risk
|
||||
|
||||
- The reworked Detail island in the app is still in flux. The website's detail pane
|
||||
must be kept in sync with `2026-06-04-task-detail-redesign-design.md`; expect a
|
||||
visual-polish pass once that rework lands.
|
||||
|
||||
## Repo layout
|
||||
|
||||
```
|
||||
claudedo-web/
|
||||
├── nuxt.config.ts
|
||||
├── app.vue
|
||||
├── pages/index.vue # the one landing page
|
||||
├── components/
|
||||
│ ├── AppWindow.vue # window chrome + statusbar
|
||||
│ ├── ListsIsland.vue # page nav
|
||||
│ ├── TasksIsland.vue # feature cards + download card
|
||||
│ ├── DetailIsland.vue # three-zone detail (header / DETAILS md / WorkConsole)
|
||||
│ ├── WorkConsole.vue # tabs: Output / Actions / Session
|
||||
│ └── content/ # per-feature markdown/blurbs + download panel
|
||||
├── server/
|
||||
│ ├── api/releases/latest.get.ts
|
||||
│ ├── api/download/[...path].get.ts
|
||||
│ └── utils/gitea.ts
|
||||
├── assets/css/tokens.css # palette + type ported from Tokens.axaml/styles.css
|
||||
├── public/screenshots/ # 3 PNGs
|
||||
└── Dockerfile
|
||||
```
|
||||
127
docs/superpowers/specs/2026-06-04-refine-task-design.md
Normal file
127
docs/superpowers/specs/2026-06-04-refine-task-design.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# Refine Task — Design
|
||||
|
||||
**Date:** 2026-06-04
|
||||
**Status:** Approved (pending spec review)
|
||||
|
||||
## Goal
|
||||
|
||||
Add a one-click **Refine Task** action to a task card. Clicking it spawns a
|
||||
headless Claude session that reads the task (and the repo), rewrites the task's
|
||||
description to be clearer and runnable autonomously, and — where it helps —
|
||||
breaks the work into subtasks. The user then reviews/hand-edits the result and
|
||||
queues the task manually.
|
||||
|
||||
This is **not** an interactive terminal session. It is a fire-and-forget
|
||||
headless run, structurally similar to the existing daily-prep ("Prime Claude")
|
||||
flow (`PrimeRunner`), not the interactive planning flow.
|
||||
|
||||
## Non-goals / scope
|
||||
|
||||
- No new task status. The task stays `Idle` throughout; refine only mutates the
|
||||
task's `Title`/`Description` and its subtasks.
|
||||
- No worktree, no interactive terminal, no auto-queue.
|
||||
- No per-task refine config (model, turns) — uses the worker's defaults.
|
||||
- Refine does not edit repository files; repo access is read-only.
|
||||
|
||||
## User flow
|
||||
|
||||
1. User clicks the refine icon on an `Idle` task's card.
|
||||
2. UI calls `WorkerHub.RefineTask(taskId)` → `RefineRunner`.
|
||||
3. `RefineRunner` spawns `claude -p` headless in the list's working directory,
|
||||
seeded with a fixed refine prompt + the task's title/description/current
|
||||
subtasks + the task id.
|
||||
4. Claude reads the repo (read-only), then calls:
|
||||
- `mcp__claudedo__update_task` to improve title/description, and
|
||||
- `mcp__claudedo__add_subtask` to add steps where useful.
|
||||
Each MCP call broadcasts `TaskUpdated`, so the description and Steps card
|
||||
update live in the UI.
|
||||
5. Run finishes; the card's refine button returns to its idle state. User
|
||||
reviews, optionally hand-edits the description/steps, then queues manually.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Worker — `RefineRunner`
|
||||
|
||||
- New `Worker/Refine/RefineRunner.cs` implementing `IRefineRunner`
|
||||
(`Worker/Refine/Interfaces/IRefineRunner.cs`). Modeled on `PrimeRunner`.
|
||||
- **Concurrency / single-flight:** an in-flight `HashSet<string>` of task ids
|
||||
guarded by a lock (or `SemaphoreSlim`), so the *same* task cannot refine
|
||||
twice concurrently, but different tasks may refine in parallel. A second
|
||||
click on an already-refining task is a no-op.
|
||||
- **Guards:** only runs when `task.Status == Idle`. Resolves the list's working
|
||||
directory. If the list has **no valid working dir**, fall back to a sandbox
|
||||
directory and run text-only (drop `Read`/`Grep`/`Glob` from the allowlist).
|
||||
- **CLI invocation** (relies on the globally-registered `claudedo` MCP, like
|
||||
daily-prep — no `--mcp-config`):
|
||||
```
|
||||
claude -p --output-format stream-json --verbose
|
||||
--permission-mode acceptEdits
|
||||
--max-turns <N>
|
||||
--allowedTools mcp__claudedo__get_task,mcp__claudedo__update_task,mcp__claudedo__add_subtask,Read,Grep,Glob
|
||||
```
|
||||
`Edit`/`Write`/`Bash` are deliberately **not** whitelisted, so the run is
|
||||
read-only on the repo even under `acceptEdits`. (Chosen over `plan` mode to
|
||||
avoid the headless "exit plan mode to act" friction; the allowlist is the
|
||||
real read-only gate.)
|
||||
- **Logging:** stream stdout to a per-run log at
|
||||
`logs/refine-<taskId[:8]>.log`, truncated at the start of each run.
|
||||
|
||||
### Prompt — `PromptKind.Refine`
|
||||
|
||||
- Add `Refine` to the `PromptKind` enum in `PromptFiles.cs`, file
|
||||
`prompts/refine.md`, with a bundled default.
|
||||
- Default prompt instructs: refine one ClaudeDo task so it is ready to run
|
||||
autonomously; ground the description in the actual code (read-only); keep
|
||||
scope tight (no scope creep into adjacent work); add steps as subtasks only
|
||||
when they genuinely help; use only `get_task`, `update_task`, `add_subtask`
|
||||
and the read-only tools; never edit files.
|
||||
- Rendered via `PromptFiles.Render` with `{taskId}`, `{title}`,
|
||||
`{description}`, and the current subtask list seeded into the prompt so the
|
||||
agent knows which steps already exist.
|
||||
|
||||
### MCP tool — `add_subtask`
|
||||
|
||||
- New `[McpServerTool]` on `ExternalMcpService` (part of the global `claudedo`
|
||||
MCP), signature `add_subtask(taskId, title, orderNum?)`.
|
||||
- Creates a `SubtaskEntity` via `SubtaskRepository`; `orderNum` defaults to
|
||||
append-at-end (max existing + 1). Refuses if the task is `Running`.
|
||||
Broadcasts `TaskUpdated`.
|
||||
- **Append semantics, not replace:** the current subtasks are already in the
|
||||
prompt, so the agent only adds missing steps; re-running refine will not
|
||||
silently wipe steps the user hand-edited.
|
||||
- `update_task` already exists (title/description/commitType) and is reused
|
||||
unchanged.
|
||||
|
||||
### UI — button, icon, feedback
|
||||
|
||||
- **Icon:** add the supplied SVG as an `Icon.Refine` `StreamGeometry` in
|
||||
`IslandStyles.axaml`, rendered as a **stroked `Path`** (`plan-icon` style,
|
||||
fill none) — it is line art, so per the PathIcon-fills-geometry gotcha it
|
||||
must be stroked, not filled.
|
||||
- **Button:** a new `icon-btn` in `TaskRowView.axaml` near the star button,
|
||||
visible only when the task is `Idle`. Bound to a new `RefineTaskCommand` on
|
||||
`TasksIslandViewModel`.
|
||||
- **Feedback:** new broadcaster events `RefineStarted(taskId)` /
|
||||
`RefineFinished(taskId, ok, error?)` drive an `IsRefining` flag on
|
||||
`TaskRowViewModel`; the button shows a busy/disabled state while running. The
|
||||
description and Steps card update live via the existing `TaskUpdated` events
|
||||
fired by the MCP calls.
|
||||
- Wire `RefineTask` through `IWorkerClient` / `WorkerClient`, the `WorkerHub`
|
||||
method, and update the hand-rolled test fakes in both test projects.
|
||||
|
||||
## Testing
|
||||
|
||||
- `add_subtask`: creates the row, appends order correctly, refuses when
|
||||
`Running`, broadcasts `TaskUpdated`.
|
||||
- Refine prompt builder and CLI-args builder produce the expected prompt/flags
|
||||
(including the text-only fallback when no working dir).
|
||||
- `RefineRunner` guards: `Idle`-only, per-task single-flight no-op on a second
|
||||
concurrent call.
|
||||
- **No test spawns the real `claude` CLI** (project rule). The end-to-end run
|
||||
is a manual smoke step.
|
||||
|
||||
## Open implementation calls (decided)
|
||||
|
||||
- **Permission mode:** `acceptEdits` + restricted allowlist for read-only
|
||||
(rather than `plan` mode).
|
||||
- **`add_subtask`:** append-only (rather than replace-all).
|
||||
200
docs/superpowers/specs/2026-06-04-task-detail-redesign-design.md
Normal file
200
docs/superpowers/specs/2026-06-04-task-detail-redesign-design.md
Normal file
@@ -0,0 +1,200 @@
|
||||
# Task Detail Island Redesign — Design
|
||||
|
||||
**Date:** 2026-06-04
|
||||
**Status:** Approved (design), pending implementation
|
||||
**Author:** brainstormed with user via visual companion
|
||||
|
||||
## Problem
|
||||
|
||||
The Detail island (`DetailsIslandView`, 413 lines) grew into one long scrolling
|
||||
column as features piled on. The user has to scroll constantly. Specific pains
|
||||
(confirmed by the user):
|
||||
|
||||
- **Everything is always stacked** — Steps, Description, Terminal, and several
|
||||
conditional sections share one scroll column with no way to hide/fold.
|
||||
- **Duplicated info** — `model` shows in the gear flyout *and* the agent strip;
|
||||
the branch line shows in the agent strip *and* as the terminal label.
|
||||
- **Agent strip is a heavy 5-row block** pinned near the bottom even when idle.
|
||||
- **Steps + Description take a lot of room** before the action controls.
|
||||
|
||||
The terminal staying prominent is *fine* — not a pain point.
|
||||
|
||||
## Solution overview
|
||||
|
||||
Replace the linear body with a **fixed-region layout** built from **3 new
|
||||
self-contained components**, plus a roadblock band. Top region (header + details
|
||||
card) stays put; the work console is pinned to the lower third.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────┐
|
||||
│ TaskHeaderBar (separated title) │ #T42 · title · 🗑/💀 · ⚙
|
||||
├─────────────────────────────────────┤
|
||||
│ DescriptionStepsCard │ card; text ⇄ steps toggle icon
|
||||
│ (Preview = what Claude gets) │ copy · preview/edit
|
||||
├─────────────────────────────────────┤
|
||||
│ Roadblock band (only when failed) │ ⚠ message · Continue · Reset&Retry
|
||||
├─────────────────────────────────────┤
|
||||
│ WorkConsole (pinned, terminal) │ ●●● · model·turns·diff
|
||||
│ tabs: Output | Actions | Session │
|
||||
└─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## The 3 components
|
||||
|
||||
Each is a standalone `UserControl` + dedicated `ViewModel` with **design-time
|
||||
sample data** so it renders fully in the Avalonia previewer in isolation. Built
|
||||
in separate worktrees; **none touch `DetailsIslandView.axaml` or
|
||||
`DetailsIslandViewModel.cs`** (that is the wiring session). All visuals use
|
||||
**only** the existing design tokens (`Design/Tokens.axaml`) and style classes
|
||||
(`Design/IslandStyles.axaml`) — no hardcoded colors/sizes.
|
||||
|
||||
New folder: `src/ClaudeDo.Ui/Views/Islands/Detail/`
|
||||
New VMs: `src/ClaudeDo.Ui/ViewModels/Islands/Detail/`
|
||||
|
||||
### 1. TaskHeaderBar
|
||||
|
||||
- **Layout:** one row — `#T42` id badge (mono `meta`, copyable) · editable title
|
||||
`TextBox` (transparent, `FontSizeTaskTitle`, wraps) · **trash/skull button** ·
|
||||
⚙ gear button with the agent-settings flyout.
|
||||
- **Trash → Skull:** when **not** running show `Icon.Trash` (delete task,
|
||||
`BloodBrush`); when **running** show a **skull** glyph (kill session). One
|
||||
button, swaps icon + command on running state. Skull is a *new* filled
|
||||
geometry to add to `IslandStyles.axaml` resources (`Icon.Skull`).
|
||||
- **No done circle. No star** (the star lives on the task card/row already).
|
||||
- **Gear flyout:** keep the existing agent-settings content verbatim — Model
|
||||
combo + `InheritedBadge` + reset; Max Turns `NumericUpDown` + badge + reset;
|
||||
System Prompt `TextBox` + "prepended" hint; Agent File combo + badge + reset.
|
||||
Disabled while running (`IsAgentSectionEnabled`).
|
||||
- **Existing bindings reused:** `TaskIdBadge`, `EditableTitle`, `DeleteTaskCommand`,
|
||||
`StopCommand`, `IsRunning`, `IsAgentSectionEnabled`, all the agent-settings
|
||||
members (`TaskModelOptions`/`TaskModelSelection`/`ModelBadge`/
|
||||
`ResetTaskModelCommand`/`TaskMaxTurns`/`TurnsBadge`/`ResetTaskTurnsCommand`/
|
||||
`TaskSystemPrompt`/`EffectiveSystemPromptHint`/`TaskAgentOptions`/
|
||||
`TaskSelectedAgent`/`AgentBadge`/`ResetTaskAgentCommand`).
|
||||
|
||||
### 2. DescriptionStepsCard
|
||||
|
||||
A `Border.island`-style card. The single explicitly-requested "separate
|
||||
component." Top-right **toggle icon** switches the card between **Description**
|
||||
and **Steps** views; the icon shows the *other* mode (in Description view → steps
|
||||
icon `Icon.MoreHorizontal`/list glyph; in Steps view → text glyph).
|
||||
|
||||
- **Header row:** small `section-label` ("DETAILS" / "STEPS") · spacer · **Copy**
|
||||
icon button (`Icon.Copy`) · **Preview/Edit** toggle button (Description view
|
||||
only) · **toggle icon** (top-right).
|
||||
- **Description view:**
|
||||
- *Preview mode* = renders **what Claude gets** via `MarkdownView`: the
|
||||
canonical composed text (Title + Description + open steps — see below).
|
||||
- *Edit mode* = raw description `TextBox` (mono, `Surface2Brush`, multiline).
|
||||
- **Steps view:** add-step input (Enter to add) + list of step rows (check
|
||||
circle `Ellipse.task-check` + inline-editable title, `subtask-row` style).
|
||||
- **Copy** copies the **formatted** version (Title + Description + open steps),
|
||||
nothing else, to the clipboard.
|
||||
- **Existing bindings reused (when wired):** `EditableDescription`,
|
||||
`IsEditingDescription`/`ToggleEditDescriptionCommand`, `Subtasks`,
|
||||
`NewSubtaskTitle`/`AddSubtaskCommand`, `ToggleSubtaskDoneCommand`,
|
||||
`CommitSubtaskEditCommand`.
|
||||
- **New members (defined on the component VM now, lifted into
|
||||
`DetailsIslandViewModel` at wiring):** `IsStepsView` + `ToggleCardViewCommand`;
|
||||
`ComposedPreview` (string, the canonical format); `CopyFormattedCommand`.
|
||||
|
||||
### 3. WorkConsole
|
||||
|
||||
Terminal-styled card (`Border.terminal`) pinned to the lower third.
|
||||
|
||||
- **Title bar:** three cosmetic traffic-light dots (`Ellipse.dot-red`,
|
||||
`dot-yellow`, `dot-green`) on the left; centered/!right small **info header**:
|
||||
`model · {turns} turns · +adds −dels` (mono `meta`; `diff-add`/`diff-del`
|
||||
classes for the numbers). **No branch line.** LIVE/DONE/FAILED chip
|
||||
(`live-chip`) on the right.
|
||||
- **Tab strip:** `Output` | `Actions` | `Session`.
|
||||
- **Output** — the live log. Reuse `SessionTerminalView` (`Entries`, `Label`,
|
||||
`IsRunning`, `IsDone`, `IsFailed`) for the body, *or* the same
|
||||
timestamp+`SelectableTextBlock` row template.
|
||||
- **Actions** — worktree management: merge-target `ComboBox`, **Open Diff**,
|
||||
**Worktree**, **Merge** (+ planning **Merge All Subtasks** when planning
|
||||
parent). Bindings: `MergeTargetBranches`/`SelectedMergeTarget`,
|
||||
`OpenDiffCommand`, `OpenWorktreeCommand`, `MergeAllCommand`/`CanMergeAll`/
|
||||
`MergeAllDisabledReason`/`MergeAllError`, `ReviewCombinedDiffCommand`.
|
||||
- **Session** — review + outcomes: feedback `TextBox` + Approve/Reject/Park/
|
||||
Cancel (`ReviewFeedback`, `ApproveReviewCommand`, `RejectReviewCommand`,
|
||||
`ParkReviewCommand`, `CancelReviewCommand`, shown when `IsWaitingForReview`)
|
||||
and the child-outcomes list (`ChildOutcomes`, `HasChildOutcomes`).
|
||||
- **Roadblock band** (above the tabs, inside or just above the card): visible on
|
||||
`IsFailed`/`IsCancelled`; shows a warning (`Icon.Warning`, `BloodBrush`) and
|
||||
**Continue** (`ContinueCommand`, `ShowContinue`) + **Reset & Retry**
|
||||
(`ResetAndRetryCommand`, `ShowResetAndRetry`).
|
||||
- **Info-header bindings:** `Model`, `Turns`, `DiffAdditions`, `DiffDeletions`,
|
||||
`IsRunning`/`IsDone`/`IsFailed`.
|
||||
|
||||
## Combined Description + Steps behavior
|
||||
|
||||
Steps are part of the description. When the task runs, the **effective prompt =
|
||||
Title + Description + only the OPEN steps**. Resolved steps are dropped.
|
||||
|
||||
**Canonical composed format** (shared by the Worker prompt, the card's Preview,
|
||||
and Copy):
|
||||
|
||||
```
|
||||
<Title>
|
||||
|
||||
<Description>
|
||||
|
||||
## Sub-Tasks
|
||||
- [ ] <open step 1>
|
||||
- [ ] <open step 2>
|
||||
```
|
||||
|
||||
- Omit the `## Sub-Tasks` section entirely when no open steps remain.
|
||||
- Omit the description paragraph when description is empty.
|
||||
|
||||
**Worker change (wiring session, by Claude):** `TaskRunner.cs:104-113` currently
|
||||
appends *all* subtasks with `[x]`/`[ ]`. Change to append **only incomplete**
|
||||
subtasks as `- [ ]` lines (drop completed). Factor the format into a shared
|
||||
`TaskPromptComposer` in `ClaudeDo.Data` (referenced by both Worker and UI) so the
|
||||
card's Preview and the real prompt never diverge.
|
||||
|
||||
## Color / token guidelines (mandatory)
|
||||
|
||||
- Backgrounds: `IslandBackgroundBrush`, `Surface2Brush`, `Surface3Brush`,
|
||||
`DeepBrush`, `VoidBrush` (terminal). Borders: `LineBrush`/`LineBrightBrush`,
|
||||
`HairlineOverlayBrush`. Text: `TextBrush`/`TextDimBrush`/`TextMuteBrush`/
|
||||
`TextFaintBrush`. Accent: `AccentBrush`/`AccentDimBrush`. Status: blood/peat/
|
||||
moss/sage + the `*TintBrush` pairs.
|
||||
- Radii: `IslandCornerRadius` (14), `ButtonCornerRadius` (6), `InputCornerRadius`
|
||||
(8). Spacing: `SpaceXs..Space2Xl`. Fonts: `SansFont`, `MonoFont`; sizes
|
||||
`FontSizeMono`/`FontSizeBody`/`FontSizeTaskTitle`.
|
||||
- Reuse style classes: `island`, `island-header`, `chip`, `btn`/`btn accent`/
|
||||
`primary`/`danger`, `icon-btn`, `flat`, `terminal`, `dot-red/yellow/green`,
|
||||
`live-chip`, `task-check`, `subtask-row`, `section-label`, `field-label`,
|
||||
`meta`, `diff-add`/`diff-del`, `diff-meter-*`.
|
||||
- **No inline hex, no magic numbers** where a token exists. `PathIcon` fills
|
||||
geometry — line-art must be filled or stroked via `Path`.
|
||||
|
||||
## Build / isolation strategy
|
||||
|
||||
1. Three ClaudeDo tasks (list "Claude do", repo `C:\Private\ClaudeDo`), one per
|
||||
component, run sequentially in their own worktrees.
|
||||
2. Each delivers: `Detail/<Name>.axaml` + `.axaml.cs` + `Detail/<Name>ViewModel.cs`
|
||||
with design-time sample data; the `ClaudeDo.Ui` project **builds green**
|
||||
(`dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj -c Release`).
|
||||
3. Components are visual-only against sample data. Real `DetailsIslandViewModel`
|
||||
binding + the Worker steps→prompt change happen in the **wiring session**
|
||||
(this Claude session, done while the build tasks run).
|
||||
|
||||
## Wiring plan (this session)
|
||||
|
||||
- Implement `TaskPromptComposer` + the `TaskRunner` open-steps change + a unit
|
||||
test in `ClaudeDo.Worker.Tests`/`Data.Tests`.
|
||||
- After the 3 components land: host them in `DetailsIslandView` (header top,
|
||||
card below, roadblock band, work console pinned bottom), lift the new card VM
|
||||
members into `DetailsIslandViewModel`, repoint `x:DataType`, delete the
|
||||
superseded inline sections + `AgentStripView` usage. Update locale parity and
|
||||
the test fakes.
|
||||
|
||||
## Monitoring loop (this session)
|
||||
|
||||
While the build tasks run: poll each via `get_task` / `get_task_log` /
|
||||
`get_task_diff`, summarize progress and anything a session got stuck on, and if a
|
||||
session is blocked on something missing, add a small follow-up task to the
|
||||
"Claude do" list.
|
||||
99
docs/superpowers/specs/2026-06-05-terminal-review-design.md
Normal file
99
docs/superpowers/specs/2026-06-05-terminal-review-design.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# Terminal-style review controls
|
||||
|
||||
**Date:** 2026-06-05
|
||||
**Status:** Approved (design)
|
||||
|
||||
## Problem
|
||||
|
||||
Review feedback today is a multi-line `TextBox` plus four buttons (Approve / Reject /
|
||||
Park / Cancel) tucked into the WorkConsole **Session** tab
|
||||
(`WorkConsole.axaml:169-193`). It feels disconnected from the live terminal. Entering
|
||||
feedback should feel like typing into the terminal, with action buttons docked at the
|
||||
bottom — and merge/approve actions should live in an obvious, dedicated place.
|
||||
|
||||
## Goal
|
||||
|
||||
- Type review feedback directly in the **Output (terminal)** tab, prompt-style.
|
||||
- Bottom-docked action strip on the terminal: `[Retry]` `[Reset]`.
|
||||
- Move all git/merge/worktree actions (including **Approve**) into a new **Git** tab so
|
||||
it is obvious where each action lives.
|
||||
|
||||
## Tab structure
|
||||
|
||||
Three tabs in WorkConsole: **Output** · **Git** · **Session**.
|
||||
|
||||
| Tab | Contents |
|
||||
| --- | --- |
|
||||
| **Output** | Live `Log` (unchanged) + new review footer (below), footer gated on `IsWaitingForReview`. |
|
||||
| **Git** | The current "Merge & worktree" block — merge-target dropdown, mergeability indicator, **Approve**, Open Diff, Merge, Worktree, Review Combined Diff, Merge All Subtasks. Visibility gated on `ShowMergeSection` / `IsWaitingForReview` as today. |
|
||||
| **Session** | Child outcomes + empty-state only. |
|
||||
|
||||
### ViewModel changes (`DetailsIslandViewModel`)
|
||||
|
||||
- Add `public bool IsGitTab => SelectedTab == "git";`
|
||||
- Add `[NotifyPropertyChangedFor(nameof(IsGitTab))]` alongside the existing
|
||||
`IsOutputTab` / `IsSessionTab` notifications on `SelectedTab` (`:139-144`).
|
||||
- `SelectTab` already accepts a string parameter — no change beyond the new `"git"`
|
||||
value wired from XAML.
|
||||
- No command renames (avoids breaking hand-rolled test fakes).
|
||||
|
||||
## Terminal footer (Output tab)
|
||||
|
||||
A `Border` docked `Bottom` inside the Output tab body, visible only when
|
||||
`IsWaitingForReview`:
|
||||
|
||||
- Background `Surface2Brush`, top border `LineBrush` (`BorderThickness="0,1,0,0"`).
|
||||
- A `❯` prompt-prefix `TextBlock` (mono, `TextMuteBrush`) + a borderless mono `TextBox`:
|
||||
- Bound `Text="{Binding ReviewFeedback, Mode=TwoWay}"`.
|
||||
- `AcceptsReturn="True"`, `TextWrapping="Wrap"`, transparent background, no border.
|
||||
- Starts ~1 line tall; grows with content up to `MaxHeight≈160`, then scrolls.
|
||||
- `PlaceholderText` e.g. "Feedback for the next run…".
|
||||
- Right-aligned button strip:
|
||||
- `[Retry]` — `Classes="btn accent"` → `RejectReviewCommand`.
|
||||
- `[Reset]` — `Classes="btn"` → `ParkReviewCommand`.
|
||||
|
||||
`[Accept]` is **not** in the footer; approval happens on the Git tab via
|
||||
`ApproveReviewCommand`. The old `Cancel` review button is dropped from this UI; cancel
|
||||
remains reachable through the task's existing cancel control (`CancelReviewCommand`
|
||||
stays on the ViewModel, just not surfaced here).
|
||||
|
||||
### Enter handling (`WorkConsole.axaml.cs`)
|
||||
|
||||
- Handle `KeyDown` on the input `TextBox`:
|
||||
- **Enter** without Shift → execute `RejectReviewCommand` (if it can execute) and set
|
||||
`e.Handled = true`.
|
||||
- **Shift+Enter** → fall through to default behavior (inserts newline).
|
||||
- `RejectReviewAsync` already returns early on whitespace-only feedback
|
||||
(`DetailsIslandViewModel.cs:1464`), so pressing Enter with an empty prompt is a no-op
|
||||
with no extra guard needed.
|
||||
|
||||
## Command mapping
|
||||
|
||||
| Button | Location | Command | Effect |
|
||||
| --- | --- | --- | --- |
|
||||
| `[Retry]` | Output footer | `RejectReviewCommand` | Reject-to-queue with feedback; resumes the session (Queued). |
|
||||
| `[Reset]` | Output footer | `ParkReviewCommand` | Park back to Idle. |
|
||||
| `[Approve]` | Git tab | `ApproveReviewCommand` | Merge `SelectedMergeTarget` → Done (conflict keeps it in review). |
|
||||
|
||||
## Copy / empty state
|
||||
|
||||
- Update the Session empty-state text (`WorkConsole.axaml:270`) — it currently says
|
||||
"review and merge controls appear here once the run finishes", which is no longer
|
||||
accurate. Reword to reflect that only outcomes live on Session.
|
||||
- Button labels remain literal strings (`Retry`, `Reset`, `Approve`), matching the
|
||||
existing review buttons (no new localization keys).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- No changes to worker-side review/merge logic or `IWorkerClient` signatures.
|
||||
- No merge-target selector duplicated into the terminal footer (Approve uses the Git
|
||||
tab dropdown / default target).
|
||||
- No command renames on the ViewModel.
|
||||
|
||||
## Testing / verification
|
||||
|
||||
- Build `ClaudeDo.App` and `ClaudeDo.Worker` in `-c Release`.
|
||||
- Manual visual verification (must be flagged — cannot be auto-verified):
|
||||
- Footer appears only in `WaitingForReview`, on the Output tab.
|
||||
- Enter sends Retry; Shift+Enter inserts a newline; empty Enter does nothing.
|
||||
- Git tab shows Approve + merge/worktree controls; Session shows only outcomes.
|
||||
@@ -35,7 +35,7 @@ All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. The atomic `Q
|
||||
|
||||
## Git
|
||||
|
||||
- **GitService** — async wrapper around git CLI (ProcessStartInfo, no shell). Operations: worktree add/remove, add all, commit (stdin for message), merge ff-only, rev-parse, diff-stat, has-changes, is-git-repo
|
||||
- **GitService** — async wrapper around git CLI (ProcessStartInfo, no shell). Operations: worktree add/remove, add all, commit (stdin for message), merge ff-only, rev-parse, diff-stat, has-changes, is-git-repo, `PreviewMergeAsync` (non-destructive mergeability check via `git merge-tree --write-tree`), `CountChangedFilesAsync`
|
||||
|
||||
## Schema
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ using System.Text;
|
||||
|
||||
namespace ClaudeDo.Data.Git;
|
||||
|
||||
public sealed record MergePreview(bool Supported, bool Clean, IReadOnlyList<string> ConflictFiles);
|
||||
|
||||
public sealed class GitService
|
||||
{
|
||||
public async Task<bool> IsGitRepoAsync(string dir, CancellationToken ct = default)
|
||||
@@ -236,6 +238,49 @@ public sealed class GitService
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Non-destructive mergeability probe via `git merge-tree --write-tree`. Writes only
|
||||
/// loose objects — the working tree, index, and refs are left untouched.
|
||||
/// </summary>
|
||||
public async Task<MergePreview> PreviewMergeAsync(
|
||||
string repoDir, string targetBranch, string sourceBranch, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, stdout, _) = await RunGitAsync(repoDir,
|
||||
["merge-tree", "--write-tree", "--name-only", targetBranch, sourceBranch], ct);
|
||||
|
||||
if (exitCode == 0)
|
||||
return new MergePreview(true, true, Array.Empty<string>());
|
||||
|
||||
if (exitCode == 1)
|
||||
{
|
||||
// stdout: <tree-oid>\n<file>\n...\n\n<informational messages>
|
||||
var lines = stdout.Split('\n');
|
||||
var files = new List<string>();
|
||||
for (int i = 1; i < lines.Length; i++)
|
||||
{
|
||||
var line = lines[i].TrimEnd('\r');
|
||||
if (string.IsNullOrWhiteSpace(line)) break;
|
||||
files.Add(line.Trim());
|
||||
}
|
||||
return new MergePreview(true, false, files);
|
||||
}
|
||||
|
||||
// Any other exit (e.g. git too old: "unknown option --write-tree").
|
||||
return new MergePreview(false, false, Array.Empty<string>());
|
||||
}
|
||||
|
||||
/// <summary>Count of files that differ on <paramref name="sourceBranch"/> since its merge base with the target.</summary>
|
||||
public async Task<int> CountChangedFilesAsync(
|
||||
string repoDir, string targetBranch, string sourceBranch, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, stdout, _) = await RunGitAsync(repoDir,
|
||||
["diff", "--name-only", $"{targetBranch}...{sourceBranch}"], ct);
|
||||
if (exitCode != 0) return 0;
|
||||
return stdout
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Count(s => s.Length > 0);
|
||||
}
|
||||
|
||||
public async Task MergeFfOnlyAsync(string repoDir, string branchName, CancellationToken ct = default)
|
||||
{
|
||||
var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["merge", "--ff-only", branchName], ct);
|
||||
|
||||
@@ -2,7 +2,7 @@ using System.Text;
|
||||
|
||||
namespace ClaudeDo.Data;
|
||||
|
||||
public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport, ImprovementChild }
|
||||
public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport, ImprovementChild, Refine }
|
||||
|
||||
public static class PromptFiles
|
||||
{
|
||||
@@ -17,6 +17,7 @@ public static class PromptFiles
|
||||
PromptKind.DailyPrep => Path.Combine(Root, "daily-prep.md"),
|
||||
PromptKind.WeeklyReport => Path.Combine(Root, "weekly-report.md"),
|
||||
PromptKind.ImprovementChild => Path.Combine(Root, "improvement-child.md"),
|
||||
PromptKind.Refine => Path.Combine(Root, "refine.md"),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(kind))
|
||||
};
|
||||
|
||||
@@ -61,6 +62,7 @@ public static class PromptFiles
|
||||
PromptKind.DailyPrep => DailyPrepDefault,
|
||||
PromptKind.WeeklyReport => WeeklyReportDefault,
|
||||
PromptKind.ImprovementChild => ImprovementChildDefault,
|
||||
PromptKind.Refine => RefineDefault,
|
||||
_ => ""
|
||||
};
|
||||
|
||||
@@ -181,6 +183,33 @@ public static class PromptFiles
|
||||
If there are no candidates, do nothing.
|
||||
""";
|
||||
|
||||
private const string RefineDefault = """
|
||||
You are refining ONE ClaudeDo task so it is ready to run autonomously later.
|
||||
You are NOT executing the task — only improving its specification.
|
||||
|
||||
The task you are refining:
|
||||
- id: {taskId}
|
||||
- title: {title}
|
||||
- description: {description}
|
||||
- current subtasks (steps):
|
||||
{subtasks}
|
||||
|
||||
What to do:
|
||||
1. If a repository is available, read the relevant code (read-only) to ground your
|
||||
understanding. Do NOT edit, create, or delete any files. Do NOT run commands.
|
||||
2. Rewrite the description so it is clear, specific, and self-contained: what to change,
|
||||
where, and what "done" looks like. Keep scope tight — do not invent adjacent work.
|
||||
3. Call mcp__claudedo__update_task to save the improved title (only if it genuinely
|
||||
helps) and description.
|
||||
4. If the work is clearer as discrete steps, add them as subtasks with
|
||||
mcp__claudedo__add_subtask (one call per step, in order). Only add steps that are
|
||||
not already present in the current subtasks above.
|
||||
|
||||
Use ONLY these tools: mcp__claudedo__get_task, mcp__claudedo__update_task,
|
||||
mcp__claudedo__add_subtask, and read-only Read/Grep/Glob. When you have updated the
|
||||
task, stop.
|
||||
""";
|
||||
|
||||
private const string WeeklyReportDefault = """
|
||||
You are generating a concise weekly standup report for a software developer,
|
||||
covering {start} to {end}.
|
||||
|
||||
29
src/ClaudeDo.Data/TaskPromptComposer.cs
Normal file
29
src/ClaudeDo.Data/TaskPromptComposer.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using System.Text;
|
||||
|
||||
namespace ClaudeDo.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Single source of truth for the text handed to Claude as a task prompt:
|
||||
/// title + description + the OPEN sub-tasks. Resolved sub-tasks are dropped.
|
||||
/// Shared by the Worker (real prompt) and the UI (the card's "what Claude gets" preview).
|
||||
/// </summary>
|
||||
public static class TaskPromptComposer
|
||||
{
|
||||
public static string Compose(string title, string? description, IEnumerable<(string Title, bool Completed)> subtasks)
|
||||
{
|
||||
var sb = new StringBuilder((title ?? "").Trim());
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(description))
|
||||
sb.Append("\n\n").Append(description.Trim());
|
||||
|
||||
var open = subtasks?.Where(s => !s.Completed).ToList() ?? new List<(string, bool)>();
|
||||
if (open.Count > 0)
|
||||
{
|
||||
sb.Append("\n\n## Sub-Tasks\n");
|
||||
foreach (var s in open)
|
||||
sb.Append("- [ ] ").Append(s.Title).Append('\n');
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
@@ -111,7 +111,8 @@
|
||||
"reviewTitle": "Review",
|
||||
"feedbackLabel": "FEEDBACK FÜR DEN AGENTEN",
|
||||
"feedbackPlaceholder": "Was soll der Agent korrigieren?",
|
||||
"rerun": "Erneut ausführen"
|
||||
"rerun": "Erneut ausführen",
|
||||
"refineTip": "Aufgabe mit Claude verfeinern"
|
||||
},
|
||||
"lists": {
|
||||
"heading": "Listen",
|
||||
|
||||
@@ -111,7 +111,8 @@
|
||||
"reviewTitle": "Review",
|
||||
"feedbackLabel": "FEEDBACK FOR THE AGENT",
|
||||
"feedbackPlaceholder": "What should the agent fix?",
|
||||
"rerun": "Re-run"
|
||||
"rerun": "Re-run",
|
||||
"refineTip": "Refine this task with Claude"
|
||||
},
|
||||
"lists": {
|
||||
"heading": "Lists",
|
||||
|
||||
@@ -38,12 +38,12 @@ All views use compiled bindings (`x:DataType`).
|
||||
- **StatusBarViewModel** — connection state and active tasks
|
||||
- **WeeklyReportModalViewModel** — drives the weekly report modal
|
||||
- **NotesEditorViewModel** — manages daily note bullet CRUD for the selected day
|
||||
- **DetailsIslandViewModel** gains `IsNotesMode`, `ShowNotes()`, and hosts `NotesEditorViewModel`. Also gains daily-prep mode: `IsPrepMode`, `PrepLog` (`ObservableCollection<LogLineViewModel>`), `ShowPrep()`, `PlanDayCommand` (calls `RunDailyPrepNowAsync`), `ShowPrepEmptyState`, and computed `IsTaskDetailVisible` (= `!IsNotesMode && !IsPrepMode`). Subscribes to `PrepStartedEvent`, `PrepLineEvent`, `PrepFinishedEvent`; streams lines into `PrepLog` via `StreamLineFormatter` (same path as task logs). On open, if log is empty and no run is in progress, loads persisted last run via `GetLastPrepLogAsync`.
|
||||
- **DetailsIslandViewModel** gains `IsNotesMode`, `ShowNotes()`, and hosts `NotesEditorViewModel`. Also gains daily-prep mode: `IsPrepMode`, `PrepLog` (`ObservableCollection<LogLineViewModel>`), `ShowPrep()`, `PlanDayCommand` (calls `RunDailyPrepNowAsync`), `ShowPrepEmptyState`, and computed `IsTaskDetailVisible` (= `!IsNotesMode && !IsPrepMode`). Subscribes to `PrepStartedEvent`, `PrepLineEvent`, `PrepFinishedEvent`; streams lines into `PrepLog` via `StreamLineFormatter` (same path as task logs). On open, if log is empty and no run is in progress, loads persisted last run via `GetLastPrepLogAsync`. The WorkConsole Session tab gains a mergeability indicator (`MergePreviewPresenter`) and a single-task Merge button; indicator is populated via `PreviewMergeAsync` and displayed for tasks in WaitingForReview.
|
||||
- **TasksIslandViewModel** gains a pinned "Notes" pseudo-row (`ShowNotesRow`, `OpenNotesCommand`, `NotesRequested` event) that switches the Details island to notes mode. Also gains `IsMyDayList` (true when selected list is `smart:my-day`), `ShowPrepLogCommand` (raises `PrepRequested` event → shell calls `Details.ShowPrep()`), and `ClearDayCommand` (calls `ClearMyDayAsync`).
|
||||
|
||||
## Services
|
||||
|
||||
- **WorkerClient** / **IWorkerClient** — SignalR client connecting to `http://127.0.0.1:47821/hub`. Auto-reconnect with exponential backoff. Methods: StartAsync, RunNowAsync, CancelTaskAsync, WakeQueueAsync, ContinueTaskAsync, ResetTaskAsync, GetAgentsAsync, UpdateAppSettingsAsync, UpdateListAsync, UpdateListConfigAsync, GetListConfigAsync, UpdateTaskAgentSettingsAsync, `GetWeekReportAsync`, `GenerateWeekReportAsync`, `GetDailyNotesAsync`, `AddDailyNoteAsync`, `UpdateDailyNoteAsync`, `DeleteDailyNoteAsync`, `RunDailyPrepNowAsync`, `ClearMyDayAsync`, `GetLastPrepLogAsync`. Events: TaskStarted, TaskFinished, TaskMessage, TaskUpdated, WorktreeUpdated, ListUpdated, `PrepStartedEvent`, `PrepLineEvent`, `PrepFinishedEvent`
|
||||
- **WorkerClient** / **IWorkerClient** — SignalR client connecting to `http://127.0.0.1:47821/hub`. Auto-reconnect with exponential backoff. Methods: StartAsync, RunNowAsync, CancelTaskAsync, WakeQueueAsync, ContinueTaskAsync, ResetTaskAsync, GetAgentsAsync, UpdateAppSettingsAsync, UpdateListAsync, UpdateListConfigAsync, GetListConfigAsync, UpdateTaskAgentSettingsAsync, `ApproveReviewAsync(taskId, targetBranch) -> MergeResultDto`, `PreviewMergeAsync(taskId, targetBranch) -> MergePreviewDto`, `MergeTaskAsync`, `GetWeekReportAsync`, `GenerateWeekReportAsync`, `GetDailyNotesAsync`, `AddDailyNoteAsync`, `UpdateDailyNoteAsync`, `DeleteDailyNoteAsync`, `RunDailyPrepNowAsync`, `ClearMyDayAsync`, `GetLastPrepLogAsync`. Events: TaskStarted, TaskFinished, TaskMessage, TaskUpdated, WorktreeUpdated, ListUpdated, `PrepStartedEvent`, `PrepLineEvent`, `PrepFinishedEvent`
|
||||
- **INotesApi** / **WorkerNotesApi** — thin wrapper over the daily-note WorkerClient methods (mirrors `IPrimeScheduleApi`). UI DTO: `DailyNoteDto(Id, Date, Text, SortOrder)`.
|
||||
|
||||
## Converters
|
||||
|
||||
@@ -76,6 +76,9 @@
|
||||
<!-- Icon.PlanDay (stroke-rendered via Path.plan-icon — sun over horizon) -->
|
||||
<StreamGeometry x:Key="Icon.PlanDay">M3,20 L21,20 M8.4,11 a3.6,3.6 0 1,0 7.2,0 a3.6,3.6 0 1,0 -7.2,0 M12,4.5 L12,3 M6,11 L4.5,11 M18,11 L19.5,11 M7.5,6.5 L6.4,5.4 M16.5,6.5 L17.6,5.4</StreamGeometry>
|
||||
|
||||
<!-- Icon.Refine (stroke-rendered via Path.plan-icon — list lines + two sparkles) -->
|
||||
<StreamGeometry x:Key="Icon.Refine">M3,6 L13,6 M3,11 L11,11 M3,16 L9,16 M18.5,3 L19.28,5.22 L21.5,6 L19.28,6.78 L18.5,9 L17.72,6.78 L15.5,6 L17.72,5.22 Z M19.5,14.9 L19.85,16.15 L21.1,16.5 L19.85,16.85 L19.5,18.1 L19.15,16.85 L17.9,16.5 L19.15,16.15 Z</StreamGeometry>
|
||||
|
||||
<!-- Icon.X -->
|
||||
<StreamGeometry x:Key="Icon.X">M6 6l12 12M18 6L6 18</StreamGeometry>
|
||||
|
||||
@@ -85,6 +88,9 @@
|
||||
<!-- Icon.ArrowOut — filled arrow for "open external" button -->
|
||||
<StreamGeometry x:Key="Icon.ArrowOut">M13 4 H20 V11 H18 V7.4 L11.4 14 L10 12.6 L16.6 6 H13 Z M4 6 H10 V8 H6 V18 H16 V14 H18 V20 H4 Z</StreamGeometry>
|
||||
|
||||
<!-- Icon.Text — three filled horizontal bars (paragraph / description icon) -->
|
||||
<StreamGeometry x:Key="Icon.Text">M4 6 H20 V8 H4 Z M4 11 H20 V13 H4 Z M4 16 H14 V18 H4 Z</StreamGeometry>
|
||||
|
||||
<!-- Icon.Warning — filled triangle with exclamation (roadblock badge) -->
|
||||
<StreamGeometry x:Key="Icon.Warning">F0 M12 3 L22 20 H2 Z M11 9 H13 V14 H11 Z M11 16 H13 V18 H11 Z</StreamGeometry>
|
||||
|
||||
@@ -94,6 +100,9 @@
|
||||
<!-- Icon.Settings (gear) -->
|
||||
<StreamGeometry x:Key="Icon.Settings">M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65a.5.5 0 0 0 .12-.64l-2-3.46a.5.5 0 0 0-.61-.22l-2.49 1a7.03 7.03 0 0 0-1.69-.98l-.38-2.65a.5.5 0 0 0-.5-.42h-4a.5.5 0 0 0-.5.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1a.5.5 0 0 0-.61.22l-2 3.46a.5.5 0 0 0 .12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65a.5.5 0 0 0-.12.64l2 3.46a.5.5 0 0 0 .61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65a.5.5 0 0 0 .5.42h4a.5.5 0 0 0 .5-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1a.5.5 0 0 0 .61-.22l2-3.46a.5.5 0 0 0-.12-.64l-2.11-1.65z</StreamGeometry>
|
||||
|
||||
<!-- Icon.Skull — filled silhouette: rounded cranium + eye holes (EvenOdd) + jaw -->
|
||||
<StreamGeometry x:Key="Icon.Skull">F0 M12 2 C7 2 4 5.5 4 10 C4 13.5 6 16 8 17.5 L8 19 C8 20 8.9 21 10 21 L10 18.5 L14 18.5 L14 21 C15.1 21 16 20 16 19 L16 17.5 C18 16 20 13.5 20 10 C20 5.5 17 2 12 2 Z M8.5 8 L8.5 12 L11 12 L11 8 Z M13 8 L13 12 L15.5 12 L15.5 8 Z</StreamGeometry>
|
||||
|
||||
<!-- Badge brushes -->
|
||||
<SolidColorBrush x:Key="DraftBadgeBrush" Color="{StaticResource TextMuteColor}"/>
|
||||
<SolidColorBrush x:Key="PlanningBadgeBrush" Color="{StaticResource PeatColor}"/>
|
||||
|
||||
@@ -37,7 +37,9 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
Task<ListConfigDto?> GetListConfigAsync(string listId);
|
||||
Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto);
|
||||
Task SetTaskStatusAsync(string taskId, TaskStatus status);
|
||||
Task ApproveReviewAsync(string taskId);
|
||||
Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch);
|
||||
Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch);
|
||||
Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage);
|
||||
Task RejectReviewToQueueAsync(string taskId, string feedback);
|
||||
Task RejectReviewToIdleAsync(string taskId);
|
||||
Task CancelReviewAsync(string taskId);
|
||||
@@ -57,6 +59,10 @@ public interface IWorkerClient : INotifyPropertyChanged
|
||||
Task<string?> GetWeekReportAsync(DateOnly start, DateOnly end);
|
||||
Task<string> GenerateWeekReportAsync(DateOnly start, DateOnly end);
|
||||
Task<bool> RunDailyPrepNowAsync();
|
||||
Task RefineTaskAsync(string taskId);
|
||||
|
||||
event Action<string>? RefineStartedEvent;
|
||||
event Action<string, bool, string?>? RefineFinishedEvent;
|
||||
Task ClearMyDayAsync();
|
||||
Task<AppSettingsDto?> GetAppSettingsAsync();
|
||||
Task<List<DailyNoteDto>> GetDailyNotesAsync(DateOnly day);
|
||||
|
||||
@@ -55,6 +55,9 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
public event Action<string>? PrepLineEvent;
|
||||
public event Action<bool>? PrepFinishedEvent;
|
||||
|
||||
public event Action<string>? RefineStartedEvent;
|
||||
public event Action<string, bool, string?>? RefineFinishedEvent;
|
||||
|
||||
public event Action<string, string>? PlanningMergeStartedEvent;
|
||||
public event Action<string, string>? PlanningSubtaskMergedEvent;
|
||||
public event Action<string, string, IReadOnlyList<string>>? PlanningMergeConflictEvent;
|
||||
@@ -179,6 +182,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
_hub.On("PrepStarted", () => Dispatcher.UIThread.Post(() => PrepStartedEvent?.Invoke()));
|
||||
_hub.On<string>("PrepLine", line => Dispatcher.UIThread.Post(() => PrepLineEvent?.Invoke(line)));
|
||||
_hub.On<bool>("PrepFinished", ok => Dispatcher.UIThread.Post(() => PrepFinishedEvent?.Invoke(ok)));
|
||||
|
||||
_hub.On<string>("RefineStarted", id =>
|
||||
Dispatcher.UIThread.Post(() => RefineStartedEvent?.Invoke(id)));
|
||||
_hub.On<string, bool, string?>("RefineFinished", (id, ok, err) =>
|
||||
Dispatcher.UIThread.Post(() => RefineFinishedEvent?.Invoke(id, ok, err)));
|
||||
}
|
||||
|
||||
public Task StartAsync()
|
||||
@@ -345,6 +353,8 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
public Task<bool> RunDailyPrepNowAsync()
|
||||
=> _hub.InvokeAsync<bool>("RunDailyPrepNow");
|
||||
|
||||
public Task RefineTaskAsync(string taskId) => _hub.InvokeAsync("RefineTask", taskId);
|
||||
|
||||
public Task ClearMyDayAsync()
|
||||
=> _hub.InvokeAsync("ClearMyDay");
|
||||
|
||||
@@ -386,10 +396,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
||||
await _hub.InvokeAsync("SetTaskStatus", taskId, status.ToString());
|
||||
}
|
||||
|
||||
public async Task ApproveReviewAsync(string taskId)
|
||||
{
|
||||
await _hub.InvokeAsync("ApproveReview", taskId);
|
||||
}
|
||||
public Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch)
|
||||
=> TryInvokeAsync<MergeResultDto>("ApproveReview", taskId, targetBranch);
|
||||
|
||||
public Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch)
|
||||
=> TryInvokeAsync<MergePreviewDto>("PreviewMerge", taskId, targetBranch);
|
||||
|
||||
public async Task RejectReviewToQueueAsync(string taskId, string feedback)
|
||||
{
|
||||
@@ -519,6 +530,7 @@ public sealed record AppSettingsDto(
|
||||
public sealed record WorktreeCleanupDto(int Removed);
|
||||
public sealed record WorktreeResetDto(int Removed, int TasksAffected, bool Blocked, int RunningTasks);
|
||||
public record MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage);
|
||||
public record MergePreviewDto(string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount);
|
||||
public record MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalBranches);
|
||||
public sealed record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType);
|
||||
public sealed record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
|
||||
|
||||
@@ -74,8 +74,12 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
private TaskRowViewModel? _task;
|
||||
|
||||
// Editable fields
|
||||
[ObservableProperty] private string _editableTitle = "";
|
||||
[ObservableProperty] private string _editableDescription = "";
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ComposedPreview))]
|
||||
private string _editableTitle = "";
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ComposedPreview))]
|
||||
private string _editableDescription = "";
|
||||
[ObservableProperty] private bool _isEditingDescription;
|
||||
[ObservableProperty] private bool _isDescriptionExpanded = true;
|
||||
|
||||
@@ -100,6 +104,80 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
[RelayCommand]
|
||||
private void ToggleDescriptionExpanded() => IsDescriptionExpanded = !IsDescriptionExpanded;
|
||||
|
||||
// ── Description / Steps card (redesign) ─────────────────────────────
|
||||
// Description is always the card body; steps live in an expandable summary
|
||||
// strip below it so step presence is visible without switching views.
|
||||
[ObservableProperty] private bool _isStepsExpanded;
|
||||
|
||||
[RelayCommand]
|
||||
private void ToggleStepsExpanded() => IsStepsExpanded = !IsStepsExpanded;
|
||||
|
||||
public int TotalStepCount => Subtasks.Count;
|
||||
public int OpenStepCount => Subtasks.Count(s => !s.Done);
|
||||
public string StepsSummary =>
|
||||
TotalStepCount == 0 ? "no steps yet"
|
||||
: OpenStepCount == 0 ? $"all done · {TotalStepCount} total"
|
||||
: $"{OpenStepCount} open · {TotalStepCount} total";
|
||||
|
||||
private void NotifyStepsChanged()
|
||||
{
|
||||
OnPropertyChanged(nameof(TotalStepCount));
|
||||
OnPropertyChanged(nameof(OpenStepCount));
|
||||
OnPropertyChanged(nameof(StepsSummary));
|
||||
OnPropertyChanged(nameof(ComposedPreview));
|
||||
}
|
||||
|
||||
// The exact text handed to Claude: title + description + open steps only.
|
||||
public string ComposedPreview =>
|
||||
ClaudeDo.Data.TaskPromptComposer.Compose(
|
||||
EditableTitle, EditableDescription, Subtasks.Select(s => (s.Title, s.Done)));
|
||||
|
||||
// ── Work console (redesign) ────────────────────────────────────────
|
||||
// Two tabs: Output (live log) and Session (review + merge/worktree +
|
||||
// outcomes, each section gated on the relevant state).
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(IsOutputTab))]
|
||||
[NotifyPropertyChangedFor(nameof(IsGitTab))]
|
||||
[NotifyPropertyChangedFor(nameof(IsSessionTab))]
|
||||
private string _selectedTab = "output";
|
||||
|
||||
public bool IsOutputTab => SelectedTab == "output";
|
||||
public bool IsGitTab => SelectedTab == "git";
|
||||
public bool IsSessionTab => SelectedTab == "session";
|
||||
|
||||
[RelayCommand]
|
||||
private void SelectTab(string? tab) => SelectedTab = tab ?? "output";
|
||||
|
||||
// Merge/worktree controls only matter once there's a worktree to manage
|
||||
// (standalone task), or a planning parent / improvement parent with children.
|
||||
public bool ShowMergeSection =>
|
||||
WorktreePath != null || Task?.IsPlanningParent == true || HasChildOutcomes;
|
||||
|
||||
// Nothing to manage yet (idle/queued/running standalone): show a hint.
|
||||
public bool ShowSessionEmpty =>
|
||||
!IsWaitingForReview && !ShowMergeSection && !HasChildOutcomes;
|
||||
|
||||
private void NotifySessionSections()
|
||||
{
|
||||
OnPropertyChanged(nameof(ShowMergeSection));
|
||||
OnPropertyChanged(nameof(ShowSessionEmpty));
|
||||
}
|
||||
|
||||
public string TurnsText => $"{Turns}/{EffectiveMaxTurns}";
|
||||
public string DiffAddText => $"+{DiffAdditions}";
|
||||
public string DiffDelText => $"-{DiffDeletions}";
|
||||
|
||||
// Resolved turn budget: per-task override → list default → global default.
|
||||
public int EffectiveMaxTurns =>
|
||||
TaskMaxTurns is decimal t ? (int)t : (_listMaxTurns ?? _globalMaxTurns);
|
||||
|
||||
public bool ShowRoadblock => IsFailed || IsCancelled;
|
||||
public string RoadblockMessage =>
|
||||
IsFailed ? "The session ended with an error." :
|
||||
IsCancelled ? "The session was cancelled." : "";
|
||||
|
||||
public string SessionLabel => "claude-session";
|
||||
|
||||
// Short task-id badge, e.g. "#T1A"
|
||||
public string TaskIdBadge => Task != null ? $"#T{Task.Id[..Math.Min(3, Task.Id.Length)].ToUpperInvariant()}" : "";
|
||||
|
||||
@@ -142,6 +220,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
OnPropertyChanged(nameof(ShowContinue));
|
||||
OnPropertyChanged(nameof(ShowResetAndRetry));
|
||||
OnPropertyChanged(nameof(IsAgentSectionEnabled));
|
||||
OnPropertyChanged(nameof(ShowRoadblock));
|
||||
OnPropertyChanged(nameof(RoadblockMessage));
|
||||
NotifySessionSections();
|
||||
}
|
||||
[ObservableProperty] private string? _model;
|
||||
|
||||
@@ -164,7 +245,13 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
private string? _listAgentName;
|
||||
|
||||
partial void OnTaskModelSelectionChanged(string? value) { RecomputeModelBadge(); QueueAgentSave(); }
|
||||
partial void OnTaskMaxTurnsChanged(decimal? value) { RecomputeTurnsBadge(); QueueAgentSave(); }
|
||||
partial void OnTaskMaxTurnsChanged(decimal? value)
|
||||
{
|
||||
RecomputeTurnsBadge();
|
||||
OnPropertyChanged(nameof(EffectiveMaxTurns));
|
||||
OnPropertyChanged(nameof(TurnsText));
|
||||
QueueAgentSave();
|
||||
}
|
||||
|
||||
private void RecomputeModelBadge()
|
||||
{
|
||||
@@ -221,8 +308,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
public string ElapsedFormatted => ""; // placeholder — no start-time stored yet
|
||||
|
||||
partial void OnTokensChanged(int value) => OnPropertyChanged(nameof(TokensFormatted));
|
||||
partial void OnDiffAdditionsChanged(int value) => OnPropertyChanged(nameof(DiffMeterRatio));
|
||||
partial void OnDiffDeletionsChanged(int value) => OnPropertyChanged(nameof(DiffMeterRatio));
|
||||
partial void OnTurnsChanged(int value) => OnPropertyChanged(nameof(TurnsText));
|
||||
partial void OnDiffAdditionsChanged(int value) { OnPropertyChanged(nameof(DiffMeterRatio)); OnPropertyChanged(nameof(DiffAddText)); }
|
||||
partial void OnDiffDeletionsChanged(int value) { OnPropertyChanged(nameof(DiffMeterRatio)); OnPropertyChanged(nameof(DiffDelText)); }
|
||||
|
||||
// 0.0–1.0 additions share for the diff meter
|
||||
public double DiffMeterRatio
|
||||
@@ -253,6 +341,24 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
[ObservableProperty] private string? _mergeAllDisabledReason;
|
||||
[ObservableProperty] private string? _mergeAllError;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ShowMergePreviewMuted))]
|
||||
private string _mergePreviewText = "";
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ShowMergePreviewMuted))]
|
||||
private bool _mergeIsClean;
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ShowMergePreviewMuted))]
|
||||
private bool _mergeIsConflict;
|
||||
|
||||
public bool ShowMergePreviewMuted =>
|
||||
!MergeIsClean && !MergeIsConflict && !string.IsNullOrEmpty(MergePreviewText);
|
||||
|
||||
public bool ShowSingleMerge =>
|
||||
WorktreePath != null && Task?.IsPlanningParent != true;
|
||||
|
||||
// Claude CLI stream-json parser + buffer for partial text deltas
|
||||
private readonly StreamLineFormatter _formatter = new();
|
||||
private readonly StringBuilder _claudeBuf = new();
|
||||
@@ -328,6 +434,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
_services = services;
|
||||
_notesApi = notesApi;
|
||||
Notes = new NotesEditorViewModel(_notesApi);
|
||||
Subtasks.CollectionChanged += (_, _) => NotifyStepsChanged();
|
||||
Loc.LanguageChanged += (_, _) =>
|
||||
{
|
||||
OnPropertyChanged(nameof(AgentStatusLabel));
|
||||
@@ -399,6 +506,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
{
|
||||
RecomputeCanMergeAll();
|
||||
ReviewCombinedDiffCommand.NotifyCanExecuteChanged();
|
||||
NotifySessionSections();
|
||||
};
|
||||
|
||||
PrepLog.CollectionChanged += (_, _) => OnPropertyChanged(nameof(ShowPrepEmptyState));
|
||||
@@ -576,6 +684,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
RecomputeModelBadge();
|
||||
RecomputeTurnsBadge();
|
||||
RecomputeAgentBadge();
|
||||
OnPropertyChanged(nameof(EffectiveMaxTurns));
|
||||
OnPropertyChanged(nameof(TurnsText));
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -722,6 +832,20 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
{
|
||||
await LoadChildOutcomesAsync(row.Id, ct);
|
||||
}
|
||||
|
||||
if (entity.Worktree != null
|
||||
&& entity.PlanningPhase == ClaudeDo.Data.Models.PlanningPhase.None
|
||||
&& MergeTargetBranches.Count == 0)
|
||||
{
|
||||
var targets = await _worker.GetMergeTargetsAsync(row.Id);
|
||||
if (targets != null)
|
||||
{
|
||||
MergeTargetBranches.Clear();
|
||||
foreach (var b in targets.LocalBranches) MergeTargetBranches.Add(b);
|
||||
SelectedMergeTarget = targets.DefaultBranch; // triggers OnSelectedMergeTargetChanged → preview
|
||||
}
|
||||
}
|
||||
await RefreshMergePreviewAsync();
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
}
|
||||
@@ -1012,6 +1136,49 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
catch { /* best-effort refresh */ }
|
||||
}
|
||||
|
||||
private async System.Threading.Tasks.Task RefreshMergePreviewAsync()
|
||||
{
|
||||
if (Task is null || WorktreePath is null)
|
||||
{
|
||||
MergePreviewText = ""; MergeIsClean = false; MergeIsConflict = false;
|
||||
return;
|
||||
}
|
||||
// Only probe Active worktrees; terminal states show their label instead.
|
||||
if (WorktreeStateLabel is { } label && label != "Active")
|
||||
{
|
||||
MergePreviewText = label; MergeIsClean = false; MergeIsConflict = false;
|
||||
return;
|
||||
}
|
||||
var capturedTaskId = Task.Id;
|
||||
var capturedTarget = SelectedMergeTarget;
|
||||
var dto = await _worker.PreviewMergeAsync(capturedTaskId, capturedTarget ?? "");
|
||||
// Discard a probe that resolved after the user switched task or target.
|
||||
if (Task?.Id != capturedTaskId || SelectedMergeTarget != capturedTarget) return;
|
||||
var (text, clean, conflict) = MergePreviewPresenter.Describe(dto);
|
||||
MergePreviewText = text; MergeIsClean = clean; MergeIsConflict = conflict;
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async System.Threading.Tasks.Task MergeAsync()
|
||||
{
|
||||
if (Task is null || WorktreePath is null || !_worker.IsConnected) return;
|
||||
try
|
||||
{
|
||||
var result = await _worker.MergeTaskAsync(Task.Id, SelectedMergeTarget ?? "", false, "Merge task");
|
||||
if (result.Status == "conflict")
|
||||
{
|
||||
var (text, _, _) = MergePreviewPresenter.Describe(
|
||||
new MergePreviewDto("conflict", result.ConflictFiles, 0));
|
||||
MergePreviewText = text; MergeIsClean = false; MergeIsConflict = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
await RefreshMergePreviewAsync();
|
||||
}
|
||||
}
|
||||
catch { /* broadcast reconciles */ }
|
||||
}
|
||||
|
||||
[RelayCommand(CanExecute = nameof(CanOpenDiff))]
|
||||
private async System.Threading.Tasks.Task OpenDiffAsync()
|
||||
{
|
||||
@@ -1048,12 +1215,21 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
|
||||
private bool CanOpenWorktree() => WorktreePath != null;
|
||||
|
||||
partial void OnSelectedMergeTargetChanged(string? value)
|
||||
{
|
||||
_ = RefreshMergePreviewAsync();
|
||||
}
|
||||
|
||||
partial void OnWorktreePathChanged(string? value)
|
||||
{
|
||||
OpenDiffCommand.NotifyCanExecuteChanged();
|
||||
OpenWorktreeCommand.NotifyCanExecuteChanged();
|
||||
NotifySessionSections();
|
||||
OnPropertyChanged(nameof(ShowSingleMerge));
|
||||
}
|
||||
|
||||
partial void OnTaskChanged(TaskRowViewModel? value) => NotifySessionSections();
|
||||
|
||||
[RelayCommand]
|
||||
private void CloseDetails() => CloseDetail?.Invoke();
|
||||
|
||||
@@ -1092,6 +1268,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
{
|
||||
if (row is null) return;
|
||||
row.Done = !row.Done;
|
||||
NotifyStepsChanged();
|
||||
await using var ctx = _dbFactory.CreateDbContext();
|
||||
var repo = new SubtaskRepository(ctx);
|
||||
var subs = await repo.GetByTaskIdAsync(Task?.Id ?? "");
|
||||
@@ -1157,6 +1334,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
await repo.UpdateAsync(entity);
|
||||
}
|
||||
row.Title = title;
|
||||
OnPropertyChanged(nameof(ComposedPreview));
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
@@ -1267,10 +1445,16 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||
private async System.Threading.Tasks.Task ApproveReviewAsync()
|
||||
{
|
||||
if (Task is null || !_worker.IsConnected) return;
|
||||
// The hub rejects (HubException) if the task is no longer WaitingForReview
|
||||
// — e.g. after "Merge all" folded the parent. Swallow it; the TaskUpdated
|
||||
// broadcast reconciles the UI. An unhandled command exception would crash.
|
||||
try { await _worker.ApproveReviewAsync(Task.Id); }
|
||||
try
|
||||
{
|
||||
var result = await _worker.ApproveReviewAsync(Task.Id, SelectedMergeTarget ?? "");
|
||||
if (result?.Status == "conflict")
|
||||
{
|
||||
var (text, _, _) = MergePreviewPresenter.Describe(
|
||||
new MergePreviewDto("conflict", result.ConflictFiles, 0));
|
||||
MergePreviewText = text; MergeIsClean = false; MergeIsConflict = true;
|
||||
}
|
||||
}
|
||||
catch { /* stale review action; broadcast reconciles */ }
|
||||
}
|
||||
|
||||
|
||||
28
src/ClaudeDo.Ui/ViewModels/Islands/MergePreviewPresenter.cs
Normal file
28
src/ClaudeDo.Ui/ViewModels/Islands/MergePreviewPresenter.cs
Normal file
@@ -0,0 +1,28 @@
|
||||
using System.Linq;
|
||||
using ClaudeDo.Ui.Services;
|
||||
|
||||
namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||
|
||||
/// Pure mapping from a merge-preview DTO to display text + color flags.
|
||||
public static class MergePreviewPresenter
|
||||
{
|
||||
public static (string Text, bool IsClean, bool IsConflict) Describe(MergePreviewDto? dto)
|
||||
{
|
||||
if (dto is null) return ("", false, false);
|
||||
|
||||
switch (dto.Status)
|
||||
{
|
||||
case "clean":
|
||||
var unit = dto.ChangedFileCount == 1 ? "file" : "files";
|
||||
return ($"Merges cleanly · {dto.ChangedFileCount} {unit}", true, false);
|
||||
|
||||
case "conflict":
|
||||
var names = string.Join(", ", dto.ConflictFiles.Take(3));
|
||||
var more = dto.ConflictFiles.Count > 3 ? $" (+{dto.ConflictFiles.Count - 3} more)" : "";
|
||||
return ($"Conflicts in {names}{more}", false, true);
|
||||
|
||||
default:
|
||||
return ("Mergeability unknown", false, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,9 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
[ObservableProperty] private bool _showListChip = true;
|
||||
[ObservableProperty] private bool _parentFinalized;
|
||||
[ObservableProperty] private int _roadblockCount;
|
||||
[ObservableProperty] private bool _isRefining;
|
||||
|
||||
public bool CanRefine => Status == TaskStatus.Idle && PlanningPhase == PlanningPhase.None && !IsRefining;
|
||||
|
||||
public DateTime CreatedAt { get; init; }
|
||||
public string CreatedAtFormatted => CreatedAt == default ? "—" : Loc.T("vm.taskRow.createdPrefix", CreatedAt.ToString("MMM d"));
|
||||
@@ -125,6 +128,7 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
||||
OnPropertyChanged(nameof(CanRemoveFromQueue));
|
||||
OnPropertyChanged(nameof(CanSendToQueue));
|
||||
OnPropertyChanged(nameof(CanRefine));
|
||||
}
|
||||
|
||||
partial void OnParentTaskIdChanged(string? value)
|
||||
@@ -155,8 +159,11 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
||||
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
||||
OnPropertyChanged(nameof(CanResumeOrDiscardPlanning));
|
||||
OnPropertyChanged(nameof(CanQueuePlan));
|
||||
OnPropertyChanged(nameof(CanRefine));
|
||||
}
|
||||
|
||||
partial void OnIsRefiningChanged(bool value) => OnPropertyChanged(nameof(CanRefine));
|
||||
|
||||
partial void OnHasQueuedSubtasksChanged(bool value)
|
||||
{
|
||||
OnPropertyChanged(nameof(CanRemoveFromQueue));
|
||||
|
||||
@@ -82,6 +82,8 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
_worker.WorktreeUpdatedEvent += OnWorkerTaskUpdated;
|
||||
_worker.ListUpdatedEvent += OnWorkerListUpdated;
|
||||
_worker.ConnectionRestoredEvent += () => LoadForList(_currentList);
|
||||
_worker.RefineStartedEvent += OnRefineStarted;
|
||||
_worker.RefineFinishedEvent += OnRefineFinished;
|
||||
}
|
||||
Loc.LanguageChanged += (_, _) => RefreshLocalizedText();
|
||||
}
|
||||
@@ -645,7 +647,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
private async Task ApproveReviewAsync(TaskRowViewModel? row)
|
||||
{
|
||||
if (row is null || !row.IsWaitingForReview || _worker is null) return;
|
||||
try { await _worker.ApproveReviewAsync(row.Id); }
|
||||
try { await _worker.ApproveReviewAsync(row.Id, ""); }
|
||||
catch { /* offline; broadcast reconciles on return */ }
|
||||
}
|
||||
|
||||
@@ -830,6 +832,27 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||
Regroup();
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private async Task RefineTask(TaskRowViewModel row)
|
||||
{
|
||||
if (row is null || !row.CanRefine) return;
|
||||
row.IsRefining = true;
|
||||
try { await _worker!.RefineTaskAsync(row.Id); }
|
||||
catch { row.IsRefining = false; }
|
||||
}
|
||||
|
||||
private void OnRefineStarted(string taskId)
|
||||
{
|
||||
var row = Items.FirstOrDefault(r => r.Id == taskId);
|
||||
if (row is not null) row.IsRefining = true;
|
||||
}
|
||||
|
||||
private void OnRefineFinished(string taskId, bool ok, string? error)
|
||||
{
|
||||
var row = Items.FirstOrDefault(r => r.Id == taskId);
|
||||
if (row is not null) row.IsRefining = false;
|
||||
}
|
||||
|
||||
partial void OnSelectedTaskChanged(TaskRowViewModel? value)
|
||||
{
|
||||
foreach (var i in Items) i.IsSelected = ReferenceEquals(i, value);
|
||||
|
||||
165
src/ClaudeDo.Ui/Views/Islands/Detail/DescriptionStepsCard.axaml
Normal file
165
src/ClaudeDo.Ui/Views/Islands/Detail/DescriptionStepsCard.axaml
Normal file
@@ -0,0 +1,165 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
|
||||
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
|
||||
x:Class="ClaudeDo.Ui.Views.Islands.Detail.DescriptionStepsCard"
|
||||
x:DataType="vm:DetailsIslandViewModel">
|
||||
|
||||
<Border Classes="island"
|
||||
Background="{DynamicResource Surface2Brush}"
|
||||
BorderBrush="{DynamicResource LineBrush}">
|
||||
<DockPanel>
|
||||
|
||||
<!-- Header: DETAILS · copy · preview/edit -->
|
||||
<Border DockPanel.Dock="Top" Classes="island-header">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto" VerticalAlignment="Center">
|
||||
|
||||
<TextBlock Grid.Column="0" Classes="section-label" Text="DETAILS"
|
||||
VerticalAlignment="Center"/>
|
||||
|
||||
<!-- Copy formatted -->
|
||||
<Button Grid.Column="2"
|
||||
Classes="icon-btn"
|
||||
Margin="0,0,4,0"
|
||||
ToolTip.Tip="Copy formatted (title + description + open steps)"
|
||||
Click="OnCopyClick">
|
||||
<PathIcon Data="{StaticResource Icon.Copy}" Width="11" Height="11"/>
|
||||
</Button>
|
||||
|
||||
<!-- Preview/Edit toggle -->
|
||||
<Button Grid.Column="3"
|
||||
Classes="btn"
|
||||
Padding="8,3"
|
||||
Command="{Binding ToggleEditDescriptionCommand}">
|
||||
<Panel>
|
||||
<TextBlock Text="Preview" IsVisible="{Binding IsEditingDescription}"/>
|
||||
<TextBlock Text="Edit" IsVisible="{Binding !IsEditingDescription}"/>
|
||||
</Panel>
|
||||
</Button>
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- Body -->
|
||||
<StackPanel Margin="14" Spacing="10">
|
||||
|
||||
<!-- Description (always visible) -->
|
||||
<Panel>
|
||||
<!-- Edit mode: raw TextBox -->
|
||||
<TextBox Text="{Binding EditableDescription, Mode=TwoWay}"
|
||||
AcceptsReturn="True"
|
||||
TextWrapping="Wrap"
|
||||
MinHeight="80"
|
||||
MaxHeight="320"
|
||||
Padding="8"
|
||||
FontFamily="{DynamicResource MonoFont}"
|
||||
FontSize="{StaticResource FontSizeBody}"
|
||||
Background="{DynamicResource Surface3Brush}"
|
||||
BorderBrush="{DynamicResource LineBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="8"
|
||||
IsVisible="{Binding IsEditingDescription}"/>
|
||||
<!-- Preview mode: rendered composed text (title + description + open steps) -->
|
||||
<ctl:MarkdownView Markdown="{Binding ComposedPreview}"
|
||||
IsVisible="{Binding !IsEditingDescription}"/>
|
||||
</Panel>
|
||||
|
||||
<!-- Steps: always-visible summary strip; expand to manage -->
|
||||
<Border BorderBrush="{DynamicResource LineBrush}"
|
||||
BorderThickness="0,1,0,0"
|
||||
Padding="0,8,0,0">
|
||||
<StackPanel Spacing="6">
|
||||
|
||||
<!-- Summary header (click to expand/collapse) -->
|
||||
<Button Classes="flat" Cursor="Hand"
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Stretch"
|
||||
Command="{Binding ToggleStepsExpandedCommand}">
|
||||
<Grid ColumnDefinitions="Auto,Auto,*,Auto">
|
||||
<Panel Grid.Column="0" Width="12" Margin="0,0,6,0" VerticalAlignment="Center">
|
||||
<TextBlock Classes="meta" Text="▸" IsVisible="{Binding !IsStepsExpanded}"/>
|
||||
<TextBlock Classes="meta" Text="▾" IsVisible="{Binding IsStepsExpanded}"/>
|
||||
</Panel>
|
||||
<TextBlock Grid.Column="1" Classes="section-label" Text="STEPS"
|
||||
VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="3" Classes="meta" Text="{Binding StepsSummary}"
|
||||
Foreground="{DynamicResource TextMuteBrush}"
|
||||
VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
</Button>
|
||||
|
||||
<!-- Expanded: add-step input + step rows -->
|
||||
<StackPanel IsVisible="{Binding IsStepsExpanded}" Spacing="6">
|
||||
<TextBox Text="{Binding NewSubtaskTitle, Mode=TwoWay}"
|
||||
PlaceholderText="Add step…"
|
||||
Padding="8"
|
||||
Background="{DynamicResource Surface3Brush}"
|
||||
BorderBrush="{DynamicResource LineBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="8">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding AddSubtaskCommand}"/>
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
|
||||
<!-- Subtask rows -->
|
||||
<ItemsControl ItemsSource="{Binding Subtasks}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="vm:SubtaskRowViewModel">
|
||||
<Border Classes="subtask-row" Classes.done="{Binding Done}">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
|
||||
<!-- Check circle -->
|
||||
<Button Grid.Column="0"
|
||||
Classes="flat"
|
||||
Padding="0"
|
||||
Margin="0,0,8,0"
|
||||
VerticalAlignment="Center"
|
||||
Command="{Binding $parent[ItemsControl].((vm:DetailsIslandViewModel)DataContext).ToggleSubtaskDoneCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<Ellipse Classes="task-check"
|
||||
Classes.done="{Binding Done}"
|
||||
Width="16" Height="16"
|
||||
Cursor="Hand"/>
|
||||
</Button>
|
||||
|
||||
<!-- Title / edit -->
|
||||
<Panel Grid.Column="1" VerticalAlignment="Center">
|
||||
<TextBlock Classes="subtask-title"
|
||||
Text="{Binding Title}"
|
||||
IsVisible="{Binding !IsEditing}"
|
||||
FontSize="{StaticResource FontSizeBody}"
|
||||
Foreground="{DynamicResource TextDimBrush}"
|
||||
VerticalAlignment="Center"
|
||||
TextWrapping="Wrap"
|
||||
Cursor="Ibeam"
|
||||
Tapped="OnSubtaskTitleTapped"/>
|
||||
<TextBox Classes="subtask-edit"
|
||||
Text="{Binding Title, Mode=TwoWay}"
|
||||
IsVisible="{Binding IsEditing}"
|
||||
FontSize="{StaticResource FontSizeBody}"
|
||||
AcceptsReturn="False"
|
||||
TextWrapping="Wrap"
|
||||
LostFocus="OnSubtaskEditLostFocus">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter"
|
||||
Command="{Binding $parent[ItemsControl].((vm:DetailsIslandViewModel)DataContext).CommitSubtaskEditCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
</Panel>
|
||||
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
</UserControl>
|
||||
@@ -0,0 +1,35 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Input.Platform;
|
||||
using Avalonia.Interactivity;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
|
||||
namespace ClaudeDo.Ui.Views.Islands.Detail;
|
||||
|
||||
public partial class DescriptionStepsCard : UserControl
|
||||
{
|
||||
public DescriptionStepsCard()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private async void OnCopyClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not DetailsIslandViewModel vm) return;
|
||||
var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
|
||||
if (clipboard is null) return;
|
||||
await clipboard.SetTextAsync(vm.ComposedPreview);
|
||||
}
|
||||
|
||||
private void OnSubtaskTitleTapped(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (sender is TextBlock { DataContext: SubtaskRowViewModel row })
|
||||
row.IsEditing = true;
|
||||
}
|
||||
|
||||
private void OnSubtaskEditLostFocus(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is TextBox { DataContext: SubtaskRowViewModel row })
|
||||
row.IsEditing = false;
|
||||
}
|
||||
}
|
||||
125
src/ClaudeDo.Ui/Views/Islands/Detail/TaskHeaderBar.axaml
Normal file
125
src/ClaudeDo.Ui/Views/Islands/Detail/TaskHeaderBar.axaml
Normal file
@@ -0,0 +1,125 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
|
||||
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
|
||||
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
||||
x:Class="ClaudeDo.Ui.Views.Islands.Detail.TaskHeaderBar"
|
||||
x:DataType="vm:DetailsIslandViewModel">
|
||||
|
||||
<Grid ColumnDefinitions="*,Auto,Auto">
|
||||
|
||||
<!-- Column 0: id badge + editable title -->
|
||||
<StackPanel Grid.Column="0" Spacing="0">
|
||||
<TextBlock Classes="meta"
|
||||
Text="{Binding TaskIdBadge}"
|
||||
Margin="0,0,0,4"
|
||||
Cursor="Hand"
|
||||
ToolTip.Tip="{loc:Tr details.copyTaskIdTip}"
|
||||
Tapped="OnTaskIdTapped"/>
|
||||
<TextBox Text="{Binding EditableTitle, Mode=TwoWay}"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
FontSize="{StaticResource FontSizeTaskTitle}"
|
||||
FontWeight="Medium"
|
||||
Foreground="{DynamicResource TextBrush}"
|
||||
TextWrapping="Wrap"
|
||||
AcceptsReturn="False"
|
||||
Padding="0"/>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Column 1: trash button (not running) -->
|
||||
<Button Grid.Column="1" Classes="icon-btn"
|
||||
Command="{Binding DeleteTaskCommand}"
|
||||
ToolTip.Tip="Delete task"
|
||||
IsVisible="{Binding !IsRunning}"
|
||||
VerticalAlignment="Top"
|
||||
Margin="6,0,0,0">
|
||||
<PathIcon Data="{StaticResource Icon.Trash}" Width="14" Height="14"
|
||||
Foreground="{DynamicResource BloodBrush}"/>
|
||||
</Button>
|
||||
|
||||
<!-- Column 1: skull button (running) -->
|
||||
<Button Grid.Column="1" Classes="icon-btn"
|
||||
Command="{Binding StopCommand}"
|
||||
ToolTip.Tip="Kill session"
|
||||
IsVisible="{Binding IsRunning}"
|
||||
VerticalAlignment="Top"
|
||||
Margin="6,0,0,0">
|
||||
<PathIcon Data="{StaticResource Icon.Skull}" Width="14" Height="14"
|
||||
Foreground="{DynamicResource BloodBrush}"/>
|
||||
</Button>
|
||||
|
||||
<!-- Column 2: gear button with agent settings flyout -->
|
||||
<Button Grid.Column="2" Classes="icon-btn"
|
||||
ToolTip.Tip="{loc:Tr details.agentSettingsTip}"
|
||||
IsEnabled="{Binding IsAgentSectionEnabled}"
|
||||
VerticalAlignment="Top"
|
||||
Margin="6,0,0,0">
|
||||
<TextBlock Text="⚙" FontSize="{StaticResource FontSizeTaskTitle}"/>
|
||||
<Button.Flyout>
|
||||
<Flyout Placement="BottomEdgeAlignedRight" ShowMode="Standard">
|
||||
<StackPanel Width="340" Spacing="10" Margin="4">
|
||||
<TextBlock Text="{loc:Tr details.agentSettingsHeading}" FontWeight="SemiBold"/>
|
||||
|
||||
<StackPanel Spacing="2">
|
||||
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
|
||||
<TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr details.modelLabel}" VerticalAlignment="Center"/>
|
||||
<ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding ModelBadge}"/>
|
||||
<Button Grid.Column="3" Classes="icon-btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
|
||||
Command="{Binding ResetTaskModelCommand}"/>
|
||||
</Grid>
|
||||
<ComboBox ItemsSource="{Binding TaskModelOptions}"
|
||||
SelectedItem="{Binding TaskModelSelection, Mode=TwoWay}"
|
||||
PlaceholderText="{Binding ModelInheritedHint}"
|
||||
HorizontalAlignment="Stretch"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="2">
|
||||
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
|
||||
<TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr details.maxTurnsLabel}" VerticalAlignment="Center"/>
|
||||
<ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding TurnsBadge}"/>
|
||||
<Button Grid.Column="3" Classes="icon-btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
|
||||
Command="{Binding ResetTaskTurnsCommand}"/>
|
||||
</Grid>
|
||||
<NumericUpDown Value="{Binding TaskMaxTurns, Mode=TwoWay}"
|
||||
PlaceholderText="{Binding TurnsInheritedHint}"
|
||||
Minimum="1" Maximum="200" Increment="1" FormatString="0"
|
||||
HorizontalAlignment="Stretch"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="2">
|
||||
<TextBlock Classes="field-label" Text="{loc:Tr details.systemPromptLabel}"/>
|
||||
<TextBox Text="{Binding TaskSystemPrompt, Mode=TwoWay}"
|
||||
AcceptsReturn="True" TextWrapping="Wrap" MinHeight="70"/>
|
||||
<TextBlock Classes="meta" Opacity="0.6"
|
||||
Text="{loc:Tr details.systemPromptPrepended}"
|
||||
IsVisible="{Binding EffectiveSystemPromptHint, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
<TextBlock Classes="meta" Opacity="0.6" TextWrapping="Wrap"
|
||||
Text="{Binding EffectiveSystemPromptHint}"
|
||||
IsVisible="{Binding EffectiveSystemPromptHint, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="2">
|
||||
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
|
||||
<TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr details.agentFileLabel}" VerticalAlignment="Center"/>
|
||||
<ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding AgentBadge}"/>
|
||||
<Button Grid.Column="3" Classes="icon-btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
|
||||
Command="{Binding ResetTaskAgentCommand}"/>
|
||||
</Grid>
|
||||
<ComboBox ItemsSource="{Binding TaskAgentOptions}"
|
||||
SelectedItem="{Binding TaskSelectedAgent, Mode=TwoWay}"
|
||||
HorizontalAlignment="Stretch">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding Name}"/>
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Flyout>
|
||||
</Button.Flyout>
|
||||
</Button>
|
||||
|
||||
</Grid>
|
||||
</UserControl>
|
||||
22
src/ClaudeDo.Ui/Views/Islands/Detail/TaskHeaderBar.axaml.cs
Normal file
22
src/ClaudeDo.Ui/Views/Islands/Detail/TaskHeaderBar.axaml.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Input.Platform;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
|
||||
namespace ClaudeDo.Ui.Views.Islands.Detail;
|
||||
|
||||
public partial class TaskHeaderBar : UserControl
|
||||
{
|
||||
public TaskHeaderBar()
|
||||
{
|
||||
InitializeComponent();
|
||||
}
|
||||
|
||||
private async void OnTaskIdTapped(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (DataContext is not DetailsIslandViewModel vm || vm.Task is null) return;
|
||||
var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
|
||||
if (clipboard is null) return;
|
||||
await clipboard.SetTextAsync(vm.Task.Id);
|
||||
}
|
||||
}
|
||||
331
src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml
Normal file
331
src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml
Normal file
@@ -0,0 +1,331 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
|
||||
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
||||
x:DataType="vm:DetailsIslandViewModel"
|
||||
x:Class="ClaudeDo.Ui.Views.Islands.Detail.WorkConsole">
|
||||
|
||||
<UserControl.Styles>
|
||||
<Style Selector="Button.tab-btn">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Padding" Value="12,8" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="{StaticResource FontSizeMono}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
|
||||
<Setter Property="CornerRadius" Value="0" />
|
||||
</Style>
|
||||
<Style Selector="Button.tab-btn:pointerover /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
|
||||
<Setter Property="TextElement.Foreground" Value="{StaticResource TextDimBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Button.tab-btn.active /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="{StaticResource Surface3Brush}" />
|
||||
<Setter Property="TextElement.Foreground" Value="{StaticResource AccentBrush}" />
|
||||
</Style>
|
||||
|
||||
<!-- Terminal prompt action: bracketed text, no button chrome -->
|
||||
<Style Selector="Button.prompt-action">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Padding" Value="2,0" />
|
||||
<Setter Property="CornerRadius" Value="0" />
|
||||
<Setter Property="Cursor" Value="Hand" />
|
||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
||||
<Setter Property="FontSize" Value="{StaticResource FontSizeMono}" />
|
||||
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Button.prompt-action /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
</Style>
|
||||
<Style Selector="Button.prompt-action:pointerover /template/ ContentPresenter">
|
||||
<Setter Property="Background" Value="Transparent" />
|
||||
<Setter Property="TextElement.Foreground" Value="{StaticResource TextBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Button.prompt-action.accent">
|
||||
<Setter Property="Foreground" Value="{StaticResource AccentBrush}" />
|
||||
</Style>
|
||||
<Style Selector="Button.prompt-action.accent:pointerover /template/ ContentPresenter">
|
||||
<Setter Property="TextElement.Foreground" Value="{StaticResource MossBrightBrush}" />
|
||||
</Style>
|
||||
</UserControl.Styles>
|
||||
|
||||
<!-- Outer terminal card — Padding="0" so header/strip span edge-to-edge;
|
||||
ClipToBounds keeps tab content inside the rounded corners (no bottom clip). -->
|
||||
<Border Classes="terminal" Padding="0" ClipToBounds="True">
|
||||
<DockPanel LastChildFill="True">
|
||||
|
||||
<!-- ── Title bar ── -->
|
||||
<Grid DockPanel.Dock="Top" ColumnDefinitions="Auto,*,Auto"
|
||||
Background="{DynamicResource Surface2Brush}" Height="28">
|
||||
|
||||
<!-- Traffic-light dots -->
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="6"
|
||||
Margin="12,0" VerticalAlignment="Center">
|
||||
<Ellipse Classes="dot-red" />
|
||||
<Ellipse Classes="dot-yellow" />
|
||||
<Ellipse Classes="dot-green" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Right cluster: info header (model · turns · diff) + status chip -->
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="12"
|
||||
Margin="0,0,8,0" VerticalAlignment="Center">
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
|
||||
<TextBlock Classes="meta" Text="{Binding Model}"
|
||||
Foreground="{DynamicResource TextMuteBrush}" />
|
||||
<TextBlock Classes="meta" Text="·"
|
||||
Foreground="{DynamicResource TextFaintBrush}" />
|
||||
<TextBlock Classes="meta" Text="{Binding TurnsText}"
|
||||
Foreground="{DynamicResource TextMuteBrush}" />
|
||||
<TextBlock Classes="meta" Text="·"
|
||||
Foreground="{DynamicResource TextFaintBrush}" />
|
||||
<TextBlock Classes="diff-add" Text="{Binding DiffAddText}" />
|
||||
<TextBlock Classes="diff-del" Text="{Binding DiffDelText}" />
|
||||
</StackPanel>
|
||||
|
||||
<Panel VerticalAlignment="Center">
|
||||
<Border Classes="live-chip pulsing"
|
||||
IsVisible="{Binding IsRunning}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="5" VerticalAlignment="Center">
|
||||
<Ellipse VerticalAlignment="Center" />
|
||||
<TextBlock Text="{loc:Tr session.chipLive}" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Classes="live-chip done"
|
||||
IsVisible="{Binding IsDone}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="5" VerticalAlignment="Center">
|
||||
<Ellipse VerticalAlignment="Center" Fill="{DynamicResource MossBrush}" />
|
||||
<TextBlock Text="{loc:Tr session.chipDone}" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource MossBrush}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<Border Classes="live-chip failed"
|
||||
IsVisible="{Binding IsFailed}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="5" VerticalAlignment="Center">
|
||||
<Ellipse VerticalAlignment="Center" Fill="{DynamicResource BloodBrush}" />
|
||||
<TextBlock Text="{loc:Tr session.chipFailed}" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource BloodBrush}" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
</Panel>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
</Grid>
|
||||
|
||||
<!-- ── Roadblock band ── -->
|
||||
<Border DockPanel.Dock="Top"
|
||||
IsVisible="{Binding ShowRoadblock}"
|
||||
Background="{DynamicResource ErrorTintBrush}"
|
||||
BorderBrush="{DynamicResource BloodBrush}"
|
||||
BorderThickness="0,1"
|
||||
Padding="14,8">
|
||||
<StackPanel Spacing="6">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<PathIcon Data="{StaticResource Icon.Warning}"
|
||||
Foreground="{DynamicResource BloodBrush}"
|
||||
Width="14" Height="14" VerticalAlignment="Center" />
|
||||
<TextBlock Classes="meta" Text="{Binding RoadblockMessage}"
|
||||
Foreground="{DynamicResource BloodBrush}"
|
||||
TextWrapping="Wrap" VerticalAlignment="Center" />
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="btn accent" Content="Continue"
|
||||
Command="{Binding ContinueCommand}"
|
||||
IsVisible="{Binding ShowContinue}" />
|
||||
<Button Classes="btn" Content="Reset & Retry"
|
||||
Command="{Binding ResetAndRetryCommand}"
|
||||
IsVisible="{Binding ShowResetAndRetry}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ── Tab strip ── -->
|
||||
<Border DockPanel.Dock="Top"
|
||||
Background="{DynamicResource Surface2Brush}"
|
||||
BorderBrush="{DynamicResource LineBrush}"
|
||||
BorderThickness="0,0,0,1">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button Classes="tab-btn"
|
||||
Classes.active="{Binding IsOutputTab}"
|
||||
Content="Output"
|
||||
Command="{Binding SelectTabCommand}"
|
||||
CommandParameter="output" />
|
||||
<Button Classes="tab-btn"
|
||||
Classes.active="{Binding IsGitTab}"
|
||||
Content="Git"
|
||||
Command="{Binding SelectTabCommand}"
|
||||
CommandParameter="git" />
|
||||
<Button Classes="tab-btn"
|
||||
Classes.active="{Binding IsSessionTab}"
|
||||
Content="Session"
|
||||
Command="{Binding SelectTabCommand}"
|
||||
CommandParameter="session" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- ── Tab body (bottom inset keeps content clear of the rounded corner) ── -->
|
||||
<Grid Margin="0,0,0,8">
|
||||
|
||||
<!-- Output: log + review footer, both gated on IsOutputTab -->
|
||||
<DockPanel IsVisible="{Binding IsOutputTab}" LastChildFill="True">
|
||||
|
||||
<!-- Review prompt — sits directly on the terminal, like a shell input line;
|
||||
only while awaiting review. No border/fill so it reads as part of the log. -->
|
||||
<Grid DockPanel.Dock="Bottom"
|
||||
IsVisible="{Binding IsWaitingForReview}"
|
||||
ColumnDefinitions="Auto,*,Auto"
|
||||
Margin="12,2,12,8">
|
||||
<TextBlock Grid.Column="0" Text="❯"
|
||||
FontFamily="{StaticResource MonoFont}"
|
||||
FontSize="{StaticResource FontSizeMono}"
|
||||
Foreground="{DynamicResource AccentBrush}"
|
||||
VerticalAlignment="Top" Margin="0,2,8,0" />
|
||||
<TextBox Grid.Column="1"
|
||||
Name="ReviewInput"
|
||||
KeyDown="OnReviewInputKeyDown"
|
||||
Text="{Binding ReviewFeedback, Mode=TwoWay}"
|
||||
AcceptsReturn="True"
|
||||
TextWrapping="Wrap"
|
||||
MaxHeight="160"
|
||||
PlaceholderText="Feedback for the next run…"
|
||||
Background="Transparent"
|
||||
BorderThickness="0"
|
||||
Padding="0"
|
||||
VerticalContentAlignment="Center"
|
||||
FontFamily="{StaticResource MonoFont}"
|
||||
FontSize="{StaticResource FontSizeMono}" />
|
||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="10"
|
||||
VerticalAlignment="Top" Margin="12,2,0,0">
|
||||
<Button Classes="prompt-action accent" Content="[Retry]"
|
||||
Command="{Binding RejectReviewCommand}" />
|
||||
<Button Classes="prompt-action" Content="[Reset]"
|
||||
Command="{Binding ParkReviewCommand}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<ScrollViewer Name="LogScroll"
|
||||
VerticalScrollBarVisibility="Visible"
|
||||
AllowAutoHide="False"
|
||||
Padding="12,8,12,4">
|
||||
<ItemsControl ItemsSource="{Binding Log}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="vm:LogLineViewModel">
|
||||
<Grid ColumnDefinitions="60,*" Margin="0,1">
|
||||
<TextBlock Grid.Column="0"
|
||||
Classes="log-ts"
|
||||
Text="{Binding TimestampFormatted}" />
|
||||
<SelectableTextBlock Grid.Column="1"
|
||||
Text="{Binding Text}" Tag="{Binding ClassName}"
|
||||
Foreground="{DynamicResource TextDimBrush}"
|
||||
TextWrapping="Wrap" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</ScrollViewer>
|
||||
|
||||
</DockPanel>
|
||||
|
||||
<!-- Git: merge target, approve, diff, worktree -->
|
||||
<ScrollViewer IsVisible="{Binding IsGitTab}" Padding="14,10">
|
||||
<StackPanel Spacing="14">
|
||||
|
||||
<!-- Approve (review-gated) -->
|
||||
<StackPanel Spacing="8" IsVisible="{Binding IsWaitingForReview}">
|
||||
<TextBlock Classes="section-label" Text="REVIEW" />
|
||||
<Button Classes="btn accent" Content="Approve"
|
||||
Command="{Binding ApproveReviewCommand}" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Merge & worktree management (moved from Session tab) -->
|
||||
<StackPanel Spacing="10" IsVisible="{Binding ShowMergeSection}">
|
||||
<TextBlock Classes="section-label" Text="MERGE & WORKTREE" />
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Classes="field-label" Text="Merge target" />
|
||||
<ComboBox ItemsSource="{Binding MergeTargetBranches}"
|
||||
SelectedItem="{Binding SelectedMergeTarget, Mode=TwoWay}"
|
||||
HorizontalAlignment="Stretch" />
|
||||
</StackPanel>
|
||||
<StackPanel Spacing="0">
|
||||
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource MossBrush}"
|
||||
IsVisible="{Binding MergeIsClean}" />
|
||||
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource BloodBrush}"
|
||||
IsVisible="{Binding MergeIsConflict}" />
|
||||
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource TextMuteBrush}"
|
||||
IsVisible="{Binding ShowMergePreviewMuted}" />
|
||||
</StackPanel>
|
||||
<WrapPanel Orientation="Horizontal">
|
||||
<Button Classes="btn" Content="Open Diff" Margin="0,0,8,8"
|
||||
Command="{Binding OpenDiffCommand}" />
|
||||
<Button Classes="btn accent" Content="Merge" Margin="0,0,8,8"
|
||||
Command="{Binding MergeCommand}"
|
||||
IsVisible="{Binding ShowSingleMerge}" />
|
||||
<Button Classes="btn" Margin="0,0,8,8"
|
||||
Command="{Binding OpenWorktreeCommand}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="5">
|
||||
<TextBlock Text="Worktree" />
|
||||
<PathIcon Data="{StaticResource Icon.ArrowOut}" Width="11" Height="11" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Classes="btn" Content="Review Combined Diff" Margin="0,0,8,8"
|
||||
Command="{Binding ReviewCombinedDiffCommand}" />
|
||||
<Button Classes="btn accent" Content="Merge All Subtasks" Margin="0,0,0,8"
|
||||
Command="{Binding MergeAllCommand}"
|
||||
IsEnabled="{Binding CanMergeAll}"
|
||||
ToolTip.Tip="{Binding MergeAllDisabledReason}" />
|
||||
</WrapPanel>
|
||||
<TextBlock Text="{Binding MergeAllError}"
|
||||
Foreground="{DynamicResource BloodBrush}"
|
||||
TextWrapping="Wrap"
|
||||
IsVisible="{Binding MergeAllError,
|
||||
Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||
</StackPanel>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
<!-- Session: subtask outcomes (review lives in Output, merge in Git) -->
|
||||
<ScrollViewer IsVisible="{Binding IsSessionTab}" Padding="14,10">
|
||||
<StackPanel Spacing="14">
|
||||
|
||||
<!-- Child outcomes -->
|
||||
<StackPanel Spacing="6" IsVisible="{Binding HasChildOutcomes}">
|
||||
<TextBlock Classes="section-label" Text="OUTCOMES" />
|
||||
<ItemsControl ItemsSource="{Binding ChildOutcomes}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ChildOutcomeRowViewModel">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto" Margin="0,2">
|
||||
<TextBlock Grid.Column="0" Text="{Binding Title}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="1" Text="{Binding RoadblockText}"
|
||||
IsVisible="{Binding HasRoadblock}"
|
||||
Foreground="#E0A030"
|
||||
Margin="8,0" VerticalAlignment="Center" />
|
||||
<TextBlock Grid.Column="2" Text="{Binding StatusLabel}"
|
||||
Opacity="0.75" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Empty state: nothing to manage yet -->
|
||||
<TextBlock IsVisible="{Binding ShowSessionEmpty}"
|
||||
Classes="meta"
|
||||
Foreground="{DynamicResource TextMuteBrush}"
|
||||
TextWrapping="Wrap"
|
||||
Text="Nothing to manage yet — subtask outcomes appear here once the run finishes. Review in the Output tab, merge in the Git tab." />
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</UserControl>
|
||||
55
src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml.cs
Normal file
55
src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
using System.Collections.Specialized;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
|
||||
namespace ClaudeDo.Ui.Views.Islands.Detail;
|
||||
|
||||
public partial class WorkConsole : UserControl
|
||||
{
|
||||
private INotifyCollectionChanged? _log;
|
||||
|
||||
public WorkConsole()
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContextChanged += OnDataContextChanged;
|
||||
}
|
||||
|
||||
private void OnDataContextChanged(object? sender, EventArgs e)
|
||||
{
|
||||
if (_log is not null)
|
||||
_log.CollectionChanged -= OnLogChanged;
|
||||
|
||||
_log = (DataContext as DetailsIslandViewModel)?.Log;
|
||||
|
||||
if (_log is not null)
|
||||
_log.CollectionChanged += OnLogChanged;
|
||||
}
|
||||
|
||||
private void OnLogChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
if (e.Action != NotifyCollectionChangedAction.Add) return;
|
||||
EventHandler? handler = null;
|
||||
handler = (_, _) =>
|
||||
{
|
||||
LogScroll.LayoutUpdated -= handler;
|
||||
LogScroll.ScrollToEnd();
|
||||
};
|
||||
LogScroll.LayoutUpdated += handler;
|
||||
}
|
||||
|
||||
private void OnReviewInputKeyDown(object? sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key != Key.Enter || e.KeyModifiers.HasFlag(KeyModifiers.Shift))
|
||||
return;
|
||||
|
||||
if (DataContext is DetailsIslandViewModel vm &&
|
||||
vm.RejectReviewCommand.CanExecute(null))
|
||||
{
|
||||
vm.RejectReviewCommand.Execute(null);
|
||||
}
|
||||
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
@@ -2,32 +2,25 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
|
||||
xmlns:islands="using:ClaudeDo.Ui.Views.Islands"
|
||||
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
|
||||
xmlns:detail="using:ClaudeDo.Ui.Views.Islands.Detail"
|
||||
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
||||
x:Class="ClaudeDo.Ui.Views.Islands.DetailsIslandView"
|
||||
x:DataType="vm:DetailsIslandViewModel">
|
||||
<DockPanel>
|
||||
|
||||
<!-- ── Metadata footer (sticky bottom) — task detail only ── -->
|
||||
<!-- ── Metadata footer (sticky bottom) — created-at + close — task detail only ── -->
|
||||
<Border DockPanel.Dock="Bottom"
|
||||
IsVisible="{Binding IsTaskDetailVisible}"
|
||||
BorderBrush="{DynamicResource LineBrush}"
|
||||
BorderThickness="0,1,0,0"
|
||||
Padding="14,8">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||
<Button Grid.Column="0" Classes="icon-btn"
|
||||
Command="{Binding DeleteTaskCommand}"
|
||||
ToolTip.Tip="{loc:Tr details.deleteTaskTip}"
|
||||
VerticalAlignment="Center">
|
||||
<PathIcon Data="{StaticResource Icon.Trash}" Width="14" Height="14"
|
||||
Foreground="{DynamicResource BloodBrush}"/>
|
||||
</Button>
|
||||
<TextBlock Grid.Column="1"
|
||||
<Grid ColumnDefinitions="*,Auto">
|
||||
<TextBlock Grid.Column="0"
|
||||
Classes="meta"
|
||||
Text="{Binding Task.CreatedAtFormatted}"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"/>
|
||||
<Button Grid.Column="2" Classes="icon-btn"
|
||||
<Button Grid.Column="1" Classes="icon-btn"
|
||||
Command="{Binding CloseDetailsCommand}"
|
||||
ToolTip.Tip="{loc:Tr details.closeTip}"
|
||||
VerticalAlignment="Center">
|
||||
@@ -36,377 +29,60 @@
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<!-- ── Header (sticky top): check · eyebrow · title · status · star · gear — task detail only ── -->
|
||||
<!-- ── Header (sticky top): id · title · trash/skull · gear — task detail only ── -->
|
||||
<Border DockPanel.Dock="Top" Classes="island-header"
|
||||
IsVisible="{Binding IsTaskDetailVisible}">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
|
||||
<Button Grid.Column="0" Classes="flat"
|
||||
Command="{Binding ToggleDoneCommand}"
|
||||
Padding="0"
|
||||
VerticalAlignment="Top"
|
||||
Margin="0,2,10,0">
|
||||
<Ellipse Classes="task-check"
|
||||
Classes.done="{Binding Task.Done}"
|
||||
Width="18" Height="18"
|
||||
Cursor="Hand"/>
|
||||
</Button>
|
||||
<StackPanel Grid.Column="1" Spacing="0">
|
||||
<TextBlock Classes="meta"
|
||||
Text="{Binding TaskIdBadge}"
|
||||
Margin="0,0,0,4"
|
||||
Cursor="Hand"
|
||||
ToolTip.Tip="{loc:Tr details.copyTaskIdTip}"
|
||||
Tapped="OnTaskIdTapped"/>
|
||||
<TextBox Text="{Binding EditableTitle, Mode=TwoWay}"
|
||||
FontSize="{StaticResource FontSizeTaskTitle}" FontWeight="Medium"
|
||||
BorderThickness="0" Background="Transparent"
|
||||
Foreground="{DynamicResource TextBrush}"
|
||||
TextWrapping="Wrap"
|
||||
AcceptsReturn="False"
|
||||
Padding="0"/>
|
||||
</StackPanel>
|
||||
|
||||
<Button Grid.Column="2"
|
||||
Classes="icon-btn star-btn"
|
||||
Classes.on="{Binding Task.IsStarred}"
|
||||
Command="{Binding ToggleStarCommand}"
|
||||
ToolTip.Tip="{loc:Tr details.starTip}"
|
||||
VerticalAlignment="Top"
|
||||
Margin="6,0,0,0">
|
||||
<PathIcon Data="{StaticResource Icon.Star}" Width="14" Height="14"/>
|
||||
</Button>
|
||||
|
||||
<Button Grid.Column="3" Classes="icon-btn"
|
||||
ToolTip.Tip="{loc:Tr details.agentSettingsTip}"
|
||||
IsEnabled="{Binding IsAgentSectionEnabled}"
|
||||
VerticalAlignment="Top"
|
||||
Margin="6,0,0,0">
|
||||
<TextBlock Text="⚙" FontSize="{StaticResource FontSizeTaskTitle}"/>
|
||||
<Button.Flyout>
|
||||
<Flyout Placement="BottomEdgeAlignedRight" ShowMode="Standard">
|
||||
<StackPanel Width="340" Spacing="10" Margin="4">
|
||||
<TextBlock Text="{loc:Tr details.agentSettingsHeading}" FontWeight="SemiBold"/>
|
||||
|
||||
<StackPanel Spacing="2">
|
||||
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
|
||||
<TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr details.modelLabel}" VerticalAlignment="Center"/>
|
||||
<ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding ModelBadge}"/>
|
||||
<Button Grid.Column="3" Classes="icon-btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
|
||||
Command="{Binding ResetTaskModelCommand}"/>
|
||||
</Grid>
|
||||
<ComboBox ItemsSource="{Binding TaskModelOptions}"
|
||||
SelectedItem="{Binding TaskModelSelection, Mode=TwoWay}"
|
||||
PlaceholderText="{Binding ModelInheritedHint}"
|
||||
HorizontalAlignment="Stretch"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="2">
|
||||
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
|
||||
<TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr details.maxTurnsLabel}" VerticalAlignment="Center"/>
|
||||
<ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding TurnsBadge}"/>
|
||||
<Button Grid.Column="3" Classes="icon-btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
|
||||
Command="{Binding ResetTaskTurnsCommand}"/>
|
||||
</Grid>
|
||||
<NumericUpDown Value="{Binding TaskMaxTurns, Mode=TwoWay}"
|
||||
PlaceholderText="{Binding TurnsInheritedHint}"
|
||||
Minimum="1" Maximum="200" Increment="1" FormatString="0"
|
||||
HorizontalAlignment="Stretch"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="2">
|
||||
<TextBlock Classes="field-label" Text="{loc:Tr details.systemPromptLabel}"/>
|
||||
<TextBox Text="{Binding TaskSystemPrompt, Mode=TwoWay}"
|
||||
AcceptsReturn="True" TextWrapping="Wrap" MinHeight="70"/>
|
||||
<TextBlock Classes="meta" Opacity="0.6"
|
||||
Text="{loc:Tr details.systemPromptPrepended}"
|
||||
IsVisible="{Binding EffectiveSystemPromptHint, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
<TextBlock Classes="meta" Opacity="0.6" TextWrapping="Wrap"
|
||||
Text="{Binding EffectiveSystemPromptHint}"
|
||||
IsVisible="{Binding EffectiveSystemPromptHint, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Spacing="2">
|
||||
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
|
||||
<TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr details.agentFileLabel}" VerticalAlignment="Center"/>
|
||||
<ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding AgentBadge}"/>
|
||||
<Button Grid.Column="3" Classes="icon-btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
|
||||
Command="{Binding ResetTaskAgentCommand}"/>
|
||||
</Grid>
|
||||
<ComboBox ItemsSource="{Binding TaskAgentOptions}"
|
||||
SelectedItem="{Binding TaskSelectedAgent, Mode=TwoWay}"
|
||||
HorizontalAlignment="Stretch">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding Name}"/>
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Flyout>
|
||||
</Button.Flyout>
|
||||
</Button>
|
||||
</Grid>
|
||||
<detail:TaskHeaderBar/>
|
||||
</Border>
|
||||
|
||||
<!-- ── Agent status strip (sticky, above metadata footer) — task detail only ── -->
|
||||
<islands:AgentStripView DockPanel.Dock="Bottom"
|
||||
IsVisible="{Binding IsTaskDetailVisible}"/>
|
||||
|
||||
<!-- ── Body: task details (normal), notes editor (notes mode), or prep log (prep mode) ── -->
|
||||
<Grid>
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto"
|
||||
IsVisible="{Binding IsTaskDetailVisible}">
|
||||
<StackPanel Spacing="0">
|
||||
|
||||
<!-- Planning merge section — visible only for planning parent tasks -->
|
||||
<Border Classes="section-divider"
|
||||
IsVisible="{Binding Task.IsPlanningParent}">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="section-label" Text="{loc:Tr details.mergeLabel}" Margin="0,0,0,2"/>
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Classes="field-label" Text="{loc:Tr details.mergeTargetLabel}"/>
|
||||
<ComboBox ItemsSource="{Binding MergeTargetBranches}"
|
||||
SelectedItem="{Binding SelectedMergeTarget, Mode=TwoWay}"
|
||||
HorizontalAlignment="Stretch"/>
|
||||
</StackPanel>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="btn" Content="{loc:Tr details.reviewCombinedDiff}"
|
||||
Command="{Binding ReviewCombinedDiffCommand}"/>
|
||||
<Button Classes="btn" Content="{loc:Tr details.mergeAllSubtasks}"
|
||||
IsEnabled="{Binding CanMergeAll}"
|
||||
Command="{Binding MergeAllCommand}"
|
||||
ToolTip.Tip="{Binding MergeAllDisabledReason}"/>
|
||||
</StackPanel>
|
||||
<TextBlock Text="{Binding MergeAllError}"
|
||||
Foreground="{DynamicResource BloodBrush}"
|
||||
TextWrapping="Wrap"
|
||||
IsVisible="{Binding MergeAllError, Converter={x:Static ObjectConverters.IsNotNull}}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<!-- Task detail: description/steps card (upper) + pinned work console (lower) -->
|
||||
<Grid IsVisible="{Binding IsTaskDetailVisible}"
|
||||
Margin="14,12,14,12"
|
||||
RowDefinitions="2*,*">
|
||||
<ScrollViewer Grid.Row="0" VerticalScrollBarVisibility="Auto">
|
||||
<detail:DescriptionStepsCard VerticalAlignment="Top"/>
|
||||
</ScrollViewer>
|
||||
<detail:WorkConsole Grid.Row="1" Margin="0,10,0,0"/>
|
||||
<!-- Resize by dragging the console's top edge — a transparent splitter
|
||||
over the gap above the console; no standalone separator bar. -->
|
||||
<GridSplitter Grid.Row="1"
|
||||
VerticalAlignment="Top"
|
||||
Height="10"
|
||||
HorizontalAlignment="Stretch"
|
||||
ResizeDirection="Rows"
|
||||
Background="Transparent"/>
|
||||
</Grid>
|
||||
|
||||
<!-- Review section — visible when task is WaitingForReview -->
|
||||
<Border Classes="section-divider"
|
||||
IsVisible="{Binding IsWaitingForReview}">
|
||||
<StackPanel Spacing="8">
|
||||
<TextBlock Classes="section-label" Text="{loc:Tr tasks.reviewTitle}" Margin="0,0,0,2"/>
|
||||
<TextBlock Classes="field-label" Text="{loc:Tr tasks.feedbackLabel}"/>
|
||||
<TextBox Text="{Binding ReviewFeedback, Mode=TwoWay}"
|
||||
AcceptsReturn="True"
|
||||
TextWrapping="Wrap"
|
||||
MinHeight="60"
|
||||
MaxHeight="180"
|
||||
PlaceholderText="{loc:Tr tasks.feedbackPlaceholder}"
|
||||
Padding="8"
|
||||
Background="{DynamicResource Surface2Brush}"
|
||||
BorderBrush="{DynamicResource LineBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="8"/>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button Classes="btn accent"
|
||||
Content="{loc:Tr tasks.approve}"
|
||||
ToolTip.Tip="{loc:Tr tasks.approveTip}"
|
||||
Command="{Binding ApproveReviewCommand}"/>
|
||||
<Button Classes="btn"
|
||||
Content="{loc:Tr tasks.reject}"
|
||||
ToolTip.Tip="{loc:Tr tasks.rejectTip}"
|
||||
Command="{Binding RejectReviewCommand}"/>
|
||||
<Button Classes="btn"
|
||||
Content="{loc:Tr tasks.park}"
|
||||
ToolTip.Tip="{loc:Tr tasks.parkTip}"
|
||||
Command="{Binding ParkReviewCommand}"/>
|
||||
<Button Classes="btn"
|
||||
Content="{loc:Tr tasks.cancel}"
|
||||
ToolTip.Tip="{loc:Tr tasks.cancelTip}"
|
||||
Command="{Binding CancelReviewCommand}"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<!-- Notes mode -->
|
||||
<Panel IsVisible="{Binding IsNotesMode}">
|
||||
<islands:NotesEditorView DataContext="{Binding Notes}"/>
|
||||
</Panel>
|
||||
|
||||
<!-- Improvement-children outcomes — visible when this task has agent-suggested children -->
|
||||
<Border Classes="section-divider"
|
||||
IsVisible="{Binding HasChildOutcomes}">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="section-label" Text="{loc:Tr details.childOutcomesLabel}" Margin="0,0,0,2"/>
|
||||
<ItemsControl ItemsSource="{Binding ChildOutcomes}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ChildOutcomeRowViewModel">
|
||||
<Grid ColumnDefinitions="*,Auto,Auto" Margin="0,2">
|
||||
<TextBlock Grid.Column="0" Text="{Binding Title}" TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="1" Text="{Binding RoadblockText}"
|
||||
IsVisible="{Binding HasRoadblock}"
|
||||
Foreground="#E0A030" Margin="8,0" VerticalAlignment="Center"/>
|
||||
<TextBlock Grid.Column="2" Text="{Binding StatusLabel}"
|
||||
Opacity="0.75" VerticalAlignment="Center"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
<!-- Daily-prep mode -->
|
||||
<Panel IsVisible="{Binding IsPrepMode}">
|
||||
<DockPanel>
|
||||
<Border DockPanel.Dock="Top" Padding="12,8">
|
||||
<Button Classes="btn primary"
|
||||
Command="{Binding PlanDayCommand}"
|
||||
IsEnabled="{Binding !IsPrepRunning}"
|
||||
Content="{loc:Tr details.planDay}"/>
|
||||
</Border>
|
||||
<Panel>
|
||||
<islands:SessionTerminalView
|
||||
Margin="18,8,18,0"
|
||||
Entries="{Binding PrepLog}" Label="daily-prep"
|
||||
IsRunning="{Binding IsPrepRunning}"/>
|
||||
<TextBlock IsVisible="{Binding ShowPrepEmptyState}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource TextMuteBrush}"
|
||||
Text="{loc:Tr details.prepEmpty}"/>
|
||||
</Panel>
|
||||
</DockPanel>
|
||||
</Panel>
|
||||
|
||||
<!-- Steps section -->
|
||||
<Border Classes="section-divider">
|
||||
<StackPanel Spacing="6">
|
||||
<TextBlock Classes="section-label" Text="{loc:Tr details.stepsLabel}" Margin="0,0,0,2"/>
|
||||
<TextBox Text="{Binding NewSubtaskTitle, Mode=TwoWay}"
|
||||
PlaceholderText="{loc:Tr details.addStepPlaceholder}"
|
||||
Padding="8"
|
||||
Background="{DynamicResource Surface2Brush}"
|
||||
BorderBrush="{DynamicResource LineBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="8">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter" Command="{Binding AddSubtaskCommand}"/>
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
<ItemsControl ItemsSource="{Binding Subtasks}"
|
||||
IsVisible="{Binding Subtasks.Count}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate DataType="vm:SubtaskRowViewModel">
|
||||
<Border Classes="subtask-row"
|
||||
Classes.done="{Binding Done}">
|
||||
<Grid ColumnDefinitions="Auto,*">
|
||||
<Button Grid.Column="0" Classes="flat"
|
||||
Padding="0"
|
||||
Margin="0,0,8,0"
|
||||
VerticalAlignment="Center"
|
||||
Command="{Binding $parent[ItemsControl].((vm:DetailsIslandViewModel)DataContext).ToggleSubtaskDoneCommand}"
|
||||
CommandParameter="{Binding}">
|
||||
<Ellipse Classes="task-check"
|
||||
Classes.done="{Binding Done}"
|
||||
Width="16" Height="16"
|
||||
Cursor="Hand"/>
|
||||
</Button>
|
||||
<Panel Grid.Column="1" VerticalAlignment="Center">
|
||||
<TextBlock Classes="subtask-title"
|
||||
Text="{Binding Title}"
|
||||
IsVisible="{Binding !IsEditing}"
|
||||
FontSize="{StaticResource FontSizeBody}"
|
||||
Foreground="{DynamicResource TextDimBrush}"
|
||||
VerticalAlignment="Center"
|
||||
TextWrapping="Wrap"
|
||||
Cursor="Ibeam"
|
||||
Tapped="OnSubtaskTitleTapped"/>
|
||||
<TextBox Classes="subtask-edit"
|
||||
Text="{Binding Title, Mode=TwoWay}"
|
||||
IsVisible="{Binding IsEditing}"
|
||||
FontSize="{StaticResource FontSizeBody}"
|
||||
AcceptsReturn="False"
|
||||
TextWrapping="Wrap"
|
||||
LostFocus="OnSubtaskEditLostFocus">
|
||||
<TextBox.KeyBindings>
|
||||
<KeyBinding Gesture="Enter"
|
||||
Command="{Binding $parent[ItemsControl].((vm:DetailsIslandViewModel)DataContext).CommitSubtaskEditCommand}"
|
||||
CommandParameter="{Binding}"/>
|
||||
</TextBox.KeyBindings>
|
||||
</TextBox>
|
||||
</Panel>
|
||||
</Grid>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Details (description) section -->
|
||||
<Border Classes="section-divider">
|
||||
<StackPanel Spacing="6">
|
||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
|
||||
<Button Grid.Column="0"
|
||||
Classes="flat"
|
||||
Command="{Binding ToggleDescriptionExpandedCommand}"
|
||||
Padding="0"
|
||||
Margin="0,0,6,2"
|
||||
VerticalAlignment="Center">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<TextBlock Classes="meta"
|
||||
Text="▾"
|
||||
IsVisible="{Binding IsDescriptionExpanded}"/>
|
||||
<TextBlock Classes="meta"
|
||||
Text="▸"
|
||||
IsVisible="{Binding !IsDescriptionExpanded}"/>
|
||||
<TextBlock Classes="section-label" Text="{loc:Tr details.detailsLabel}"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<Button Grid.Column="2"
|
||||
Classes="icon-btn"
|
||||
Padding="6,2"
|
||||
Margin="0,0,4,0"
|
||||
ToolTip.Tip="{loc:Tr details.copyDescriptionTip}"
|
||||
IsVisible="{Binding IsDescriptionExpanded}"
|
||||
Click="OnCopyDescriptionClick">
|
||||
<PathIcon Data="{StaticResource Icon.Copy}" Width="11" Height="11"/>
|
||||
</Button>
|
||||
<Button Grid.Column="3"
|
||||
Classes="btn"
|
||||
Command="{Binding ToggleEditDescriptionCommand}"
|
||||
Padding="8,3"
|
||||
ToolTip.Tip="{loc:Tr details.toggleEditPreviewTip}"
|
||||
IsVisible="{Binding IsDescriptionEditorVisible}">
|
||||
<TextBlock Text="{loc:Tr details.previewBtn}"/>
|
||||
</Button>
|
||||
<Button Grid.Column="3"
|
||||
Classes="btn"
|
||||
Command="{Binding ToggleEditDescriptionCommand}"
|
||||
Padding="8,3"
|
||||
ToolTip.Tip="{loc:Tr details.toggleEditPreviewTip}"
|
||||
IsVisible="{Binding IsDescriptionPreviewVisible}">
|
||||
<TextBlock Text="{loc:Tr details.editBtn}"/>
|
||||
</Button>
|
||||
</Grid>
|
||||
|
||||
<TextBox Text="{Binding EditableDescription, Mode=TwoWay}"
|
||||
AcceptsReturn="True"
|
||||
TextWrapping="Wrap"
|
||||
MinHeight="80"
|
||||
MaxHeight="320"
|
||||
PlaceholderText="{loc:Tr details.descriptionPlaceholder}"
|
||||
Padding="8"
|
||||
FontFamily="{DynamicResource MonoFont}"
|
||||
FontSize="{StaticResource FontSizeBody}"
|
||||
Background="{DynamicResource Surface2Brush}"
|
||||
BorderBrush="{DynamicResource LineBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="8"
|
||||
IsVisible="{Binding IsDescriptionEditorVisible}"/>
|
||||
|
||||
<ctl:MarkdownView Markdown="{Binding EditableDescription}"
|
||||
IsVisible="{Binding IsDescriptionPreviewVisible}"/>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- Session terminal — auto-sizes to output, scrolls internally after MaxHeight -->
|
||||
<islands:SessionTerminalView MaxHeight="420"
|
||||
Entries="{Binding Log}"
|
||||
Label="{Binding BranchLine, StringFormat='claude-session · {0}'}"
|
||||
IsRunning="{Binding IsRunning}" IsDone="{Binding IsDone}" IsFailed="{Binding IsFailed}"/>
|
||||
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
<Panel IsVisible="{Binding IsNotesMode}">
|
||||
<islands:NotesEditorView DataContext="{Binding Notes}"/>
|
||||
</Panel>
|
||||
<Panel IsVisible="{Binding IsPrepMode}">
|
||||
<DockPanel>
|
||||
<Border DockPanel.Dock="Top" Padding="12,8">
|
||||
<Button Classes="btn primary"
|
||||
Command="{Binding PlanDayCommand}"
|
||||
IsEnabled="{Binding !IsPrepRunning}"
|
||||
Content="{loc:Tr details.planDay}"/>
|
||||
</Border>
|
||||
<Panel>
|
||||
<islands:SessionTerminalView
|
||||
Entries="{Binding PrepLog}" Label="daily-prep"
|
||||
IsRunning="{Binding IsPrepRunning}"/>
|
||||
<TextBlock IsVisible="{Binding ShowPrepEmptyState}"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource TextMuteBrush}"
|
||||
Text="{loc:Tr details.prepEmpty}"/>
|
||||
</Panel>
|
||||
</DockPanel>
|
||||
</Panel>
|
||||
</Grid>
|
||||
|
||||
</DockPanel>
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
using System.Linq;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Input.Platform;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Threading;
|
||||
using Avalonia.VisualTree;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
using ClaudeDo.Ui.Views.Modals;
|
||||
using ClaudeDo.Ui.Views.Planning;
|
||||
@@ -138,37 +132,4 @@ public partial class DetailsIslandView : UserControl
|
||||
_ = dialog.ShowDialog(owner);
|
||||
return await tcs.Task;
|
||||
}
|
||||
|
||||
private void OnSubtaskTitleTapped(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (sender is not Control c || c.DataContext is not SubtaskRowViewModel row) return;
|
||||
row.IsEditing = true;
|
||||
|
||||
var box = (c.GetVisualParent() as Panel)?.GetVisualDescendants().OfType<TextBox>().FirstOrDefault();
|
||||
if (box is not null)
|
||||
Dispatcher.UIThread.Post(() => { box.Focus(); box.SelectAll(); }, DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
private void OnSubtaskEditLostFocus(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is DetailsIslandViewModel vm
|
||||
&& sender is Control c && c.DataContext is SubtaskRowViewModel row)
|
||||
vm.CommitSubtaskEditCommand.Execute(row);
|
||||
}
|
||||
|
||||
private async void OnTaskIdTapped(object? sender, TappedEventArgs e)
|
||||
{
|
||||
if (DataContext is not DetailsIslandViewModel vm || vm.Task is null) return;
|
||||
var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
|
||||
if (clipboard is null) return;
|
||||
await clipboard.SetTextAsync(vm.Task.Id);
|
||||
}
|
||||
|
||||
private async void OnCopyDescriptionClick(object? sender, RoutedEventArgs e)
|
||||
{
|
||||
if (DataContext is not DetailsIslandViewModel vm) return;
|
||||
var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
|
||||
if (clipboard is null) return;
|
||||
await clipboard.SetTextAsync(vm.EditableDescription ?? string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
||||
x:Class="ClaudeDo.Ui.Views.Islands.SessionTerminalView"
|
||||
x:Name="Root">
|
||||
<Border Classes="terminal" Margin="18,8,18,0">
|
||||
<Border Classes="terminal" Margin="0">
|
||||
<DockPanel LastChildFill="True">
|
||||
|
||||
<!-- ── Terminal header bar ── -->
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
Click="OnClearScheduleClick"/>
|
||||
</ContextMenu>
|
||||
</Border.ContextMenu>
|
||||
<Grid ColumnDefinitions="0,18,32,*,Auto,32" Margin="6,8,10,8">
|
||||
<Grid ColumnDefinitions="0,18,32,*,Auto,Auto,32" Margin="6,8,10,8">
|
||||
|
||||
<!-- Chevron toggle (only for planning parent tasks) -->
|
||||
<Button Grid.Column="1"
|
||||
@@ -194,8 +194,20 @@
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
|
||||
<!-- Refine button -->
|
||||
<Button Grid.Column="5" Classes="icon-btn refine-btn"
|
||||
IsVisible="{Binding CanRefine}"
|
||||
VerticalAlignment="Top" Margin="0,2,0,0"
|
||||
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).RefineTaskCommand}"
|
||||
CommandParameter="{Binding}"
|
||||
ToolTip.Tip="{loc:Tr tasks.refineTip}">
|
||||
<Viewbox Width="16" Height="16">
|
||||
<Path Classes="plan-icon" Data="{StaticResource Icon.Refine}"/>
|
||||
</Viewbox>
|
||||
</Button>
|
||||
|
||||
<!-- Star toggle -->
|
||||
<Button Grid.Column="5" Classes="icon-btn star-btn"
|
||||
<Button Grid.Column="6" Classes="icon-btn star-btn"
|
||||
Classes.on="{Binding IsStarred}"
|
||||
VerticalAlignment="Top" Margin="0,2,0,0"
|
||||
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).ToggleStarCommand}"
|
||||
|
||||
@@ -69,7 +69,7 @@ Allowed transitions (enforced by `TaskStateService`):
|
||||
Idle → Queued | Running (RunNow)
|
||||
Queued → Running | Cancelled | Idle
|
||||
Running → WaitingForReview (standalone success) | Done (planning child success) | Failed | Cancelled
|
||||
WaitingForReview → Done (approve) | Queued (reject-rerun, +feedback) | Idle (reject-park) | Cancelled
|
||||
WaitingForReview → Done (approve: merges worktree first; conflicts keep it in WaitingForReview) | Queued (reject-rerun, +feedback) | Idle (reject-park) | Cancelled
|
||||
Done → Idle (re-run)
|
||||
Failed → Idle | Queued
|
||||
Cancelled → Idle | Queued
|
||||
@@ -121,7 +121,7 @@ Each CLI invocation is recorded in the `task_runs` table via `TaskRunRepository`
|
||||
|
||||
## SignalR Hub
|
||||
|
||||
**WorkerHub** methods: `Ping`, `GetActive`, `RunNow`, `CancelTask`, `WakeQueue`, `ContinueTask`, `ResetTask`, `ApproveReview`, `RejectReviewToQueue`, `RejectReviewToIdle`, `CancelReview`, `GetAgents`, `RefreshAgents`, `GetAppSettings`, `UpdateAppSettings`, `CleanupFinishedWorktrees`, `ResetAllWorktrees`, `MergeTask`, `GetMergeTargets`, `UpdateList`, `UpdateListConfig`, `GetListConfig`, `UpdateTaskAgentSettings`, `GetWeekReport`, `GenerateWeekReport`, `GetDailyNotes`, `AddDailyNote`, `UpdateDailyNote`, `DeleteDailyNote`, `RunDailyPrepNow`, `ClearMyDay`, `GetLastPrepLog`
|
||||
**WorkerHub** methods: `Ping`, `GetActive`, `RunNow`, `CancelTask`, `WakeQueue`, `ContinueTask`, `ResetTask`, `ApproveReview(taskId, targetBranch) -> MergeResultDto` (merges worktree then transitions to Done; on conflict stays WaitingForReview), `PreviewMerge(taskId, targetBranch) -> MergePreviewDto` (non-destructive mergeability check), `RejectReviewToQueue`, `RejectReviewToIdle`, `CancelReview`, `GetAgents`, `RefreshAgents`, `GetAppSettings`, `UpdateAppSettings`, `CleanupFinishedWorktrees`, `ResetAllWorktrees`, `MergeTask`, `GetMergeTargets`, `UpdateList`, `UpdateListConfig`, `GetListConfig`, `UpdateTaskAgentSettings`, `GetWeekReport`, `GenerateWeekReport`, `GetDailyNotes`, `AddDailyNote`, `UpdateDailyNote`, `DeleteDailyNote`, `RunDailyPrepNow`, `ClearMyDay`, `GetLastPrepLog`
|
||||
|
||||
**HubBroadcaster** events: `TaskStarted`, `TaskFinished`, `TaskMessage`, `WorktreeUpdated`, `TaskUpdated`, `RunCreated`, `ListUpdated`, `PrimeFired`, `PrepStarted`, `PrepLine`, `PrepFinished`
|
||||
|
||||
|
||||
@@ -207,6 +207,44 @@ public sealed class ExternalMcpService
|
||||
return ToDto(reload);
|
||||
}
|
||||
|
||||
[McpServerTool, Description(
|
||||
"Append a subtask (step) to a task. orderNum defaults to the end. " +
|
||||
"Refuses if the task is currently Running. Subtasks are surfaced to the agent at run time and shown in the task's Steps list.")]
|
||||
public async Task<TaskDto> AddSubtask(
|
||||
string taskId,
|
||||
string title,
|
||||
int? orderNum,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
throw new InvalidOperationException("title is required.");
|
||||
|
||||
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
|
||||
var tasks = new TaskRepository(ctx);
|
||||
var subtasks = new SubtaskRepository(ctx);
|
||||
|
||||
var task = await tasks.GetByIdAsync(taskId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
if (task.Status == TaskStatus.Running)
|
||||
throw new InvalidOperationException("Cannot add a subtask to a running task. Cancel it first.");
|
||||
|
||||
var existing = await subtasks.GetByTaskIdAsync(taskId, cancellationToken);
|
||||
var order = orderNum ?? (existing.Count == 0 ? 0 : existing.Max(s => s.OrderNum) + 1);
|
||||
|
||||
await subtasks.AddAsync(new SubtaskEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
TaskId = taskId,
|
||||
Title = title.Trim(),
|
||||
Completed = false,
|
||||
OrderNum = order,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
}, cancellationToken);
|
||||
|
||||
await _broadcaster.TaskUpdated(taskId);
|
||||
return ToDto(task);
|
||||
}
|
||||
|
||||
[McpServerTool, Description(
|
||||
"Update a task's status. Only 'Idle' and 'Queued' are permitted externally — " +
|
||||
"use run_task_now or cancel_task for execution control, and review_task to act on a WaitingForReview task. " +
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Worker.Prime;
|
||||
using ClaudeDo.Worker.Refine;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace ClaudeDo.Worker.Hub;
|
||||
|
||||
public sealed class HubBroadcaster : IPrimeBroadcaster
|
||||
public sealed class HubBroadcaster : IPrimeBroadcaster, IRefineBroadcaster
|
||||
{
|
||||
private readonly IHubContext<WorkerHub> _hub;
|
||||
|
||||
@@ -62,4 +63,12 @@ public sealed class HubBroadcaster : IPrimeBroadcaster
|
||||
Task IPrimeBroadcaster.PrepStartedAsync() => PrepStarted();
|
||||
Task IPrimeBroadcaster.PrepLineAsync(string line) => PrepLine(line);
|
||||
Task IPrimeBroadcaster.PrepFinishedAsync(bool success) => PrepFinished(success);
|
||||
|
||||
public Task RefineStarted(string taskId) => _hub.Clients.All.SendAsync("RefineStarted", taskId);
|
||||
public Task RefineFinished(string taskId, bool success, string? error) =>
|
||||
_hub.Clients.All.SendAsync("RefineFinished", taskId, success, error);
|
||||
|
||||
Task IRefineBroadcaster.RefineStartedAsync(string taskId) => RefineStarted(taskId);
|
||||
Task IRefineBroadcaster.RefineFinishedAsync(string taskId, bool success, string? error) =>
|
||||
RefineFinished(taskId, success, error);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ using ClaudeDo.Worker.Lifecycle;
|
||||
using ClaudeDo.Worker.Planning;
|
||||
using ClaudeDo.Worker.Prime;
|
||||
using ClaudeDo.Worker.Queue;
|
||||
using ClaudeDo.Worker.Refine;
|
||||
using ClaudeDo.Worker.Report;
|
||||
using ClaudeDo.Worker.Report.Interfaces;
|
||||
using ClaudeDo.Worker.State;
|
||||
@@ -53,6 +54,7 @@ public record WorktreeOverviewDto(
|
||||
|
||||
public record ForceRemoveResultDto(bool Removed, string? Reason);
|
||||
public record MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage);
|
||||
public record MergePreviewDto(string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount);
|
||||
public record MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalBranches);
|
||||
public record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType);
|
||||
public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
|
||||
@@ -83,6 +85,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
private readonly IPrimeRunner _primeRunner;
|
||||
private readonly ITaskStateService _state;
|
||||
private readonly IWeekReportService _report;
|
||||
private readonly IRefineRunner _refineRunner;
|
||||
|
||||
public WorkerHub(
|
||||
QueueService queue,
|
||||
@@ -102,7 +105,8 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
IPrimeScheduleSignal primeSignal,
|
||||
IPrimeRunner primeRunner,
|
||||
ITaskStateService state,
|
||||
IWeekReportService report)
|
||||
IWeekReportService report,
|
||||
IRefineRunner refineRunner)
|
||||
{
|
||||
_queue = queue;
|
||||
_waker = waker;
|
||||
@@ -122,6 +126,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
_primeRunner = primeRunner;
|
||||
_state = state;
|
||||
_report = report;
|
||||
_refineRunner = refineRunner;
|
||||
}
|
||||
|
||||
// Maps the two exceptions service methods throw into client-facing HubExceptions:
|
||||
@@ -316,6 +321,13 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
return new MergeTargetsDto(t.DefaultBranch, t.LocalBranches);
|
||||
});
|
||||
|
||||
public Task<MergePreviewDto> PreviewMerge(string taskId, string targetBranch)
|
||||
=> HubGuard(async () =>
|
||||
{
|
||||
var p = await _mergeService.PreviewAsync(taskId, targetBranch ?? "", CancellationToken.None);
|
||||
return new MergePreviewDto(p.Status, p.ConflictFiles, p.ChangedFileCount);
|
||||
});
|
||||
|
||||
public async Task UpdateList(UpdateListDto dto)
|
||||
{
|
||||
using var ctx = _dbFactory.CreateDbContext();
|
||||
@@ -380,11 +392,14 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
if (!result.Ok) throw new HubException(result.Reason ?? "set status failed");
|
||||
}
|
||||
|
||||
public async Task ApproveReview(string taskId)
|
||||
{
|
||||
var result = await _state.ApproveReviewAsync(taskId, Context.ConnectionAborted);
|
||||
if (!result.Ok) throw new HubException(result.Reason ?? "approve failed");
|
||||
}
|
||||
public Task<MergeResultDto> ApproveReview(string taskId, string targetBranch)
|
||||
=> HubGuard(async () =>
|
||||
{
|
||||
var r = await _mergeService.ApproveAndMergeAsync(taskId, targetBranch ?? "", CancellationToken.None);
|
||||
if (r.Status == TaskMergeService.StatusBlocked)
|
||||
throw new HubException(r.ErrorMessage ?? "approve failed");
|
||||
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
|
||||
});
|
||||
|
||||
public async Task RejectReviewToQueue(string taskId, string feedback)
|
||||
{
|
||||
@@ -541,6 +556,12 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
||||
_primeSignal.Signal();
|
||||
}
|
||||
|
||||
public Task RefineTask(string taskId)
|
||||
{
|
||||
_ = _refineRunner.RefineAsync(taskId, CancellationToken.None);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task<bool> RunDailyPrepNow()
|
||||
{
|
||||
var schedule = new PrimeScheduleDto(Guid.Empty, 0, TimeSpan.Zero, true, null, null);
|
||||
|
||||
@@ -3,6 +3,7 @@ using ClaudeDo.Data.Git;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.State;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
@@ -17,6 +18,11 @@ public sealed record MergeTargets(
|
||||
string DefaultBranch,
|
||||
IReadOnlyList<string> LocalBranches);
|
||||
|
||||
public sealed record MergePreviewResult(
|
||||
string Status,
|
||||
IReadOnlyList<string> ConflictFiles,
|
||||
int ChangedFileCount);
|
||||
|
||||
public sealed class TaskMergeService
|
||||
{
|
||||
public const string StatusMerged = "merged";
|
||||
@@ -24,20 +30,27 @@ public sealed class TaskMergeService
|
||||
public const string StatusBlocked = "blocked";
|
||||
public const string StatusAborted = "aborted";
|
||||
|
||||
public const string PreviewClean = "clean";
|
||||
public const string PreviewConflict = "conflict";
|
||||
public const string PreviewUnavailable = "unavailable";
|
||||
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly GitService _git;
|
||||
private readonly HubBroadcaster _broadcaster;
|
||||
private readonly ITaskStateService _state;
|
||||
private readonly ILogger<TaskMergeService> _logger;
|
||||
|
||||
public TaskMergeService(
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
GitService git,
|
||||
HubBroadcaster broadcaster,
|
||||
ITaskStateService state,
|
||||
ILogger<TaskMergeService> logger)
|
||||
{
|
||||
_dbFactory = dbFactory;
|
||||
_git = git;
|
||||
_broadcaster = broadcaster;
|
||||
_state = state;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -216,6 +229,59 @@ public sealed class TaskMergeService
|
||||
return new MergeTargets(current, branches);
|
||||
}
|
||||
|
||||
public async Task<MergePreviewResult> PreviewAsync(string taskId, string targetBranch, CancellationToken ct)
|
||||
{
|
||||
var (_, list, wt) = await LoadMergeContextAsync(taskId, ct);
|
||||
|
||||
if (wt is null || wt.State != WorktreeState.Active)
|
||||
return new MergePreviewResult(PreviewUnavailable, Array.Empty<string>(), 0);
|
||||
if (string.IsNullOrWhiteSpace(list.WorkingDir) || !await _git.IsGitRepoAsync(list.WorkingDir, ct))
|
||||
return new MergePreviewResult(PreviewUnavailable, Array.Empty<string>(), 0);
|
||||
|
||||
var target = string.IsNullOrWhiteSpace(targetBranch)
|
||||
? await _git.GetCurrentBranchAsync(list.WorkingDir, ct)
|
||||
: targetBranch;
|
||||
|
||||
var preview = await _git.PreviewMergeAsync(list.WorkingDir, target, wt.BranchName, ct);
|
||||
if (!preview.Supported)
|
||||
return new MergePreviewResult(PreviewUnavailable, Array.Empty<string>(), 0);
|
||||
if (!preview.Clean)
|
||||
return new MergePreviewResult(PreviewConflict, preview.ConflictFiles, 0);
|
||||
|
||||
var count = await _git.CountChangedFilesAsync(list.WorkingDir, target, wt.BranchName, ct);
|
||||
return new MergePreviewResult(PreviewClean, Array.Empty<string>(), count);
|
||||
}
|
||||
|
||||
public async Task<MergeResult> ApproveAndMergeAsync(string taskId, string targetBranch, CancellationToken ct)
|
||||
{
|
||||
var (task, list, wt) = await LoadMergeContextAsync(taskId, ct);
|
||||
|
||||
if (task.Status != TaskStatus.WaitingForReview)
|
||||
return Blocked("task is not waiting for review");
|
||||
|
||||
if (wt is null || wt.State != WorktreeState.Active)
|
||||
{
|
||||
var done = await _state.ApproveReviewAsync(taskId, ct);
|
||||
return done.Ok
|
||||
? new MergeResult(StatusMerged, Array.Empty<string>(), null)
|
||||
: Blocked(done.Reason ?? "approve failed");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(list.WorkingDir))
|
||||
return Blocked("list has no working directory");
|
||||
|
||||
var target = string.IsNullOrWhiteSpace(targetBranch)
|
||||
? await _git.GetCurrentBranchAsync(list.WorkingDir, ct)
|
||||
: targetBranch;
|
||||
|
||||
var merge = await MergeAsync(taskId, target, removeWorktree: false, $"Merge {wt.BranchName}", ct);
|
||||
if (merge.Status != StatusMerged)
|
||||
return merge;
|
||||
|
||||
var approve = await _state.ApproveReviewAsync(taskId, ct);
|
||||
return approve.Ok ? merge : Blocked(approve.Reason ?? "approve failed");
|
||||
}
|
||||
|
||||
private static MergeResult Blocked(string reason) =>
|
||||
new(StatusBlocked, Array.Empty<string>(), reason);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ using ClaudeDo.Worker.Queue;
|
||||
using ClaudeDo.Worker.Runner;
|
||||
using ClaudeDo.Worker.State;
|
||||
using ClaudeDo.Worker.Prime;
|
||||
using ClaudeDo.Worker.Refine;
|
||||
using ClaudeDo.Worker.Report;
|
||||
using ClaudeDo.Worker.Report.Interfaces;
|
||||
using ClaudeDo.Worker.Worktrees;
|
||||
@@ -108,6 +109,10 @@ builder.Services.AddSingleton(PrimeSchedulerOptions.Default);
|
||||
builder.Services.AddSingleton<IPrimeBroadcaster>(sp => sp.GetRequiredService<HubBroadcaster>());
|
||||
builder.Services.AddHostedService<PrimeScheduler>();
|
||||
|
||||
// Refine
|
||||
builder.Services.AddSingleton<IRefineRunner, RefineRunner>();
|
||||
builder.Services.AddSingleton<IRefineBroadcaster>(sp => sp.GetRequiredService<HubBroadcaster>());
|
||||
|
||||
// QueueService: singleton + hosted service (same instance).
|
||||
builder.Services.AddSingleton<QueueService>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<QueueService>());
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ClaudeDo.Worker.Refine;
|
||||
|
||||
public interface IRefineBroadcaster
|
||||
{
|
||||
Task RefineStartedAsync(string taskId);
|
||||
Task RefineFinishedAsync(string taskId, bool success, string? error);
|
||||
}
|
||||
8
src/ClaudeDo.Worker/Refine/Interfaces/IRefineRunner.cs
Normal file
8
src/ClaudeDo.Worker/Refine/Interfaces/IRefineRunner.cs
Normal file
@@ -0,0 +1,8 @@
|
||||
namespace ClaudeDo.Worker.Refine;
|
||||
|
||||
public interface IRefineRunner
|
||||
{
|
||||
Task<RefineRunOutcome> RefineAsync(string taskId, CancellationToken ct);
|
||||
}
|
||||
|
||||
public sealed record RefineRunOutcome(bool Success, string Message);
|
||||
38
src/ClaudeDo.Worker/Refine/RefinePrompt.cs
Normal file
38
src/ClaudeDo.Worker/Refine/RefinePrompt.cs
Normal file
@@ -0,0 +1,38 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
|
||||
namespace ClaudeDo.Worker.Refine;
|
||||
|
||||
public static class RefinePrompt
|
||||
{
|
||||
public const string GetTaskTool = "mcp__claudedo__get_task";
|
||||
public const string UpdateTaskTool = "mcp__claudedo__update_task";
|
||||
public const string AddSubtaskTool = "mcp__claudedo__add_subtask";
|
||||
|
||||
public static string LogPath(string taskId) =>
|
||||
System.IO.Path.Combine(Paths.AppDataRoot(), "logs", $"refine-{Short(taskId)}.log");
|
||||
|
||||
public static string BuildArgs(int maxTurns, bool canReadRepo)
|
||||
{
|
||||
var tools = canReadRepo
|
||||
? $"{GetTaskTool} {UpdateTaskTool} {AddSubtaskTool} Read Grep Glob"
|
||||
: $"{GetTaskTool} {UpdateTaskTool} {AddSubtaskTool}";
|
||||
return "-p --output-format stream-json --verbose --permission-mode acceptEdits " +
|
||||
$"--max-turns {maxTurns} --allowedTools {tools}";
|
||||
}
|
||||
|
||||
public static string BuildPrompt(TaskEntity task, IEnumerable<SubtaskEntity> subtasks)
|
||||
{
|
||||
var open = subtasks.Where(s => !s.Completed).Select(s => $"- {s.Title}").ToList();
|
||||
var subText = open.Count == 0 ? "(none)" : string.Join("\n", open);
|
||||
return PromptFiles.Render(PromptKind.Refine, new Dictionary<string, string>
|
||||
{
|
||||
["taskId"] = task.Id,
|
||||
["title"] = task.Title,
|
||||
["description"] = string.IsNullOrWhiteSpace(task.Description) ? "(empty)" : task.Description!,
|
||||
["subtasks"] = subText,
|
||||
});
|
||||
}
|
||||
|
||||
private static string Short(string id) => id.Length >= 8 ? id[..8] : id;
|
||||
}
|
||||
108
src/ClaudeDo.Worker/Refine/RefineRunner.cs
Normal file
108
src/ClaudeDo.Worker/Refine/RefineRunner.cs
Normal file
@@ -0,0 +1,108 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Runner;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Worker.Refine;
|
||||
|
||||
public sealed class RefineRunner : IRefineRunner
|
||||
{
|
||||
private static readonly TimeSpan RunTimeout = TimeSpan.FromMinutes(5);
|
||||
private const int MaxTurns = 25;
|
||||
|
||||
private readonly IClaudeProcess _claude;
|
||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||
private readonly ILogger<RefineRunner> _logger;
|
||||
private readonly IRefineBroadcaster _broadcaster;
|
||||
|
||||
private readonly object _lock = new();
|
||||
private readonly HashSet<string> _inFlight = new();
|
||||
|
||||
public RefineRunner(
|
||||
IClaudeProcess claude,
|
||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||
ILogger<RefineRunner> logger,
|
||||
IRefineBroadcaster broadcaster)
|
||||
{
|
||||
_claude = claude;
|
||||
_dbFactory = dbFactory;
|
||||
_logger = logger;
|
||||
_broadcaster = broadcaster;
|
||||
}
|
||||
|
||||
public async Task<RefineRunOutcome> RefineAsync(string taskId, CancellationToken ct)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_inFlight.Add(taskId))
|
||||
return new RefineRunOutcome(false, "Already refining this task");
|
||||
}
|
||||
|
||||
var success = false;
|
||||
string? error = null;
|
||||
try
|
||||
{
|
||||
ClaudeDo.Data.Models.TaskEntity task;
|
||||
List<ClaudeDo.Data.Models.SubtaskEntity> subs;
|
||||
string? workingDir;
|
||||
await using (var dbCtx = await _dbFactory.CreateDbContextAsync(ct))
|
||||
{
|
||||
var tasks = new TaskRepository(dbCtx);
|
||||
task = await tasks.GetByIdAsync(taskId, ct)
|
||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||
if (task.Status != TaskStatus.Idle)
|
||||
return new RefineRunOutcome(false, $"Task must be Idle to refine (is {task.Status}).");
|
||||
subs = await new SubtaskRepository(dbCtx).GetByTaskIdAsync(taskId, ct);
|
||||
var list = await new ListRepository(dbCtx).GetByIdAsync(task.ListId, ct);
|
||||
workingDir = list?.WorkingDir;
|
||||
}
|
||||
|
||||
var canReadRepo = !string.IsNullOrWhiteSpace(workingDir) && Directory.Exists(workingDir);
|
||||
var cwd = canReadRepo ? workingDir! : Paths.AppDataRoot();
|
||||
Directory.CreateDirectory(cwd);
|
||||
|
||||
var logPath = RefinePrompt.LogPath(taskId);
|
||||
try { if (File.Exists(logPath)) File.Delete(logPath); } catch { }
|
||||
await using var logWriter = new LogWriter(logPath);
|
||||
|
||||
await _broadcaster.RefineStartedAsync(taskId);
|
||||
|
||||
var prompt = RefinePrompt.BuildPrompt(task, subs);
|
||||
var args = RefinePrompt.BuildArgs(MaxTurns, canReadRepo);
|
||||
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
timeoutCts.CancelAfter(RunTimeout);
|
||||
|
||||
var result = await _claude.RunAsync(
|
||||
arguments: args,
|
||||
prompt: prompt,
|
||||
workingDirectory: cwd,
|
||||
onStdoutLine: async line => await logWriter.WriteLineAsync(line),
|
||||
ct: timeoutCts.Token);
|
||||
|
||||
success = result.IsSuccess;
|
||||
if (!success) error = $"exit code {result.ExitCode}";
|
||||
return success
|
||||
? new RefineRunOutcome(true, "Refine complete")
|
||||
: new RefineRunOutcome(false, error!);
|
||||
}
|
||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
||||
{
|
||||
error = $"timed out after {RunTimeout.TotalMinutes:0} min";
|
||||
return new RefineRunOutcome(false, error);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Refine run failed for {TaskId}", taskId);
|
||||
error = ex.Message;
|
||||
return new RefineRunOutcome(false, ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
await _broadcaster.RefineFinishedAsync(taskId, success, error);
|
||||
lock (_lock) { _inFlight.Remove(taskId); }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -101,16 +101,10 @@ public sealed class TaskRunner
|
||||
await _state.StartRunningAsync(task.Id, now, ct);
|
||||
await _broadcaster.TaskStarted(slot, task.Id, now);
|
||||
|
||||
// Build prompt.
|
||||
var sb = new System.Text.StringBuilder(task.Title);
|
||||
if (!string.IsNullOrWhiteSpace(task.Description)) sb.Append("\n\n").Append(task.Description.Trim());
|
||||
if (subtasks.Count > 0)
|
||||
{
|
||||
sb.Append("\n\n## Sub-Tasks\n");
|
||||
foreach (var s in subtasks)
|
||||
sb.Append(s.Completed ? "- [x] " : "- [ ] ").Append(s.Title).Append('\n');
|
||||
}
|
||||
var prompt = sb.ToString();
|
||||
// Build prompt: title + description + only the OPEN sub-tasks (resolved ones are dropped).
|
||||
var prompt = TaskPromptComposer.Compose(
|
||||
task.Title, task.Description,
|
||||
subtasks.Select(s => (s.Title, s.Completed)));
|
||||
|
||||
// Run 1.
|
||||
var result = await RunOnceAsync(task.Id, task.Title, slot, runDir, resolvedConfig, 1, false, prompt, ct);
|
||||
|
||||
45
tests/ClaudeDo.Data.Tests/TaskPromptComposerTests.cs
Normal file
45
tests/ClaudeDo.Data.Tests/TaskPromptComposerTests.cs
Normal file
@@ -0,0 +1,45 @@
|
||||
using ClaudeDo.Data;
|
||||
using Xunit;
|
||||
|
||||
namespace ClaudeDo.Data.Tests;
|
||||
|
||||
public class TaskPromptComposerTests
|
||||
{
|
||||
[Fact]
|
||||
public void Composes_title_description_and_open_steps()
|
||||
{
|
||||
var result = TaskPromptComposer.Compose(
|
||||
"Refactor diff viewer",
|
||||
"Share the row template.",
|
||||
new (string, bool)[] { ("Done step", true), ("Open step", false) });
|
||||
|
||||
Assert.Equal(
|
||||
"Refactor diff viewer\n\nShare the row template.\n\n## Sub-Tasks\n- [ ] Open step\n",
|
||||
result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Drops_resolved_steps_and_omits_section_when_none_open()
|
||||
{
|
||||
var result = TaskPromptComposer.Compose(
|
||||
"Title",
|
||||
"Desc",
|
||||
new (string, bool)[] { ("a", true), ("b", true) });
|
||||
|
||||
Assert.Equal("Title\n\nDesc", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Omits_description_when_blank()
|
||||
{
|
||||
var result = TaskPromptComposer.Compose("Title", " ", new (string, bool)[] { ("open", false) });
|
||||
|
||||
Assert.Equal("Title\n\n## Sub-Tasks\n- [ ] open\n", result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Title_only_when_no_description_or_steps()
|
||||
{
|
||||
Assert.Equal("Just a title", TaskPromptComposer.Compose("Just a title", null, System.Array.Empty<(string, bool)>()));
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,8 @@ public abstract class StubWorkerClient : IWorkerClient
|
||||
public event Action? PrepStartedEvent;
|
||||
public event Action<string>? PrepLineEvent;
|
||||
public event Action<bool>? PrepFinishedEvent;
|
||||
public event Action<string>? RefineStartedEvent;
|
||||
public event Action<string, bool, string?>? RefineFinishedEvent;
|
||||
public event Action<string, string>? PlanningMergeStartedEvent;
|
||||
public event Action<string, string>? PlanningSubtaskMergedEvent;
|
||||
public event Action<string, string, IReadOnlyList<string>>? PlanningMergeConflictEvent;
|
||||
@@ -50,7 +52,9 @@ public abstract class StubWorkerClient : IWorkerClient
|
||||
public virtual Task<ListConfigDto?> GetListConfigAsync(string listId) => Task.FromResult<ListConfigDto?>(null);
|
||||
public virtual Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) => Task.CompletedTask;
|
||||
public virtual Task SetTaskStatusAsync(string taskId, TaskStatus status) => Task.CompletedTask;
|
||||
public virtual Task ApproveReviewAsync(string taskId) => Task.CompletedTask;
|
||||
public virtual Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) => Task.FromResult<MergeResultDto?>(null);
|
||||
public virtual Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch) => Task.FromResult<MergePreviewDto?>(null);
|
||||
public virtual Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));
|
||||
public virtual Task RejectReviewToQueueAsync(string taskId, string feedback) => Task.CompletedTask;
|
||||
public virtual Task RejectReviewToIdleAsync(string taskId) => Task.CompletedTask;
|
||||
public virtual Task CancelReviewAsync(string taskId) => Task.CompletedTask;
|
||||
@@ -81,6 +85,7 @@ public abstract class StubWorkerClient : IWorkerClient
|
||||
public virtual Task DeleteDailyNoteAsync(string id) => Task.CompletedTask;
|
||||
public string LastPrepLog = "";
|
||||
public virtual Task<string> GetLastPrepLogAsync() => Task.FromResult(LastPrepLog);
|
||||
public virtual Task RefineTaskAsync(string taskId) => Task.CompletedTask;
|
||||
|
||||
protected void RaisePropertyChanged(string name) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
|
||||
}
|
||||
|
||||
@@ -74,8 +74,8 @@ public class DetailsIslandPlanningTests : IDisposable
|
||||
private sealed class ThrowingReviewWorkerClient : StubWorkerClient
|
||||
{
|
||||
public override bool IsConnected => true;
|
||||
public override Task ApproveReviewAsync(string taskId) =>
|
||||
Task.FromException(new InvalidOperationException("Task is not waiting for review; cannot approve."));
|
||||
public override Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) =>
|
||||
Task.FromException<MergeResultDto?>(new InvalidOperationException("Task is not waiting for review; cannot approve."));
|
||||
}
|
||||
|
||||
private static SubtaskRowViewModel MakeSubtask(TaskStatus status, WorktreeState wt = WorktreeState.Active) =>
|
||||
|
||||
83
tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandTabsTests.cs
Normal file
83
tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandTabsTests.cs
Normal file
@@ -0,0 +1,83 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||
|
||||
public class DetailsIslandTabsTests : IDisposable
|
||||
{
|
||||
private readonly string _dbPath;
|
||||
|
||||
public DetailsIslandTabsTests()
|
||||
{
|
||||
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_tabs_test_{Guid.NewGuid():N}.db");
|
||||
using var ctx = NewContext();
|
||||
ctx.Database.EnsureCreated();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { File.Delete(_dbPath); } catch { }
|
||||
try { File.Delete(_dbPath + "-wal"); } catch { }
|
||||
try { File.Delete(_dbPath + "-shm"); } catch { }
|
||||
}
|
||||
|
||||
private ClaudeDoDbContext NewContext()
|
||||
{
|
||||
var opts = new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
||||
.UseSqlite($"Data Source={_dbPath}")
|
||||
.Options;
|
||||
return new ClaudeDoDbContext(opts);
|
||||
}
|
||||
|
||||
private sealed class TestDbFactory : IDbContextFactory<ClaudeDoDbContext>
|
||||
{
|
||||
private readonly Func<ClaudeDoDbContext> _create;
|
||||
public TestDbFactory(Func<ClaudeDoDbContext> create) => _create = create;
|
||||
public ClaudeDoDbContext CreateDbContext() => _create();
|
||||
}
|
||||
|
||||
private sealed class StubNotesApi : ClaudeDo.Ui.Services.Interfaces.INotesApi
|
||||
{
|
||||
public Task<List<DailyNoteDto>> ListAsync(DateOnly day) => Task.FromResult(new List<DailyNoteDto>());
|
||||
public Task<DailyNoteDto?> AddAsync(DateOnly day, string text) => Task.FromResult<DailyNoteDto?>(null);
|
||||
public Task UpdateAsync(string id, string text) => Task.CompletedTask;
|
||||
public Task DeleteAsync(string id) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
private sealed class NullServiceProvider : IServiceProvider
|
||||
{
|
||||
public object? GetService(Type serviceType) => null;
|
||||
}
|
||||
|
||||
// StubWorkerClient is abstract — use a concrete no-op subclass (same pattern as DetailsIslandPrepModeTests).
|
||||
private sealed class DefaultStub : StubWorkerClient { }
|
||||
|
||||
private DetailsIslandViewModel NewVm()
|
||||
{
|
||||
var factory = new TestDbFactory(NewContext);
|
||||
return new DetailsIslandViewModel(factory, new DefaultStub(), new NullServiceProvider(), new StubNotesApi());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectTab_git_sets_IsGitTab_and_clears_others()
|
||||
{
|
||||
var vm = NewVm();
|
||||
|
||||
vm.SelectTabCommand.Execute("git");
|
||||
|
||||
Assert.True(vm.IsGitTab);
|
||||
Assert.False(vm.IsOutputTab);
|
||||
Assert.False(vm.IsSessionTab);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_tab_is_output_not_git()
|
||||
{
|
||||
var vm = NewVm();
|
||||
|
||||
Assert.True(vm.IsOutputTab);
|
||||
Assert.False(vm.IsGitTab);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using ClaudeDo.Ui.Services;
|
||||
using ClaudeDo.Ui.ViewModels.Islands;
|
||||
|
||||
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||
|
||||
public class MergePreviewPresenterTests
|
||||
{
|
||||
[Fact]
|
||||
public void Clean_Plural()
|
||||
{
|
||||
var (text, clean, conflict) = MergePreviewPresenter.Describe(
|
||||
new MergePreviewDto("clean", System.Array.Empty<string>(), 3));
|
||||
Assert.Equal("Merges cleanly · 3 files", text);
|
||||
Assert.True(clean);
|
||||
Assert.False(conflict);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Clean_Singular()
|
||||
{
|
||||
var (text, _, _) = MergePreviewPresenter.Describe(
|
||||
new MergePreviewDto("clean", System.Array.Empty<string>(), 1));
|
||||
Assert.Equal("Merges cleanly · 1 file", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Conflict_ListsUpToThree()
|
||||
{
|
||||
var (text, clean, conflict) = MergePreviewPresenter.Describe(
|
||||
new MergePreviewDto("conflict", new[] { "a.cs", "b.cs" }, 0));
|
||||
Assert.Equal("Conflicts in a.cs, b.cs", text);
|
||||
Assert.False(clean);
|
||||
Assert.True(conflict);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Conflict_TruncatesWithMore()
|
||||
{
|
||||
var (text, _, _) = MergePreviewPresenter.Describe(
|
||||
new MergePreviewDto("conflict", new[] { "a", "b", "c", "d", "e" }, 0));
|
||||
Assert.Equal("Conflicts in a, b, c (+2 more)", text);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unavailable_IsMuted()
|
||||
{
|
||||
var (text, clean, conflict) = MergePreviewPresenter.Describe(
|
||||
new MergePreviewDto("unavailable", System.Array.Empty<string>(), 0));
|
||||
Assert.Equal("Mergeability unknown", text);
|
||||
Assert.False(clean);
|
||||
Assert.False(conflict);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Null_IsEmpty()
|
||||
{
|
||||
var (text, clean, conflict) = MergePreviewPresenter.Describe(null);
|
||||
Assert.Equal("", text);
|
||||
Assert.False(clean);
|
||||
Assert.False(conflict);
|
||||
}
|
||||
}
|
||||
118
tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs
vendored
Normal file
118
tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs
vendored
Normal file
@@ -0,0 +1,118 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.External;
|
||||
using ClaudeDo.Worker.Hub;
|
||||
using ClaudeDo.Worker.Lifecycle;
|
||||
using ClaudeDo.Worker.Queue;
|
||||
using ClaudeDo.Worker.Runner;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
using ClaudeDo.Worker.Tests.Services;
|
||||
using ClaudeDo.Worker.Worktrees;
|
||||
using ClaudeDo.Worker.Config;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.External;
|
||||
|
||||
public sealed class AddSubtaskToolTests : IDisposable
|
||||
{
|
||||
private readonly DbFixture _db = new();
|
||||
private readonly ClaudeDoDbContext _ctx;
|
||||
private readonly TaskRepository _tasks;
|
||||
private readonly ListRepository _lists;
|
||||
|
||||
public AddSubtaskToolTests()
|
||||
{
|
||||
_ctx = _db.CreateContext();
|
||||
_tasks = new TaskRepository(_ctx);
|
||||
_lists = new ListRepository(_ctx);
|
||||
}
|
||||
|
||||
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
|
||||
|
||||
private async Task<string> SeedListAsync()
|
||||
{
|
||||
var id = Guid.NewGuid().ToString();
|
||||
await _lists.AddAsync(new ListEntity { Id = id, Name = "L", CreatedAt = DateTime.UtcNow });
|
||||
return id;
|
||||
}
|
||||
|
||||
private async Task<TaskEntity> SeedTaskAsync(string listId, TaskStatus status = TaskStatus.Idle)
|
||||
{
|
||||
var task = new TaskEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
ListId = listId,
|
||||
Title = "t",
|
||||
Status = status,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
CommitType = "chore",
|
||||
};
|
||||
await _tasks.AddAsync(task);
|
||||
return task;
|
||||
}
|
||||
|
||||
private ExternalMcpService BuildSut()
|
||||
{
|
||||
var cfg = new WorkerConfig
|
||||
{
|
||||
SandboxRoot = Path.Combine(Path.GetTempPath(), $"cd_{Guid.NewGuid():N}"),
|
||||
LogRoot = Path.Combine(Path.GetTempPath(), $"cdl_{Guid.NewGuid():N}"),
|
||||
QueueBackstopIntervalMs = 50,
|
||||
};
|
||||
var dbFactory = _db.CreateFactory();
|
||||
var hubCtx = new FakeHubContext();
|
||||
var broadcaster = new HubBroadcaster(hubCtx);
|
||||
var git = new ClaudeDo.Data.Git.GitService();
|
||||
var wtManager = new WorktreeManager(git, dbFactory, cfg, NullLogger<WorktreeManager>.Instance);
|
||||
var fake = new FakeClaudeProcess();
|
||||
var argsBuilder = new ClaudeArgsBuilder();
|
||||
var state = TaskStateServiceBuilder.Build(dbFactory).State;
|
||||
var runner = new TaskRunner(fake, dbFactory, broadcaster, wtManager, argsBuilder, cfg,
|
||||
NullLogger<TaskRunner>.Instance, state, new TaskRunTokenRegistry());
|
||||
var waker = new ClaudeDo.Worker.Queue.QueueWaker();
|
||||
var picker = new ClaudeDo.Worker.Queue.QueuePicker(dbFactory);
|
||||
var overrideSlot = new OverrideSlotService(dbFactory, runner, NullLogger<OverrideSlotService>.Instance);
|
||||
var queue = new QueueService(dbFactory, runner, cfg, NullLogger<QueueService>.Instance, waker, picker, overrideSlot, state);
|
||||
var maintenance = new WorktreeMaintenanceService(dbFactory, git, NullLogger<WorktreeMaintenanceService>.Instance);
|
||||
var merge = new TaskMergeService(dbFactory, git, broadcaster, TaskStateServiceBuilder.Build(dbFactory).State, NullLogger<TaskMergeService>.Instance);
|
||||
return new ExternalMcpService(
|
||||
_tasks, _lists, queue, broadcaster,
|
||||
state,
|
||||
git, dbFactory, maintenance, merge);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddSubtask_appends_row_with_next_order()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = await SeedTaskAsync(listId, TaskStatus.Idle);
|
||||
var sut = BuildSut();
|
||||
|
||||
await sut.AddSubtask(task.Id, "First step", null, CancellationToken.None);
|
||||
await sut.AddSubtask(task.Id, "Second step", null, CancellationToken.None);
|
||||
|
||||
await using var verifyCtx = _db.CreateContext();
|
||||
var subtasks = await new SubtaskRepository(verifyCtx).GetByTaskIdAsync(task.Id);
|
||||
|
||||
Assert.Equal(2, subtasks.Count);
|
||||
Assert.Equal("First step", subtasks[0].Title);
|
||||
Assert.Equal("Second step", subtasks[1].Title);
|
||||
Assert.Equal(0, subtasks[0].OrderNum);
|
||||
Assert.Equal(1, subtasks[1].OrderNum);
|
||||
Assert.False(subtasks[0].Completed);
|
||||
Assert.False(subtasks[1].Completed);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AddSubtask_refuses_running_task()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = await SeedTaskAsync(listId, TaskStatus.Running);
|
||||
var sut = BuildSut();
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => sut.AddSubtask(task.Id, "Should fail", null, CancellationToken.None));
|
||||
}
|
||||
}
|
||||
@@ -125,7 +125,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
||||
var git = new GitService();
|
||||
var factory = _db.CreateFactory();
|
||||
var maintenance = new WorktreeMaintenanceService(factory, git, NullLogger<WorktreeMaintenanceService>.Instance);
|
||||
var merge = new TaskMergeService(factory, git, _broadcaster, NullLogger<TaskMergeService>.Instance);
|
||||
var merge = new TaskMergeService(factory, git, _broadcaster, TaskStateServiceBuilder.Build(factory).State, NullLogger<TaskMergeService>.Instance);
|
||||
return new ExternalMcpService(
|
||||
_tasks, _lists, queue, _broadcaster,
|
||||
TaskStateServiceBuilder.Build(factory).State,
|
||||
|
||||
@@ -19,7 +19,7 @@ public sealed class ClearMyDayHubTests : IDisposable
|
||||
var broadcaster = new HubBroadcaster(new CapturingHubContext());
|
||||
var hub = new WorkerHub(
|
||||
null!, null!, null!, null!, broadcaster, _db.CreateFactory(),
|
||||
null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!);
|
||||
null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!);
|
||||
hub.Clients = new FakeHubCallerClients(new RecordingClientProxy());
|
||||
hub.Context = new FakeHubCallerContext();
|
||||
return hub;
|
||||
|
||||
@@ -55,7 +55,7 @@ public sealed class PlanningHubTests : IDisposable
|
||||
{
|
||||
var hub = new WorkerHub(
|
||||
null!, null!, null!, null!, null!, null!, null!, null!, null!,
|
||||
_planning, _launcher, null!, null!, null!, null!, null!, null!, null!);
|
||||
_planning, _launcher, null!, null!, null!, null!, null!, null!, null!, null!);
|
||||
hub.Clients = new FakeHubCallerClients(_proxy);
|
||||
hub.Context = new FakeHubCallerContext();
|
||||
return hub;
|
||||
|
||||
@@ -19,7 +19,7 @@ public sealed class WorktreeStateHubTests : IDisposable
|
||||
var broadcaster = new HubBroadcaster(new CapturingHubContext());
|
||||
var hub = new WorkerHub(
|
||||
null!, null!, null!, null!, broadcaster, _db.CreateFactory(),
|
||||
null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!);
|
||||
null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!, null!);
|
||||
hub.Clients = new FakeHubCallerClients(new RecordingClientProxy());
|
||||
hub.Context = new FakeHubCallerContext();
|
||||
return hub;
|
||||
|
||||
@@ -254,6 +254,7 @@ public sealed class PlanningMergeOrchestratorTests : IDisposable
|
||||
var factory = db.CreateFactory();
|
||||
var merge = new TaskMergeService(
|
||||
factory, git, broadcaster,
|
||||
TaskStateServiceBuilder.Build(factory).State,
|
||||
NullLogger<TaskMergeService>.Instance);
|
||||
var aggregator = new PlanningAggregator(
|
||||
factory, git,
|
||||
|
||||
@@ -128,6 +128,7 @@ public sealed class TreeMergeTests : IDisposable
|
||||
var factory = db.CreateFactory();
|
||||
var merge = new TaskMergeService(
|
||||
factory, git, broadcaster,
|
||||
TaskStateServiceBuilder.Build(factory).State,
|
||||
NullLogger<TaskMergeService>.Instance);
|
||||
var aggregator = new PlanningAggregator(
|
||||
factory, git,
|
||||
|
||||
52
tests/ClaudeDo.Worker.Tests/Refine/RefinePromptTests.cs
Normal file
52
tests/ClaudeDo.Worker.Tests/Refine/RefinePromptTests.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Worker.Refine;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.Refine;
|
||||
|
||||
public sealed class RefinePromptTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildArgs_includes_read_tools_when_repo_available()
|
||||
{
|
||||
var args = RefinePrompt.BuildArgs(20, canReadRepo: true);
|
||||
|
||||
Assert.Contains("--permission-mode acceptEdits", args);
|
||||
Assert.Contains("mcp__claudedo__add_subtask", args);
|
||||
Assert.Contains(" Read Grep Glob", args);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildArgs_drops_read_tools_in_text_only_mode()
|
||||
{
|
||||
var args = RefinePrompt.BuildArgs(20, canReadRepo: false);
|
||||
|
||||
Assert.DoesNotContain("Glob", args);
|
||||
Assert.Contains("mcp__claudedo__update_task", args);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPrompt_seeds_task_fields_and_open_subtasks()
|
||||
{
|
||||
var task = new TaskEntity
|
||||
{
|
||||
Id = "abc12345",
|
||||
ListId = "l",
|
||||
Title = "T",
|
||||
Description = "D",
|
||||
Status = TaskStatus.Idle,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
var subtasks = new List<SubtaskEntity>
|
||||
{
|
||||
new() { Id = "s1", TaskId = "abc12345", Title = "open one", Completed = false, CreatedAt = DateTime.UtcNow },
|
||||
new() { Id = "s2", TaskId = "abc12345", Title = "done one", Completed = true, CreatedAt = DateTime.UtcNow },
|
||||
};
|
||||
|
||||
var prompt = RefinePrompt.BuildPrompt(task, subtasks);
|
||||
|
||||
Assert.Contains("abc12345", prompt);
|
||||
Assert.Contains("open one", prompt);
|
||||
Assert.DoesNotContain("done one", prompt);
|
||||
}
|
||||
}
|
||||
138
tests/ClaudeDo.Worker.Tests/Refine/RefineRunnerTests.cs
Normal file
138
tests/ClaudeDo.Worker.Tests/Refine/RefineRunnerTests.cs
Normal file
@@ -0,0 +1,138 @@
|
||||
using ClaudeDo.Data;
|
||||
using ClaudeDo.Data.Models;
|
||||
using ClaudeDo.Data.Repositories;
|
||||
using ClaudeDo.Worker.Refine;
|
||||
using ClaudeDo.Worker.Runner;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.Refine;
|
||||
|
||||
public sealed class RefineRunnerTests : IDisposable
|
||||
{
|
||||
private readonly DbFixture _db = new();
|
||||
private readonly ClaudeDoDbContext _ctx;
|
||||
|
||||
public RefineRunnerTests()
|
||||
{
|
||||
_ctx = _db.CreateContext();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_ctx.Dispose();
|
||||
_db.Dispose();
|
||||
}
|
||||
|
||||
private async Task<string> SeedListAsync()
|
||||
{
|
||||
var listId = Guid.NewGuid().ToString();
|
||||
await new ListRepository(_ctx).AddAsync(new ListEntity
|
||||
{
|
||||
Id = listId,
|
||||
Name = "Test",
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
WorkingDir = null,
|
||||
});
|
||||
return listId;
|
||||
}
|
||||
|
||||
private async Task<TaskEntity> SeedTaskAsync(string listId, TaskStatus status)
|
||||
{
|
||||
var task = new TaskEntity
|
||||
{
|
||||
Id = Guid.NewGuid().ToString(),
|
||||
ListId = listId,
|
||||
Title = "Test task",
|
||||
Status = status,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
};
|
||||
await new TaskRepository(_ctx).AddAsync(task);
|
||||
return task;
|
||||
}
|
||||
|
||||
private RefineRunner BuildRunner(RecordingClaudeProcess claude, RecordingRefineBroadcaster broadcaster)
|
||||
{
|
||||
return new RefineRunner(
|
||||
claude,
|
||||
_db.CreateFactory(),
|
||||
NullLogger<RefineRunner>.Instance,
|
||||
broadcaster);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Refuses_when_task_not_idle()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = await SeedTaskAsync(listId, TaskStatus.Queued);
|
||||
|
||||
var claude = new RecordingClaudeProcess(success: true);
|
||||
var broadcaster = new RecordingRefineBroadcaster();
|
||||
var runner = BuildRunner(claude, broadcaster);
|
||||
|
||||
var outcome = await runner.RefineAsync(task.Id, CancellationToken.None);
|
||||
|
||||
Assert.False(outcome.Success);
|
||||
Assert.Equal(0, claude.CallCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Idle_task_invokes_claude_once_and_brackets_with_events()
|
||||
{
|
||||
var listId = await SeedListAsync();
|
||||
var task = await SeedTaskAsync(listId, TaskStatus.Idle);
|
||||
|
||||
var claude = new RecordingClaudeProcess(success: true);
|
||||
var broadcaster = new RecordingRefineBroadcaster();
|
||||
var runner = BuildRunner(claude, broadcaster);
|
||||
|
||||
var outcome = await runner.RefineAsync(task.Id, CancellationToken.None);
|
||||
|
||||
Assert.True(outcome.Success);
|
||||
Assert.Equal(1, claude.CallCount);
|
||||
Assert.Equal(1, broadcaster.StartedCount);
|
||||
Assert.Equal(1, broadcaster.FinishedCount);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class RecordingClaudeProcess : IClaudeProcess
|
||||
{
|
||||
private readonly bool _success;
|
||||
private int _callCount;
|
||||
|
||||
public int CallCount => _callCount;
|
||||
|
||||
public RecordingClaudeProcess(bool success) => _success = success;
|
||||
|
||||
public Task<RunResult> RunAsync(string arguments, string prompt, string workingDirectory,
|
||||
Func<string, Task> onStdoutLine, CancellationToken ct)
|
||||
{
|
||||
Interlocked.Increment(ref _callCount);
|
||||
var result = _success
|
||||
? new RunResult { ExitCode = 0, ResultMarkdown = "ok" }
|
||||
: new RunResult { ExitCode = 1, ResultMarkdown = null };
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class RecordingRefineBroadcaster : IRefineBroadcaster
|
||||
{
|
||||
private int _startedCount;
|
||||
private int _finishedCount;
|
||||
|
||||
public int StartedCount => _startedCount;
|
||||
public int FinishedCount => _finishedCount;
|
||||
|
||||
public Task RefineStartedAsync(string taskId)
|
||||
{
|
||||
Interlocked.Increment(ref _startedCount);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task RefineFinishedAsync(string taskId, bool success, string? error)
|
||||
{
|
||||
Interlocked.Increment(ref _finishedCount);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using ClaudeDo.Data.Git;
|
||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.Runner;
|
||||
|
||||
public class GitServicePreviewMergeTests : IDisposable
|
||||
{
|
||||
private readonly List<GitRepoFixture> _repos = new();
|
||||
private GitRepoFixture NewRepo() { var r = new GitRepoFixture(); _repos.Add(r); return r; }
|
||||
public void Dispose() { foreach (var r in _repos) try { r.Dispose(); } catch { } }
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewMergeAsync_NonConflicting_ReportsCleanWithChangedCount()
|
||||
{
|
||||
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||
var repo = NewRepo();
|
||||
var git = new GitService();
|
||||
var baseBranch = await git.GetCurrentBranchAsync(repo.RepoDir);
|
||||
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "checkout", "-b", "feature");
|
||||
File.WriteAllText(Path.Combine(repo.RepoDir, "newfile.txt"), "x\n");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "feat");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "checkout", baseBranch);
|
||||
|
||||
var preview = await git.PreviewMergeAsync(repo.RepoDir, baseBranch, "feature", CancellationToken.None);
|
||||
|
||||
Assert.True(preview.Supported);
|
||||
Assert.True(preview.Clean);
|
||||
Assert.Empty(preview.ConflictFiles);
|
||||
|
||||
var count = await git.CountChangedFilesAsync(repo.RepoDir, baseBranch, "feature", CancellationToken.None);
|
||||
Assert.Equal(1, count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewMergeAsync_Conflicting_ReportsFilesAndDoesNotMutateTree()
|
||||
{
|
||||
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||
var repo = NewRepo();
|
||||
var git = new GitService();
|
||||
var baseBranch = await git.GetCurrentBranchAsync(repo.RepoDir);
|
||||
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "checkout", "-b", "feature");
|
||||
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# from feature\n");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "feat readme");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "checkout", baseBranch);
|
||||
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# from base\n");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "base readme");
|
||||
|
||||
var headBefore = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
|
||||
|
||||
var preview = await git.PreviewMergeAsync(repo.RepoDir, baseBranch, "feature", CancellationToken.None);
|
||||
|
||||
Assert.True(preview.Supported);
|
||||
Assert.False(preview.Clean);
|
||||
Assert.Contains("README.md", preview.ConflictFiles);
|
||||
|
||||
Assert.Equal(headBefore, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim());
|
||||
Assert.False(await git.IsMidMergeAsync(repo.RepoDir));
|
||||
}
|
||||
}
|
||||
@@ -34,10 +34,12 @@ public class TaskMergeServiceTests : IDisposable
|
||||
{
|
||||
var fakeHub = new MergeRecordingHubContext();
|
||||
var broadcaster = new HubBroadcaster(fakeHub);
|
||||
var state = TaskStateServiceBuilder.Build(db.CreateFactory()).State;
|
||||
var svc = new TaskMergeService(
|
||||
db.CreateFactory(),
|
||||
new GitService(),
|
||||
broadcaster,
|
||||
state,
|
||||
NullLogger<TaskMergeService>.Instance);
|
||||
return (svc, fakeHub.Proxy);
|
||||
}
|
||||
@@ -442,6 +444,146 @@ public class TaskMergeServiceTests : IDisposable
|
||||
Assert.Equal(WorktreeState.Active, wt.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewAsync_CleanWorktree_ReturnsClean()
|
||||
{
|
||||
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||
var repo = NewRepo();
|
||||
var db = NewDb();
|
||||
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
|
||||
|
||||
var wtMgr = BuildWorktreeManager(db);
|
||||
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
|
||||
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
|
||||
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "x\n");
|
||||
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
||||
|
||||
var (svc, _) = BuildService(db);
|
||||
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
||||
|
||||
var preview = await svc.PreviewAsync(task.Id, target, CancellationToken.None);
|
||||
|
||||
Assert.Equal(TaskMergeService.PreviewClean, preview.Status);
|
||||
Assert.True(preview.ChangedFileCount >= 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewAsync_Conflict_ReturnsConflictFiles()
|
||||
{
|
||||
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||
var repo = NewRepo();
|
||||
var db = NewDb();
|
||||
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
|
||||
|
||||
var wtMgr = BuildWorktreeManager(db);
|
||||
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
|
||||
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
|
||||
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "README.md"), "# from worktree\n");
|
||||
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
||||
|
||||
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# from main\n");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main edit");
|
||||
|
||||
var (svc, _) = BuildService(db);
|
||||
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
||||
|
||||
var preview = await svc.PreviewAsync(task.Id, target, CancellationToken.None);
|
||||
|
||||
Assert.Equal(TaskMergeService.PreviewConflict, preview.Status);
|
||||
Assert.Contains("README.md", preview.ConflictFiles);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PreviewAsync_NoActiveWorktree_ReturnsUnavailable()
|
||||
{
|
||||
var db = NewDb();
|
||||
var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.WaitingForReview);
|
||||
var (svc, _) = BuildService(db);
|
||||
|
||||
var preview = await svc.PreviewAsync(task.Id, "main", CancellationToken.None);
|
||||
|
||||
Assert.Equal(TaskMergeService.PreviewUnavailable, preview.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApproveAndMergeAsync_CleanWorktree_MergesAndMarksDone()
|
||||
{
|
||||
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||
var repo = NewRepo();
|
||||
var db = NewDb();
|
||||
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
|
||||
|
||||
var wtMgr = BuildWorktreeManager(db);
|
||||
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
|
||||
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
|
||||
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "new\n");
|
||||
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
||||
|
||||
var (svc, _) = BuildService(db);
|
||||
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
||||
|
||||
var result = await svc.ApproveAndMergeAsync(task.Id, target, CancellationToken.None);
|
||||
|
||||
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
|
||||
using var ctx = db.CreateContext();
|
||||
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
|
||||
Assert.Equal(TaskStatus.Done, updated!.Status);
|
||||
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
|
||||
Assert.Equal(WorktreeState.Merged, wt!.State);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApproveAndMergeAsync_Conflict_LeavesTaskWaitingForReview()
|
||||
{
|
||||
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||
var repo = NewRepo();
|
||||
var db = NewDb();
|
||||
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
|
||||
|
||||
var wtMgr = BuildWorktreeManager(db);
|
||||
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
|
||||
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
|
||||
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "README.md"), "# from worktree\n");
|
||||
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
||||
|
||||
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# from main\n");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
||||
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main edit");
|
||||
var headBefore = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
|
||||
|
||||
var (svc, _) = BuildService(db);
|
||||
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
||||
|
||||
var result = await svc.ApproveAndMergeAsync(task.Id, target, CancellationToken.None);
|
||||
|
||||
Assert.Equal(TaskMergeService.StatusConflict, result.Status);
|
||||
Assert.Contains("README.md", result.ConflictFiles);
|
||||
|
||||
using var ctx = db.CreateContext();
|
||||
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
|
||||
Assert.Equal(TaskStatus.WaitingForReview, updated!.Status);
|
||||
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
|
||||
Assert.Equal(WorktreeState.Active, wt!.State);
|
||||
Assert.Equal(headBefore, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim());
|
||||
Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApproveAndMergeAsync_NoWorktree_MarksDone()
|
||||
{
|
||||
var db = NewDb();
|
||||
var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.WaitingForReview);
|
||||
var (svc, _) = BuildService(db);
|
||||
|
||||
var result = await svc.ApproveAndMergeAsync(task.Id, "main", CancellationToken.None);
|
||||
|
||||
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
|
||||
using var ctx = db.CreateContext();
|
||||
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
|
||||
Assert.Equal(TaskStatus.Done, updated!.Status);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MergeAsync_LeaveConflicts_DoesNotAbortAndReturnsConflictFiles()
|
||||
{
|
||||
|
||||
@@ -42,7 +42,9 @@ sealed class FakeWorkerClient : IWorkerClient
|
||||
public Task<ListConfigDto?> GetListConfigAsync(string listId) => Task.FromResult<ListConfigDto?>(null);
|
||||
public Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto) => Task.CompletedTask;
|
||||
public Task SetTaskStatusAsync(string taskId, TaskStatus status) => Task.CompletedTask;
|
||||
public Task ApproveReviewAsync(string taskId) => Task.CompletedTask;
|
||||
public Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) => Task.FromResult<MergeResultDto?>(null);
|
||||
public Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch) => Task.FromResult<MergePreviewDto?>(null);
|
||||
public Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));
|
||||
public Task RejectReviewToQueueAsync(string taskId, string feedback) => Task.CompletedTask;
|
||||
public Task RejectReviewToIdleAsync(string taskId) => Task.CompletedTask;
|
||||
public Task CancelReviewAsync(string taskId) => Task.CompletedTask;
|
||||
@@ -62,6 +64,8 @@ sealed class FakeWorkerClient : IWorkerClient
|
||||
public event Action? PrepStartedEvent;
|
||||
public event Action<string>? PrepLineEvent;
|
||||
public event Action<bool>? PrepFinishedEvent;
|
||||
public event Action<string>? RefineStartedEvent;
|
||||
public event Action<string, bool, string?>? RefineFinishedEvent;
|
||||
public event Action<string, string>? PlanningMergeStartedEvent;
|
||||
public event Action<string, string>? PlanningSubtaskMergedEvent;
|
||||
public event Action<string, string, IReadOnlyList<string>>? PlanningMergeConflictEvent;
|
||||
@@ -86,6 +90,7 @@ sealed class FakeWorkerClient : IWorkerClient
|
||||
public Task UpdateDailyNoteAsync(string id, string text) => Task.CompletedTask;
|
||||
public Task DeleteDailyNoteAsync(string id) => Task.CompletedTask;
|
||||
public Task<string> GetLastPrepLogAsync() => Task.FromResult(string.Empty);
|
||||
public Task RefineTaskAsync(string taskId) => Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── Helper to build VM with pre-seeded Items ──────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user