Compare commits
52 Commits
19435b2d48
...
v1.8.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f21c65be18 | ||
|
|
c300f8c313 | ||
|
|
d6e0953293 | ||
|
|
a8b86e25e6 | ||
|
|
1abb429f12 | ||
|
|
803c04d9e0 | ||
|
|
12732d6dc9 | ||
|
|
b3a2daf40d | ||
|
|
8f49ebb248 | ||
|
|
f56cc617c3 | ||
|
|
ca8326c4c5 | ||
|
|
f5d165baae | ||
|
|
61a40d549b | ||
|
|
5723b81992 | ||
|
|
7f1a14ab80 | ||
|
|
33bdff8a6e | ||
|
|
b5cf19b19a | ||
|
|
9f19a714f7 | ||
|
|
b672c9aaf3 | ||
|
|
384e058812 | ||
|
|
01e0c1d794 | ||
|
|
00a065bf7f | ||
|
|
763732a9b3 | ||
|
|
a41b8de47a | ||
|
|
18b777a712 | ||
|
|
7f173daecb | ||
|
|
e71c0ed24f | ||
|
|
d450153183 | ||
|
|
72687e9b30 | ||
|
|
d52243ccd1 | ||
|
|
8cafad370e | ||
|
|
d8a973d0e1 | ||
|
|
0b623b8e4a | ||
|
|
5edb433755 | ||
|
|
c8f82ed3c2 | ||
|
|
1aa06077a8 | ||
|
|
cb20877620 | ||
|
|
dcbf67c63b | ||
|
|
02b11c727c | ||
|
|
74afc46909 | ||
|
|
ef3fba1690 | ||
|
|
ef2f5c51e4 | ||
|
|
3060cb0242 | ||
|
|
3596053512 | ||
|
|
4bf4a27036 | ||
|
|
de4ad5dcf3 | ||
|
|
2dfc4559b1 | ||
|
|
dd3b03b9e4 | ||
|
|
f4416ee1c3 | ||
|
|
42bb79e2b7 | ||
|
|
561028e67b | ||
|
|
07a9d07cf6 |
101
.gitea/workflows/audit.yml
Normal file
101
.gitea/workflows/audit.yml
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
name: Dependency Audit
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 6 * * 1' # Mondays 06:00 UTC
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
audit:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
DOTNET_ROOT: /home/mika/.dotnet
|
||||||
|
GITEA_API: https://git.kuns.dev/api/v1
|
||||||
|
REPO: releases/ClaudeDo
|
||||||
|
ISSUE_TITLE: 'Dependency audit: vulnerable packages detected'
|
||||||
|
steps:
|
||||||
|
- name: Checkout main
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
git clone --depth 1 --branch main \
|
||||||
|
"https://oauth2:${TOKEN}@git.kuns.dev/${REPO}.git" src
|
||||||
|
|
||||||
|
- name: Scan for vulnerable / outdated packages
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
export PATH="$DOTNET_ROOT:$PATH"
|
||||||
|
cd src
|
||||||
|
|
||||||
|
: > audit.log
|
||||||
|
: > vuln.md
|
||||||
|
found=0
|
||||||
|
|
||||||
|
# .slnx tooling needs .NET 9; iterate per-project to stay on .NET 8.
|
||||||
|
while IFS= read -r proj; do
|
||||||
|
echo "==== $proj ====" | tee -a audit.log
|
||||||
|
dotnet restore "$proj" >/dev/null
|
||||||
|
|
||||||
|
vuln="$(dotnet list "$proj" package --vulnerable --include-transitive 2>&1)"
|
||||||
|
echo "$vuln" | tee -a audit.log
|
||||||
|
if echo "$vuln" | grep -qi "has the following vulnerable"; then
|
||||||
|
found=1
|
||||||
|
{
|
||||||
|
printf '#### `%s`\n\n```\n' "$proj"
|
||||||
|
echo "$vuln"
|
||||||
|
printf '```\n\n'
|
||||||
|
} >> vuln.md
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Outdated is informational only — never fails the run.
|
||||||
|
dotnet list "$proj" package --outdated 2>&1 | tee -a audit.log || true
|
||||||
|
echo "" | tee -a audit.log
|
||||||
|
done < <(find . -name '*.csproj' | sort)
|
||||||
|
|
||||||
|
if [ "$found" -ne 0 ]; then
|
||||||
|
echo "::error::Vulnerable packages detected — see log above." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "No vulnerable packages found."
|
||||||
|
|
||||||
|
- name: Report vulnerabilities to a Gitea issue
|
||||||
|
if: failure()
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd src
|
||||||
|
|
||||||
|
if [ -s vuln.md ]; then
|
||||||
|
DETAILS="$(cat vuln.md)"
|
||||||
|
else
|
||||||
|
DETAILS="The audit job failed before producing findings — check the run log."
|
||||||
|
fi
|
||||||
|
BODY="$(printf 'Automated weekly dependency audit found vulnerable packages.\n\n%s\n\n[View workflow run](%s)' \
|
||||||
|
"$DETAILS" "$RUN_URL")"
|
||||||
|
|
||||||
|
# Reuse an existing open issue if one is already tracking this.
|
||||||
|
EXISTING="$(curl -sS \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
"${GITEA_API}/repos/${REPO}/issues?state=open&type=issues&limit=50" \
|
||||||
|
| jq -r --arg t "$ISSUE_TITLE" '.[] | select(.title==$t) | .number' | head -n1)"
|
||||||
|
|
||||||
|
if [ -n "$EXISTING" ]; then
|
||||||
|
echo "Commenting on existing issue #$EXISTING"
|
||||||
|
jq -n --arg body "$BODY" '{body:$body}' \
|
||||||
|
| curl -sS --fail-with-body -X POST \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d @- \
|
||||||
|
"${GITEA_API}/repos/${REPO}/issues/${EXISTING}/comments" >/dev/null
|
||||||
|
else
|
||||||
|
echo "Creating new issue"
|
||||||
|
jq -n --arg title "$ISSUE_TITLE" --arg body "$BODY" '{title:$title, body:$body}' \
|
||||||
|
| curl -sS --fail-with-body -X POST \
|
||||||
|
-H "Authorization: token ${TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d @- \
|
||||||
|
"${GITEA_API}/repos/${REPO}/issues" >/dev/null
|
||||||
|
fi
|
||||||
85
.gitea/workflows/changelog.yml
Normal file
85
.gitea/workflows/changelog.yml
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
name: Changelog
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
changelog:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
REPO: releases/ClaudeDo
|
||||||
|
steps:
|
||||||
|
- name: Checkout main (full history)
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
git clone "https://oauth2:${TOKEN}@git.kuns.dev/${REPO}.git" src
|
||||||
|
cd src
|
||||||
|
git fetch --tags --force
|
||||||
|
git checkout main
|
||||||
|
|
||||||
|
- name: Regenerate CHANGELOG.md
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd src
|
||||||
|
|
||||||
|
emit_group() {
|
||||||
|
# $1 range, $2 conventional-type, $3 heading
|
||||||
|
local range="$1" type="$2" title="$3" lines
|
||||||
|
lines="$(git log "$range" --no-merges --pretty=format:'%s|%h' \
|
||||||
|
| grep -E "^${type}(\([^)]*\))?(!)?: " || true)"
|
||||||
|
[ -z "$lines" ] && return 0
|
||||||
|
printf '### %s\n\n' "$title"
|
||||||
|
while IFS='|' read -r subject hash; do
|
||||||
|
printf -- '- %s (%s)\n' "${subject#*: }" "$hash"
|
||||||
|
done <<< "$lines"
|
||||||
|
printf '\n'
|
||||||
|
}
|
||||||
|
|
||||||
|
emit_section() {
|
||||||
|
# $1 range, $2 tag, $3 date
|
||||||
|
printf '## %s — %s\n\n' "$2" "$3"
|
||||||
|
emit_group "$1" feat "Features"
|
||||||
|
emit_group "$1" fix "Fixes"
|
||||||
|
emit_group "$1" perf "Performance"
|
||||||
|
emit_group "$1" refactor "Refactoring"
|
||||||
|
emit_group "$1" docs "Documentation"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Tags ascending by semver so we can pair each with its predecessor.
|
||||||
|
mapfile -t TAGS < <(git tag --sort=v:refname | grep -E '^v' || true)
|
||||||
|
|
||||||
|
{
|
||||||
|
printf '# Changelog\n\n'
|
||||||
|
for ((i=${#TAGS[@]}-1; i>=0; i--)); do
|
||||||
|
TAG="${TAGS[$i]}"
|
||||||
|
DATE="$(git log -1 --format=%ad --date=short "$TAG")"
|
||||||
|
if (( i > 0 )); then
|
||||||
|
RANGE="${TAGS[$((i-1))]}..${TAG}"
|
||||||
|
else
|
||||||
|
RANGE="$TAG"
|
||||||
|
fi
|
||||||
|
emit_section "$RANGE" "$TAG" "$DATE"
|
||||||
|
done
|
||||||
|
} > CHANGELOG.md
|
||||||
|
|
||||||
|
cat CHANGELOG.md
|
||||||
|
|
||||||
|
- name: Commit and push if changed
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
cd src
|
||||||
|
if git diff --quiet -- CHANGELOG.md; then
|
||||||
|
echo "CHANGELOG.md unchanged; nothing to commit."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
git config user.name "ClaudeDo CI"
|
||||||
|
git config user.email "ci@kuns.dev"
|
||||||
|
git add CHANGELOG.md
|
||||||
|
git commit -m "docs(changelog): update for ${GITHUB_REF_NAME}"
|
||||||
|
git push origin main
|
||||||
36
CHANGELOG.md
Normal file
36
CHANGELOG.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to ClaudeDo are documented here.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
Versions are derived from git tags (`vX.Y.Z`) via MinVer.
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Review queue is no longer permanently empty: the review virtual list now
|
||||||
|
matches tasks in `WaitingForReview` (it previously matched `Done` + active
|
||||||
|
worktree, a state successful runs never land in).
|
||||||
|
- UI ViewModels (`Details`, `Tasks`, `Lists` islands and the shell) now dispose
|
||||||
|
their `Loc.LanguageChanged`, worker-event, and timer subscriptions, fixing
|
||||||
|
long-lived subscription leaks.
|
||||||
|
- Stopping a task while the worker is offline no longer throws: `StopAsync`
|
||||||
|
guards on task/running/connection state and handles hub errors.
|
||||||
|
|
||||||
|
### Hardening
|
||||||
|
|
||||||
|
- Release-readiness audit pass across Worker, Data, UI, MCP, App and Installer
|
||||||
|
(see `docs/open.md` backlog for tracked follow-ups).
|
||||||
|
|
||||||
|
## [1.7.0]
|
||||||
|
|
||||||
|
- Agent roadblock + run-outcome surfacing in the task detail pane.
|
||||||
|
- i18n: localized task-header, task-row and prime-schedule tooltips.
|
||||||
|
- CI: dependency-audit and changelog Gitea workflows.
|
||||||
|
- Layer A/B/C git merge & conflict-resolution cockpit (multi-worktree
|
||||||
|
batch-merge, inline conflict resolver).
|
||||||
|
|
||||||
|
[Unreleased]: https://git.kuns.dev/releases/ClaudeDo/compare/v1.7.0...HEAD
|
||||||
|
[1.7.0]: https://git.kuns.dev/releases/ClaudeDo/releases/tag/v1.7.0
|
||||||
@@ -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)
|
- EF Core migrations manage schema (Migrations/ folder in ClaudeDo.Data)
|
||||||
- `IDbContextFactory<ClaudeDoDbContext>` used by singleton consumers (e.g. Worker)
|
- `IDbContextFactory<ClaudeDoDbContext>` used by singleton consumers (e.g. Worker)
|
||||||
- Entity configuration via `IEntityTypeConfiguration<T>` in Configuration/ folder
|
- 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 (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.
|
- Task status flow: Idle | Queued -> Running -> WaitingForReview -> Done | Failed | Cancelled. A task that spawns/has children passes through WaitingForChildren first, then surfaces for review once every child is terminal — this is the single parent model for both planning and improvement parents (planning/improvement *children* themselves go straight to Done, only the parent is reviewed). From review you can approve, reject-rerun (Queued, resumes the session with feedback), reject-park (Idle), or cancel. Approve is the single review+merge action: a childless task merges its own worktree then Done (conflicts keep it in WaitingForReview); a task with children drives the unit merge (parent worktree if any + each Done child in order, with conflict continue/abort). Tasks with no active worktree (sandbox run) approve straight to Done.
|
||||||
- Worktree state flow: Active -> Merged | Discarded | Kept
|
- 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
|
- 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)
|
- Interfaces live in an `Interfaces/` subfolder beside their consumers (namespace unchanged)
|
||||||
|
|||||||
@@ -0,0 +1,432 @@
|
|||||||
|
# Git Merge/Review — Shared Foundation + Layer A Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Build the shared worker conflict contract (so parallel Layer B/C sessions branch from frozen interfaces) and rework the Git tab into a single Approve+merge cockpit.
|
||||||
|
|
||||||
|
**Architecture:** Phase 0 adds the conflict-resolution contract to `IWorkerClient`/`WorkerClient` (real `_hub.InvokeAsync` bodies — the worker hub methods are implemented later by Layer C; calls simply fail at runtime until then) plus client-side DTOs and test-fake updates, then commits + pushes so B and C branch from it. Phase A reworks `WorkConsole.axaml`'s Git tab and routes single-task merge/approve conflicts into a `RequestConflictResolution` seam (wired to Layer C's resolver by the integrator at merge time).
|
||||||
|
|
||||||
|
**Tech Stack:** .NET 8, Avalonia 12 (Fluent), CommunityToolkit.Mvvm, SignalR, xUnit. Build individual csproj with `-c Release` (`.slnx` needs .NET 9; a running Worker locks `Debug`).
|
||||||
|
|
||||||
|
**Reference spec:** `docs/superpowers/specs/2026-06-05-git-merge-review-rework-design.md`
|
||||||
|
|
||||||
|
**Note on the canonical diff renderer:** the unified diff model/control already exists — `DiffFileViewModel`/`DiffLineViewModel`/`UnifiedDiffParser` (in `src/ClaudeDo.Ui/ViewModels/Modals/`) rendered by `DiffLinesView` (`src/ClaudeDo.Ui/Views/Controls/DiffLinesView.axaml`). `DiffModalView` and `PlanningDiffView` already use it. So "consolidate diff renderers" for this scope is just verifying that (Task A.3); migrating `WorktreeModalView`'s bespoke diff onto `DiffLinesView` is Layer B's job.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
**Phase 0 (foundation — pushed before B/C branch):**
|
||||||
|
- Modify `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs` — 5 new method signatures.
|
||||||
|
- Modify `src/ClaudeDo.Ui/Services/WorkerClient.cs` — 5 `InvokeAsync` bodies + 3 new DTO records.
|
||||||
|
- Modify `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs` — 5 new `virtual` no-op methods.
|
||||||
|
- Modify `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs` — 5 new methods on `FakeWorkerClient`.
|
||||||
|
|
||||||
|
**Phase A (Layer A — this session, after foundation commit):**
|
||||||
|
- Modify `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` — `RequestConflictResolution` seam; route Approve/Merge conflicts into it.
|
||||||
|
- Modify `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml` — fuse REVIEW + MERGE sections into one cockpit block.
|
||||||
|
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs` (or a sibling test file in the same folder).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 0 — Shared Foundation
|
||||||
|
|
||||||
|
### Task 0.1: Add the conflict contract (interface + client + DTOs)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the 5 method signatures to `IWorkerClient`**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs`, after the existing
|
||||||
|
`Task CancelReviewAsync(string taskId);` line (line 45), add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// ── Conflict resolution (worker hub side implemented by Layer C) ──
|
||||||
|
Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch);
|
||||||
|
Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId);
|
||||||
|
Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent);
|
||||||
|
Task<MergeResultDto> ContinueMergeAsync(string taskId);
|
||||||
|
Task AbortMergeAsync(string taskId);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the 3 DTO records to `WorkerClient.cs`**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Ui/Services/WorkerClient.cs`, immediately after line 534
|
||||||
|
(`public record MergeTargetsDto(...)`), add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public record MergeConflictsDto(string TaskId, IReadOnlyList<ConflictFileDto> Files);
|
||||||
|
public record ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks);
|
||||||
|
public record ConflictHunkDto(string Ours, string Theirs, string? Base);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the 5 client method bodies to `WorkerClient.cs`**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Ui/Services/WorkerClient.cs`, right after the `MergeTaskAsync`
|
||||||
|
method (ends at line 270), add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch)
|
||||||
|
=> _hub.InvokeAsync<MergeResultDto>("StartConflictMerge", taskId, targetBranch);
|
||||||
|
|
||||||
|
public Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId)
|
||||||
|
=> _hub.InvokeAsync<MergeConflictsDto>("GetMergeConflicts", taskId);
|
||||||
|
|
||||||
|
public Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent)
|
||||||
|
=> _hub.InvokeAsync("WriteConflictResolution", taskId, path, resolvedContent);
|
||||||
|
|
||||||
|
public Task<MergeResultDto> ContinueMergeAsync(string taskId)
|
||||||
|
=> _hub.InvokeAsync<MergeResultDto>("ContinueMerge", taskId);
|
||||||
|
|
||||||
|
public Task AbortMergeAsync(string taskId)
|
||||||
|
=> _hub.InvokeAsync("AbortMerge", taskId);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build the UI project**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj -c Release`
|
||||||
|
Expected: build FAILS — the two test projects won't compile yet, but the UI project
|
||||||
|
itself should succeed. If the UI project reports "does not implement interface member"
|
||||||
|
it means a body is missing; fix before continuing. (Test projects are fixed in 0.2.)
|
||||||
|
|
||||||
|
### Task 0.2: Update the hand-rolled test fakes
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`
|
||||||
|
- Modify: `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add 5 virtual no-ops to `StubWorkerClient`**
|
||||||
|
|
||||||
|
In `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`, after the `MergeTaskAsync` override
|
||||||
|
(line 57), add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public virtual Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch) => Task.FromResult(new MergeResultDto("conflict", System.Array.Empty<string>(), null));
|
||||||
|
public virtual Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId) => Task.FromResult(new MergeConflictsDto(taskId, System.Array.Empty<ConflictFileDto>()));
|
||||||
|
public virtual Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent) => Task.CompletedTask;
|
||||||
|
public virtual Task<MergeResultDto> ContinueMergeAsync(string taskId) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));
|
||||||
|
public virtual Task AbortMergeAsync(string taskId) => Task.CompletedTask;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add 5 methods to `FakeWorkerClient`**
|
||||||
|
|
||||||
|
In `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs`, after the
|
||||||
|
`MergeTaskAsync` method (line 47), add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch) => Task.FromResult(new MergeResultDto("conflict", System.Array.Empty<string>(), null));
|
||||||
|
public Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId) => Task.FromResult(new MergeConflictsDto(taskId, System.Array.Empty<ConflictFileDto>()));
|
||||||
|
public Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent) => Task.CompletedTask;
|
||||||
|
public Task<MergeResultDto> ContinueMergeAsync(string taskId) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));
|
||||||
|
public Task AbortMergeAsync(string taskId) => Task.CompletedTask;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build both test projects**
|
||||||
|
|
||||||
|
Run: `dotnet build tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release && dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release`
|
||||||
|
Expected: both BUILD succeed.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the UI test suite to confirm green baseline**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
|
||||||
|
Expected: PASS (no behavior changed yet).
|
||||||
|
|
||||||
|
### Task 0.3: Commit and push the foundation
|
||||||
|
|
||||||
|
- [ ] **Step 1: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs src/ClaudeDo.Ui/Services/WorkerClient.cs tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs
|
||||||
|
git commit -m "feat(ui): add conflict-resolution worker contract (foundation for merge rework)"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Push so Layer B/C can branch from this commit**
|
||||||
|
|
||||||
|
Run: `git push`
|
||||||
|
Expected: pushed to `main`. (First push to git.kuns.dev may fail auth — retry once.)
|
||||||
|
**This commit is the branch point for the Layer B and Layer C kickoff prompts.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase A — Layer A Review/Merge Cockpit
|
||||||
|
|
||||||
|
### Task A.1: Conflict-resolution seam + route Approve/Merge conflicts into it (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`
|
||||||
|
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandConflictSeamTests.cs` (new)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandConflictSeamTests.cs`. Mirror
|
||||||
|
the VM-construction harness used in
|
||||||
|
`tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs` (same folder) —
|
||||||
|
construct `DetailsIslandViewModel` exactly as that file does, including its
|
||||||
|
`StubWorkerClient` subclass pattern. The test:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task ApproveReview_OnConflict_InvokesConflictResolutionSeam()
|
||||||
|
{
|
||||||
|
string? resolvedTaskId = null;
|
||||||
|
string? resolvedTarget = null;
|
||||||
|
|
||||||
|
// Construct the VM as in DetailsIslandPlanningTests, with a worker stub whose
|
||||||
|
// ApproveReviewAsync returns a conflict result:
|
||||||
|
// public override Task<MergeResultDto?> ApproveReviewAsync(string id, string target)
|
||||||
|
// => Task.FromResult<MergeResultDto?>(new MergeResultDto("conflict", new[]{"a.cs"}, null));
|
||||||
|
var vm = CreateVm(/* worker stub above */);
|
||||||
|
vm.RequestConflictResolution = (taskId, target) =>
|
||||||
|
{
|
||||||
|
resolvedTaskId = taskId; resolvedTarget = target;
|
||||||
|
return System.Threading.Tasks.Task.CompletedTask;
|
||||||
|
};
|
||||||
|
// assign a task in WaitingForReview + a SelectedMergeTarget = "main" via the same
|
||||||
|
// helpers DetailsIslandPlanningTests uses.
|
||||||
|
|
||||||
|
await vm.ApproveReviewCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.Equal(/* the seeded task id */, resolvedTaskId);
|
||||||
|
Assert.Equal("main", resolvedTarget);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the test to verify it fails**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter ApproveReview_OnConflict_InvokesConflictResolutionSeam`
|
||||||
|
Expected: FAIL — `RequestConflictResolution` property does not exist (compile error).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the seam property**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`, beside the other
|
||||||
|
view-wired delegates (`ShowDiffModal`, `ShowMergeModal` around line 387–390), add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Invoked when a single-task merge/approve hits a conflict. Wired by the
|
||||||
|
// integrator to Layer C's conflict resolver. Args: (taskId, targetBranch).
|
||||||
|
public Func<string, string, System.Threading.Tasks.Task>? RequestConflictResolution { get; set; }
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Route the Approve conflict branch into the seam**
|
||||||
|
|
||||||
|
In `ApproveReviewAsync` (around line 1453), replace the conflict branch body so it
|
||||||
|
prefers the seam, falling back to the current preview-text behavior:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var result = await _worker.ApproveReviewAsync(Task.Id, SelectedMergeTarget ?? "");
|
||||||
|
if (result?.Status == "conflict")
|
||||||
|
{
|
||||||
|
if (RequestConflictResolution is not null)
|
||||||
|
{
|
||||||
|
await RequestConflictResolution(Task.Id, SelectedMergeTarget ?? "");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var (text, _, _) = MergePreviewPresenter.Describe(
|
||||||
|
new MergePreviewDto("conflict", result.ConflictFiles, 0));
|
||||||
|
MergePreviewText = text; MergeIsClean = false; MergeIsConflict = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Route the manual Merge conflict branch into the seam**
|
||||||
|
|
||||||
|
In `MergeAsync` (around line 1170), apply the same pattern to its conflict branch:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var result = await _worker.MergeTaskAsync(Task.Id, SelectedMergeTarget ?? "", false, "Merge task");
|
||||||
|
if (result.Status == "conflict")
|
||||||
|
{
|
||||||
|
if (RequestConflictResolution is not null)
|
||||||
|
{
|
||||||
|
await RequestConflictResolution(Task.Id, SelectedMergeTarget ?? "");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var (text, _, _) = MergePreviewPresenter.Describe(
|
||||||
|
new MergePreviewDto("conflict", result.ConflictFiles, 0));
|
||||||
|
MergePreviewText = text; MergeIsClean = false; MergeIsConflict = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await RefreshMergePreviewAsync();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run the test to verify it passes**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter ApproveReview_OnConflict_InvokesConflictResolutionSeam`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Run the full UI suite (no regressions)**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandConflictSeamTests.cs
|
||||||
|
git commit -m "feat(ui): route single-task merge conflicts into a resolution seam"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task A.2: Fuse the Git tab into one Approve+merge cockpit
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Replace the two Git-tab sections with one cockpit block**
|
||||||
|
|
||||||
|
In `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml`, replace the entire Git
|
||||||
|
`ScrollViewer` body (lines 255–313 — the `<!-- Git: ... -->` block containing the
|
||||||
|
separate `REVIEW` `StackPanel` and the `MERGE & WORKTREE` `StackPanel`) with a single
|
||||||
|
cockpit where Approve sits with the merge target/preview/actions. Keep the existing
|
||||||
|
control class names (`section-label`, `field-label`, `btn`, `btn accent`, `meta`) and
|
||||||
|
the existing bindings (`SelectedMergeTarget`, `MergeTargetBranches`, `MergePreviewText`,
|
||||||
|
`MergeIsClean`, `MergeIsConflict`, `ShowMergePreviewMuted`, `OpenDiffCommand`,
|
||||||
|
`ApproveReviewCommand`, `MergeCommand`, `ShowSingleMerge`, `OpenWorktreeCommand`,
|
||||||
|
`ReviewCombinedDiffCommand`, `MergeAllCommand`, `CanMergeAll`, `MergeAllDisabledReason`,
|
||||||
|
`MergeAllError`):
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<!-- Git: one Approve + merge cockpit -->
|
||||||
|
<ScrollViewer IsVisible="{Binding IsGitTab}" Padding="14,10">
|
||||||
|
<StackPanel Spacing="12" IsVisible="{Binding ShowMergeSection}">
|
||||||
|
<TextBlock Classes="section-label" Text="MERGE" />
|
||||||
|
|
||||||
|
<StackPanel Spacing="4">
|
||||||
|
<TextBlock Classes="field-label" Text="Target branch" />
|
||||||
|
<ComboBox ItemsSource="{Binding MergeTargetBranches}"
|
||||||
|
SelectedItem="{Binding SelectedMergeTarget, Mode=TwoWay}"
|
||||||
|
HorizontalAlignment="Stretch" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Spacing="0">
|
||||||
|
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
|
||||||
|
Foreground="{DynamicResource MossBrush}"
|
||||||
|
IsVisible="{Binding MergeIsClean}" />
|
||||||
|
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
|
||||||
|
Foreground="{DynamicResource BloodBrush}"
|
||||||
|
IsVisible="{Binding MergeIsConflict}" />
|
||||||
|
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
|
||||||
|
Foreground="{DynamicResource TextMuteBrush}"
|
||||||
|
IsVisible="{Binding ShowMergePreviewMuted}" />
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Primary action: Approve flows straight into the merge.
|
||||||
|
Approve is the review-gated path; the plain Merge button covers
|
||||||
|
already-reviewed / kept worktrees. -->
|
||||||
|
<WrapPanel Orientation="Horizontal">
|
||||||
|
<Button Classes="btn accent" Content="Approve & Merge" Margin="0,0,8,8"
|
||||||
|
Command="{Binding ApproveReviewCommand}"
|
||||||
|
IsVisible="{Binding IsWaitingForReview}" />
|
||||||
|
<Button Classes="btn accent" Content="Merge" Margin="0,0,8,8"
|
||||||
|
Command="{Binding MergeCommand}"
|
||||||
|
IsVisible="{Binding ShowSingleMerge}" />
|
||||||
|
<Button Classes="btn" Content="Open Diff" Margin="0,0,8,8"
|
||||||
|
Command="{Binding OpenDiffCommand}" />
|
||||||
|
<Button Classes="btn" Margin="0,0,8,8"
|
||||||
|
Command="{Binding OpenWorktreeCommand}">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="5">
|
||||||
|
<TextBlock Text="Worktree" />
|
||||||
|
<PathIcon Data="{StaticResource Icon.ArrowOut}" Width="11" Height="11" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
<Button Classes="btn" Content="Review Combined Diff" Margin="0,0,8,8"
|
||||||
|
Command="{Binding ReviewCombinedDiffCommand}" />
|
||||||
|
<Button Classes="btn accent" Content="Merge All Subtasks" Margin="0,0,0,8"
|
||||||
|
Command="{Binding MergeAllCommand}"
|
||||||
|
IsEnabled="{Binding CanMergeAll}"
|
||||||
|
ToolTip.Tip="{Binding MergeAllDisabledReason}" />
|
||||||
|
</WrapPanel>
|
||||||
|
|
||||||
|
<TextBlock Text="{Binding MergeAllError}"
|
||||||
|
Foreground="{DynamicResource BloodBrush}"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
IsVisible="{Binding MergeAllError,
|
||||||
|
Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: the cockpit now shows whenever `ShowMergeSection` is true. `ShowMergeSection`
|
||||||
|
(DetailsIslandViewModel line 161) must be true while `IsWaitingForReview` so the
|
||||||
|
Approve button appears. Check its expression in Step 2.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Verify `ShowMergeSection` covers the review state**
|
||||||
|
|
||||||
|
Read `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` line 161. If
|
||||||
|
`ShowMergeSection` is false while `IsWaitingForReview` (e.g. it requires a non-review
|
||||||
|
state), widen it to also be true when `IsWaitingForReview && WorktreePath != null`, and
|
||||||
|
ensure `OnPropertyChanged(nameof(ShowMergeSection))` already fires on the relevant state
|
||||||
|
transitions (it is notified via `NotifySessionSections`). Make the minimal change needed
|
||||||
|
so the Approve button is visible in review state. If it already covers review, change
|
||||||
|
nothing.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build the app project**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||||
|
Expected: BUILD succeeds (pulls in Ui + Data).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Visual verification (manual — flag for the user)**
|
||||||
|
|
||||||
|
This is an AXAML layout change with no automated coverage. Launch the app, open a task
|
||||||
|
in `WaitingForReview`, open the Git tab, and confirm: the single MERGE block shows the
|
||||||
|
target combo, the colored preview line, an "Approve & Merge" button (review state), and
|
||||||
|
the diff/worktree/combined/merge-all actions. **Explicitly tell the user this needs a
|
||||||
|
visual pass — do not claim it works without running it.**
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs
|
||||||
|
git commit -m "feat(ui): fuse git tab into one approve+merge cockpit"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Task A.3: Verify diff-renderer consolidation
|
||||||
|
|
||||||
|
**Files:** none modified (verification only).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Confirm DiffModal + Planning already use the canonical renderer**
|
||||||
|
|
||||||
|
Run: `rg -l "DiffLinesView" src/ClaudeDo.Ui/Views`
|
||||||
|
Expected: matches in `Modals/DiffModalView.axaml` and `Planning/PlanningDiffView.axaml`.
|
||||||
|
If `PlanningDiffView.axaml` does NOT use `DiffLinesView`, change its diff `ItemsControl`
|
||||||
|
to a `<controls:DiffLinesView Lines="{Binding SelectedFile.Lines}" />` (matching
|
||||||
|
`DiffModalView.axaml`'s usage) and rebuild the App project. If both already use it, this
|
||||||
|
task is a no-op — record that and move on. (`WorktreeModalView`'s bespoke diff is
|
||||||
|
intentionally left for Layer B.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
- **Spec coverage:** Foundation contract (spec §"Frozen worker conflict contract") →
|
||||||
|
Task 0.1. Test fakes (spec parallel-boundaries row) → Task 0.2. Branch point (spec
|
||||||
|
§"built & pushed this session") → Task 0.3. Layer A cockpit + Approve/merge flow
|
||||||
|
together (spec §"Layer A") → Task A.2. Single-task approve-on-conflict opens resolver
|
||||||
|
via seam (spec §"Layer A" + §"integration seams") → Task A.1. Diff consolidation
|
||||||
|
(spec §"One diff model") → Task A.3. Output-footer feedback unchanged → not touched
|
||||||
|
(correct). No spec requirement left unmapped for this session's scope.
|
||||||
|
- **Placeholder scan:** none — every code step has concrete code; the only "mirror the
|
||||||
|
existing harness" reference (Task A.1 Step 1) points at a real file with a working
|
||||||
|
pattern, not a TODO.
|
||||||
|
- **Type consistency:** `MergeConflictsDto`/`ConflictFileDto`/`ConflictHunkDto` and the
|
||||||
|
5 method names match between `IWorkerClient` (0.1 Step 1), `WorkerClient` (0.1 Steps
|
||||||
|
2–3), and both fakes (0.2). The seam `RequestConflictResolution` is
|
||||||
|
`Func<string,string,Task>?` everywhere (A.1 Steps 1, 3–5). DTO field names match the
|
||||||
|
spec.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Integration notes (for the integrator merging A + B + C)
|
||||||
|
|
||||||
|
- Wire `DetailsIslandViewModel.RequestConflictResolution` and Layer B's equivalent
|
||||||
|
callback to Layer C's `ConflictResolverViewModel` factory + `ShowConflictResolver`
|
||||||
|
dialog delegate.
|
||||||
|
- Layer C implements the worker hub methods `StartConflictMerge`, `GetMergeConflicts`,
|
||||||
|
`WriteConflictResolution`, `ContinueMerge`, `AbortMerge`; the client side from Task
|
||||||
|
0.1 already calls them by name.
|
||||||
139
docs/superpowers/plans/2026-06-05-git-merge-review-prompts.md
Normal file
139
docs/superpowers/plans/2026-06-05-git-merge-review-prompts.md
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
# Git Merge/Review Rework — Parallel Kickoff Prompts (Layer B & Layer C)
|
||||||
|
|
||||||
|
These are self-contained prompts to paste into two fresh ClaudeDo sessions, each in its
|
||||||
|
own git worktree, run **in parallel** with the main session's Layer A work.
|
||||||
|
|
||||||
|
**Prerequisite — branch point:** Both sessions must branch from `main` **at or after**
|
||||||
|
the foundation commit `feat(ui): add conflict-resolution worker contract (foundation for
|
||||||
|
merge rework)` (Phase 0, Task 0.3 of
|
||||||
|
`docs/superpowers/plans/2026-06-05-git-merge-review-foundation-layerA.md`). That commit
|
||||||
|
adds the frozen `IWorkerClient` conflict contract both layers rely on. Do not start B/C
|
||||||
|
until that commit is pushed.
|
||||||
|
|
||||||
|
**Integration:** Neither session pushes to `main` or merges. Each leaves its branch/
|
||||||
|
worktree for the orchestrator (the main session) to review and merge.
|
||||||
|
|
||||||
|
Design reference for both: `docs/superpowers/specs/2026-06-05-git-merge-review-rework-design.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Layer B — Multi-worktree merge cockpit
|
||||||
|
|
||||||
|
```
|
||||||
|
We're reworking ClaudeDo's merge/review UX. Your job is Layer B: a multi-worktree merge
|
||||||
|
cockpit. The overall design is in docs/superpowers/specs/2026-06-05-git-merge-review-rework-design.md
|
||||||
|
(read the "Layer B" section and "Parallel boundaries" table first). A shared foundation
|
||||||
|
commit ("add conflict-resolution worker contract") is already on main — branch from it.
|
||||||
|
|
||||||
|
First, create an isolated worktree for this work (use the superpowers:using-git-worktrees
|
||||||
|
skill). Then write a plan (superpowers:writing-plans) for just Layer B and implement it
|
||||||
|
with superpowers:subagent-driven-development (sonnet subagents, TDD, commit per task).
|
||||||
|
|
||||||
|
Scope:
|
||||||
|
- Rework WorktreesOverviewModalView + WorktreesOverviewModalViewModel into a batch-merge
|
||||||
|
cockpit: list mergeable worktrees, multi-select N, pick ONE target branch, "Merge all".
|
||||||
|
- Skip-and-continue: loop the EXISTING IWorkerClient.MergeTaskAsync(taskId, target,
|
||||||
|
removeWorktree:false, msg) over the selected tasks. Clean ones merge; conflicting ones
|
||||||
|
(MergeTaskAsync returns Status=="conflict", auto-aborts leaving the tree clean) are
|
||||||
|
collected into a "needs resolution" list shown with live progress.
|
||||||
|
- Each conflict row gets a "Resolve" button that invokes a seam:
|
||||||
|
public Func<string, string, Task>? RequestConflictResolution { get; set; } // (taskId, targetBranch)
|
||||||
|
Define this callback property on the cockpit VM; leave it unwired (the orchestrator
|
||||||
|
wires it to Layer C's resolver at merge time). Do NOT reference any ConflictResolver
|
||||||
|
type.
|
||||||
|
- Migrate WorktreeModalView's bespoke inline diff onto the canonical DiffLinesView
|
||||||
|
control (src/ClaudeDo.Ui/Views/Controls/DiffLinesView.axaml) using DiffFileViewModel/
|
||||||
|
DiffLineViewModel/UnifiedDiffParser (src/ClaudeDo.Ui/ViewModels/Modals/). This removes
|
||||||
|
the last duplicate diff renderer.
|
||||||
|
|
||||||
|
Reuse these existing IWorkerClient methods (already implemented): MergeTaskAsync,
|
||||||
|
GetMergeTargetsAsync, GetWorktreesOverviewAsync, SetWorktreeStateAsync,
|
||||||
|
CleanupFinishedWorktreesAsync, ForceRemoveWorktreeAsync.
|
||||||
|
|
||||||
|
Do NOT touch (other layers own them): any worker-side files (WorkerHub, TaskMergeService,
|
||||||
|
GitService), IWorkerClient.cs / WorkerClient.cs, WorkConsole.axaml,
|
||||||
|
DetailsIslandViewModel.cs, or create the ConflictResolver UI.
|
||||||
|
|
||||||
|
Build with: dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release (a running
|
||||||
|
Worker locks Debug — use Release). Keep locales/en.json and de.json keys in parity if you
|
||||||
|
add any. If you change IWorkerClient (you shouldn't need to), update the hand-rolled fakes
|
||||||
|
in tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs and
|
||||||
|
tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs. No tests that spawn
|
||||||
|
the real claude CLI.
|
||||||
|
|
||||||
|
Commit per task with Conventional Commits. Do NOT push to main and do NOT merge — leave
|
||||||
|
your worktree/branch for the orchestrator. Flag any AXAML layout for visual verification
|
||||||
|
rather than claiming it works.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Layer C — Inline conflict resolver
|
||||||
|
|
||||||
|
```
|
||||||
|
We're reworking ClaudeDo's merge/review UX. Your job is Layer C: an in-app, VSCode-style
|
||||||
|
inline conflict resolver, plus the worker plumbing it needs. The overall design is in
|
||||||
|
docs/superpowers/specs/2026-06-05-git-merge-review-rework-design.md (read the "Layer C",
|
||||||
|
"Frozen worker conflict contract", and "Parallel boundaries" sections first). A shared
|
||||||
|
foundation commit ("add conflict-resolution worker contract") is already on main — branch
|
||||||
|
from it. That commit already wired the CLIENT side (IWorkerClient + WorkerClient call
|
||||||
|
these hub methods by name); your job includes implementing the matching WORKER hub methods.
|
||||||
|
|
||||||
|
First, create an isolated worktree (superpowers:using-git-worktrees). Then write a plan
|
||||||
|
(superpowers:writing-plans) for Layer C and implement it with
|
||||||
|
superpowers:subagent-driven-development (sonnet subagents, TDD, commit per task).
|
||||||
|
|
||||||
|
Worker side — implement these 5 hub methods in WorkerHub (names/params/returns MUST match
|
||||||
|
the client calls already shipped in the foundation):
|
||||||
|
- StartConflictMerge(string taskId, string targetBranch) -> MergeResultDto
|
||||||
|
Calls TaskMergeService.MergeAsync with leaveConflictsInTree:true (the overload/flag
|
||||||
|
already exists — used today by PlanningMergeOrchestrator). Leaves .git/MERGE_HEAD in
|
||||||
|
the list's WorkingDir, returns Status="conflict" + conflict file list.
|
||||||
|
- GetMergeConflicts(string taskId) -> MergeConflictsDto
|
||||||
|
For each conflicted file (git diff --name-only --diff-filter=U), read ours/theirs/base
|
||||||
|
via `git show :2:<path>` / `:3:<path>` / `:1:<path>`. Add GitService helpers as needed.
|
||||||
|
- WriteConflictResolution(string taskId, string path, string resolvedContent) -> void
|
||||||
|
Write resolvedContent to the file in WorkingDir and `git add` it.
|
||||||
|
- ContinueMerge(string taskId) -> MergeResultDto
|
||||||
|
Wrap the EXISTING TaskMergeService.ContinueMergeAsync (git add -A → re-check
|
||||||
|
diff --diff-filter=U → git commit). Currently service-level only; expose it on the hub.
|
||||||
|
- AbortMerge(string taskId) -> void
|
||||||
|
Wrap the EXISTING TaskMergeService.AbortMergeAsync (git merge --abort).
|
||||||
|
|
||||||
|
Define worker-side DTO records that serialize identically to the client records already in
|
||||||
|
WorkerClient.cs:
|
||||||
|
MergeConflictsDto(string TaskId, IReadOnlyList<ConflictFileDto> Files)
|
||||||
|
ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks)
|
||||||
|
ConflictHunkDto(string Ours, string Theirs, string? Base)
|
||||||
|
(place beside the other hub DTOs in WorkerHub.cs). MergeResultDto already exists.
|
||||||
|
|
||||||
|
UI side — new files only:
|
||||||
|
- ConflictResolverViewModel + ConflictResolverView. On open: StartConflictMergeAsync then
|
||||||
|
GetMergeConflictsAsync(taskId). Per conflict hunk show ours vs theirs stacked with
|
||||||
|
buttons Accept Current / Accept Incoming / Accept Both / Edit manually, plus a free-text
|
||||||
|
box for the merged result of that hunk. Use the UI conflict model from the design
|
||||||
|
(ConflictFile { Path, Hunks[] }, ConflictHunk { Ours, Theirs, Base, Resolution }) —
|
||||||
|
shape it so a future 3-way pane needs no model change.
|
||||||
|
- When every file is resolved: WriteConflictResolutionAsync per file, then
|
||||||
|
ContinueMergeAsync(taskId) (Status "merged" closes; "conflict" means not fully resolved,
|
||||||
|
stay open). AbortMergeAsync(taskId) cancels.
|
||||||
|
- Expose a factory Func<string, ConflictResolverViewModel> and a
|
||||||
|
Func<ConflictResolverViewModel, Task> ShowConflictResolver dialog delegate for the
|
||||||
|
orchestrator to wire to Layer A/B's RequestConflictResolution(taskId, target) seams.
|
||||||
|
|
||||||
|
Do NOT touch (other layers own them): WorkerClient.cs, IWorkerClient.cs (already wired),
|
||||||
|
WorkConsole.axaml, DetailsIslandViewModel.cs, WorktreesOverviewModalView/VM. You WILL need
|
||||||
|
to add the 5 worker hub methods + GitService conflict reads.
|
||||||
|
|
||||||
|
Tests: add worker tests for the conflict reads / continue / abort using real SQLite + real
|
||||||
|
git (follow existing GitService/TaskMergeService test patterns). NEVER spawn the real
|
||||||
|
claude CLI. If you change IWorkerClient (you should NOT — client is frozen), update the
|
||||||
|
fakes in both test projects.
|
||||||
|
|
||||||
|
Build with: dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release and
|
||||||
|
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release (a running Worker locks
|
||||||
|
Debug). Keep locales/en.json and de.json in parity for any new UI strings.
|
||||||
|
|
||||||
|
Commit per task with Conventional Commits. Do NOT push to main and do NOT merge — leave
|
||||||
|
your worktree/branch for the orchestrator. Flag the resolver UI for visual verification.
|
||||||
|
```
|
||||||
@@ -0,0 +1,920 @@
|
|||||||
|
# Layer C — Inline Conflict Resolver Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Build the worker-side conflict plumbing (5 frozen hub methods + GitService reads) and a VSCode-style in-app inline conflict resolver UI for ClaudeDo's merge rework.
|
||||||
|
|
||||||
|
**Architecture:** The worker performs a real merge that leaves conflicts in the list's working tree (`leaveConflictsInTree:true`), exposes ours/theirs/base per conflicted file via `git show :2:/:3:/:1:`, accepts written resolutions, and finishes via the existing `ContinueMergeAsync`/`AbortMergeAsync`. The UI presents each conflicted file's hunk with Accept Current/Incoming/Both/Edit-manually controls plus a free-text merged box, then writes resolutions and continues.
|
||||||
|
|
||||||
|
**Tech Stack:** .NET 8, ASP.NET Core SignalR (WorkerHub), EF Core/SQLite, Avalonia MVVM (CommunityToolkit), xUnit + real git/SQLite fixtures.
|
||||||
|
|
||||||
|
**Frozen client contract (already shipped in foundation commit `2dfc455`, DO NOT edit):**
|
||||||
|
- `IWorkerClient` / `WorkerClient.cs` already call hub methods by name: `StartConflictMerge`, `GetMergeConflicts`, `WriteConflictResolution`, `ContinueMerge`, `AbortMerge`.
|
||||||
|
- Client DTOs already exist in `WorkerClient.cs`: `MergeConflictsDto(string TaskId, IReadOnlyList<ConflictFileDto> Files)`, `ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks)`, `ConflictHunkDto(string Ours, string Theirs, string? Base)`, plus existing `MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage)`.
|
||||||
|
- Worker-side DTOs must serialize identically (same record shape) and live in `WorkerHub.cs`.
|
||||||
|
|
||||||
|
**Do NOT touch:** `WorkerClient.cs`, `Interfaces/IWorkerClient.cs`, `WorkConsole.axaml`, `DetailsIslandViewModel.cs`, `WorktreesOverviewModalView/VM`, `WorktreeModalView`. Test fakes for `IWorkerClient` already implement the 5 methods as no-op stubs (`StubWorkerClient` is `virtual` in Ui.Tests) — subclass/override, never edit the interface.
|
||||||
|
|
||||||
|
**Build/test commands (.NET 8 — running Worker locks `Debug`, always `-c Release`):**
|
||||||
|
```bash
|
||||||
|
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
|
||||||
|
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
**Worker / Data (create + modify):**
|
||||||
|
- Modify `src/ClaudeDo.Data/Git/GitService.cs` — add `ShowStageAsync` (untrimmed blob read) + `AddPathAsync`; add `trimOutput` param to `RunGitAsync`.
|
||||||
|
- Modify `src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs` — add records `MergeConflicts`/`ConflictFileContent`; add `GetConflictsAsync` + `WriteResolutionAsync`.
|
||||||
|
- Modify `src/ClaudeDo.Worker/Hub/WorkerHub.cs` — add DTOs `MergeConflictsDto`/`ConflictFileDto`/`ConflictHunkDto` + 5 hub methods.
|
||||||
|
|
||||||
|
**UI (create new only):**
|
||||||
|
- Create `src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictModels.cs` — `ConflictFile`, `ConflictHunk`.
|
||||||
|
- Create `src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictResolverViewModel.cs`.
|
||||||
|
- Create `src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml` + `.axaml.cs`.
|
||||||
|
|
||||||
|
**Wiring (modify):**
|
||||||
|
- Modify `src/ClaudeDo.App/Program.cs` — register `ConflictResolverViewModel` + `Func<string, ConflictResolverViewModel>`.
|
||||||
|
- Modify `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs` — additive seam (`ConflictResolverFactory`, `ShowConflictResolver`, `RequestConflictResolutionAsync`).
|
||||||
|
- Modify `src/ClaudeDo.Ui/Views/MainWindow.axaml.cs` — wire `ShowConflictResolver` dialog delegate.
|
||||||
|
- Modify `src/ClaudeDo.Localization/locales/en.json` + `de.json` — `conflictResolver.*` keys (parity enforced by Localization.Tests).
|
||||||
|
|
||||||
|
**Tests (create + modify):**
|
||||||
|
- Modify `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs` — conflict-read / write-resolution / round-trip tests.
|
||||||
|
- Create `tests/ClaudeDo.Ui.Tests/ViewModels/ConflictResolverViewModelTests.cs`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: GitService conflict-blob reads
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Data/Git/GitService.cs`
|
||||||
|
- Test: `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs` (GitService exercised here via real repo; add focused tests in Task 2 round-trip)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add `trimOutput` param to `RunGitAsync`** so blob reads keep exact bytes.
|
||||||
|
|
||||||
|
In `RunGitAsync` signature add `bool trimOutput = true`, and change the return to:
|
||||||
|
```csharp
|
||||||
|
return (proc.ExitCode, trimOutput ? stdout.TrimEnd() : stdout, stderr.TrimEnd());
|
||||||
|
```
|
||||||
|
(All existing callers keep the default `true`.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add `ShowStageAsync` + `AddPathAsync`** (place after `ListConflictedFilesAsync`):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
/// <summary>
|
||||||
|
/// Reads a conflicted file's blob at a merge stage: 1=base, 2=ours, 3=theirs.
|
||||||
|
/// Returns null when the stage doesn't exist (e.g. add/add conflict has no base).
|
||||||
|
/// Output is NOT trimmed so file content round-trips exactly.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<string?> ShowStageAsync(string repoDir, int stage, string path, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["show", $":{stage}:{path}"], ct, trimOutput: false);
|
||||||
|
return exitCode == 0 ? stdout : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AddPathAsync(string repoDir, string path, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["add", "--", path], ct);
|
||||||
|
if (exitCode != 0)
|
||||||
|
throw new InvalidOperationException($"git add '{path}' failed (exit {exitCode}): {stderr}");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build the Data + Worker projects to verify compilation.**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`
|
||||||
|
Expected: Build succeeded, 0 errors.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Data/Git/GitService.cs
|
||||||
|
git commit -m "feat(git): add conflict-stage blob reads and single-path staging"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: TaskMergeService conflict reads + resolution writes
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs`
|
||||||
|
- Test: `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests** (append inside `TaskMergeServiceTests`, before `#region Test doubles`). Reuse the existing helpers `SeedListAndTask`, `SeedWorktree`, `BuildService`, and the `GitRepoFixture` conflict setup pattern from `ContinueMergeAsync_AfterUserResolves...`.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public async Task GetConflictsAsync_AfterConflictMerge_ReturnsOursAndTheirs()
|
||||||
|
{
|
||||||
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||||
|
|
||||||
|
var db = NewDb();
|
||||||
|
var repo = NewRepo();
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||||
|
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n");
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change");
|
||||||
|
|
||||||
|
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
||||||
|
_wtCleanups.Add((repo.RepoDir, wtPath));
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/c1", wtPath, repo.BaseCommit);
|
||||||
|
File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n");
|
||||||
|
GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch change");
|
||||||
|
|
||||||
|
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.WaitingForReview);
|
||||||
|
await SeedWorktree(db, task.Id, wtPath, "claudedo/c1", repo.BaseCommit);
|
||||||
|
|
||||||
|
var (svc, _) = BuildService(db);
|
||||||
|
var start = await svc.MergeAsync(task.Id, "main", false, "msg", leaveConflictsInTree: true, CancellationToken.None);
|
||||||
|
Assert.Equal(TaskMergeService.StatusConflict, start.Status);
|
||||||
|
|
||||||
|
var conflicts = await svc.GetConflictsAsync(task.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(task.Id, conflicts.TaskId);
|
||||||
|
var file = Assert.Single(conflicts.Files);
|
||||||
|
Assert.Equal("README.md", file.Path);
|
||||||
|
Assert.Contains("main change", file.Ours); // ours = target (main) side after checkout
|
||||||
|
Assert.Contains("branch change", file.Theirs); // theirs = merged-in branch
|
||||||
|
Assert.NotNull(file.Base);
|
||||||
|
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "merge", "--abort");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WriteResolutionAsync_ThenContinue_CompletesMerge()
|
||||||
|
{
|
||||||
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||||
|
|
||||||
|
var db = NewDb();
|
||||||
|
var repo = NewRepo();
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||||
|
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n");
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change");
|
||||||
|
|
||||||
|
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
||||||
|
_wtCleanups.Add((repo.RepoDir, wtPath));
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/c2", wtPath, repo.BaseCommit);
|
||||||
|
File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n");
|
||||||
|
GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch change");
|
||||||
|
|
||||||
|
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.WaitingForReview);
|
||||||
|
await SeedWorktree(db, task.Id, wtPath, "claudedo/c2", repo.BaseCommit);
|
||||||
|
|
||||||
|
var (svc, _) = BuildService(db);
|
||||||
|
await svc.MergeAsync(task.Id, "main", false, "msg", leaveConflictsInTree: true, CancellationToken.None);
|
||||||
|
|
||||||
|
await svc.WriteResolutionAsync(task.Id, "README.md", "# resolved by user\n", CancellationToken.None);
|
||||||
|
var result = await svc.ContinueMergeAsync(task.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
|
||||||
|
Assert.Equal("# resolved by user\n", File.ReadAllText(Path.Combine(repo.RepoDir, "README.md")));
|
||||||
|
Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail** (no such methods).
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "GetConflictsAsync_AfterConflictMerge_ReturnsOursAndTheirs|WriteResolutionAsync_ThenContinue_CompletesMerge"`
|
||||||
|
Expected: compile error / FAIL (methods don't exist).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add records + methods to `TaskMergeService.cs`.**
|
||||||
|
|
||||||
|
Add records beside `MergeResult` (top of file, after the existing record declarations):
|
||||||
|
```csharp
|
||||||
|
public sealed record MergeConflicts(
|
||||||
|
string TaskId,
|
||||||
|
IReadOnlyList<ConflictFileContent> Files);
|
||||||
|
|
||||||
|
public sealed record ConflictFileContent(
|
||||||
|
string Path,
|
||||||
|
string Ours,
|
||||||
|
string Theirs,
|
||||||
|
string? Base);
|
||||||
|
```
|
||||||
|
|
||||||
|
Add methods inside the class (after `AbortMergeAsync`):
|
||||||
|
```csharp
|
||||||
|
public async Task<MergeConflicts> GetConflictsAsync(string taskId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var (_, list, _) = await LoadMergeContextAsync(taskId, ct);
|
||||||
|
if (string.IsNullOrWhiteSpace(list.WorkingDir))
|
||||||
|
throw new InvalidOperationException("list has no working directory");
|
||||||
|
|
||||||
|
var files = await _git.ListConflictedFilesAsync(list.WorkingDir, ct);
|
||||||
|
var result = new List<ConflictFileContent>(files.Count);
|
||||||
|
foreach (var path in files)
|
||||||
|
{
|
||||||
|
var ours = await _git.ShowStageAsync(list.WorkingDir, 2, path, ct) ?? "";
|
||||||
|
var theirs = await _git.ShowStageAsync(list.WorkingDir, 3, path, ct) ?? "";
|
||||||
|
var @base = await _git.ShowStageAsync(list.WorkingDir, 1, path, ct);
|
||||||
|
result.Add(new ConflictFileContent(path, ours, theirs, @base));
|
||||||
|
}
|
||||||
|
return new MergeConflicts(taskId, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task WriteResolutionAsync(string taskId, string path, string content, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var (_, list, _) = await LoadMergeContextAsync(taskId, ct);
|
||||||
|
if (string.IsNullOrWhiteSpace(list.WorkingDir))
|
||||||
|
throw new InvalidOperationException("list has no working directory");
|
||||||
|
|
||||||
|
var full = Path.Combine(list.WorkingDir, path.Replace('/', Path.DirectorySeparatorChar));
|
||||||
|
await File.WriteAllTextAsync(full, content, ct);
|
||||||
|
await _git.AddPathAsync(list.WorkingDir, path, ct);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(Note: `Path` is `System.IO.Path` — the file already uses it via other helpers; the record property `Path` does not shadow it inside these methods because it's accessed as a static type, not an instance member.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run the tests to verify they pass.**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "GetConflictsAsync_AfterConflictMerge_ReturnsOursAndTheirs|WriteResolutionAsync_ThenContinue_CompletesMerge"`
|
||||||
|
Expected: PASS (2 tests). If git unavailable they no-op.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs
|
||||||
|
git commit -m "feat(merge): read conflict stages and write user resolutions"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: WorkerHub conflict methods + DTOs
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add DTOs** beside the existing merge DTOs (after `public record MergeTargetsDto(...)`):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public record MergeConflictsDto(string TaskId, IReadOnlyList<ConflictFileDto> Files);
|
||||||
|
public record ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks);
|
||||||
|
public record ConflictHunkDto(string Ours, string Theirs, string? Base);
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the 5 hub methods** (after `PreviewMerge`). Names/params/returns MUST match the frozen client calls.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public Task<MergeResultDto> StartConflictMerge(string taskId, string targetBranch)
|
||||||
|
=> HubGuard(async () =>
|
||||||
|
{
|
||||||
|
var r = await _mergeService.MergeAsync(
|
||||||
|
taskId, targetBranch ?? "", removeWorktree: false, "Merge task",
|
||||||
|
leaveConflictsInTree: true, CancellationToken.None);
|
||||||
|
if (r.Status == TaskMergeService.StatusBlocked)
|
||||||
|
throw new HubException(r.ErrorMessage ?? "merge blocked");
|
||||||
|
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
public Task<MergeConflictsDto> GetMergeConflicts(string taskId)
|
||||||
|
=> HubGuard(async () =>
|
||||||
|
{
|
||||||
|
var c = await _mergeService.GetConflictsAsync(taskId, CancellationToken.None);
|
||||||
|
return new MergeConflictsDto(
|
||||||
|
c.TaskId,
|
||||||
|
c.Files.Select(f => new ConflictFileDto(
|
||||||
|
f.Path,
|
||||||
|
new[] { new ConflictHunkDto(f.Ours, f.Theirs, f.Base) })).ToList());
|
||||||
|
});
|
||||||
|
|
||||||
|
public Task WriteConflictResolution(string taskId, string path, string resolvedContent)
|
||||||
|
=> HubGuard(() => _mergeService.WriteResolutionAsync(
|
||||||
|
taskId, path, resolvedContent ?? "", CancellationToken.None));
|
||||||
|
|
||||||
|
public Task<MergeResultDto> ContinueMerge(string taskId)
|
||||||
|
=> HubGuard(async () =>
|
||||||
|
{
|
||||||
|
var r = await _mergeService.ContinueMergeAsync(taskId, CancellationToken.None);
|
||||||
|
if (r.Status == TaskMergeService.StatusBlocked)
|
||||||
|
throw new HubException(r.ErrorMessage ?? "continue failed");
|
||||||
|
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
public Task AbortMerge(string taskId)
|
||||||
|
=> HubGuard(async () =>
|
||||||
|
{
|
||||||
|
var r = await _mergeService.AbortMergeAsync(taskId, CancellationToken.None);
|
||||||
|
if (r.Status == TaskMergeService.StatusBlocked)
|
||||||
|
throw new HubException(r.ErrorMessage ?? "abort failed");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build the Worker project to verify compilation.**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`
|
||||||
|
Expected: Build succeeded, 0 errors.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Worker/Hub/WorkerHub.cs
|
||||||
|
git commit -m "feat(hub): expose conflict-resolution merge methods"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Conflict UI model
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictModels.cs`
|
||||||
|
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/ConflictResolverViewModelTests.cs` (model tests added here in Task 5; this task is build-verified)
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the model file.** Shaped so a 3-way pane needs no model change (`Base` retained per hunk).
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.ViewModels.Conflicts;
|
||||||
|
|
||||||
|
public sealed partial class ConflictHunk : ObservableObject
|
||||||
|
{
|
||||||
|
public string Ours { get; }
|
||||||
|
public string Theirs { get; }
|
||||||
|
public string? Base { get; }
|
||||||
|
|
||||||
|
[ObservableProperty] private string? _resolution;
|
||||||
|
|
||||||
|
public bool IsResolved => Resolution is not null;
|
||||||
|
|
||||||
|
public ConflictHunk(string ours, string theirs, string? @base)
|
||||||
|
{
|
||||||
|
Ours = ours;
|
||||||
|
Theirs = theirs;
|
||||||
|
Base = @base;
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnResolutionChanged(string? value) => OnPropertyChanged(nameof(IsResolved));
|
||||||
|
|
||||||
|
[RelayCommand] private void AcceptCurrent() => Resolution = Ours;
|
||||||
|
[RelayCommand] private void AcceptIncoming() => Resolution = Theirs;
|
||||||
|
[RelayCommand] private void AcceptBoth() => Resolution = Ours + Theirs;
|
||||||
|
[RelayCommand] private void EditManually() => Resolution ??= Ours;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ConflictFile
|
||||||
|
{
|
||||||
|
public string Path { get; }
|
||||||
|
public IReadOnlyList<ConflictHunk> Hunks { get; }
|
||||||
|
|
||||||
|
public ConflictFile(string path, IReadOnlyList<ConflictHunk> hunks)
|
||||||
|
{
|
||||||
|
Path = path;
|
||||||
|
Hunks = hunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool AllHunksResolved => Hunks.Count > 0 && Hunks.All(h => h.IsResolved);
|
||||||
|
|
||||||
|
/// <summary>The merged file content: concatenation of each hunk's resolution
|
||||||
|
/// (single whole-file hunk today; concatenation keeps it correct for multi-hunk later).</summary>
|
||||||
|
public string ComposeResolvedContent() => string.Concat(Hunks.Select(h => h.Resolution));
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build the Ui project to verify compilation.**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||||
|
Expected: Build succeeded.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictModels.cs
|
||||||
|
git commit -m "feat(ui): add inline conflict model (file/hunk with resolution)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: ConflictResolverViewModel
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictResolverViewModel.cs`
|
||||||
|
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/ConflictResolverViewModelTests.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write failing tests.** Subclass the existing `StubWorkerClient` (its conflict methods are `virtual`).
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using ClaudeDo.Ui.Services;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Conflicts;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||||
|
|
||||||
|
public class ConflictResolverViewModelTests
|
||||||
|
{
|
||||||
|
private sealed class FakeWorker : StubWorkerClient
|
||||||
|
{
|
||||||
|
public string? WrittenPath;
|
||||||
|
public string? WrittenContent;
|
||||||
|
public bool Continued;
|
||||||
|
public bool Aborted;
|
||||||
|
public string ContinueStatus = "merged";
|
||||||
|
|
||||||
|
public override Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch)
|
||||||
|
=> Task.FromResult(new MergeResultDto("conflict", new[] { "README.md" }, null));
|
||||||
|
|
||||||
|
public override Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId)
|
||||||
|
=> Task.FromResult(new MergeConflictsDto(taskId, new[]
|
||||||
|
{
|
||||||
|
new ConflictFileDto("README.md", new[] { new ConflictHunkDto("ours\n", "theirs\n", "base\n") })
|
||||||
|
}));
|
||||||
|
|
||||||
|
public override Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent)
|
||||||
|
{
|
||||||
|
WrittenPath = path; WrittenContent = resolvedContent; return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task<MergeResultDto> ContinueMergeAsync(string taskId)
|
||||||
|
{
|
||||||
|
Continued = true;
|
||||||
|
return Task.FromResult(new MergeResultDto(ContinueStatus, System.Array.Empty<string>(), null));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task AbortMergeAsync(string taskId) { Aborted = true; return Task.CompletedTask; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OpenAsync_LoadsConflicts_AndBlocksContinueUntilResolved()
|
||||||
|
{
|
||||||
|
var vm = new ConflictResolverViewModel(new FakeWorker(), "task-1");
|
||||||
|
var hasConflicts = await vm.OpenAsync("main");
|
||||||
|
|
||||||
|
Assert.True(hasConflicts);
|
||||||
|
var file = Assert.Single(vm.Files);
|
||||||
|
Assert.Equal("README.md", file.Path);
|
||||||
|
Assert.False(vm.CanContinue); // nothing resolved yet
|
||||||
|
|
||||||
|
file.Hunks[0].AcceptIncomingCommand.Execute(null);
|
||||||
|
Assert.True(vm.CanContinue); // every hunk resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Continue_WritesComposedResolution_AndClosesOnMerged()
|
||||||
|
{
|
||||||
|
var worker = new FakeWorker();
|
||||||
|
var vm = new ConflictResolverViewModel(worker, "task-1");
|
||||||
|
var closed = false;
|
||||||
|
vm.CloseRequested = () => closed = true;
|
||||||
|
|
||||||
|
await vm.OpenAsync("main");
|
||||||
|
vm.Files[0].Hunks[0].AcceptCurrentCommand.Execute(null); // resolution = "ours\n"
|
||||||
|
await vm.ContinueCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.Equal("README.md", worker.WrittenPath);
|
||||||
|
Assert.Equal("ours\n", worker.WrittenContent);
|
||||||
|
Assert.True(worker.Continued);
|
||||||
|
Assert.True(closed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Continue_StaysOpenAndReportsError_WhenStillConflicted()
|
||||||
|
{
|
||||||
|
var worker = new FakeWorker { ContinueStatus = "conflict" };
|
||||||
|
var vm = new ConflictResolverViewModel(worker, "task-1");
|
||||||
|
var closed = false;
|
||||||
|
vm.CloseRequested = () => closed = true;
|
||||||
|
|
||||||
|
await vm.OpenAsync("main");
|
||||||
|
vm.Files[0].Hunks[0].AcceptBothCommand.Execute(null);
|
||||||
|
await vm.ContinueCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.False(closed);
|
||||||
|
Assert.NotNull(vm.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Abort_CallsWorkerAndCloses()
|
||||||
|
{
|
||||||
|
var worker = new FakeWorker();
|
||||||
|
var vm = new ConflictResolverViewModel(worker, "task-1");
|
||||||
|
var closed = false;
|
||||||
|
vm.CloseRequested = () => closed = true;
|
||||||
|
|
||||||
|
await vm.AbortCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.True(worker.Aborted);
|
||||||
|
Assert.True(closed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail** (VM not defined).
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "ConflictResolverViewModelTests"`
|
||||||
|
Expected: compile error / FAIL.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the ViewModel.**
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using System;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using ClaudeDo.Ui.Services;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.ViewModels.Conflicts;
|
||||||
|
|
||||||
|
public sealed partial class ConflictResolverViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
private readonly IWorkerClient _worker;
|
||||||
|
private readonly string _taskId;
|
||||||
|
|
||||||
|
public ObservableCollection<ConflictFile> Files { get; } = new();
|
||||||
|
|
||||||
|
[ObservableProperty] private bool _isBusy;
|
||||||
|
[ObservableProperty] private string? _error;
|
||||||
|
[ObservableProperty] private bool _canContinue;
|
||||||
|
|
||||||
|
public string TaskId => _taskId;
|
||||||
|
public Action? CloseRequested { get; set; }
|
||||||
|
|
||||||
|
public ConflictResolverViewModel(IWorkerClient worker, string taskId)
|
||||||
|
{
|
||||||
|
_worker = worker;
|
||||||
|
_taskId = taskId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Starts the conflict merge and loads ours/theirs/base per file.
|
||||||
|
/// Returns true when there are conflicts to resolve (caller should show the dialog).</summary>
|
||||||
|
public async Task<bool> OpenAsync(string targetBranch)
|
||||||
|
{
|
||||||
|
IsBusy = true;
|
||||||
|
Error = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var start = await _worker.StartConflictMergeAsync(_taskId, targetBranch);
|
||||||
|
if (!string.Equals(start.Status, "conflict", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
if (string.Equals(start.Status, "blocked", StringComparison.Ordinal))
|
||||||
|
Error = start.ErrorMessage;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var conflicts = await _worker.GetMergeConflictsAsync(_taskId);
|
||||||
|
Files.Clear();
|
||||||
|
foreach (var f in conflicts.Files)
|
||||||
|
{
|
||||||
|
var hunks = f.Hunks.Select(h =>
|
||||||
|
{
|
||||||
|
var hk = new ConflictHunk(h.Ours, h.Theirs, h.Base);
|
||||||
|
hk.PropertyChanged += OnHunkChanged;
|
||||||
|
return hk;
|
||||||
|
}).ToList();
|
||||||
|
Files.Add(new ConflictFile(f.Path, hunks));
|
||||||
|
}
|
||||||
|
RecomputeCanContinue();
|
||||||
|
return Files.Count > 0;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Error = ex.Message;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
finally { IsBusy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnHunkChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.PropertyName is nameof(ConflictHunk.IsResolved) or nameof(ConflictHunk.Resolution))
|
||||||
|
RecomputeCanContinue();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RecomputeCanContinue()
|
||||||
|
=> CanContinue = Files.Count > 0 && Files.All(f => f.AllHunksResolved);
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task ContinueAsync()
|
||||||
|
{
|
||||||
|
if (!CanContinue) return;
|
||||||
|
IsBusy = true;
|
||||||
|
Error = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var file in Files)
|
||||||
|
await _worker.WriteConflictResolutionAsync(_taskId, file.Path, file.ComposeResolvedContent());
|
||||||
|
|
||||||
|
var result = await _worker.ContinueMergeAsync(_taskId);
|
||||||
|
if (string.Equals(result.Status, "merged", StringComparison.Ordinal))
|
||||||
|
CloseRequested?.Invoke();
|
||||||
|
else
|
||||||
|
Error = result.ErrorMessage ?? "Conflicts not fully resolved — review and retry.";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Error = ex.Message;
|
||||||
|
}
|
||||||
|
finally { IsBusy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task AbortAsync()
|
||||||
|
{
|
||||||
|
IsBusy = true;
|
||||||
|
try { await _worker.AbortMergeAsync(_taskId); }
|
||||||
|
catch (Exception ex) { Error = ex.Message; }
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
CloseRequested?.Invoke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass.**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "ConflictResolverViewModelTests"`
|
||||||
|
Expected: PASS (4 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictResolverViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/ConflictResolverViewModelTests.cs
|
||||||
|
git commit -m "feat(ui): add inline conflict resolver view-model"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: ConflictResolverView + localization
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml`
|
||||||
|
- Create: `src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Localization/locales/en.json`
|
||||||
|
- Modify: `src/ClaudeDo.Localization/locales/de.json`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add localization keys** to `en.json` as a new top-level section (sibling of `"planning"`):
|
||||||
|
|
||||||
|
```json
|
||||||
|
"conflictResolver": {
|
||||||
|
"windowTitle": "Resolve merge conflicts",
|
||||||
|
"modalTitle": "RESOLVE CONFLICTS",
|
||||||
|
"loading": "Loading conflicts…",
|
||||||
|
"current": "Current (ours)",
|
||||||
|
"incoming": "Incoming (theirs)",
|
||||||
|
"mergedResult": "Merged result",
|
||||||
|
"acceptCurrent": "Accept Current",
|
||||||
|
"acceptIncoming": "Accept Incoming",
|
||||||
|
"acceptBoth": "Accept Both",
|
||||||
|
"editManually": "Edit manually",
|
||||||
|
"continue": "Resolve & continue",
|
||||||
|
"abort": "Abort merge"
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the SAME keys to `de.json`** (German values, identical key set — parity enforced by Localization.Tests):
|
||||||
|
|
||||||
|
```json
|
||||||
|
"conflictResolver": {
|
||||||
|
"windowTitle": "Merge-Konflikte lösen",
|
||||||
|
"modalTitle": "KONFLIKTE LÖSEN",
|
||||||
|
"loading": "Konflikte werden geladen…",
|
||||||
|
"current": "Aktuell (unsere)",
|
||||||
|
"incoming": "Eingehend (ihre)",
|
||||||
|
"mergedResult": "Zusammengeführtes Ergebnis",
|
||||||
|
"acceptCurrent": "Aktuelle übernehmen",
|
||||||
|
"acceptIncoming": "Eingehende übernehmen",
|
||||||
|
"acceptBoth": "Beide übernehmen",
|
||||||
|
"editManually": "Manuell bearbeiten",
|
||||||
|
"continue": "Lösen & fortfahren",
|
||||||
|
"abort": "Merge abbrechen"
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create the View** (`ConflictResolverView.axaml`). A `Window` using `ModalShell`, mirroring `ConflictResolutionView.axaml`. Two stacked read-only boxes (ours/theirs), a button row, and a two-way merged-result box per hunk.
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Conflicts"
|
||||||
|
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
|
||||||
|
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
||||||
|
x:DataType="vm:ConflictResolverViewModel"
|
||||||
|
x:Class="ClaudeDo.Ui.Views.Conflicts.ConflictResolverView"
|
||||||
|
Title="{loc:Tr conflictResolver.windowTitle}"
|
||||||
|
Width="760" Height="640" MinWidth="560" MinHeight="420"
|
||||||
|
CanResize="True"
|
||||||
|
WindowDecorations="BorderOnly"
|
||||||
|
ExtendClientAreaToDecorationsHint="True"
|
||||||
|
ExtendClientAreaTitleBarHeightHint="-1"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
Background="{DynamicResource SurfaceBrush}">
|
||||||
|
|
||||||
|
<Window.KeyBindings>
|
||||||
|
<KeyBinding Gesture="Escape" Command="{Binding AbortCommand}"/>
|
||||||
|
</Window.KeyBindings>
|
||||||
|
|
||||||
|
<ctl:ModalShell Title="{loc:Tr conflictResolver.modalTitle}" CloseCommand="{Binding AbortCommand}">
|
||||||
|
<ctl:ModalShell.Footer>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8"
|
||||||
|
HorizontalAlignment="Right" VerticalAlignment="Center">
|
||||||
|
<Button Classes="btn" Content="{loc:Tr conflictResolver.continue}"
|
||||||
|
Command="{Binding ContinueCommand}" IsEnabled="{Binding CanContinue}"/>
|
||||||
|
<Button Classes="btn" Content="{loc:Tr conflictResolver.abort}" Command="{Binding AbortCommand}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</ctl:ModalShell.Footer>
|
||||||
|
|
||||||
|
<Grid RowDefinitions="Auto,*" Margin="16,12">
|
||||||
|
<TextBlock Grid.Row="0" Classes="meta" Margin="0,0,0,8"
|
||||||
|
Text="{loc:Tr conflictResolver.loading}"
|
||||||
|
IsVisible="{Binding IsBusy}"/>
|
||||||
|
<TextBlock Grid.Row="0" Classes="meta" Foreground="{DynamicResource BloodBrush}"
|
||||||
|
Text="{Binding Error}" TextWrapping="Wrap"
|
||||||
|
IsVisible="{Binding Error, Converter={x:Static ObjectConverters.IsNotNull}}"/>
|
||||||
|
|
||||||
|
<ScrollViewer Grid.Row="1">
|
||||||
|
<ItemsControl ItemsSource="{Binding Files}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:ConflictFile">
|
||||||
|
<StackPanel Spacing="8" Margin="0,0,0,16">
|
||||||
|
<TextBlock Classes="path-mono heading" Text="{Binding Path}"/>
|
||||||
|
<ItemsControl ItemsSource="{Binding Hunks}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:ConflictHunk">
|
||||||
|
<Border BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1"
|
||||||
|
CornerRadius="6" Padding="10" Margin="0,0,0,8">
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<TextBlock Classes="meta" Text="{loc:Tr conflictResolver.current}"/>
|
||||||
|
<TextBox Text="{Binding Ours, Mode=OneWay}" IsReadOnly="True"
|
||||||
|
TextWrapping="NoWrap" AcceptsReturn="True" MaxHeight="120"
|
||||||
|
FontFamily="{DynamicResource MonoFont}"/>
|
||||||
|
<TextBlock Classes="meta" Text="{loc:Tr conflictResolver.incoming}"/>
|
||||||
|
<TextBox Text="{Binding Theirs, Mode=OneWay}" IsReadOnly="True"
|
||||||
|
TextWrapping="NoWrap" AcceptsReturn="True" MaxHeight="120"
|
||||||
|
FontFamily="{DynamicResource MonoFont}"/>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
|
<Button Classes="btn" Content="{loc:Tr conflictResolver.acceptCurrent}"
|
||||||
|
Command="{Binding AcceptCurrentCommand}"/>
|
||||||
|
<Button Classes="btn" Content="{loc:Tr conflictResolver.acceptIncoming}"
|
||||||
|
Command="{Binding AcceptIncomingCommand}"/>
|
||||||
|
<Button Classes="btn" Content="{loc:Tr conflictResolver.acceptBoth}"
|
||||||
|
Command="{Binding AcceptBothCommand}"/>
|
||||||
|
<Button Classes="btn" Content="{loc:Tr conflictResolver.editManually}"
|
||||||
|
Command="{Binding EditManuallyCommand}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Classes="meta" Text="{loc:Tr conflictResolver.mergedResult}"/>
|
||||||
|
<TextBox Text="{Binding Resolution, Mode=TwoWay}"
|
||||||
|
TextWrapping="NoWrap" AcceptsReturn="True" MinHeight="80" MaxHeight="200"
|
||||||
|
FontFamily="{DynamicResource MonoFont}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Grid>
|
||||||
|
</ctl:ModalShell>
|
||||||
|
</Window>
|
||||||
|
```
|
||||||
|
**Note for the implementer:** if `MonoFont` / `path-mono` / `heading` / `meta` / `btn` resource keys or style classes don't resolve at build, drop the `FontFamily` attribute and unknown `Classes` (keep `btn`) — match whatever the existing `ConflictResolutionView.axaml` and app styles actually expose. Verify against `src/ClaudeDo.Ui/Views/Planning/ConflictResolutionView.axaml` and the app's style resources before finalizing.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Create the code-behind** (`ConflictResolverView.axaml.cs`):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using Avalonia.Controls;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Conflicts;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Views.Conflicts;
|
||||||
|
|
||||||
|
public partial class ConflictResolverView : Window
|
||||||
|
{
|
||||||
|
public ConflictResolverView()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnDataContextChanged(System.EventArgs e)
|
||||||
|
{
|
||||||
|
base.OnDataContextChanged(e);
|
||||||
|
if (DataContext is ConflictResolverViewModel vm)
|
||||||
|
vm.CloseRequested = Close;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Build the App + run Localization tests.**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release && dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release`
|
||||||
|
Expected: Build succeeded; localization parity tests PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml.cs src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json
|
||||||
|
git commit -m "feat(ui): add inline conflict resolver view and localization"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Wire factory + dialog seam for the integrator
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.App/Program.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Views/MainWindow.axaml.cs`
|
||||||
|
|
||||||
|
These are additive seams only. The integrator connects Layer A/B's `RequestConflictResolution(taskId, target)` callback to `IslandsShellViewModel.RequestConflictResolutionAsync`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Register the factory in `Program.cs`** (in the ViewModels region, near the other `Func<>` factories). Only the `Func<>` factory is needed — the VM is never resolved directly:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
sc.AddSingleton<Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>>(sp =>
|
||||||
|
taskId => new ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel(
|
||||||
|
sp.GetRequiredService<WorkerClient>(), taskId));
|
||||||
|
```
|
||||||
|
Then, after `IslandsShellViewModel` is registered, set the factory on it once resolved. Replace the existing `sc.AddSingleton<IslandsShellViewModel>();` registration with a factory that injects the conflict-resolver factory:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
sc.AddSingleton<IslandsShellViewModel>(sp =>
|
||||||
|
{
|
||||||
|
var shell = ActivatorUtilities.CreateInstance<IslandsShellViewModel>(sp);
|
||||||
|
shell.ConflictResolverFactory =
|
||||||
|
sp.GetRequiredService<Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>>();
|
||||||
|
return shell;
|
||||||
|
});
|
||||||
|
```
|
||||||
|
(`ActivatorUtilities.CreateInstance` resolves the existing big constructor + its `Func<>` deps exactly as the default registration did.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the additive seam to `IslandsShellViewModel`** (near the other `Show*` delegate properties):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// Layer C seam: composition root sets the factory; MainWindow sets the dialog opener.
|
||||||
|
// The integrator connects Layer A/B's RequestConflictResolution(taskId, target) to this method.
|
||||||
|
public Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>? ConflictResolverFactory { get; set; }
|
||||||
|
public Func<ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel, Task>? ShowConflictResolver { get; set; }
|
||||||
|
|
||||||
|
public async Task RequestConflictResolutionAsync(string taskId, string targetBranch)
|
||||||
|
{
|
||||||
|
if (ConflictResolverFactory is null || ShowConflictResolver is null) return;
|
||||||
|
var vm = ConflictResolverFactory(taskId);
|
||||||
|
var hasConflicts = await vm.OpenAsync(targetBranch);
|
||||||
|
if (hasConflicts)
|
||||||
|
await ShowConflictResolver(vm);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
(Add `using ClaudeDo.Ui.ViewModels.Conflicts;` or use fully-qualified names as above.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Wire the dialog opener in `MainWindow.axaml.cs`** inside `OnDataContextChanged`, alongside the other `vm.Show*` assignments:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
vm.ShowConflictResolver = async (resolverVm) =>
|
||||||
|
{
|
||||||
|
var dlg = new ClaudeDo.Ui.Views.Conflicts.ConflictResolverView { DataContext = resolverVm };
|
||||||
|
await dlg.ShowDialog(this);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build the App to verify compilation.**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||||
|
Expected: Build succeeded.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.App/Program.cs src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs src/ClaudeDo.Ui/Views/MainWindow.axaml.cs
|
||||||
|
git commit -m "feat(ui): expose conflict-resolver factory and dialog seam for integrator"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 8: Full verification
|
||||||
|
|
||||||
|
- [ ] **Step 1: Build both head projects.**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
|
||||||
|
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
|
||||||
|
```
|
||||||
|
Expected: both Build succeeded, 0 errors/warnings.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the full relevant test suites.**
|
||||||
|
|
||||||
|
Run:
|
||||||
|
```bash
|
||||||
|
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
|
||||||
|
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
|
||||||
|
```
|
||||||
|
Expected: all PASS.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Flag visual verification.** The resolver dialog cannot be opened end-to-end until the integrator wires Layer A/B's `RequestConflictResolution(taskId, target)` → `IslandsShellViewModel.RequestConflictResolutionAsync`. Report this as a visual-verification gap for the user/integrator: open a real conflicting merge, confirm hunks render, Accept buttons populate the merged box, Resolve & continue closes on success, Abort restores the tree.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Leave the branch for the orchestrator.** Do NOT push, do NOT merge to main.
|
||||||
837
docs/superpowers/plans/2026-06-05-merge-cockpit-layer-b.md
Normal file
837
docs/superpowers/plans/2026-06-05-merge-cockpit-layer-b.md
Normal file
@@ -0,0 +1,837 @@
|
|||||||
|
# Layer B — Multi-Worktree Merge Cockpit Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Turn the worktrees-overview modal into a batch-merge cockpit (multi-select N worktrees → one target branch → "Merge all" with skip-and-continue conflict collection), and migrate `WorktreeModalView`'s bespoke inline diff onto the canonical `DiffLinesView`.
|
||||||
|
|
||||||
|
**Architecture:** The cockpit VM keeps depending on the concrete `WorkerClient` (the overview/cleanup/state methods live only on `WorkerClient`, not `IWorkerClient`). The batch loop is extracted into a delegate-driven method `MergeSelectedAsync(Func<...> mergeFn)` so it is unit-testable with a fake merge function and a never-connected `WorkerClient`. Clean merges (`Status=="merged"`) update the row; conflicts (`Status=="conflict"`, which `MergeTaskAsync` already auto-aborts) are collected into a `ConflictRows` list whose rows expose a `Resolve` button wired to an inert `RequestConflictResolution(taskId, targetBranch)` seam. The diff migration replaces the right-pane `ItemsControl` in `WorktreeModalView` with `DiffLinesView`, feeding it `DiffLineViewModel`s produced by `UnifiedDiffParser`, and deletes the now-dead `WorktreeDiffLineViewModel`/`WorktreeDiffLineKind`.
|
||||||
|
|
||||||
|
**Tech Stack:** .NET 8, Avalonia 12, CommunityToolkit.Mvvm source generators, xUnit. Build UI with `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`; run `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`.
|
||||||
|
|
||||||
|
**Frozen contracts reused (do NOT modify):**
|
||||||
|
- `WorkerClient.MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage) -> Task<MergeResultDto>`
|
||||||
|
- `WorkerClient.GetMergeTargetsAsync(string taskId) -> Task<MergeTargetsDto?>` (`MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalBranches)`)
|
||||||
|
- `MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage)` — `Status` is `"merged" | "conflict" | "blocked" | <other>`
|
||||||
|
- `WorkerClient.GetWorktreesOverviewAsync`, `CleanupFinishedWorktreesAsync`, `SetWorktreeStateAsync`, `ForceRemoveWorktreeAsync`
|
||||||
|
- `GitService.GetFileDiffAsync(worktreePath, baseCommit?, relativePath)` returns a `git diff` blob including the `diff --git` header (so `UnifiedDiffParser.Parse` handles it)
|
||||||
|
- `DiffLinesView` (`Lines` styled property, `IEnumerable?`), `DiffLineViewModel`, `DiffFileViewModel`, `UnifiedDiffParser.Parse` / `.Flatten`
|
||||||
|
|
||||||
|
**Do NOT touch:** any worker-side files (`WorkerHub`, `TaskMergeService`, `GitService`), `IWorkerClient.cs` / `WorkerClient.cs`, `WorkConsole.axaml`, `DetailsIslandViewModel.cs`, and do not create any `ConflictResolver` UI or reference any `ConflictResolver` type.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
- `src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs` — **modify.** Add `BatchMergeOutcome` enum; add `IsChecked`/`MergeOutcome` (+ derived) to the row VM; add `MergeTargets`, `SelectedTarget`, `SelectedCount`, `IsMerging`, `BatchProgress`, `ConflictRows`, the `RequestConflictResolution` seam, `MergeSelectedAsync`, `MergeAllCommand`, `ResolveConflictCommand`, `ToggleSelectAllCommand`, target loading, and per-row check subscription. Keep all existing context-menu commands/wiring intact.
|
||||||
|
- `src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml` — **modify.** Add a per-row checkbox + outcome badge, a target `ComboBox` + "Merge all" button + progress text in the toolbar, and a "Needs resolution" panel listing `ConflictRows` with `Resolve` buttons.
|
||||||
|
- `src/ClaudeDo.Ui/ViewModels/Modals/WorktreeModalViewModel.cs` — **modify.** Replace `SelectedFileDiffLines` element type with `DiffLineViewModel` produced via `UnifiedDiffParser`; delete `WorktreeDiffLineKind` and `WorktreeDiffLineViewModel`.
|
||||||
|
- `src/ClaudeDo.Ui/Views/Modals/WorktreeModalView.axaml` — **modify.** Replace the right-pane `ItemsControl` with `ctl:DiffLinesView`; drop the `DiffLineKindToBrushConverter` resource.
|
||||||
|
- `src/ClaudeDo.Localization/locales/en.json` + `de.json` — **modify.** Add new `modals.worktreesOverview.*` and `vm.worktreesOverview.*` keys (keep parity).
|
||||||
|
- `tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs` — **create.** Unit tests for `MergeSelectedAsync` skip-and-continue, conflict collection, progress, selection gating, and the resolve seam.
|
||||||
|
|
||||||
|
No `IWorkerClient` change → no test-fake updates needed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Row-level batch state (outcome enum + row VM fields)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs`
|
||||||
|
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Create `tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Modals;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||||
|
|
||||||
|
public class WorktreesOverviewBatchMergeTests
|
||||||
|
{
|
||||||
|
private static WorktreeOverviewRowViewModel ActiveRow(string id) => new()
|
||||||
|
{
|
||||||
|
TaskId = id,
|
||||||
|
TaskTitle = $"Task {id}",
|
||||||
|
TaskStatus = TaskStatus.WaitingForReview,
|
||||||
|
State = WorktreeState.Active,
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Row_outcome_helpers_reflect_state()
|
||||||
|
{
|
||||||
|
var row = ActiveRow("a");
|
||||||
|
Assert.Equal(BatchMergeOutcome.None, row.MergeOutcome);
|
||||||
|
Assert.False(row.IsConflict);
|
||||||
|
|
||||||
|
row.MergeOutcome = BatchMergeOutcome.Conflict;
|
||||||
|
Assert.True(row.IsConflict);
|
||||||
|
|
||||||
|
row.MergeOutcome = BatchMergeOutcome.Merged;
|
||||||
|
Assert.False(row.IsConflict);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter WorktreesOverviewBatchMergeTests`
|
||||||
|
Expected: FAIL — `BatchMergeOutcome` and `MergeOutcome`/`IsConflict` do not exist (compile error).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the enum and row fields**
|
||||||
|
|
||||||
|
In `WorktreesOverviewModalViewModel.cs`, add the enum just above `WorktreeOverviewRowViewModel`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public enum BatchMergeOutcome { None, Merging, Merged, Conflict, Blocked, Failed }
|
||||||
|
```
|
||||||
|
|
||||||
|
Inside `WorktreeOverviewRowViewModel`, add after the existing `_isSelected` field:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[ObservableProperty] private bool _isChecked;
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(IsConflict))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(HasOutcome))]
|
||||||
|
private BatchMergeOutcome _mergeOutcome;
|
||||||
|
|
||||||
|
public bool IsConflict => MergeOutcome == BatchMergeOutcome.Conflict;
|
||||||
|
public bool HasOutcome => MergeOutcome != BatchMergeOutcome.None;
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter WorktreesOverviewBatchMergeTests`
|
||||||
|
Expected: PASS (1 test).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs
|
||||||
|
git commit -m "feat(ui): add batch-merge row state to worktrees cockpit VM"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: Batch orchestration (`MergeSelectedAsync` skip-and-continue)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs`
|
||||||
|
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Append to `WorktreesOverviewBatchMergeTests.cs`. The helper builds a VM with a never-connected `WorkerClient` (the loop never touches it) and seeds `Rows` directly:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private static WorktreesOverviewModalViewModel NewVm() =>
|
||||||
|
new(new ClaudeDo.Ui.Services.WorkerClient("http://127.0.0.1:1/hub"), () => null!);
|
||||||
|
|
||||||
|
private static MergeResultDto Merged() => new("merged", System.Array.Empty<string>(), null);
|
||||||
|
private static MergeResultDto Conflict() => new("conflict", new[] { "f.cs" }, null);
|
||||||
|
private static MergeResultDto Blocked() => new("blocked", System.Array.Empty<string>(), "blocked");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async System.Threading.Tasks.Task MergeSelected_only_processes_checked_active_rows()
|
||||||
|
{
|
||||||
|
var vm = NewVm();
|
||||||
|
var a = ActiveRow("a"); a.IsChecked = true;
|
||||||
|
var b = ActiveRow("b"); b.IsChecked = false; // unchecked -> skipped
|
||||||
|
var c = ActiveRow("c"); c.IsChecked = true; c.State = WorktreeState.Merged; // not active -> skipped
|
||||||
|
vm.Rows.Add(a); vm.Rows.Add(b); vm.Rows.Add(c);
|
||||||
|
vm.SelectedTarget = "main";
|
||||||
|
|
||||||
|
var seen = new System.Collections.Generic.List<string>();
|
||||||
|
await vm.MergeSelectedAsync((id, target, remove, msg) =>
|
||||||
|
{
|
||||||
|
seen.Add(id);
|
||||||
|
Assert.Equal("main", target);
|
||||||
|
Assert.False(remove); // removeWorktree must be false
|
||||||
|
return System.Threading.Tasks.Task.FromResult(Merged());
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(new[] { "a" }, seen);
|
||||||
|
Assert.Equal(BatchMergeOutcome.Merged, a.MergeOutcome);
|
||||||
|
Assert.False(a.IsChecked); // cleared after merge
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async System.Threading.Tasks.Task MergeSelected_continues_past_conflict_and_collects_it()
|
||||||
|
{
|
||||||
|
var vm = NewVm();
|
||||||
|
var a = ActiveRow("a"); a.IsChecked = true;
|
||||||
|
var b = ActiveRow("b"); b.IsChecked = true;
|
||||||
|
var c = ActiveRow("c"); c.IsChecked = true;
|
||||||
|
vm.Rows.Add(a); vm.Rows.Add(b); vm.Rows.Add(c);
|
||||||
|
vm.SelectedTarget = "main";
|
||||||
|
|
||||||
|
await vm.MergeSelectedAsync((id, target, remove, msg) =>
|
||||||
|
System.Threading.Tasks.Task.FromResult(id == "b" ? Conflict() : Merged()));
|
||||||
|
|
||||||
|
Assert.Equal(BatchMergeOutcome.Merged, a.MergeOutcome);
|
||||||
|
Assert.Equal(BatchMergeOutcome.Conflict, b.MergeOutcome);
|
||||||
|
Assert.Equal(BatchMergeOutcome.Merged, c.MergeOutcome); // continued past the conflict
|
||||||
|
Assert.Contains(b, vm.ConflictRows);
|
||||||
|
Assert.Single(vm.ConflictRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async System.Threading.Tasks.Task MergeSelected_maps_blocked_and_exception_to_failure_outcomes()
|
||||||
|
{
|
||||||
|
var vm = NewVm();
|
||||||
|
var a = ActiveRow("a"); a.IsChecked = true;
|
||||||
|
var b = ActiveRow("b"); b.IsChecked = true;
|
||||||
|
vm.Rows.Add(a); vm.Rows.Add(b);
|
||||||
|
vm.SelectedTarget = "main";
|
||||||
|
|
||||||
|
await vm.MergeSelectedAsync((id, target, remove, msg) => id == "a"
|
||||||
|
? System.Threading.Tasks.Task.FromResult(Blocked())
|
||||||
|
: throw new System.InvalidOperationException("boom"));
|
||||||
|
|
||||||
|
Assert.Equal(BatchMergeOutcome.Blocked, a.MergeOutcome);
|
||||||
|
Assert.Equal(BatchMergeOutcome.Failed, b.MergeOutcome);
|
||||||
|
Assert.Empty(vm.ConflictRows);
|
||||||
|
Assert.False(vm.IsMerging);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async System.Threading.Tasks.Task MergeSelected_noop_when_no_target()
|
||||||
|
{
|
||||||
|
var vm = NewVm();
|
||||||
|
var a = ActiveRow("a"); a.IsChecked = true;
|
||||||
|
vm.Rows.Add(a);
|
||||||
|
vm.SelectedTarget = null;
|
||||||
|
|
||||||
|
var called = false;
|
||||||
|
await vm.MergeSelectedAsync((id, t, r, m) => { called = true; return System.Threading.Tasks.Task.FromResult(Merged()); });
|
||||||
|
|
||||||
|
Assert.False(called);
|
||||||
|
Assert.Equal(BatchMergeOutcome.None, a.MergeOutcome);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter WorktreesOverviewBatchMergeTests`
|
||||||
|
Expected: FAIL — `MergeSelectedAsync`, `ConflictRows`, `IsMerging`, `SelectedTarget` do not exist (compile error).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the orchestration + cockpit fields**
|
||||||
|
|
||||||
|
In `WorktreesOverviewModalViewModel.cs`, add these `using`s if missing: `using ClaudeDo.Ui.Services;` (already present). Add fields/properties to `WorktreesOverviewModalViewModel` (after the existing `_selectedRow` field):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(MergeAllCommand))] private string? _selectedTarget;
|
||||||
|
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(MergeAllCommand))] private int _selectedCount;
|
||||||
|
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(MergeAllCommand))] private bool _isMerging;
|
||||||
|
[ObservableProperty] private string? _batchProgress;
|
||||||
|
|
||||||
|
public ObservableCollection<string> MergeTargets { get; } = new();
|
||||||
|
public ObservableCollection<WorktreeOverviewRowViewModel> ConflictRows { get; } = new();
|
||||||
|
|
||||||
|
/// Inert seam wired by the integrator to Layer C's resolver at merge time. (taskId, targetBranch)
|
||||||
|
public Func<string, string, Task>? RequestConflictResolution { get; set; }
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a helper to enumerate rows regardless of grouped/flat mode, plus the orchestration method:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public IEnumerable<WorktreeOverviewRowViewModel> AllRows =>
|
||||||
|
IsGlobal ? Groups.SelectMany(g => g.Rows) : Rows;
|
||||||
|
|
||||||
|
public async Task MergeSelectedAsync(
|
||||||
|
Func<string, string, bool, string, Task<MergeResultDto>> mergeFn,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var target = SelectedTarget;
|
||||||
|
if (string.IsNullOrWhiteSpace(target)) return;
|
||||||
|
|
||||||
|
var selected = AllRows.Where(r => r.IsChecked && r.IsActive).ToList();
|
||||||
|
if (selected.Count == 0) return;
|
||||||
|
|
||||||
|
IsMerging = true;
|
||||||
|
ConflictRows.Clear();
|
||||||
|
var done = 0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var row in selected)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
row.MergeOutcome = BatchMergeOutcome.Merging;
|
||||||
|
BatchProgress = Loc.T("vm.worktreesOverview.batchProgress", ++done, selected.Count);
|
||||||
|
|
||||||
|
MergeResultDto result;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
result = await mergeFn(row.TaskId, target!, false,
|
||||||
|
Loc.T("vm.merge.commitMessage", row.TaskTitle));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
row.MergeOutcome = BatchMergeOutcome.Failed;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (result.Status)
|
||||||
|
{
|
||||||
|
case "merged":
|
||||||
|
row.MergeOutcome = BatchMergeOutcome.Merged;
|
||||||
|
row.State = WorktreeState.Merged;
|
||||||
|
row.IsChecked = false;
|
||||||
|
break;
|
||||||
|
case "conflict":
|
||||||
|
row.MergeOutcome = BatchMergeOutcome.Conflict;
|
||||||
|
ConflictRows.Add(row);
|
||||||
|
break;
|
||||||
|
case "blocked":
|
||||||
|
row.MergeOutcome = BatchMergeOutcome.Blocked;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
row.MergeOutcome = BatchMergeOutcome.Failed;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BatchProgress = Loc.T("vm.worktreesOverview.batchDone",
|
||||||
|
selected.Count(r => r.MergeOutcome == BatchMergeOutcome.Merged), ConflictRows.Count);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsMerging = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> Note: `Loc.T` keys are added in Task 5; they resolve to the key name (harmless) until then, so tests pass now.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter WorktreesOverviewBatchMergeTests`
|
||||||
|
Expected: PASS (5 tests).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs
|
||||||
|
git commit -m "feat(ui): add skip-and-continue batch merge orchestration"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Selection tracking, target loading, commands + resolve seam
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs`
|
||||||
|
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Append to `WorktreesOverviewBatchMergeTests.cs`:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
[Fact]
|
||||||
|
public void SelectedCount_tracks_checked_active_rows()
|
||||||
|
{
|
||||||
|
var vm = NewVm();
|
||||||
|
var a = ActiveRow("a");
|
||||||
|
var b = ActiveRow("b");
|
||||||
|
var merged = ActiveRow("c"); merged.State = WorktreeState.Merged;
|
||||||
|
vm.AddRowForTest(a); vm.AddRowForTest(b); vm.AddRowForTest(merged);
|
||||||
|
|
||||||
|
Assert.Equal(0, vm.SelectedCount);
|
||||||
|
a.IsChecked = true;
|
||||||
|
Assert.Equal(1, vm.SelectedCount);
|
||||||
|
b.IsChecked = true;
|
||||||
|
merged.IsChecked = true; // not active -> not counted
|
||||||
|
Assert.Equal(2, vm.SelectedCount);
|
||||||
|
a.IsChecked = false;
|
||||||
|
Assert.Equal(1, vm.SelectedCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolveConflict_invokes_seam_with_task_and_target()
|
||||||
|
{
|
||||||
|
var vm = NewVm();
|
||||||
|
vm.SelectedTarget = "release";
|
||||||
|
var row = ActiveRow("x"); row.MergeOutcome = BatchMergeOutcome.Conflict;
|
||||||
|
|
||||||
|
(string Task, string Target)? captured = null;
|
||||||
|
vm.RequestConflictResolution = (taskId, target) => { captured = (taskId, target); return System.Threading.Tasks.Task.CompletedTask; };
|
||||||
|
|
||||||
|
vm.ResolveConflictCommand.Execute(row);
|
||||||
|
|
||||||
|
Assert.Equal(("x", "release"), captured);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MergeAll_canExecute_requires_target_selection_and_idle()
|
||||||
|
{
|
||||||
|
var vm = NewVm();
|
||||||
|
var a = ActiveRow("a");
|
||||||
|
vm.AddRowForTest(a);
|
||||||
|
|
||||||
|
Assert.False(vm.MergeAllCommand.CanExecute(null)); // no selection, no target
|
||||||
|
a.IsChecked = true;
|
||||||
|
Assert.False(vm.MergeAllCommand.CanExecute(null)); // still no target
|
||||||
|
vm.SelectedTarget = "main";
|
||||||
|
Assert.True(vm.MergeAllCommand.CanExecute(null));
|
||||||
|
vm.IsMerging = true;
|
||||||
|
Assert.False(vm.MergeAllCommand.CanExecute(null)); // busy
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter WorktreesOverviewBatchMergeTests`
|
||||||
|
Expected: FAIL — `AddRowForTest`, `ResolveConflictCommand`, `MergeAllCommand` do not exist (compile error).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement subscription, commands, target loading**
|
||||||
|
|
||||||
|
In `WorktreesOverviewModalViewModel.cs`:
|
||||||
|
|
||||||
|
(a) Add a row-hook that recomputes `SelectedCount` when a row's `IsChecked` changes, and a test seam to add a hooked row. Add these methods to the class:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private void HookRow(WorktreeOverviewRowViewModel row)
|
||||||
|
{
|
||||||
|
row.PropertyChanged += (_, e) =>
|
||||||
|
{
|
||||||
|
if (e.PropertyName is nameof(WorktreeOverviewRowViewModel.IsChecked)
|
||||||
|
or nameof(WorktreeOverviewRowViewModel.State))
|
||||||
|
RecomputeSelected();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RecomputeSelected() =>
|
||||||
|
SelectedCount = AllRows.Count(r => r.IsChecked && r.IsActive);
|
||||||
|
|
||||||
|
// Test seam: adds a row to the flat list with selection tracking wired up.
|
||||||
|
internal void AddRowForTest(WorktreeOverviewRowViewModel row)
|
||||||
|
{
|
||||||
|
HookRow(row);
|
||||||
|
Rows.Add(row);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(b) In `LoadAsync`, call `HookRow(row)` everywhere a row is added. Replace the two add sites:
|
||||||
|
|
||||||
|
In the grouped branch, change `foreach (var row in grp) group.Rows.Add(row);` to:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
foreach (var row in grp) { HookRow(row); group.Rows.Add(row); }
|
||||||
|
```
|
||||||
|
|
||||||
|
In the flat branch, change `foreach (var row in ordered) Rows.Add(row);` to:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
foreach (var row in ordered) { HookRow(row); Rows.Add(row); }
|
||||||
|
```
|
||||||
|
|
||||||
|
Also, at the start of `LoadAsync` after `IsBusy = true;`, reset batch UI state and (re)load merge targets at the end of the `try`:
|
||||||
|
|
||||||
|
After `Rows.Clear(); Groups.Clear();` add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
ConflictRows.Clear();
|
||||||
|
SelectedCount = 0;
|
||||||
|
BatchProgress = null;
|
||||||
|
```
|
||||||
|
|
||||||
|
At the very end of the `try` block (after the if/else that fills rows/groups) add:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
await LoadMergeTargetsAsync();
|
||||||
|
```
|
||||||
|
|
||||||
|
(c) Add target loading. The branch list is repo-level, so query it from the first active row:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private async Task LoadMergeTargetsAsync()
|
||||||
|
{
|
||||||
|
var anchor = AllRows.FirstOrDefault(r => r.IsActive);
|
||||||
|
if (anchor is null) { MergeTargets.Clear(); SelectedTarget = null; return; }
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var targets = await _worker.GetMergeTargetsAsync(anchor.TaskId);
|
||||||
|
MergeTargets.Clear();
|
||||||
|
if (targets is null) { SelectedTarget = null; return; }
|
||||||
|
foreach (var b in targets.LocalBranches) MergeTargets.Add(b);
|
||||||
|
SelectedTarget = MergeTargets.Contains(targets.DefaultBranch)
|
||||||
|
? targets.DefaultBranch
|
||||||
|
: MergeTargets.FirstOrDefault();
|
||||||
|
}
|
||||||
|
catch { MergeTargets.Clear(); SelectedTarget = null; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(d) Add the commands:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private bool CanMergeAll() => !IsMerging && SelectedCount > 0 && !string.IsNullOrWhiteSpace(SelectedTarget);
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanMergeAll))]
|
||||||
|
private Task MergeAll() => MergeSelectedAsync(_worker.MergeTaskAsync);
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void ResolveConflict(WorktreeOverviewRowViewModel? row)
|
||||||
|
{
|
||||||
|
if (row is null) return;
|
||||||
|
RequestConflictResolution?.Invoke(row.TaskId, SelectedTarget ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void ToggleSelectAll()
|
||||||
|
{
|
||||||
|
var actives = AllRows.Where(r => r.IsActive).ToList();
|
||||||
|
var allChecked = actives.Count > 0 && actives.All(r => r.IsChecked);
|
||||||
|
foreach (var r in actives) r.IsChecked = !allChecked;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter WorktreesOverviewBatchMergeTests`
|
||||||
|
Expected: PASS (8 tests total in this file).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Build the app project to confirm the VM compiles against generated commands**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||||
|
Expected: Build succeeded.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs
|
||||||
|
git commit -m "feat(ui): wire batch selection, target loading and resolve seam"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Cockpit view — checkboxes, target picker, Merge all, conflicts panel
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml`
|
||||||
|
|
||||||
|
This task is AXAML only (no logic) → no new unit test; flag for visual verification.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the batch toolbar controls**
|
||||||
|
|
||||||
|
In `WorktreesOverviewModalView.axaml`, replace the toolbar `StackPanel` (currently containing Refresh, Cleanup finished, StatusMessage) with one that adds select-all, the target picker, the Merge-all button and progress text. Replace the inner `<StackPanel Orientation="Horizontal" Spacing="8">...</StackPanel>` of the toolbar `Border` with:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<Button Classes="btn" Content="{loc:Tr modals.worktreesOverview.refresh}" Command="{Binding RefreshCommand}" IsEnabled="{Binding !IsBusy}"/>
|
||||||
|
<Button Classes="btn" Content="{loc:Tr modals.worktreesOverview.cleanupFinished}" Command="{Binding CleanupFinishedCommand}" IsEnabled="{Binding !IsBusy}"/>
|
||||||
|
<Button Classes="btn" Content="{loc:Tr modals.worktreesOverview.selectAll}" Command="{Binding ToggleSelectAllCommand}"/>
|
||||||
|
<Border Width="1" Background="{DynamicResource LineBrush}" Margin="4,2"/>
|
||||||
|
<TextBlock Text="{loc:Tr modals.worktreesOverview.targetLabel}" VerticalAlignment="Center" Foreground="{DynamicResource TextDimBrush}"/>
|
||||||
|
<ComboBox MinWidth="160"
|
||||||
|
ItemsSource="{Binding MergeTargets}"
|
||||||
|
SelectedItem="{Binding SelectedTarget, Mode=TwoWay}"/>
|
||||||
|
<Button Classes="btn accent"
|
||||||
|
Content="{loc:Tr modals.worktreesOverview.mergeAll}"
|
||||||
|
Command="{Binding MergeAllCommand}"/>
|
||||||
|
<TextBlock Text="{Binding SelectedCount, StringFormat='{}{0} selected'}"
|
||||||
|
VerticalAlignment="Center" Foreground="{DynamicResource TextDimBrush}"/>
|
||||||
|
<TextBlock Text="{Binding BatchProgress}" VerticalAlignment="Center" Margin="8,0,0,0"
|
||||||
|
Foreground="{DynamicResource TextDimBrush}"/>
|
||||||
|
<TextBlock Text="{Binding StatusMessage}" VerticalAlignment="Center" Margin="8,0,0,0"
|
||||||
|
Foreground="{DynamicResource TextDimBrush}"/>
|
||||||
|
</StackPanel>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add a checkbox + outcome badge to the row template**
|
||||||
|
|
||||||
|
In the `WorktreeRowTemplate` `DataTemplate`, change the row `Grid` to add a leading checkbox column and a trailing outcome column. Replace the `<Grid ColumnDefinitions="*,90,80,80">...</Grid>` (the whole grid, lines for Task/State/Diff/Age) with:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Grid ColumnDefinitions="Auto,*,90,90,80,80">
|
||||||
|
<CheckBox Grid.Column="0" VerticalAlignment="Center" Margin="0,0,8,0"
|
||||||
|
IsChecked="{Binding IsChecked, Mode=TwoWay}"
|
||||||
|
IsEnabled="{Binding IsActive}"
|
||||||
|
IsVisible="{Binding IsActive}"/>
|
||||||
|
<StackPanel Grid.Column="1" Orientation="Vertical" Spacing="2">
|
||||||
|
<TextBlock Classes="title" Text="{Binding TaskTitle}"/>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||||
|
<TextBlock Classes="meta" Text="{Binding TaskStatus}"/>
|
||||||
|
<TextBlock Classes="meta" Text="•"
|
||||||
|
IsVisible="{Binding !PathExistsOnDisk}"/>
|
||||||
|
<TextBlock Classes="meta" Text="{loc:Tr modals.worktreesOverview.phantom}" Foreground="{DynamicResource StatusErrorBrush}"
|
||||||
|
IsVisible="{Binding !PathExistsOnDisk}"
|
||||||
|
ToolTip.Tip="{loc:Tr modals.worktreesOverview.phantomTooltip}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Grid.Column="2" Classes="meta" VerticalAlignment="Center"
|
||||||
|
Text="{Binding MergeOutcome}"
|
||||||
|
IsVisible="{Binding HasOutcome}"/>
|
||||||
|
<Border Grid.Column="3" CornerRadius="3" Padding="6,2" VerticalAlignment="Center"
|
||||||
|
Background="{Binding State, Converter={StaticResource WorktreeStateColor}}">
|
||||||
|
<TextBlock Classes="meta" Text="{Binding State}" Foreground="{DynamicResource TextBrush}"
|
||||||
|
HorizontalAlignment="Center"/>
|
||||||
|
</Border>
|
||||||
|
<TextBlock Grid.Column="4" Classes="meta" Text="{Binding DiffStat}" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Grid.Column="5" Classes="meta" Text="{Binding AgeText}" VerticalAlignment="Center"/>
|
||||||
|
</Grid>
|
||||||
|
```
|
||||||
|
|
||||||
|
Then update the column-header `Grid` (the one with `ColumnDefinitions="*,90,80,80"` near the ScrollViewer top) to match the new column layout:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Grid ColumnDefinitions="Auto,*,90,90,80,80" Margin="12,0,12,4">
|
||||||
|
<TextBlock Grid.Column="1" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnTask}"/>
|
||||||
|
<TextBlock Grid.Column="2" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnOutcome}"/>
|
||||||
|
<TextBlock Grid.Column="3" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnState}"/>
|
||||||
|
<TextBlock Grid.Column="4" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnDiff}"/>
|
||||||
|
<TextBlock Grid.Column="5" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnAge}"/>
|
||||||
|
</Grid>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the "Needs resolution" panel**
|
||||||
|
|
||||||
|
Inside the content `ScrollViewer`'s root `StackPanel`, at the very top (before the column-header `Grid`), add a conflicts panel that only shows when there are conflicts:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Border IsVisible="{Binding ConflictRows.Count}"
|
||||||
|
Background="{DynamicResource ErrorTintBrush}"
|
||||||
|
BorderBrush="{DynamicResource StatusErrorBrush}"
|
||||||
|
BorderThickness="1" CornerRadius="6" Padding="12,8" Margin="0,0,0,12">
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<TextBlock Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.needsResolution}"/>
|
||||||
|
<ItemsControl ItemsSource="{Binding ConflictRows}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:WorktreeOverviewRowViewModel">
|
||||||
|
<Grid ColumnDefinitions="*,Auto" Margin="0,2">
|
||||||
|
<TextBlock Grid.Column="0" Classes="meta" VerticalAlignment="Center"
|
||||||
|
Text="{Binding TaskTitle}"/>
|
||||||
|
<Button Grid.Column="1" Classes="btn"
|
||||||
|
Content="{loc:Tr modals.worktreesOverview.resolve}"
|
||||||
|
Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).ResolveConflictCommand}"
|
||||||
|
CommandParameter="{Binding}"/>
|
||||||
|
</Grid>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
```
|
||||||
|
|
||||||
|
> `IsVisible="{Binding ConflictRows.Count}"` uses Avalonia's int→bool coercion (0 = false). If the build flags this, change to a value converter already present, but int→bool is supported.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build the app to verify the AXAML compiles**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||||
|
Expected: Build succeeded (compiled bindings resolve against the new VM members).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml
|
||||||
|
git commit -m "feat(ui): batch-merge cockpit view with checkboxes and conflicts panel"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Localization keys (en + de parity)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Localization/locales/en.json`
|
||||||
|
- Modify: `src/ClaudeDo.Localization/locales/de.json`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the new keys to `en.json`**
|
||||||
|
|
||||||
|
Under `modals.worktreesOverview`, add:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"columnOutcome": "RESULT",
|
||||||
|
"selectAll": "Select all",
|
||||||
|
"targetLabel": "Target",
|
||||||
|
"mergeAll": "Merge all",
|
||||||
|
"needsResolution": "NEEDS RESOLUTION",
|
||||||
|
"resolve": "Resolve"
|
||||||
|
```
|
||||||
|
|
||||||
|
Under `vm.worktreesOverview`, add:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"batchProgress": "Merging {0}/{1}…",
|
||||||
|
"batchDone": "Merged {0}, {1} need resolution."
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the matching keys to `de.json`**
|
||||||
|
|
||||||
|
Under `modals.worktreesOverview`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"columnOutcome": "ERGEBNIS",
|
||||||
|
"selectAll": "Alle auswählen",
|
||||||
|
"targetLabel": "Ziel",
|
||||||
|
"mergeAll": "Alle mergen",
|
||||||
|
"needsResolution": "ZU LÖSEN",
|
||||||
|
"resolve": "Lösen"
|
||||||
|
```
|
||||||
|
|
||||||
|
Under `vm.worktreesOverview`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"batchProgress": "Merge {0}/{1}…",
|
||||||
|
"batchDone": "{0} gemergt, {1} zu lösen."
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Run the localization parity test**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release`
|
||||||
|
Expected: PASS (en/de key parity holds).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json
|
||||||
|
git commit -m "feat(i18n): add batch-merge cockpit strings (en/de)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Migrate `WorktreeModalView` diff onto `DiffLinesView`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/WorktreeModalViewModel.cs`
|
||||||
|
- Modify: `src/ClaudeDo.Ui/Views/Modals/WorktreeModalView.axaml`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Switch the VM to the canonical diff model**
|
||||||
|
|
||||||
|
In `WorktreeModalViewModel.cs`:
|
||||||
|
|
||||||
|
(a) Delete the now-dead types at the top of the file:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public enum WorktreeDiffLineKind { Header, Hunk, Added, Removed, Context }
|
||||||
|
|
||||||
|
public sealed partial class WorktreeDiffLineViewModel : ViewModelBase
|
||||||
|
{
|
||||||
|
public required string Text { get; init; }
|
||||||
|
public required WorktreeDiffLineKind Kind { get; init; }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(b) Change the collection declaration from:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public ObservableCollection<WorktreeDiffLineViewModel> SelectedFileDiffLines { get; } = new();
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public ObservableCollection<DiffLineViewModel> SelectedFileDiffLines { get; } = new();
|
||||||
|
```
|
||||||
|
|
||||||
|
(c) Replace the body of `LoadFileDiffAsync` (the `foreach (var line in diff.Split('\n'))` block) so it parses via `UnifiedDiffParser`. The method becomes:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
private async Task LoadFileDiffAsync(WorktreeNodeViewModel? node)
|
||||||
|
{
|
||||||
|
SelectedFileDiffLines.Clear();
|
||||||
|
|
||||||
|
if (node is null || node.IsDirectory || string.IsNullOrEmpty(node.RelativePath))
|
||||||
|
return;
|
||||||
|
|
||||||
|
string diff;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
diff = await _git.GetFileDiffAsync(WorktreePath, BaseCommit, node.RelativePath);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var line in UnifiedDiffParser.Flatten(UnifiedDiffParser.Parse(diff)))
|
||||||
|
SelectedFileDiffLines.Add(line);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(`DiffLineViewModel`, `DiffFileViewModel`, and `UnifiedDiffParser` are all in the same `ClaudeDo.Ui.ViewModels.Modals` namespace, so no new `using` is required.)
|
||||||
|
|
||||||
|
- [ ] **Step 2: Build to confirm the VM compiles and nothing else referenced the deleted types**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||||
|
Expected: Build succeeded. (If a compile error names `WorktreeDiffLineViewModel`/`WorktreeDiffLineKind` outside this file or the view, that reference must be migrated too — there should be none besides `WorktreeModalView.axaml`, handled next.)
|
||||||
|
|
||||||
|
- [ ] **Step 3: Swap the view's inline diff for `DiffLinesView`**
|
||||||
|
|
||||||
|
In `WorktreeModalView.axaml`:
|
||||||
|
|
||||||
|
(a) Remove the now-unused converter resource. Delete:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Window.Resources>
|
||||||
|
<converters:DiffLineKindToBrushConverter x:Key="DiffLineKindToBrush"/>
|
||||||
|
</Window.Resources>
|
||||||
|
```
|
||||||
|
|
||||||
|
(b) Replace the right-pane `ScrollViewer`'s `ItemsControl` (the `SelectableTextBlock` template bound to `SelectedFileDiffLines`) with the canonical control. Replace:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<ItemsControl ItemsSource="{Binding SelectedFileDiffLines}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate DataType="vm:WorktreeDiffLineViewModel">
|
||||||
|
<SelectableTextBlock Text="{Binding Text}"
|
||||||
|
FontFamily="{DynamicResource MonoFont}"
|
||||||
|
FontSize="{StaticResource FontSizeMono}"
|
||||||
|
Foreground="{Binding Kind, Converter={StaticResource DiffLineKindToBrush}}"
|
||||||
|
TextWrapping="NoWrap"/>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
```
|
||||||
|
|
||||||
|
with:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<ctl:DiffLinesView Lines="{Binding SelectedFileDiffLines}"/>
|
||||||
|
```
|
||||||
|
|
||||||
|
(The `xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"` namespace is already declared at the top of this file.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build the app to verify the AXAML compiles**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||||
|
Expected: Build succeeded.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/ClaudeDo.Ui/ViewModels/Modals/WorktreeModalViewModel.cs src/ClaudeDo.Ui/Views/Modals/WorktreeModalView.axaml
|
||||||
|
git commit -m "refactor(ui): render worktree modal diff via canonical DiffLinesView"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Full build + test sweep
|
||||||
|
|
||||||
|
**Files:** none (verification only).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Build the whole app**
|
||||||
|
|
||||||
|
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
||||||
|
Expected: Build succeeded, 0 errors.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run the UI + localization test projects**
|
||||||
|
|
||||||
|
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
|
||||||
|
Then: `dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release`
|
||||||
|
Expected: PASS (all green, including the 8 new batch-merge tests).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Flag visual-verification gaps**
|
||||||
|
|
||||||
|
The cockpit toolbar/checkbox/conflicts-panel layout and the migrated `WorktreeModalView` diff rendering are AXAML changes that cannot be verified headlessly. Report to the user that these need a visual pass (run the app, open the worktrees overview, select several worktrees, pick a target, "Merge all", and open a worktree diff).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review Notes
|
||||||
|
|
||||||
|
- **Spec coverage:** batch-merge cockpit (Tasks 1–4), skip-and-continue + conflict collection (Task 2), single target picker (Tasks 3–4), Resolve → `RequestConflictResolution(taskId, targetBranch)` seam left unwired (Tasks 3–4), `WorktreeModalView` diff migration to `DiffLinesView` (Task 6), no worker files touched, no `IWorkerClient` change, locales in parity (Task 5). ✔
|
||||||
|
- **No ConflictResolver reference:** the seam is a bare `Func<string,string,Task>?`; no Layer C type is named. ✔
|
||||||
|
- **Type consistency:** `BatchMergeOutcome`, `MergeOutcome`, `IsConflict`, `HasOutcome`, `MergeSelectedAsync`, `ConflictRows`, `SelectedTarget`, `SelectedCount`, `IsMerging`, `BatchProgress`, `RequestConflictResolution`, `MergeAllCommand`, `ResolveConflictCommand`, `ToggleSelectAllCommand`, `AddRowForTest`, `AllRows` are used consistently across tasks. ✔
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# Plan — Unify the parent-task model
|
||||||
|
|
||||||
|
Spec: `docs/superpowers/specs/2026-06-09-unify-parent-task-model-design.md`
|
||||||
|
|
||||||
|
Subagents: `sonnet`. Stage files explicitly by path (never `git add -A`). TDD.
|
||||||
|
Build with `-c Release` per project. Commit per task (Conventional Commits).
|
||||||
|
|
||||||
|
## Task 1 — Single parent-advance path
|
||||||
|
|
||||||
|
- Rename `TaskStateService.TryAdvanceImprovementParentAsync` → `TryAdvanceParentAsync`.
|
||||||
|
- Make it advance **any** `WaitingForChildren` parent → `WaitingForReview` when all
|
||||||
|
children are terminal, and advance a parent with **zero** children straight to
|
||||||
|
`WaitingForReview`.
|
||||||
|
- In `OnChildTerminalAsync`: drop the `TryCompleteParentAsync` call; keep
|
||||||
|
`_chain.OnChildFinishedAsync`; call the renamed advance method for all parents.
|
||||||
|
- Tests: extend `WaitingForChildrenLifecycleTests` — (a) improvement parent still
|
||||||
|
advances; (b) a `WaitingForChildren` parent whose children are a *sequential chain*
|
||||||
|
advances only after the last one is terminal; (c) zero-children parent advances.
|
||||||
|
|
||||||
|
## Task 2 — Delete `TryCompleteParentAsync`
|
||||||
|
|
||||||
|
- Remove `TaskRepository.TryCompleteParentAsync` (`TaskRepository.cs:477-502`) and
|
||||||
|
any remaining references.
|
||||||
|
- Update `src/ClaudeDo.Data/CLAUDE.md` (drop it from the TaskRepository helper list).
|
||||||
|
- Build Data + Worker; fix references.
|
||||||
|
|
||||||
|
## Task 3 — Planning finalize enters `WaitingForChildren`
|
||||||
|
|
||||||
|
- `TaskStateService.FinalizePlanningAsync`: in the same `ExecuteUpdateAsync`, set
|
||||||
|
`Status = WaitingForChildren` alongside `PlanningPhase = Finalized` /
|
||||||
|
`PlanningFinalizedAt`.
|
||||||
|
- Verify `PlanningSessionManager.FinalizeAsync` ordering: finalize (→ WaitingForChildren)
|
||||||
|
**before** `SetupChainAsync` enqueues child[0]. Adjust only if ordering is wrong.
|
||||||
|
- Tests: finalizing a planning parent with N children leaves it `WaitingForChildren`;
|
||||||
|
after the chain completes it is `WaitingForReview` (not `Done`); a planning parent
|
||||||
|
with zero finalized children lands in `WaitingForReview`.
|
||||||
|
|
||||||
|
## Task 4 — Approve merges the whole unit
|
||||||
|
|
||||||
|
**Decision: full UX consolidation.** Approve becomes the single entry for reviewing
|
||||||
|
*and* merging any task; the separate planning-merge views are folded into the review
|
||||||
|
panel. The `PlanningMergeOrchestrator` (which already merges the unit + sets the
|
||||||
|
parent `Done` for both planning and improvement, with conflict continue/abort) is
|
||||||
|
reused as the engine; only its *entry/UI* moves.
|
||||||
|
|
||||||
|
Backend:
|
||||||
|
- `WorkerHub.ApproveReview`: for a parent that **has children**, drive
|
||||||
|
`PlanningMergeOrchestrator.StartAsync` (event-based: `PlanningMergeStarted` /
|
||||||
|
`PlanningSubtaskMerged` / `PlanningMergeConflict` / `PlanningMergeAborted` /
|
||||||
|
`PlanningCompleted`) instead of the one-shot `ApproveAndMergeAsync`. Childless tasks
|
||||||
|
keep `ApproveAndMergeAsync`. Conflict resolution still goes through
|
||||||
|
`ContinuePlanningMerge` / `AbortPlanningMerge`.
|
||||||
|
- Keep the orchestrator, `ContinuePlanningMerge`, `AbortPlanningMerge`,
|
||||||
|
`GetPlanningAggregate`, `BuildPlanningIntegrationBranch`. Remove the now-redundant
|
||||||
|
standalone `MergeAllPlanning` hub method (approve is the entry).
|
||||||
|
- (Optional cleanup) route the orchestrator's `FinalizeParentDoneAsync` through
|
||||||
|
`TaskStateService` so `Status` writes stay centralized; low priority.
|
||||||
|
|
||||||
|
UI (Avalonia, MVVM — visual-verification gaps, flag for user):
|
||||||
|
- The review panel (`DetailsIslandViewModel` / its view) is the single approve+merge
|
||||||
|
surface. For a child-bearing parent in `WaitingForReview`, approve shows the
|
||||||
|
unit-merge progress + per-subtask state, the aggregate/integration diff preview, and
|
||||||
|
conflict continue/abort — all inline in the review panel.
|
||||||
|
- Remove the separate planning-merge view(s)/commands and the standalone "Merge all"
|
||||||
|
button; re-wire their `PlanningMerge*` event handlers into the review panel VM.
|
||||||
|
- Sync `IWorkerClient` + hand-rolled test fakes in both UI/Worker test projects.
|
||||||
|
|
||||||
|
Tests: approving a parent with two `Done` children merges both then sets `Done`; a
|
||||||
|
conflicting second child surfaces the conflict and pauses (continue/abort) without
|
||||||
|
losing the parent's `WaitingForReview`/merge state.
|
||||||
|
|
||||||
|
## Task 5 — Cancellable `WaitingForChildren` parent
|
||||||
|
|
||||||
|
- Add `TaskStatus.WaitingForChildren` to the `CancelAsync` guard.
|
||||||
|
- Test: a parent in `WaitingForChildren` can be cancelled.
|
||||||
|
|
||||||
|
## Task 6 — Docs
|
||||||
|
|
||||||
|
- `src/ClaudeDo.Worker/CLAUDE.md`: add `WaitingForChildren` to the Status table +
|
||||||
|
transition diagram; document the unified parent flow and approve-merges-unit;
|
||||||
|
remove `MergeAllPlanning` from the Hub method list.
|
||||||
|
- `src/ClaudeDo.Data/CLAUDE.md`: add `WaitingForChildren` to the TaskEntity status list.
|
||||||
|
- Root `CLAUDE.md`: update the "Task status flow" convention line.
|
||||||
|
|
||||||
|
## Verify
|
||||||
|
|
||||||
|
- `dotnet test` for Worker.Tests + Data.Tests (`-c Release`).
|
||||||
|
- UI flows (planning finalize → review → approve-merge; improvement parent;
|
||||||
|
retired MergeAllPlanning button) are **visual-verification gaps** — flag for the
|
||||||
|
user to run the app; do not claim they work from tests alone.
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
# Git Tab / Merge & Review Rework — Design
|
||||||
|
|
||||||
|
Date: 2026-06-05
|
||||||
|
Status: Approved
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Make handling merges and reviews as simple as possible in the Terminal component's
|
||||||
|
Git tab, and rework the diff viewers and worktree modals along the way. The work is
|
||||||
|
split into three layers built across separate sessions, with a shared foundation that
|
||||||
|
is built and pushed first so the parallel sessions branch from frozen contracts.
|
||||||
|
|
||||||
|
The user mostly trusts task output but wants the diff one click away for important
|
||||||
|
work, and wants to land several independently-queued worktrees without per-task
|
||||||
|
hopping or hand-resolving conflicts in an external editor.
|
||||||
|
|
||||||
|
## Layers
|
||||||
|
|
||||||
|
- **Layer A — Review/merge cockpit** (this session). Single-task review + merge UX in
|
||||||
|
the Git tab; consolidate the four diff renderers into one `DiffView`.
|
||||||
|
- **Layer B — Multi-worktree merge cockpit** (parallel session). Batch-merge N
|
||||||
|
worktrees into one target, skip-and-continue, conflicts collected for resolution.
|
||||||
|
- **Layer C — Inline conflict resolver** (parallel session). VSCode-style inline hunk
|
||||||
|
resolver plus the worker-side conflict plumbing it needs.
|
||||||
|
|
||||||
|
They stack: A defines the single-task flow, B reuses it for many tasks, both funnel
|
||||||
|
conflicts into C.
|
||||||
|
|
||||||
|
## Shared foundation (built & pushed this session, before B/C branch)
|
||||||
|
|
||||||
|
Everything B and C depend on lands first on `main`. B and C branch from that commit.
|
||||||
|
|
||||||
|
### 1. One diff model + one `DiffView` control
|
||||||
|
|
||||||
|
Today there are four diff renderers and two parallel diff models:
|
||||||
|
|
||||||
|
- `DiffLinesView.axaml` (used by `DiffModalView`)
|
||||||
|
- the inline diff `ItemsControl` in `WorktreeModalView.axaml`
|
||||||
|
- `PlanningDiffView.axaml`
|
||||||
|
- their backing models: `DiffFileViewModel`/`DiffLineViewModel` (+ `UnifiedDiffParser`)
|
||||||
|
vs `WorktreeNodeViewModel`/`WorktreeDiffLineViewModel`
|
||||||
|
|
||||||
|
Collapse into a single canonical diff model + parser + a `DiffView` UserControl. All
|
||||||
|
diff rendering across the app goes through `DiffView`.
|
||||||
|
|
||||||
|
- Model: `DiffFileViewModel { Path, AddCount, DelCount, Lines }`,
|
||||||
|
`DiffLineViewModel { OldNo, NewNo, Kind (Add|Del|Ctx|File|Hunk), Text }`.
|
||||||
|
- Parser: one static `UnifiedDiffParser.Parse(rawUnifiedDiff)` returning the model.
|
||||||
|
- `DiffView` exposes a `Files` styled property (file list + selected-file lines), or a
|
||||||
|
simpler `Lines` property for single-file use — Layer A decides the exact surface
|
||||||
|
while building it, but the type names above are frozen so B and C can bind to them.
|
||||||
|
|
||||||
|
### 2. Frozen worker conflict contract
|
||||||
|
|
||||||
|
Added to `IWorkerClient` (and `WorkerClient` with stub bodies that throw
|
||||||
|
`NotSupportedException`) plus new DTOs, so A and B compile against the interface while
|
||||||
|
C provides the real worker-side implementation.
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
// IWorkerClient additions (signatures frozen this session)
|
||||||
|
Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch);
|
||||||
|
Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId);
|
||||||
|
Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent);
|
||||||
|
Task<MergeResultDto> ContinueMergeAsync(string taskId);
|
||||||
|
Task AbortMergeAsync(string taskId);
|
||||||
|
```
|
||||||
|
|
||||||
|
- `StartConflictMergeAsync` performs the merge with `leaveConflictsInTree: true` (the
|
||||||
|
worker already supports this flag — used today by the planning orchestrator) and
|
||||||
|
returns `MergeResultDto` with `Status="conflict"` and the conflict file list, leaving
|
||||||
|
`.git/MERGE_HEAD` in place in the list's `WorkingDir`.
|
||||||
|
- `GetMergeConflictsAsync` returns each conflicted file with ours/theirs/base content,
|
||||||
|
read via `git show :2:<path>` (ours), `:3:<path>` (theirs), `:1:<path>` (base).
|
||||||
|
- `WriteConflictResolutionAsync` writes resolved content to the file in `WorkingDir`
|
||||||
|
and `git add`s it.
|
||||||
|
- `ContinueMergeAsync` wraps the existing `TaskMergeService.ContinueMergeAsync`
|
||||||
|
(`git add -A` → re-check `git diff --name-only --diff-filter=U` → `git commit`).
|
||||||
|
- `AbortMergeAsync` wraps the existing `TaskMergeService.AbortMergeAsync`
|
||||||
|
(`git merge --abort`).
|
||||||
|
|
||||||
|
New DTOs (defined in the worker hub DTO file, mirrored client-side):
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
public record MergeConflictsDto(string TaskId, IReadOnlyList<ConflictFileDto> Files);
|
||||||
|
public record ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks);
|
||||||
|
public record ConflictHunkDto(string Ours, string Theirs, string? Base);
|
||||||
|
```
|
||||||
|
|
||||||
|
Existing DTOs reused unchanged: `MergeResultDto(Status, ConflictFiles, ErrorMessage)`,
|
||||||
|
`MergePreviewDto`, `MergeTargetsDto`.
|
||||||
|
|
||||||
|
### 3. Conflict data model (UI)
|
||||||
|
|
||||||
|
`ConflictFile { Path, Hunks[] }`, `ConflictHunk { Ours, Theirs, Base, Resolution }`.
|
||||||
|
Shaped so a future 3-way merge pane needs no model change (Layer C is the inline
|
||||||
|
resolver now; the model leaves room for 3-way later).
|
||||||
|
|
||||||
|
### 4. Integration seams (delegates, wired by the integrator at merge)
|
||||||
|
|
||||||
|
A's and B's cockpits hold a `RequestConflictResolution(string taskId)` callback (an
|
||||||
|
`Action<string>` or `Func<string, Task>`). They never reference Layer C's resolver
|
||||||
|
types. The integrator connects these callbacks to C's `ConflictResolverViewModel`
|
||||||
|
factory when merging the three branches together.
|
||||||
|
|
||||||
|
## Parallel boundaries (verified disjoint)
|
||||||
|
|
||||||
|
| Area | A (this session) | B (parallel) | C (parallel) |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `DiffView` + diff model/parser | builds | reuses | reuses |
|
||||||
|
| `WorkConsole.axaml` / `DetailsIslandViewModel` | owns | — | — |
|
||||||
|
| `DiffModalView` + `PlanningDiffView` | migrates to `DiffView` | — | — |
|
||||||
|
| `WorktreesOverviewModalView/VM` + `WorktreeModalView` | — | owns | — |
|
||||||
|
| `WorkerHub` / `TaskMergeService` / `GitService` | — | — | owns |
|
||||||
|
| New `ConflictResolverView/VM` + conflict UI model | — | — | owns |
|
||||||
|
| `IWorkerClient` / `WorkerClient` | adds frozen stubs + DTOs | reuses `MergeTaskAsync` | fills stub bodies |
|
||||||
|
| Test fakes (`IWorkerClient`) in both test projects | adds new no-op methods | — | makes them functional if needed |
|
||||||
|
|
||||||
|
The only file C and A both touch is `WorkerClient.cs` (C replaces the stub bodies A
|
||||||
|
wrote). Contained; reconciled at integration. Everything else is disjoint.
|
||||||
|
|
||||||
|
## Layer A — review/merge cockpit (this session)
|
||||||
|
|
||||||
|
- The Git tab becomes the single Approve + merge surface. `Approve` and the merge
|
||||||
|
target / preview / diff flow together as one block (no separate REVIEW vs
|
||||||
|
MERGE & WORKTREE sections).
|
||||||
|
- `Continue` (reject → requeue with feedback) and `Reset` (reject → idle) **stay** in
|
||||||
|
the Output tab footer — unchanged.
|
||||||
|
- The diff is shown via the unified `DiffView` opened as a modal from the cockpit. No
|
||||||
|
inline diff recap in the tab (the island is too small).
|
||||||
|
- On a single-task **Approve that conflicts**: instead of today's auto-abort, call
|
||||||
|
`StartConflictMergeAsync` and fire `RequestConflictResolution(taskId)`. This leaves
|
||||||
|
the main checkout mid-merge until the user resolves or aborts (behavior change,
|
||||||
|
intended). The callback is inert until Layer C is merged; the integrator wires it.
|
||||||
|
- Migrate `DiffModalView` and `PlanningDiffView` onto the new `DiffView`.
|
||||||
|
|
||||||
|
### Behavior change accepted
|
||||||
|
|
||||||
|
Today `MergeTask`/`ApproveReview` use `leaveConflictsInTree: false` (auto-abort on
|
||||||
|
conflict). Under this design, an Approve that conflicts leaves the merge in progress
|
||||||
|
and opens the resolver. The mid-merge guard (`IsMidMergeAsync`) still prevents a second
|
||||||
|
concurrent merge.
|
||||||
|
|
||||||
|
## Layer B — multi-worktree merge cockpit (parallel)
|
||||||
|
|
||||||
|
- Rework `WorktreesOverviewModalView`/`WorktreesOverviewModalViewModel` into a
|
||||||
|
batch-merge cockpit: list mergeable worktrees, select N, choose one target branch
|
||||||
|
(single target — 99% of the time everything goes to the same branch), "Merge all".
|
||||||
|
- **Skip-and-continue**: client-side loop calling the existing
|
||||||
|
`MergeTaskAsync(taskId, target, removeWorktree, msg)` per selected task. Clean merges
|
||||||
|
apply; conflicting ones are collected (existing `MergeTaskAsync` auto-aborts on
|
||||||
|
conflict, leaving the tree clean) into a "needs resolution" list with live progress.
|
||||||
|
- Each conflict row exposes a **Resolve** action → `RequestConflictResolution(taskId)`
|
||||||
|
(wired to Layer C at integration).
|
||||||
|
- Per-task diff via the shared `DiffView`; migrate `WorktreeModalView`'s inline diff
|
||||||
|
onto it.
|
||||||
|
- B touches no worker files — keeps it parallel-safe.
|
||||||
|
|
||||||
|
## Layer C — inline conflict resolver (parallel)
|
||||||
|
|
||||||
|
### Worker side
|
||||||
|
|
||||||
|
Implement the five frozen contract methods:
|
||||||
|
|
||||||
|
- Add hub methods `StartConflictMerge`, `GetMergeConflicts`, `WriteConflictResolution`,
|
||||||
|
`ContinueMerge`, `AbortMerge` in `WorkerHub`.
|
||||||
|
- `StartConflictMerge` calls the existing `TaskMergeService.MergeAsync` overload with
|
||||||
|
`leaveConflictsInTree: true`.
|
||||||
|
- `ContinueMerge` / `AbortMerge` wrap the existing `TaskMergeService.ContinueMergeAsync`
|
||||||
|
/ `AbortMergeAsync` (currently service-level only, not hub-exposed).
|
||||||
|
- `GetMergeConflicts` reads ours/theirs/base per conflicted file via
|
||||||
|
`git show :2:/:3:/:1:`; add the `GitService` helpers needed.
|
||||||
|
- `WriteConflictResolution` writes the resolved content to `WorkingDir` and stages it.
|
||||||
|
- Fill the `WorkerClient` stub bodies (real SignalR `InvokeAsync` calls).
|
||||||
|
- Update the hand-rolled `IWorkerClient` fakes in both test projects.
|
||||||
|
|
||||||
|
### UI
|
||||||
|
|
||||||
|
- New `ConflictResolverView` + `ConflictResolverViewModel`. Per conflict hunk, show
|
||||||
|
ours vs theirs stacked, with buttons **Accept Current / Accept Incoming / Accept Both
|
||||||
|
/ Edit manually** plus a free-text box for the merged result of that hunk.
|
||||||
|
- When every file's hunks are resolved → `ContinueMergeAsync(taskId)` → `MergeResultDto`
|
||||||
|
(`merged` closes the resolver; `conflict` means not fully resolved, stay open).
|
||||||
|
- `AbortMergeAsync(taskId)` cancels and aborts the merge.
|
||||||
|
- Expose a factory (`Func<string, ConflictResolverViewModel>`) the integrator wires to
|
||||||
|
A's and B's `RequestConflictResolution` callbacks.
|
||||||
|
|
||||||
|
## Build / test
|
||||||
|
|
||||||
|
`.slnx` needs .NET 9; on .NET 8 build individual csproj with `-c Release` (a running
|
||||||
|
Worker locks `Debug`). Run the relevant test projects. No tests that spawn the real
|
||||||
|
`claude` CLI. Keep `en.json`/`de.json` localization keys in parity.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Full 3-way synchronized merge editor (model leaves room; not built now).
|
||||||
|
- Per-task differing merge targets in the batch (single target only).
|
||||||
|
- Any CI/PR tooling (direct push-to-main workflow).
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# Unify the parent-task model (planning · improvement · normal)
|
||||||
|
|
||||||
|
**Date:** 2026-06-09
|
||||||
|
**Status:** Approved-pending-implementation
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
ClaudeDo has three ways a task produces and waits on work, grown as separate
|
||||||
|
mechanisms that represent the *same shape* — "a task runs, may emit children,
|
||||||
|
and once it + its children are terminal it surfaces for review":
|
||||||
|
|
||||||
|
| | children authored | scheduling | parent flow today | merge of children |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| **Normal** | none | — | `Running → WaitingForReview → Done` | own worktree on approve |
|
||||||
|
| **Improvement** | autonomously *during* run (`suggest_improvement`) | parallel (no blockers) | `Running → WaitingForChildren → WaitingForReview → Done` | separate `MergeAllPlanning` |
|
||||||
|
| **Planning** | interactively *before* run (planning session) | sequential chain (`BlockedByTaskId`) | `Idle →(Active→Finalized)→ Done` (skips review) | separate `MergeAllPlanning` |
|
||||||
|
|
||||||
|
The incidental divergence we want to remove:
|
||||||
|
|
||||||
|
1. **Two "parent is waiting on children" representations** — improvement uses
|
||||||
|
`Status=WaitingForChildren`; planning uses `PlanningPhase=Finalized` with the
|
||||||
|
parent's `Status` jumping `Idle → Done`, never passing through the waiting/review
|
||||||
|
states at all.
|
||||||
|
2. **Two parent-advance methods** doing the same job —
|
||||||
|
`TaskRepository.TryCompleteParentAsync` (planning → `Done`, no review) vs
|
||||||
|
`TaskStateService.TryAdvanceImprovementParentAsync` (improvement → `WaitingForReview`).
|
||||||
|
3. **A separate merge action** — `MergeAllPlanning` / `PlanningMergeOrchestrator`
|
||||||
|
merges children, decoupled from the parent's `approve`. Approving a parent and
|
||||||
|
merging its unit are two clicks.
|
||||||
|
|
||||||
|
What is **genuinely unique and kept**: `PlanningPhase.Active` — the interactive,
|
||||||
|
human-in-the-loop authoring gate where children are drafted and cannot run until
|
||||||
|
finalize. Improvement has no equivalent. The two *authoring* entry points
|
||||||
|
(`PlanningMcpService.CreateChildTask` vs `TaskRunMcpService.SuggestImprovement`)
|
||||||
|
also stay distinct — they already share `CreateChildAsync`; unifying the authoring
|
||||||
|
UX is explicitly out of scope.
|
||||||
|
|
||||||
|
## Decisions (locked)
|
||||||
|
|
||||||
|
- **All parents get review.** A planning parent now surfaces in `WaitingForReview`
|
||||||
|
after its children finish, instead of auto-completing to `Done`.
|
||||||
|
- **Approve merges the whole unit — full UX consolidation.** Approve is the single
|
||||||
|
entry for reviewing *and* merging any task. For a parent with children it drives the
|
||||||
|
existing `PlanningMergeOrchestrator` (unit merge + parent→`Done` + conflict
|
||||||
|
continue/abort, all already implemented); the standalone "Merge All" button is
|
||||||
|
removed and the orchestrator's conflict dialog + combined-diff preview are reused
|
||||||
|
in-place. Childless tasks keep `ApproveAndMergeAsync`.
|
||||||
|
- **Scope = state model + code paths.** Internal refactor; authoring UX and child
|
||||||
|
base-commit resolution are unchanged.
|
||||||
|
|
||||||
|
## Target model
|
||||||
|
|
||||||
|
**One parent-with-children lifecycle, used by every parent regardless of how its
|
||||||
|
children were authored:**
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ (no children) ──────────────┐
|
||||||
|
Idle → Queued → Running ──┤ ├→ WaitingForReview → Done
|
||||||
|
└─ (has/spawns children) ─┐ │ (approve =
|
||||||
|
│ │ merge unit)
|
||||||
|
WaitingForChildren ─┘ │
|
||||||
|
│ │
|
||||||
|
(all children terminal) ───────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Planning parent (never runs as an agent — it runs an interactive session):
|
||||||
|
|
||||||
|
```
|
||||||
|
Idle (PlanningPhase None)
|
||||||
|
→[StartPlanning] Idle (PlanningPhase Active) ← authoring gate (KEPT)
|
||||||
|
→[FinalizePlanning] WaitingForChildren (Finalized) ← children chain runs
|
||||||
|
→[all children terminal] WaitingForReview
|
||||||
|
→[approve] merge unit → Done
|
||||||
|
```
|
||||||
|
|
||||||
|
Children (planning **and** improvement) keep going straight to `Done` with no
|
||||||
|
individual review; they accumulate on their branches and merge as a unit when the
|
||||||
|
parent is approved.
|
||||||
|
|
||||||
|
### State machine after the change
|
||||||
|
|
||||||
|
- `WaitingForChildren` is the **single** "parent waiting on children" state, used by
|
||||||
|
both planning and improvement parents.
|
||||||
|
- `WaitingForReview` is reached by every parent before `Done`.
|
||||||
|
- `PlanningPhase`: `None | Active | Finalized` — unchanged; `Active` remains the
|
||||||
|
authoring gate, `Finalized` marks "was a planning parent" and is set together with
|
||||||
|
`Status=WaitingForChildren`.
|
||||||
|
|
||||||
|
## Code changes
|
||||||
|
|
||||||
|
1. **Single parent-advance path.** Rename
|
||||||
|
`TaskStateService.TryAdvanceImprovementParentAsync` →
|
||||||
|
`TryAdvanceParentAsync`; it already only checks `Status==WaitingForChildren` +
|
||||||
|
"all children terminal" → `WaitingForReview` (with the failed/cancelled
|
||||||
|
annotation on `Result`). It becomes the only path for both systems.
|
||||||
|
- Handle **zero children**: a finalized planning parent with no children must go
|
||||||
|
straight to `WaitingForReview` (today `TryComplete`/`TryAdvance` both `return`
|
||||||
|
on `Count == 0`).
|
||||||
|
|
||||||
|
2. **Delete `TaskRepository.TryCompleteParentAsync`** (`TaskRepository.cs:477`) and
|
||||||
|
its invocation in `TaskStateService.OnChildTerminalAsync`. Planning parents now
|
||||||
|
advance via `TryAdvanceParentAsync` to `WaitingForReview` instead of `Done`.
|
||||||
|
- Keep `_chain.OnChildFinishedAsync` (inter-child unblock — planning-only effect).
|
||||||
|
|
||||||
|
3. **`FinalizePlanningAsync`** (`TaskStateService.cs:289`) sets the parent
|
||||||
|
`Status = WaitingForChildren` in the same update that sets
|
||||||
|
`PlanningPhase = Finalized`. This happens before `SetupChainAsync` enqueues
|
||||||
|
child[0], so the parent is in `WaitingForChildren` before any child can finish.
|
||||||
|
|
||||||
|
4. **Approve merges the unit.** `WorkerHub.ApproveReview` (and the MCP
|
||||||
|
`ReviewTask` approve path): when the approved task has children, run
|
||||||
|
`PlanningMergeOrchestrator` (parent worktree if `Active` + each `Done` child in
|
||||||
|
order), then transition the parent to `Done`. On a child merge conflict, the
|
||||||
|
parent stays in `WaitingForReview` (mirrors current single-task approve-conflict
|
||||||
|
behavior). Retire the `MergeAllPlanning` Hub method + UI button.
|
||||||
|
|
||||||
|
5. **Allow cancelling a `WaitingForChildren` parent.** Add `WaitingForChildren` to
|
||||||
|
the `CancelAsync` guard so a parent waiting on children can be cancelled (today it
|
||||||
|
cannot — minor gap).
|
||||||
|
|
||||||
|
6. **Docs.** Fix the `WaitingForChildren`-missing drift in
|
||||||
|
`src/ClaudeDo.Data/CLAUDE.md` and `src/ClaudeDo.Worker/CLAUDE.md`, and update the
|
||||||
|
transition diagram + the root `CLAUDE.md` status-flow line to the unified model.
|
||||||
|
|
||||||
|
## Out of scope (unchanged)
|
||||||
|
|
||||||
|
- Authoring UX: planning session vs `suggest_improvement` stay as two distinct
|
||||||
|
entry points (both already call `CreateChildAsync`).
|
||||||
|
- `WorktreeManager.ResolveBaseCommitAsync` base-commit divergence (planning children
|
||||||
|
branch from list HEAD; improvement children from parent head) — left as-is.
|
||||||
|
- Sequential-vs-parallel scheduling — already shared infrastructure
|
||||||
|
(`BlockedByTaskId`); planning chains, improvement doesn't. No change.
|
||||||
|
|
||||||
|
## Risks / edge cases
|
||||||
|
|
||||||
|
- **Ordering on finalize** — parent must be `WaitingForChildren` before the first
|
||||||
|
child can reach terminal. Guaranteed by setting it inside `FinalizePlanningAsync`,
|
||||||
|
which runs before `SetupChainAsync`.
|
||||||
|
- **Zero-children planning parent** — must advance to `WaitingForReview`, not stick
|
||||||
|
in `WaitingForChildren`. Explicit branch in `TryAdvanceParentAsync` /
|
||||||
|
`FinalizePlanningAsync`.
|
||||||
|
- **Failed/cancelled children** — parent still advances to `WaitingForReview` with
|
||||||
|
the existing `⚠ Children: N failed, M cancelled` annotation; no wedge.
|
||||||
|
- **Approve-merge conflict** — keep parent in `WaitingForReview`; surface the
|
||||||
|
conflicting child like the current merge-conflict path.
|
||||||
|
- **Existing rows** — planning parents currently sitting at `Idle`+`Finalized` with
|
||||||
|
live children: behavior change is forward-only (new finalizes use the new flow);
|
||||||
|
no migration needed since `Status`/`PlanningPhase` columns already exist.
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 55 KiB |
@@ -132,6 +132,9 @@ sealed class Program
|
|||||||
sc.AddTransient<Func<RepoImportModalViewModel>>(sp => () => sp.GetRequiredService<RepoImportModalViewModel>());
|
sc.AddTransient<Func<RepoImportModalViewModel>>(sp => () => sp.GetRequiredService<RepoImportModalViewModel>());
|
||||||
sc.AddTransient<WeeklyReportModalViewModel>();
|
sc.AddTransient<WeeklyReportModalViewModel>();
|
||||||
sc.AddTransient<Func<WeeklyReportModalViewModel>>(sp => () => sp.GetRequiredService<WeeklyReportModalViewModel>());
|
sc.AddTransient<Func<WeeklyReportModalViewModel>>(sp => () => sp.GetRequiredService<WeeklyReportModalViewModel>());
|
||||||
|
sc.AddSingleton<Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>>(sp =>
|
||||||
|
taskId => new ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel(
|
||||||
|
sp.GetRequiredService<WorkerClient>(), taskId));
|
||||||
|
|
||||||
// Islands shell VMs
|
// Islands shell VMs
|
||||||
sc.AddSingleton<ListsIslandViewModel>(sp =>
|
sc.AddSingleton<ListsIslandViewModel>(sp =>
|
||||||
@@ -149,7 +152,13 @@ sealed class Program
|
|||||||
sp.GetRequiredService<WorkerClient>(),
|
sp.GetRequiredService<WorkerClient>(),
|
||||||
sp,
|
sp,
|
||||||
sp.GetRequiredService<INotesApi>()));
|
sp.GetRequiredService<INotesApi>()));
|
||||||
sc.AddSingleton<IslandsShellViewModel>();
|
sc.AddSingleton<IslandsShellViewModel>(sp =>
|
||||||
|
{
|
||||||
|
var shell = ActivatorUtilities.CreateInstance<IslandsShellViewModel>(sp);
|
||||||
|
shell.ConflictResolverFactory =
|
||||||
|
sp.GetRequiredService<Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>>();
|
||||||
|
return shell;
|
||||||
|
});
|
||||||
|
|
||||||
return sc.BuildServiceProvider();
|
return sc.BuildServiceProvider();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
|
|||||||
|
|
||||||
## Models
|
## Models
|
||||||
|
|
||||||
- **TaskEntity** — Id, ListId, Title, Description, Status (`Idle|Queued|Running|WaitingForReview|Done|Failed|Cancelled`), PlanningPhase (`None|Active|Finalized` — parent-only), BlockedByTaskId (nullable FK to predecessor in a chain), ScheduledFor, Result, ReviewFeedback (nullable; reviewer's rejection comment, consumed and cleared by the runner on the next re-run), LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath / MaxTurns (nullable overrides), IsStarred, IsMyDay, Notes, ParentTaskId, PlanningSessionId, PlanningSessionToken, PlanningFinalizedAt, CreatedBy. Legacy values `Manual`/`Planning`/`Planned`/`Draft`/`Waiting` were retired; existing rows backfill automatically via the `RetireLegacyTaskStatus` migration.
|
- **TaskEntity** — Id, ListId, Title, Description, Status (`Idle|Queued|Running|WaitingForChildren|WaitingForReview|Done|Failed|Cancelled`), PlanningPhase (`None|Active|Finalized` — parent-only), BlockedByTaskId (nullable FK to predecessor in a chain), ScheduledFor, Result, ReviewFeedback (nullable; reviewer's rejection comment, consumed and cleared by the runner on the next re-run), LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath / MaxTurns (nullable overrides), IsStarred, IsMyDay, Notes, ParentTaskId, PlanningSessionId, PlanningSessionToken, PlanningFinalizedAt, CreatedBy. Legacy values `Manual`/`Planning`/`Planned`/`Draft`/`Waiting` were retired; existing rows backfill automatically via the `RetireLegacyTaskStatus` migration.
|
||||||
- **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt
|
- **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt
|
||||||
- **ListConfigEntity** — ListId (PK, 1:1 with list), Model, SystemPrompt, AgentPath, MaxTurns (all nullable)
|
- **ListConfigEntity** — ListId (PK, 1:1 with list), Model, SystemPrompt, AgentPath, MaxTurns (all nullable)
|
||||||
- **WorktreeEntity** — TaskId (PK, 1:1 with task), Path, BranchName, BaseCommit, HeadCommit, DiffStat, State (Active|Merged|Discarded|Kept)
|
- **WorktreeEntity** — TaskId (PK, 1:1 with task), Path, BranchName, BaseCommit, HeadCommit, DiffStat, State (Active|Merged|Discarded|Kept)
|
||||||
@@ -19,7 +19,7 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
|
|||||||
|
|
||||||
All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. The atomic `Queued -> Running` claim lives in the Worker's `QueuePicker` (uses `FromSqlRaw`), not here.
|
All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. The atomic `Queued -> Running` claim lives in the Worker's `QueuePicker` (uses `FromSqlRaw`), not here.
|
||||||
|
|
||||||
- **TaskRepository** — CRUD, planning helpers (`CreateChildAsync`, `SetPlanningStartedAsync`, `DiscardPlanningAsync`, `TryCompleteParentAsync`, `UpdateChildAsync`), `UpdateAgentSettingsAsync` (model / system-prompt / agent-path overrides). Status-mutation primitives `MarkRunningAsync` / `MarkDoneAsync` / `MarkFailedAsync` / `FlipAllRunningToFailedAsync` are `internal` and called only by `TaskStateService` in the worker. `CreateChildAsync` produces children with `Status=Idle, PlanningPhase=None`; once their parent's `PlanningPhase` becomes `Finalized`, the chain coordinator queues them.
|
- **TaskRepository** — CRUD, planning helpers (`CreateChildAsync`, `SetPlanningStartedAsync`, `DiscardPlanningAsync`, `UpdateChildAsync`), `UpdateAgentSettingsAsync` (model / system-prompt / agent-path overrides). Status-mutation primitives `MarkRunningAsync` / `MarkDoneAsync` / `MarkFailedAsync` / `FlipAllRunningToFailedAsync` are `internal` and called only by `TaskStateService` in the worker. `CreateChildAsync` produces children with `Status=Idle, PlanningPhase=None`; once their parent's `PlanningPhase` becomes `Finalized`, the chain coordinator queues them.
|
||||||
- **ListRepository** — CRUD, `GetConfigAsync` / `SetConfigAsync` (upsert) / `DeleteConfigAsync` for `list_config`
|
- **ListRepository** — CRUD, `GetConfigAsync` / `SetConfigAsync` (upsert) / `DeleteConfigAsync` for `list_config`
|
||||||
- **WorktreeRepository** — CRUD, `UpdateHeadAsync`, `SetStateAsync`
|
- **WorktreeRepository** — CRUD, `UpdateHeadAsync`, `SetStateAsync`
|
||||||
- **TaskRunRepository**, **SubtaskRepository**, **AppSettingsRepository**
|
- **TaskRunRepository**, **SubtaskRepository**, **AppSettingsRepository**
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
using System.Data.Common;
|
||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using ClaudeDo.Data.Seeding;
|
using ClaudeDo.Data.Seeding;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
using Microsoft.EntityFrameworkCore.Storage;
|
using Microsoft.EntityFrameworkCore.Storage;
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
@@ -9,8 +11,35 @@ namespace ClaudeDo.Data;
|
|||||||
|
|
||||||
public class ClaudeDoDbContext : DbContext
|
public class ClaudeDoDbContext : DbContext
|
||||||
{
|
{
|
||||||
|
// Runs PRAGMA foreign_keys=ON on every EF-managed connection open so FK
|
||||||
|
// enforcement is active for all IDbContextFactory-created contexts, not
|
||||||
|
// just the single context used in MigrateAndConfigure.
|
||||||
|
private sealed class SqliteForeignKeyInterceptor : DbConnectionInterceptor
|
||||||
|
{
|
||||||
|
internal static readonly SqliteForeignKeyInterceptor Instance = new();
|
||||||
|
|
||||||
|
public override void ConnectionOpened(DbConnection connection, ConnectionEndEventData eventData)
|
||||||
|
=> Apply(connection);
|
||||||
|
|
||||||
|
public override Task ConnectionOpenedAsync(DbConnection connection, ConnectionEndEventData eventData, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
Apply(connection);
|
||||||
|
return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Apply(DbConnection connection)
|
||||||
|
{
|
||||||
|
using var cmd = connection.CreateCommand();
|
||||||
|
cmd.CommandText = "PRAGMA foreign_keys=ON;";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public ClaudeDoDbContext(DbContextOptions<ClaudeDoDbContext> options) : base(options) { }
|
public ClaudeDoDbContext(DbContextOptions<ClaudeDoDbContext> options) : base(options) { }
|
||||||
|
|
||||||
|
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||||
|
=> optionsBuilder.AddInterceptors(SqliteForeignKeyInterceptor.Instance);
|
||||||
|
|
||||||
public DbSet<TaskEntity> Tasks => Set<TaskEntity>();
|
public DbSet<TaskEntity> Tasks => Set<TaskEntity>();
|
||||||
public DbSet<ListEntity> Lists => Set<ListEntity>();
|
public DbSet<ListEntity> Lists => Set<ListEntity>();
|
||||||
public DbSet<ListConfigEntity> ListConfigs => Set<ListConfigEntity>();
|
public DbSet<ListConfigEntity> ListConfigs => Set<ListConfigEntity>();
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ public sealed class ReviewFilter : ITaskListFilter
|
|||||||
{
|
{
|
||||||
public string Id => "virtual:review";
|
public string Id => "virtual:review";
|
||||||
public bool Matches(TaskEntity t) =>
|
public bool Matches(TaskEntity t) =>
|
||||||
t.Status == TaskStatus.Done &&
|
t.Status == TaskStatus.WaitingForReview;
|
||||||
t.Worktree is { State: WorktreeState.Active };
|
|
||||||
public bool ShouldCount(TaskEntity t) => Matches(t);
|
public bool ShouldCount(TaskEntity t) => Matches(t);
|
||||||
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
|
public bool MatchesAsContext(TaskEntity t, IReadOnlyList<TaskEntity> all) => false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ public sealed record MergePreview(bool Supported, bool Clean, IReadOnlyList<stri
|
|||||||
|
|
||||||
public sealed class GitService
|
public sealed class GitService
|
||||||
{
|
{
|
||||||
|
// git mutates shared .git/worktrees/ metadata during `worktree add`; concurrent adds
|
||||||
|
// race and fail with "failed to read .git/worktrees/<other>/commondir". Serialize them
|
||||||
|
// process-wide so parallel task starts don't collide.
|
||||||
|
private static readonly SemaphoreSlim WorktreeAddGate = new(1, 1);
|
||||||
|
|
||||||
public async Task<bool> IsGitRepoAsync(string dir, CancellationToken ct = default)
|
public async Task<bool> IsGitRepoAsync(string dir, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var (exitCode, _, _) = await RunGitAsync(dir, ["rev-parse", "--git-dir"], ct);
|
var (exitCode, _, _) = await RunGitAsync(dir, ["rev-parse", "--git-dir"], ct);
|
||||||
@@ -23,10 +28,30 @@ public sealed class GitService
|
|||||||
|
|
||||||
public async Task WorktreeAddAsync(string repoDir, string branchName, string worktreePath, string baseCommit, CancellationToken ct = default)
|
public async Task WorktreeAddAsync(string repoDir, string branchName, string worktreePath, string baseCommit, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var (exitCode, _, stderr) = await RunGitAsync(repoDir,
|
await WorktreeAddGate.WaitAsync(ct);
|
||||||
["worktree", "add", "-b", branchName, worktreePath, baseCommit], ct);
|
try
|
||||||
if (exitCode != 0)
|
{
|
||||||
throw new InvalidOperationException($"git worktree add failed (exit {exitCode}): {stderr}");
|
const int maxAttempts = 3;
|
||||||
|
for (var attempt = 1; ; attempt++)
|
||||||
|
{
|
||||||
|
var (exitCode, _, stderr) = await RunGitAsync(repoDir,
|
||||||
|
["worktree", "add", "-b", branchName, worktreePath, baseCommit], ct);
|
||||||
|
if (exitCode == 0)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// Transient races leave a half-written worktree metadata dir; retry briefly.
|
||||||
|
var transient = stderr.Contains("commondir", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| stderr.Contains("failed to read", StringComparison.OrdinalIgnoreCase);
|
||||||
|
if (!transient || attempt >= maxAttempts)
|
||||||
|
throw new InvalidOperationException($"git worktree add failed (exit {exitCode}): {stderr}");
|
||||||
|
|
||||||
|
await Task.Delay(150 * attempt, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
WorktreeAddGate.Release();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string> GetStatusPorcelainAsync(string workingDirectory, CancellationToken ct = default)
|
public async Task<string> GetStatusPorcelainAsync(string workingDirectory, CancellationToken ct = default)
|
||||||
@@ -99,6 +124,20 @@ public sealed class GitService
|
|||||||
return await GetDiffAsync(worktreePath, ct);
|
return await GetDiffAsync(worktreePath, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Diff between two commits, run in any repo that can reach them. Used to view a
|
||||||
|
/// task's changes after its worktree has been merged away (the commits survive on
|
||||||
|
/// the target branch even though the worktree directory and branch ref are gone).
|
||||||
|
/// </summary>
|
||||||
|
public async Task<string> GetCommitRangeDiffAsync(string repoDir, string baseCommit, string headCommit, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var (exitCode, stdout, stderr) = await RunGitAsync(repoDir,
|
||||||
|
["diff", $"{baseCommit}..{headCommit}"], ct);
|
||||||
|
if (exitCode != 0)
|
||||||
|
throw new InvalidOperationException($"git diff {baseCommit}..{headCommit} failed (exit {exitCode}): {stderr}");
|
||||||
|
return stdout;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<string> DiffStatAsync(string worktreePath, string baseCommit, string headCommit, CancellationToken ct = default)
|
public async Task<string> DiffStatAsync(string worktreePath, string baseCommit, string headCommit, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath,
|
var (exitCode, stdout, stderr) = await RunGitAsync(worktreePath,
|
||||||
@@ -238,6 +277,24 @@ public sealed class GitService
|
|||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads a conflicted file's blob at a merge stage: 1=base, 2=ours, 3=theirs.
|
||||||
|
/// Returns null when the stage doesn't exist (e.g. add/add conflict has no base).
|
||||||
|
/// Output is NOT trimmed so file content round-trips exactly.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<string?> ShowStageAsync(string repoDir, int stage, string path, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["show", $":{stage}:{path}"], ct, trimOutput: false);
|
||||||
|
return exitCode == 0 ? stdout : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AddPathAsync(string repoDir, string path, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["add", "--", path], ct);
|
||||||
|
if (exitCode != 0)
|
||||||
|
throw new InvalidOperationException($"git add '{path}' failed (exit {exitCode}): {stderr}");
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Non-destructive mergeability probe via `git merge-tree --write-tree`. Writes only
|
/// Non-destructive mergeability probe via `git merge-tree --write-tree`. Writes only
|
||||||
/// loose objects — the working tree, index, and refs are left untouched.
|
/// loose objects — the working tree, index, and refs are left untouched.
|
||||||
@@ -289,7 +346,7 @@ public sealed class GitService
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<(int ExitCode, string Stdout, string Stderr)> RunGitAsync(
|
private static async Task<(int ExitCode, string Stdout, string Stderr)> RunGitAsync(
|
||||||
string workDir, IEnumerable<string> args, CancellationToken ct, string? stdinData = null)
|
string workDir, IEnumerable<string> args, CancellationToken ct, string? stdinData = null, bool trimOutput = true)
|
||||||
{
|
{
|
||||||
var psi = new ProcessStartInfo
|
var psi = new ProcessStartInfo
|
||||||
{
|
{
|
||||||
@@ -338,6 +395,6 @@ public sealed class GitService
|
|||||||
|
|
||||||
ct.ThrowIfCancellationRequested();
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
return (proc.ExitCode, stdout.TrimEnd(), stderr.TrimEnd());
|
return (proc.ExitCode, trimOutput ? stdout.TrimEnd() : stdout, stderr.TrimEnd());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
696
src/ClaudeDo.Data/Migrations/20260609000000_UniqueListName.Designer.cs
generated
Normal file
696
src/ClaudeDo.Data/Migrations/20260609000000_UniqueListName.Designer.cs
generated
Normal file
@@ -0,0 +1,696 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using ClaudeDo.Data;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(ClaudeDoDbContext))]
|
||||||
|
[Migration("20260609000000_UniqueListName")]
|
||||||
|
partial class UniqueListName
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
|
||||||
|
|
||||||
|
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<int>("Id")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<string>("CentralWorktreeRoot")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("central_worktree_root");
|
||||||
|
|
||||||
|
b.Property<int>("DailyPrepMaxTasks")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(5)
|
||||||
|
.HasColumnName("daily_prep_max_tasks");
|
||||||
|
|
||||||
|
b.Property<string>("DefaultClaudeInstructions")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasDefaultValue("")
|
||||||
|
.HasColumnName("default_claude_instructions");
|
||||||
|
|
||||||
|
b.Property<int>("DefaultMaxTurns")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(30)
|
||||||
|
.HasColumnName("default_max_turns");
|
||||||
|
|
||||||
|
b.Property<string>("DefaultModel")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasDefaultValue("sonnet")
|
||||||
|
.HasColumnName("default_model");
|
||||||
|
|
||||||
|
b.Property<string>("DefaultPermissionMode")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasDefaultValue("bypassPermissions")
|
||||||
|
.HasColumnName("default_permission_mode");
|
||||||
|
|
||||||
|
b.Property<int>("MaxParallelExecutions")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(1)
|
||||||
|
.HasColumnName("max_parallel_executions");
|
||||||
|
|
||||||
|
b.Property<string>("RepoImportFolders")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("repo_import_folders");
|
||||||
|
|
||||||
|
b.Property<string>("ReportExcludedPaths")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("report_excluded_paths");
|
||||||
|
|
||||||
|
b.Property<int>("StandupWeekday")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(3)
|
||||||
|
.HasColumnName("standup_weekday");
|
||||||
|
|
||||||
|
b.Property<int>("WorktreeAutoCleanupDays")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(7)
|
||||||
|
.HasColumnName("worktree_auto_cleanup_days");
|
||||||
|
|
||||||
|
b.Property<bool>("WorktreeAutoCleanupEnabled")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false)
|
||||||
|
.HasColumnName("worktree_auto_cleanup_enabled");
|
||||||
|
|
||||||
|
b.Property<string>("WorktreeStrategy")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasDefaultValue("sibling")
|
||||||
|
.HasColumnName("worktree_strategy");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("app_settings", (string)null);
|
||||||
|
|
||||||
|
b.HasData(
|
||||||
|
new
|
||||||
|
{
|
||||||
|
Id = 1,
|
||||||
|
DailyPrepMaxTasks = 5,
|
||||||
|
DefaultClaudeInstructions = "",
|
||||||
|
DefaultMaxTurns = 100,
|
||||||
|
DefaultModel = "sonnet",
|
||||||
|
DefaultPermissionMode = "auto",
|
||||||
|
MaxParallelExecutions = 1,
|
||||||
|
StandupWeekday = 3,
|
||||||
|
WorktreeAutoCleanupDays = 7,
|
||||||
|
WorktreeAutoCleanupEnabled = false,
|
||||||
|
WorktreeStrategy = "sibling"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("created_at");
|
||||||
|
|
||||||
|
b.Property<DateOnly>("Date")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("note_date");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("sort_order");
|
||||||
|
|
||||||
|
b.Property<string>("Text")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Date");
|
||||||
|
|
||||||
|
b.ToTable("daily_notes", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("ListId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("list_id");
|
||||||
|
|
||||||
|
b.Property<string>("AgentPath")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("agent_path");
|
||||||
|
|
||||||
|
b.Property<int?>("MaxTurns")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("max_turns");
|
||||||
|
|
||||||
|
b.Property<string>("Model")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("model");
|
||||||
|
|
||||||
|
b.Property<string>("SystemPrompt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("system_prompt");
|
||||||
|
|
||||||
|
b.HasKey("ListId");
|
||||||
|
|
||||||
|
b.ToTable("list_config", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("created_at");
|
||||||
|
|
||||||
|
b.Property<string>("DefaultCommitType")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasDefaultValue("chore")
|
||||||
|
.HasColumnName("default_commit_type");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("name");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(0)
|
||||||
|
.HasColumnName("sort_order");
|
||||||
|
|
||||||
|
b.Property<string>("WorkingDir")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("working_dir");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("SortOrder")
|
||||||
|
.HasDatabaseName("idx_lists_sort");
|
||||||
|
|
||||||
|
b.ToTable("lists", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("created_at");
|
||||||
|
|
||||||
|
b.Property<int>("Days")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(31)
|
||||||
|
.HasColumnName("days_of_week");
|
||||||
|
|
||||||
|
b.Property<bool>("Enabled")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(true)
|
||||||
|
.HasColumnName("enabled");
|
||||||
|
|
||||||
|
b.Property<DateTimeOffset?>("LastRunAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("last_run_at");
|
||||||
|
|
||||||
|
b.Property<string>("PromptOverride")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("prompt_override");
|
||||||
|
|
||||||
|
b.Property<TimeSpan>("TimeOfDay")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("time_of_day");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("prime_schedules", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<bool>("Completed")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false)
|
||||||
|
.HasColumnName("completed");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("created_at");
|
||||||
|
|
||||||
|
b.Property<int>("OrderNum")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("order_num");
|
||||||
|
|
||||||
|
b.Property<string>("TaskId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("task_id");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("title");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TaskId")
|
||||||
|
.HasDatabaseName("idx_subtasks_task_id");
|
||||||
|
|
||||||
|
b.ToTable("subtasks", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<string>("AgentPath")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("agent_path");
|
||||||
|
|
||||||
|
b.Property<string>("BlockedByTaskId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("blocked_by_task_id");
|
||||||
|
|
||||||
|
b.Property<string>("CommitType")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasDefaultValue("chore")
|
||||||
|
.HasColumnName("commit_type");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("created_at");
|
||||||
|
|
||||||
|
b.Property<string>("CreatedBy")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("created_by");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("description");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("FinishedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("finished_at");
|
||||||
|
|
||||||
|
b.Property<bool>("IsMyDay")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false)
|
||||||
|
.HasColumnName("is_my_day");
|
||||||
|
|
||||||
|
b.Property<bool>("IsStarred")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false)
|
||||||
|
.HasColumnName("is_starred");
|
||||||
|
|
||||||
|
b.Property<string>("ListId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("list_id");
|
||||||
|
|
||||||
|
b.Property<string>("LogPath")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("log_path");
|
||||||
|
|
||||||
|
b.Property<int?>("MaxTurns")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("max_turns");
|
||||||
|
|
||||||
|
b.Property<string>("Model")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("model");
|
||||||
|
|
||||||
|
b.Property<string>("Notes")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("notes");
|
||||||
|
|
||||||
|
b.Property<string>("ParentTaskId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("parent_task_id");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("PlanningFinalizedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("planning_finalized_at");
|
||||||
|
|
||||||
|
b.Property<string>("PlanningPhase")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasDefaultValue("none")
|
||||||
|
.HasColumnName("planning_phase");
|
||||||
|
|
||||||
|
b.Property<string>("PlanningSessionId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("planning_session_id");
|
||||||
|
|
||||||
|
b.Property<string>("PlanningSessionToken")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("planning_session_token");
|
||||||
|
|
||||||
|
b.Property<string>("Result")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("result");
|
||||||
|
|
||||||
|
b.Property<string>("ReviewFeedback")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("review_feedback");
|
||||||
|
|
||||||
|
b.Property<int>("RoadblockCount")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(0)
|
||||||
|
.HasColumnName("roadblock_count");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ScheduledFor")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("scheduled_for");
|
||||||
|
|
||||||
|
b.Property<int>("SortOrder")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(0)
|
||||||
|
.HasColumnName("sort_order");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("StartedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("started_at");
|
||||||
|
|
||||||
|
b.Property<string>("Status")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("status");
|
||||||
|
|
||||||
|
b.Property<string>("SystemPrompt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("system_prompt");
|
||||||
|
|
||||||
|
b.Property<string>("Title")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("title");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("BlockedByTaskId")
|
||||||
|
.HasDatabaseName("idx_tasks_blocked_by");
|
||||||
|
|
||||||
|
b.HasIndex("ListId")
|
||||||
|
.HasDatabaseName("idx_tasks_list_id");
|
||||||
|
|
||||||
|
b.HasIndex("ParentTaskId")
|
||||||
|
.HasDatabaseName("idx_tasks_parent_task_id");
|
||||||
|
|
||||||
|
b.HasIndex("Status")
|
||||||
|
.HasDatabaseName("idx_tasks_status");
|
||||||
|
|
||||||
|
b.HasIndex("ListId", "SortOrder")
|
||||||
|
.HasDatabaseName("idx_tasks_list_sort");
|
||||||
|
|
||||||
|
b.ToTable("tasks", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<string>("ErrorMarkdown")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("error_markdown");
|
||||||
|
|
||||||
|
b.Property<int?>("ExitCode")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("exit_code");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("FinishedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("finished_at");
|
||||||
|
|
||||||
|
b.Property<bool>("IsRetry")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasDefaultValue(false)
|
||||||
|
.HasColumnName("is_retry");
|
||||||
|
|
||||||
|
b.Property<string>("LogPath")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("log_path");
|
||||||
|
|
||||||
|
b.Property<string>("Prompt")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("prompt");
|
||||||
|
|
||||||
|
b.Property<string>("ResultMarkdown")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("result_markdown");
|
||||||
|
|
||||||
|
b.Property<int>("RunNumber")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("run_number");
|
||||||
|
|
||||||
|
b.Property<string>("SessionId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("session_id");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("StartedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("started_at");
|
||||||
|
|
||||||
|
b.Property<string>("StructuredOutputJson")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("structured_output");
|
||||||
|
|
||||||
|
b.Property<string>("TaskId")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("task_id");
|
||||||
|
|
||||||
|
b.Property<int?>("TokensIn")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("tokens_in");
|
||||||
|
|
||||||
|
b.Property<int?>("TokensOut")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("tokens_out");
|
||||||
|
|
||||||
|
b.Property<int?>("TurnCount")
|
||||||
|
.HasColumnType("INTEGER")
|
||||||
|
.HasColumnName("turn_count");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TaskId")
|
||||||
|
.HasDatabaseName("idx_task_runs_task_id");
|
||||||
|
|
||||||
|
b.ToTable("task_runs", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("Id")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<DateOnly>("EndDate")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("end_date");
|
||||||
|
|
||||||
|
b.Property<DateTime>("GeneratedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("generated_at");
|
||||||
|
|
||||||
|
b.Property<string>("Markdown")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("markdown");
|
||||||
|
|
||||||
|
b.Property<DateOnly>("StartDate")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("start_date");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("StartDate", "EndDate")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("week_reports", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
|
||||||
|
{
|
||||||
|
b.Property<string>("TaskId")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("task_id");
|
||||||
|
|
||||||
|
b.Property<string>("BaseCommit")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("base_commit");
|
||||||
|
|
||||||
|
b.Property<string>("BranchName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("branch_name");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("created_at");
|
||||||
|
|
||||||
|
b.Property<string>("DiffStat")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("diff_stat");
|
||||||
|
|
||||||
|
b.Property<string>("HeadCommit")
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("head_commit");
|
||||||
|
|
||||||
|
b.Property<string>("Path")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasColumnName("path");
|
||||||
|
|
||||||
|
b.Property<string>("State")
|
||||||
|
.IsRequired()
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT")
|
||||||
|
.HasDefaultValue("active")
|
||||||
|
.HasColumnName("state");
|
||||||
|
|
||||||
|
b.HasKey("TaskId");
|
||||||
|
|
||||||
|
b.ToTable("worktrees", (string)null);
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
||||||
|
.WithOne("Config")
|
||||||
|
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("List");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||||
|
.WithMany("Subtasks")
|
||||||
|
.HasForeignKey("TaskId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Task");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("BlockedByTaskId")
|
||||||
|
.OnDelete(DeleteBehavior.SetNull);
|
||||||
|
|
||||||
|
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
||||||
|
.WithMany("Tasks")
|
||||||
|
.HasForeignKey("ListId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
|
||||||
|
.WithMany("Children")
|
||||||
|
.HasForeignKey("ParentTaskId")
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
|
||||||
|
b.Navigation("List");
|
||||||
|
|
||||||
|
b.Navigation("Parent");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||||
|
.WithMany("Runs")
|
||||||
|
.HasForeignKey("TaskId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Task");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
||||||
|
.WithOne("Worktree")
|
||||||
|
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Task");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Config");
|
||||||
|
|
||||||
|
b.Navigation("Tasks");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
||||||
|
{
|
||||||
|
b.Navigation("Children");
|
||||||
|
|
||||||
|
b.Navigation("Runs");
|
||||||
|
|
||||||
|
b.Navigation("Subtasks");
|
||||||
|
|
||||||
|
b.Navigation("Worktree");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class UniqueListName : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// Remove duplicate list rows that have no tasks — keep the oldest rowid.
|
||||||
|
// This handles the startup-race case where both App and Worker seeded
|
||||||
|
// the same default list names concurrently.
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
DELETE FROM lists
|
||||||
|
WHERE (SELECT COUNT(*) FROM tasks WHERE list_id = lists.id) = 0
|
||||||
|
AND rowid NOT IN (
|
||||||
|
SELECT MIN(l2.rowid) FROM lists l2 WHERE l2.name = lists.name
|
||||||
|
)
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,8 +18,18 @@ public sealed class AppSettingsRepository
|
|||||||
|
|
||||||
row = new AppSettingsEntity { Id = AppSettingsEntity.SingletonId };
|
row = new AppSettingsEntity { Id = AppSettingsEntity.SingletonId };
|
||||||
_context.AppSettings.Add(row);
|
_context.AppSettings.Add(row);
|
||||||
await _context.SaveChangesAsync(ct);
|
try
|
||||||
_context.Entry(row).State = EntityState.Detached;
|
{
|
||||||
|
await _context.SaveChangesAsync(ct);
|
||||||
|
_context.Entry(row).State = EntityState.Detached;
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
// Concurrent process already inserted the singleton — discard our attempt and re-read.
|
||||||
|
_context.Entry(row).State = EntityState.Detached;
|
||||||
|
row = await _context.AppSettings.AsNoTracking()
|
||||||
|
.FirstAsync(s => s.Id == AppSettingsEntity.SingletonId, ct);
|
||||||
|
}
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -474,32 +474,5 @@ public sealed class TaskRepository
|
|||||||
return chainIds.Count;
|
return chainIds.Count;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task TryCompleteParentAsync(
|
|
||||||
string parentId,
|
|
||||||
CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var parent = await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == parentId, ct);
|
|
||||||
if (parent is null || parent.PlanningPhase != PlanningPhase.Finalized) return;
|
|
||||||
|
|
||||||
var children = await _context.Tasks
|
|
||||||
.Where(t => t.ParentTaskId == parentId)
|
|
||||||
.Select(t => t.Status)
|
|
||||||
.ToListAsync(ct);
|
|
||||||
|
|
||||||
if (children.Count == 0) return;
|
|
||||||
|
|
||||||
bool allTerminal = children.All(s => s == TaskStatus.Done || s == TaskStatus.Failed);
|
|
||||||
if (!allTerminal) return;
|
|
||||||
|
|
||||||
bool anyFailed = children.Any(s => s == TaskStatus.Failed);
|
|
||||||
var finalStatus = anyFailed ? TaskStatus.Failed : TaskStatus.Done;
|
|
||||||
var finishedAt = DateTime.UtcNow;
|
|
||||||
await _context.Tasks
|
|
||||||
.Where(t => t.Id == parentId)
|
|
||||||
.ExecuteUpdateAsync(s => s
|
|
||||||
.SetProperty(t => t.Status, finalStatus)
|
|
||||||
.SetProperty(t => t.FinishedAt, finishedAt), ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
using ClaudeDo.Data.Models;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace ClaudeDo.Data.Seeding;
|
namespace ClaudeDo.Data.Seeding;
|
||||||
@@ -9,17 +8,18 @@ public static class DefaultListsSeeder
|
|||||||
|
|
||||||
public static async Task SeedAsync(ClaudeDoDbContext ctx, CancellationToken ct = default)
|
public static async Task SeedAsync(ClaudeDoDbContext ctx, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var existing = await ctx.Lists.Select(l => l.Name).ToListAsync(ct);
|
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
foreach (var name in Defaults.Where(n => !existing.Contains(n)))
|
foreach (var name in Defaults)
|
||||||
{
|
{
|
||||||
ctx.Lists.Add(new ListEntity
|
var id = Guid.NewGuid().ToString();
|
||||||
{
|
// Atomic conditional insert: the SELECT ... WHERE NOT EXISTS is a single
|
||||||
Id = Guid.NewGuid().ToString(),
|
// SQLite statement and cannot race — only one writer holds the lock.
|
||||||
Name = name,
|
await ctx.Database.ExecuteSqlAsync(
|
||||||
CreatedAt = now,
|
$"""
|
||||||
});
|
INSERT INTO lists (id, name, created_at, default_commit_type, sort_order)
|
||||||
|
SELECT {id}, {name}, {now}, 'chore', 0
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM lists WHERE name = {name})
|
||||||
|
""", ct);
|
||||||
}
|
}
|
||||||
await ctx.SaveChangesAsync(ct);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 374 B After Width: | Height: | Size: 57 KiB |
@@ -53,6 +53,7 @@
|
|||||||
"prime": {
|
"prime": {
|
||||||
"description": "Bereite dein Claude-Nutzungsfenster vor, indem an den von dir gewählten Tagen zu einer bestimmten Zeit ein einzelner nicht-interaktiver Ping ausgelöst wird. Läuft nur, solange ClaudeDo geöffnet ist. Wenn die App innerhalb von 30 Minuten vor der Zielzeit startet, wird der Ping sofort ausgelöst.",
|
"description": "Bereite dein Claude-Nutzungsfenster vor, indem an den von dir gewählten Tagen zu einer bestimmten Zeit ein einzelner nicht-interaktiver Ping ausgelöst wird. Läuft nur, solange ClaudeDo geöffnet ist. Wenn die App innerhalb von 30 Minuten vor der Zielzeit startet, wird der Ping sofort ausgelöst.",
|
||||||
"addSchedule": "+ Zeitplan hinzufügen",
|
"addSchedule": "+ Zeitplan hinzufügen",
|
||||||
|
"removeScheduleTip": "Zeitplan entfernen",
|
||||||
"dailyPrepMaxTasks": "Max. Aufgaben pro Tag",
|
"dailyPrepMaxTasks": "Max. Aufgaben pro Tag",
|
||||||
"dayMo": "Mo",
|
"dayMo": "Mo",
|
||||||
"dayTu": "Di",
|
"dayTu": "Di",
|
||||||
@@ -104,6 +105,8 @@
|
|||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen",
|
||||||
"cancelTip": "Diese Aufgabe abbrechen",
|
"cancelTip": "Diese Aufgabe abbrechen",
|
||||||
"removeFromQueueTip": "Aus Warteschlange entfernen",
|
"removeFromQueueTip": "Aus Warteschlange entfernen",
|
||||||
|
"toggleSubtasksTip": "Unteraufgaben ein-/ausklappen",
|
||||||
|
"agentSuggestedTip": "Vom Agenten vorgeschlagen",
|
||||||
"scheduleTitle": "Aufgabe planen",
|
"scheduleTitle": "Aufgabe planen",
|
||||||
"scheduleWhen": "WANN",
|
"scheduleWhen": "WANN",
|
||||||
"scheduleConfirm": "Planen",
|
"scheduleConfirm": "Planen",
|
||||||
@@ -130,6 +133,7 @@
|
|||||||
},
|
},
|
||||||
"details": {
|
"details": {
|
||||||
"deleteTaskTip": "Aufgabe löschen",
|
"deleteTaskTip": "Aufgabe löschen",
|
||||||
|
"killSessionTip": "Laufende Sitzung beenden",
|
||||||
"closeTip": "Schließen",
|
"closeTip": "Schließen",
|
||||||
"copyTaskIdTip": "Aufgaben-ID kopieren",
|
"copyTaskIdTip": "Aufgaben-ID kopieren",
|
||||||
"starTip": "Favorit",
|
"starTip": "Favorit",
|
||||||
@@ -149,6 +153,7 @@
|
|||||||
"addStepPlaceholder": "Schritt hinzufügen...",
|
"addStepPlaceholder": "Schritt hinzufügen...",
|
||||||
"detailsLabel": "DETAILS",
|
"detailsLabel": "DETAILS",
|
||||||
"copyDescriptionTip": "Beschreibung in die Zwischenablage kopieren",
|
"copyDescriptionTip": "Beschreibung in die Zwischenablage kopieren",
|
||||||
|
"copyFormattedTip": "Titel, Beschreibung und offene Schritte kopieren",
|
||||||
"toggleEditPreviewTip": "Bearbeiten/Vorschau umschalten",
|
"toggleEditPreviewTip": "Bearbeiten/Vorschau umschalten",
|
||||||
"previewBtn": "Vorschau",
|
"previewBtn": "Vorschau",
|
||||||
"editBtn": "Bearbeiten",
|
"editBtn": "Bearbeiten",
|
||||||
@@ -184,7 +189,9 @@
|
|||||||
"session": {
|
"session": {
|
||||||
"chipLive": "LIVE",
|
"chipLive": "LIVE",
|
||||||
"chipDone": "FERTIG",
|
"chipDone": "FERTIG",
|
||||||
"chipFailed": "FEHLGESCHLAGEN"
|
"chipFailed": "FEHLGESCHLAGEN",
|
||||||
|
"reviewContinueTip": "Dieses Feedback senden und die Aufgabe erneut ausführen",
|
||||||
|
"reviewResetTip": "Alle Änderungen verwerfen und die Aufgabe auf Leerlauf zurücksetzen"
|
||||||
},
|
},
|
||||||
"modals": {
|
"modals": {
|
||||||
"about": {
|
"about": {
|
||||||
@@ -231,7 +238,10 @@
|
|||||||
"diff": {
|
"diff": {
|
||||||
"title": "DIFF",
|
"title": "DIFF",
|
||||||
"windowTitle": "Diff",
|
"windowTitle": "Diff",
|
||||||
"merge": "Mergen…"
|
"merge": "Mergen…",
|
||||||
|
"filesHeader": "Dateien",
|
||||||
|
"binary": "Binärdatei — kein Text-Diff",
|
||||||
|
"empty": "Kein Inhalt"
|
||||||
},
|
},
|
||||||
"worktree": {
|
"worktree": {
|
||||||
"title": "Worktree"
|
"title": "Worktree"
|
||||||
@@ -243,6 +253,12 @@
|
|||||||
"columnState": "STATUS",
|
"columnState": "STATUS",
|
||||||
"columnDiff": "DIFF",
|
"columnDiff": "DIFF",
|
||||||
"columnAge": "ALTER",
|
"columnAge": "ALTER",
|
||||||
|
"columnOutcome": "ERGEBNIS",
|
||||||
|
"selectAll": "Alle auswählen",
|
||||||
|
"targetLabel": "Ziel",
|
||||||
|
"mergeAll": "Alle mergen",
|
||||||
|
"needsResolution": "ZU LÖSEN",
|
||||||
|
"resolve": "Lösen",
|
||||||
"phantom": "Phantom",
|
"phantom": "Phantom",
|
||||||
"phantomTooltip": "Verzeichnis fehlt auf der Festplatte",
|
"phantomTooltip": "Verzeichnis fehlt auf der Festplatte",
|
||||||
"ctxShowDiff": "Diff anzeigen",
|
"ctxShowDiff": "Diff anzeigen",
|
||||||
@@ -358,6 +374,20 @@
|
|||||||
"loading": "Wird geladen…"
|
"loading": "Wird geladen…"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"conflictResolver": {
|
||||||
|
"windowTitle": "Merge-Konflikte lösen",
|
||||||
|
"modalTitle": "KONFLIKTE LÖSEN",
|
||||||
|
"loading": "Konflikte werden geladen…",
|
||||||
|
"current": "Aktuell (unsere)",
|
||||||
|
"incoming": "Eingehend (ihre)",
|
||||||
|
"mergedResult": "Zusammengeführtes Ergebnis",
|
||||||
|
"acceptCurrent": "Aktuelle übernehmen",
|
||||||
|
"acceptIncoming": "Eingehende übernehmen",
|
||||||
|
"acceptBoth": "Beide übernehmen",
|
||||||
|
"editManually": "Manuell bearbeiten",
|
||||||
|
"continue": "Lösen & fortfahren",
|
||||||
|
"abort": "Merge abbrechen"
|
||||||
|
},
|
||||||
"controls": {
|
"controls": {
|
||||||
"datePicker": {
|
"datePicker": {
|
||||||
"today": "Heute",
|
"today": "Heute",
|
||||||
@@ -400,7 +430,7 @@
|
|||||||
"weeklyReport": { "invalidRange": "Ungültiger Datumsbereich.", "generating": "Bericht wird erstellt…", "error": "Fehler: {0}" },
|
"weeklyReport": { "invalidRange": "Ungültiger Datumsbereich.", "generating": "Bericht wird erstellt…", "error": "Fehler: {0}" },
|
||||||
"filesTab": { "workerOffline": "Worker offline.", "noneBundled": "Keine Standard-Agenten mitgeliefert.", "allPresent": "Alle Standard-Agenten bereits vorhanden.", "restored": "{0} Standard-Agent(en) wiederhergestellt.", "restoreFailed": "Wiederherstellung fehlgeschlagen: {0}", "openFailed": "Öffnen fehlgeschlagen: {0}" },
|
"filesTab": { "workerOffline": "Worker offline.", "noneBundled": "Keine Standard-Agenten mitgeliefert.", "allPresent": "Alle Standard-Agenten bereits vorhanden.", "restored": "{0} Standard-Agent(en) wiederhergestellt.", "restoreFailed": "Wiederherstellung fehlgeschlagen: {0}", "openFailed": "Öffnen fehlgeschlagen: {0}" },
|
||||||
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "{0} Worktree(s) entfernt.", "blocked": "Zwangsentfernung nicht möglich: {0} Aufgabe(n) laufen noch. Brich sie zuerst ab.", "removedFrom": "{0} Worktree(s) von {1} Aufgabe(n) entfernt." },
|
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "{0} Worktree(s) entfernt.", "blocked": "Zwangsentfernung nicht möglich: {0} Aufgabe(n) laufen noch. Brich sie zuerst ab.", "removedFrom": "{0} Worktree(s) von {1} Aufgabe(n) entfernt." },
|
||||||
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "Liste", "cleanupFailed": "Aufräumen fehlgeschlagen.", "removed": "{0} Worktree(s) entfernt.", "discardFailed": "Worktree konnte nicht verworfen werden.", "keepFailed": "Worktree konnte nicht behalten werden.", "cannotForceRunning": "Eine laufende Aufgabe kann nicht zwangsweise entfernt werden.", "forceRemoveFailed": "Zwangsentfernung fehlgeschlagen." },
|
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "Liste", "cleanupFailed": "Aufräumen fehlgeschlagen.", "removed": "{0} Worktree(s) entfernt.", "discardFailed": "Worktree konnte nicht verworfen werden.", "keepFailed": "Worktree konnte nicht behalten werden.", "cannotForceRunning": "Eine laufende Aufgabe kann nicht zwangsweise entfernt werden.", "forceRemoveFailed": "Zwangsentfernung fehlgeschlagen.", "batchProgress": "Merge {0}/{1}…", "batchDone": "{0} gemergt, {1} zu lösen." },
|
||||||
"listSettings": { "untitled": "Unbenannt" },
|
"listSettings": { "untitled": "Unbenannt" },
|
||||||
"lists": { "localSuffix": "{0} / lokal", "smartMyDay": "Mein Tag", "smartImportant": "Wichtig", "smartPlanned": "Geplant", "virtualQueue": "Warteschlange", "virtualRunning": "Läuft", "virtualReview": "Prüfung", "newList": "Neue Liste" }
|
"lists": { "localSuffix": "{0} / lokal", "smartMyDay": "Mein Tag", "smartImportant": "Wichtig", "smartPlanned": "Geplant", "virtualQueue": "Warteschlange", "virtualRunning": "Läuft", "virtualReview": "Prüfung", "newList": "Neue Liste" }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,6 +53,7 @@
|
|||||||
"prime": {
|
"prime": {
|
||||||
"description": "Prime your Claude usage window by firing a single non-interactive ping on the days you choose, at a chosen time. Only runs while ClaudeDo is open. If the app starts within 30 minutes of the target time, the ping fires immediately.",
|
"description": "Prime your Claude usage window by firing a single non-interactive ping on the days you choose, at a chosen time. Only runs while ClaudeDo is open. If the app starts within 30 minutes of the target time, the ping fires immediately.",
|
||||||
"addSchedule": "+ Add schedule",
|
"addSchedule": "+ Add schedule",
|
||||||
|
"removeScheduleTip": "Remove schedule",
|
||||||
"dailyPrepMaxTasks": "Max tasks per day",
|
"dailyPrepMaxTasks": "Max tasks per day",
|
||||||
"dayMo": "Mo",
|
"dayMo": "Mo",
|
||||||
"dayTu": "Tu",
|
"dayTu": "Tu",
|
||||||
@@ -104,6 +105,8 @@
|
|||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"cancelTip": "Cancel this task",
|
"cancelTip": "Cancel this task",
|
||||||
"removeFromQueueTip": "Remove from queue",
|
"removeFromQueueTip": "Remove from queue",
|
||||||
|
"toggleSubtasksTip": "Expand / collapse subtasks",
|
||||||
|
"agentSuggestedTip": "Suggested by the agent",
|
||||||
"scheduleTitle": "Schedule task",
|
"scheduleTitle": "Schedule task",
|
||||||
"scheduleWhen": "WHEN",
|
"scheduleWhen": "WHEN",
|
||||||
"scheduleConfirm": "Schedule",
|
"scheduleConfirm": "Schedule",
|
||||||
@@ -130,6 +133,7 @@
|
|||||||
},
|
},
|
||||||
"details": {
|
"details": {
|
||||||
"deleteTaskTip": "Delete task",
|
"deleteTaskTip": "Delete task",
|
||||||
|
"killSessionTip": "Kill the running session",
|
||||||
"closeTip": "Close",
|
"closeTip": "Close",
|
||||||
"copyTaskIdTip": "Copy task ID",
|
"copyTaskIdTip": "Copy task ID",
|
||||||
"starTip": "Star",
|
"starTip": "Star",
|
||||||
@@ -149,6 +153,7 @@
|
|||||||
"addStepPlaceholder": "Add a step...",
|
"addStepPlaceholder": "Add a step...",
|
||||||
"detailsLabel": "DETAILS",
|
"detailsLabel": "DETAILS",
|
||||||
"copyDescriptionTip": "Copy description to clipboard",
|
"copyDescriptionTip": "Copy description to clipboard",
|
||||||
|
"copyFormattedTip": "Copy title, description and open steps",
|
||||||
"toggleEditPreviewTip": "Toggle edit/preview",
|
"toggleEditPreviewTip": "Toggle edit/preview",
|
||||||
"previewBtn": "Preview",
|
"previewBtn": "Preview",
|
||||||
"editBtn": "Edit",
|
"editBtn": "Edit",
|
||||||
@@ -184,7 +189,9 @@
|
|||||||
"session": {
|
"session": {
|
||||||
"chipLive": "LIVE",
|
"chipLive": "LIVE",
|
||||||
"chipDone": "DONE",
|
"chipDone": "DONE",
|
||||||
"chipFailed": "FAILED"
|
"chipFailed": "FAILED",
|
||||||
|
"reviewContinueTip": "Send this feedback and re-run the task",
|
||||||
|
"reviewResetTip": "Discard all changes and reset the task to Idle"
|
||||||
},
|
},
|
||||||
"modals": {
|
"modals": {
|
||||||
"about": {
|
"about": {
|
||||||
@@ -231,7 +238,10 @@
|
|||||||
"diff": {
|
"diff": {
|
||||||
"title": "DIFF",
|
"title": "DIFF",
|
||||||
"windowTitle": "Diff",
|
"windowTitle": "Diff",
|
||||||
"merge": "Merge…"
|
"merge": "Merge…",
|
||||||
|
"filesHeader": "Files",
|
||||||
|
"binary": "Binary file — no text diff",
|
||||||
|
"empty": "No content"
|
||||||
},
|
},
|
||||||
"worktree": {
|
"worktree": {
|
||||||
"title": "Worktree"
|
"title": "Worktree"
|
||||||
@@ -243,6 +253,12 @@
|
|||||||
"columnState": "STATE",
|
"columnState": "STATE",
|
||||||
"columnDiff": "DIFF",
|
"columnDiff": "DIFF",
|
||||||
"columnAge": "AGE",
|
"columnAge": "AGE",
|
||||||
|
"columnOutcome": "RESULT",
|
||||||
|
"selectAll": "Select all",
|
||||||
|
"targetLabel": "Target",
|
||||||
|
"mergeAll": "Merge all",
|
||||||
|
"needsResolution": "NEEDS RESOLUTION",
|
||||||
|
"resolve": "Resolve",
|
||||||
"phantom": "phantom",
|
"phantom": "phantom",
|
||||||
"phantomTooltip": "Directory missing on disk",
|
"phantomTooltip": "Directory missing on disk",
|
||||||
"ctxShowDiff": "Show diff",
|
"ctxShowDiff": "Show diff",
|
||||||
@@ -358,6 +374,20 @@
|
|||||||
"loading": "Loading…"
|
"loading": "Loading…"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"conflictResolver": {
|
||||||
|
"windowTitle": "Resolve merge conflicts",
|
||||||
|
"modalTitle": "RESOLVE CONFLICTS",
|
||||||
|
"loading": "Loading conflicts…",
|
||||||
|
"current": "Current (ours)",
|
||||||
|
"incoming": "Incoming (theirs)",
|
||||||
|
"mergedResult": "Merged result",
|
||||||
|
"acceptCurrent": "Accept Current",
|
||||||
|
"acceptIncoming": "Accept Incoming",
|
||||||
|
"acceptBoth": "Accept Both",
|
||||||
|
"editManually": "Edit manually",
|
||||||
|
"continue": "Resolve & continue",
|
||||||
|
"abort": "Abort merge"
|
||||||
|
},
|
||||||
"controls": {
|
"controls": {
|
||||||
"datePicker": {
|
"datePicker": {
|
||||||
"today": "Today",
|
"today": "Today",
|
||||||
@@ -400,7 +430,7 @@
|
|||||||
"weeklyReport": { "invalidRange": "Invalid date range.", "generating": "Generating report…", "error": "Error: {0}" },
|
"weeklyReport": { "invalidRange": "Invalid date range.", "generating": "Generating report…", "error": "Error: {0}" },
|
||||||
"filesTab": { "workerOffline": "Worker offline.", "noneBundled": "No default agents bundled.", "allPresent": "All default agents already present.", "restored": "Restored {0} default agent(s).", "restoreFailed": "Restore failed: {0}", "openFailed": "Open failed: {0}" },
|
"filesTab": { "workerOffline": "Worker offline.", "noneBundled": "No default agents bundled.", "allPresent": "All default agents already present.", "restored": "Restored {0} default agent(s).", "restoreFailed": "Restore failed: {0}", "openFailed": "Open failed: {0}" },
|
||||||
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "Removed {0} worktree(s).", "blocked": "Cannot force-remove: {0} task(s) still running. Cancel them first.", "removedFrom": "Removed {0} worktree(s) from {1} task(s)." },
|
"worktreesTab": { "workerOffline": "Worker offline.", "removed": "Removed {0} worktree(s).", "blocked": "Cannot force-remove: {0} task(s) still running. Cancel them first.", "removedFrom": "Removed {0} worktree(s) from {1} task(s)." },
|
||||||
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "list", "cleanupFailed": "Cleanup failed.", "removed": "Removed {0} worktree(s).", "discardFailed": "Failed to discard worktree.", "keepFailed": "Failed to keep worktree.", "cannotForceRunning": "Cannot force-remove a running task.", "forceRemoveFailed": "Force remove failed." },
|
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "list", "cleanupFailed": "Cleanup failed.", "removed": "Removed {0} worktree(s).", "discardFailed": "Failed to discard worktree.", "keepFailed": "Failed to keep worktree.", "cannotForceRunning": "Cannot force-remove a running task.", "forceRemoveFailed": "Force remove failed.", "batchProgress": "Merging {0}/{1}…", "batchDone": "Merged {0}, {1} need resolution." },
|
||||||
"listSettings": { "untitled": "Untitled" },
|
"listSettings": { "untitled": "Untitled" },
|
||||||
"lists": { "localSuffix": "{0} / local", "smartMyDay": "My Day", "smartImportant": "Important", "smartPlanned": "Planned", "virtualQueue": "Queue", "virtualRunning": "Running", "virtualReview": "Review", "newList": "New list" }
|
"lists": { "localSuffix": "{0} / local", "smartMyDay": "My Day", "smartImportant": "Important", "smartPlanned": "Planned", "virtualQueue": "Queue", "virtualRunning": "Running", "virtualReview": "Review", "newList": "New list" }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
using System.Globalization;
|
|
||||||
using Avalonia.Data.Converters;
|
|
||||||
using Avalonia.Media;
|
|
||||||
using ClaudeDo.Ui.ViewModels.Modals;
|
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.Converters;
|
|
||||||
|
|
||||||
public sealed class DiffLineKindToBrushConverter : IValueConverter
|
|
||||||
{
|
|
||||||
private static readonly ISolidColorBrush Added = new SolidColorBrush(Color.Parse("#66BB6A"));
|
|
||||||
private static readonly ISolidColorBrush Removed = new SolidColorBrush(Color.Parse("#EF5350"));
|
|
||||||
private static readonly ISolidColorBrush Hunk = new SolidColorBrush(Color.Parse("#42A5F5"));
|
|
||||||
private static readonly ISolidColorBrush Header = new SolidColorBrush(Color.Parse("#9E9E9E"));
|
|
||||||
private static readonly ISolidColorBrush Default = new SolidColorBrush(Color.Parse("#CFD8DC"));
|
|
||||||
|
|
||||||
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
|
||||||
value is WorktreeDiffLineKind kind
|
|
||||||
? kind switch
|
|
||||||
{
|
|
||||||
WorktreeDiffLineKind.Added => Added,
|
|
||||||
WorktreeDiffLineKind.Removed => Removed,
|
|
||||||
WorktreeDiffLineKind.Hunk => Hunk,
|
|
||||||
WorktreeDiffLineKind.Header => Header,
|
|
||||||
_ => Default,
|
|
||||||
}
|
|
||||||
: Default;
|
|
||||||
|
|
||||||
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
|
||||||
=> throw new NotSupportedException();
|
|
||||||
}
|
|
||||||
@@ -76,8 +76,8 @@
|
|||||||
<!-- Icon.PlanDay (stroke-rendered via Path.plan-icon — sun over horizon) -->
|
<!-- 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>
|
<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 + sparkle + edit tail) -->
|
<!-- Icon.Refine (stroke-rendered via Path.plan-icon — list lines + two sparkles) -->
|
||||||
<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>
|
<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 -->
|
<!-- Icon.X -->
|
||||||
<StreamGeometry x:Key="Icon.X">M6 6l12 12M18 6L6 18</StreamGeometry>
|
<StreamGeometry x:Key="Icon.X">M6 6l12 12M18 6L6 18</StreamGeometry>
|
||||||
@@ -574,6 +574,13 @@
|
|||||||
<Style Selector="Border[Tag=?] > TextBlock">
|
<Style Selector="Border[Tag=?] > TextBlock">
|
||||||
<Setter Property="Foreground" Value="{StaticResource TextFaintBrush}"/>
|
<Setter Property="Foreground" Value="{StaticResource TextFaintBrush}"/>
|
||||||
</Style>
|
</Style>
|
||||||
|
<!-- R → rename (sage) -->
|
||||||
|
<Style Selector="Border[Tag=R]">
|
||||||
|
<Setter Property="Background" Value="#268B9D7A"/>
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Border[Tag=R] > TextBlock">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource SageBrush}"/>
|
||||||
|
</Style>
|
||||||
|
|
||||||
<!-- ============================================================ -->
|
<!-- ============================================================ -->
|
||||||
<!-- LIST NAV ITEM -->
|
<!-- LIST NAV ITEM -->
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ public interface IWorkerClient : INotifyPropertyChanged
|
|||||||
Task RejectReviewToQueueAsync(string taskId, string feedback);
|
Task RejectReviewToQueueAsync(string taskId, string feedback);
|
||||||
Task RejectReviewToIdleAsync(string taskId);
|
Task RejectReviewToIdleAsync(string taskId);
|
||||||
Task CancelReviewAsync(string taskId);
|
Task CancelReviewAsync(string taskId);
|
||||||
|
|
||||||
|
// ── Conflict resolution (worker hub side implemented by Layer C) ──
|
||||||
|
Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch);
|
||||||
|
Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId);
|
||||||
|
Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent);
|
||||||
|
Task<MergeResultDto> ContinueMergeAsync(string taskId);
|
||||||
|
Task AbortMergeAsync(string taskId);
|
||||||
Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default);
|
Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default);
|
||||||
Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default);
|
Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default);
|
||||||
Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default);
|
Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default);
|
||||||
@@ -52,7 +59,6 @@ public interface IWorkerClient : INotifyPropertyChanged
|
|||||||
Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId);
|
Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId);
|
||||||
Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregateAsync(string planningTaskId);
|
Task<IReadOnlyList<SubtaskDiffDto>> GetPlanningAggregateAsync(string planningTaskId);
|
||||||
Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch);
|
Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch);
|
||||||
Task MergeAllPlanningAsync(string planningTaskId, string targetBranch);
|
|
||||||
Task ContinuePlanningMergeAsync(string planningTaskId);
|
Task ContinuePlanningMergeAsync(string planningTaskId);
|
||||||
Task AbortPlanningMergeAsync(string planningTaskId);
|
Task AbortPlanningMergeAsync(string planningTaskId);
|
||||||
Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default);
|
Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default);
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
|
|
||||||
public event Action<PrimeFiredEvent>? PrimeFired;
|
public event Action<PrimeFiredEvent>? PrimeFired;
|
||||||
|
|
||||||
public string? LastMergeAllTarget { get; private set; }
|
public string? LastApproveTarget { get; private set; }
|
||||||
|
|
||||||
public WorkerClient(string signalRUrl)
|
public WorkerClient(string signalRUrl)
|
||||||
{
|
{
|
||||||
@@ -269,6 +269,21 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
"MergeTask", taskId, targetBranch, removeWorktree, commitMessage);
|
"MergeTask", taskId, targetBranch, removeWorktree, commitMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch)
|
||||||
|
=> _hub.InvokeAsync<MergeResultDto>("StartConflictMerge", taskId, targetBranch);
|
||||||
|
|
||||||
|
public Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId)
|
||||||
|
=> _hub.InvokeAsync<MergeConflictsDto>("GetMergeConflicts", taskId);
|
||||||
|
|
||||||
|
public Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent)
|
||||||
|
=> _hub.InvokeAsync("WriteConflictResolution", taskId, path, resolvedContent);
|
||||||
|
|
||||||
|
public Task<MergeResultDto> ContinueMergeAsync(string taskId)
|
||||||
|
=> _hub.InvokeAsync<MergeResultDto>("ContinueMerge", taskId);
|
||||||
|
|
||||||
|
public Task AbortMergeAsync(string taskId)
|
||||||
|
=> _hub.InvokeAsync("AbortMerge", taskId);
|
||||||
|
|
||||||
public Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId)
|
public Task<MergeTargetsDto?> GetMergeTargetsAsync(string taskId)
|
||||||
=> TryInvokeAsync<MergeTargetsDto>("GetMergeTargets", taskId);
|
=> TryInvokeAsync<MergeTargetsDto>("GetMergeTargets", taskId);
|
||||||
|
|
||||||
@@ -397,7 +412,10 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
}
|
}
|
||||||
|
|
||||||
public Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch)
|
public Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch)
|
||||||
=> TryInvokeAsync<MergeResultDto>("ApproveReview", taskId, targetBranch);
|
{
|
||||||
|
LastApproveTarget = targetBranch;
|
||||||
|
return TryInvokeAsync<MergeResultDto>("ApproveReview", taskId, targetBranch);
|
||||||
|
}
|
||||||
|
|
||||||
public Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch)
|
public Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch)
|
||||||
=> TryInvokeAsync<MergePreviewDto>("PreviewMerge", taskId, targetBranch);
|
=> TryInvokeAsync<MergePreviewDto>("PreviewMerge", taskId, targetBranch);
|
||||||
@@ -471,12 +489,6 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
public Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch)
|
public Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch)
|
||||||
=> TryInvokeAsync<CombinedDiffResultDto>("BuildPlanningIntegrationBranch", planningTaskId, targetBranch);
|
=> TryInvokeAsync<CombinedDiffResultDto>("BuildPlanningIntegrationBranch", planningTaskId, targetBranch);
|
||||||
|
|
||||||
public async Task MergeAllPlanningAsync(string planningTaskId, string targetBranch)
|
|
||||||
{
|
|
||||||
LastMergeAllTarget = targetBranch;
|
|
||||||
await _hub.InvokeAsync("MergeAllPlanning", planningTaskId, targetBranch);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task ContinuePlanningMergeAsync(string planningTaskId)
|
public async Task ContinuePlanningMergeAsync(string planningTaskId)
|
||||||
{
|
{
|
||||||
await _hub.InvokeAsync("ContinuePlanningMerge", planningTaskId);
|
await _hub.InvokeAsync("ContinuePlanningMerge", planningTaskId);
|
||||||
@@ -532,6 +544,9 @@ public sealed record WorktreeResetDto(int Removed, int TasksAffected, bool Block
|
|||||||
public record MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage);
|
public record MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage);
|
||||||
public record MergePreviewDto(string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount);
|
public record MergePreviewDto(string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount);
|
||||||
public record MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalBranches);
|
public record MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalBranches);
|
||||||
|
public record MergeConflictsDto(string TaskId, IReadOnlyList<ConflictFileDto> Files);
|
||||||
|
public record ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks);
|
||||||
|
public record ConflictHunkDto(string Ours, string Theirs, string? Base);
|
||||||
public sealed record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType);
|
public sealed record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType);
|
||||||
public sealed record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
|
public sealed record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
|
||||||
public sealed record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
|
public sealed record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
|
||||||
|
|||||||
49
src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictModels.cs
Normal file
49
src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictModels.cs
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.ViewModels.Conflicts;
|
||||||
|
|
||||||
|
public sealed partial class ConflictHunk : ObservableObject
|
||||||
|
{
|
||||||
|
public string Ours { get; }
|
||||||
|
public string Theirs { get; }
|
||||||
|
public string? Base { get; }
|
||||||
|
|
||||||
|
[ObservableProperty] private string? _resolution;
|
||||||
|
|
||||||
|
public bool IsResolved => Resolution is not null;
|
||||||
|
|
||||||
|
public ConflictHunk(string ours, string theirs, string? @base)
|
||||||
|
{
|
||||||
|
Ours = ours;
|
||||||
|
Theirs = theirs;
|
||||||
|
Base = @base;
|
||||||
|
}
|
||||||
|
|
||||||
|
partial void OnResolutionChanged(string? value) => OnPropertyChanged(nameof(IsResolved));
|
||||||
|
|
||||||
|
[RelayCommand] private void AcceptCurrent() => Resolution = Ours;
|
||||||
|
[RelayCommand] private void AcceptIncoming() => Resolution = Theirs;
|
||||||
|
[RelayCommand] private void AcceptBoth() => Resolution = Ours + Theirs;
|
||||||
|
[RelayCommand] private void EditManually() => Resolution ??= Ours;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ConflictFile
|
||||||
|
{
|
||||||
|
public string Path { get; }
|
||||||
|
public IReadOnlyList<ConflictHunk> Hunks { get; }
|
||||||
|
|
||||||
|
public ConflictFile(string path, IReadOnlyList<ConflictHunk> hunks)
|
||||||
|
{
|
||||||
|
Path = path;
|
||||||
|
Hunks = hunks;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool AllHunksResolved => Hunks.Count > 0 && Hunks.All(h => h.IsResolved);
|
||||||
|
|
||||||
|
/// <summary>Merged file content: concatenation of each hunk's resolution
|
||||||
|
/// (single whole-file hunk today; concatenation stays correct for multi-hunk later).</summary>
|
||||||
|
public string ComposeResolvedContent() => string.Concat(Hunks.Select(h => h.Resolution));
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.ComponentModel;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using ClaudeDo.Ui.Services;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.ViewModels.Conflicts;
|
||||||
|
|
||||||
|
public sealed partial class ConflictResolverViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
private readonly IWorkerClient _worker;
|
||||||
|
private readonly string _taskId;
|
||||||
|
|
||||||
|
public ObservableCollection<ConflictFile> Files { get; } = new();
|
||||||
|
|
||||||
|
[ObservableProperty] private bool _isBusy;
|
||||||
|
[ObservableProperty] private string? _error;
|
||||||
|
[ObservableProperty] private bool _canContinue;
|
||||||
|
|
||||||
|
public string TaskId => _taskId;
|
||||||
|
public Action? CloseRequested { get; set; }
|
||||||
|
|
||||||
|
public ConflictResolverViewModel(IWorkerClient worker, string taskId)
|
||||||
|
{
|
||||||
|
_worker = worker;
|
||||||
|
_taskId = taskId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Starts the conflict merge and loads ours/theirs/base per file.
|
||||||
|
/// Returns true when there are conflicts to resolve (caller should show the dialog).</summary>
|
||||||
|
public async Task<bool> OpenAsync(string targetBranch)
|
||||||
|
{
|
||||||
|
IsBusy = true;
|
||||||
|
Error = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var start = await _worker.StartConflictMergeAsync(_taskId, targetBranch);
|
||||||
|
if (!string.Equals(start.Status, "conflict", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
if (string.Equals(start.Status, "blocked", StringComparison.Ordinal))
|
||||||
|
Error = start.ErrorMessage;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var conflicts = await _worker.GetMergeConflictsAsync(_taskId);
|
||||||
|
Files.Clear();
|
||||||
|
foreach (var f in conflicts.Files)
|
||||||
|
{
|
||||||
|
var hunks = f.Hunks.Select(h =>
|
||||||
|
{
|
||||||
|
var hk = new ConflictHunk(h.Ours, h.Theirs, h.Base);
|
||||||
|
hk.PropertyChanged += OnHunkChanged;
|
||||||
|
return hk;
|
||||||
|
}).ToList();
|
||||||
|
Files.Add(new ConflictFile(f.Path, hunks));
|
||||||
|
}
|
||||||
|
RecomputeCanContinue();
|
||||||
|
return Files.Count > 0;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Error = ex.Message;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
finally { IsBusy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnHunkChanged(object? sender, PropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.PropertyName is nameof(ConflictHunk.IsResolved) or nameof(ConflictHunk.Resolution))
|
||||||
|
RecomputeCanContinue();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RecomputeCanContinue()
|
||||||
|
=> CanContinue = Files.Count > 0 && Files.All(f => f.AllHunksResolved);
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task ContinueAsync()
|
||||||
|
{
|
||||||
|
if (!CanContinue) return;
|
||||||
|
IsBusy = true;
|
||||||
|
Error = null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var file in Files)
|
||||||
|
await _worker.WriteConflictResolutionAsync(_taskId, file.Path, file.ComposeResolvedContent());
|
||||||
|
|
||||||
|
var result = await _worker.ContinueMergeAsync(_taskId);
|
||||||
|
if (string.Equals(result.Status, "merged", StringComparison.Ordinal))
|
||||||
|
CloseRequested?.Invoke();
|
||||||
|
else
|
||||||
|
Error = result.ErrorMessage ?? "Conflicts not fully resolved — review and retry.";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Error = ex.Message;
|
||||||
|
}
|
||||||
|
finally { IsBusy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task AbortAsync()
|
||||||
|
{
|
||||||
|
IsBusy = true;
|
||||||
|
try { await _worker.AbortMergeAsync(_taskId); }
|
||||||
|
catch (Exception ex) { Error = ex.Message; }
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsBusy = false;
|
||||||
|
CloseRequested?.Invoke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,13 +46,21 @@ public sealed class LogLineViewModel
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed partial class DetailsIslandViewModel : ViewModelBase
|
public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
||||||
{
|
{
|
||||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
private readonly IWorkerClient _worker;
|
private readonly IWorkerClient _worker;
|
||||||
private readonly IServiceProvider _services;
|
private readonly IServiceProvider _services;
|
||||||
private readonly INotesApi _notesApi;
|
private readonly INotesApi _notesApi;
|
||||||
|
|
||||||
|
// Captured handler delegates for disposal
|
||||||
|
private readonly EventHandler _langChangedHandler;
|
||||||
|
private readonly System.ComponentModel.PropertyChangedEventHandler _workerPropertyChangedHandler;
|
||||||
|
private readonly Action<string, string, DateTime> _workerTaskStartedHandler;
|
||||||
|
private readonly Action<string, string, string, DateTime> _workerTaskFinishedHandler;
|
||||||
|
private readonly Action<string> _workerWorktreeUpdatedHandler;
|
||||||
|
private readonly Action<string> _workerTaskUpdatedHandler;
|
||||||
|
|
||||||
[ObservableProperty] private bool _isNotesMode;
|
[ObservableProperty] private bool _isNotesMode;
|
||||||
[ObservableProperty] private bool _isPrepMode;
|
[ObservableProperty] private bool _isPrepMode;
|
||||||
[ObservableProperty] private bool _isPrepRunning;
|
[ObservableProperty] private bool _isPrepRunning;
|
||||||
@@ -153,14 +161,16 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
public bool ShowMergeSection =>
|
public bool ShowMergeSection =>
|
||||||
WorktreePath != null || Task?.IsPlanningParent == true || HasChildOutcomes;
|
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()
|
private void NotifySessionSections()
|
||||||
{
|
{
|
||||||
|
OnPropertyChanged(nameof(HasChildOutcomes));
|
||||||
OnPropertyChanged(nameof(ShowMergeSection));
|
OnPropertyChanged(nameof(ShowMergeSection));
|
||||||
OnPropertyChanged(nameof(ShowSessionEmpty));
|
NotifyAttention();
|
||||||
|
|
||||||
|
// The Session tab is only visible when it has outcomes; if it just
|
||||||
|
// emptied while selected, fall back to Output so the body isn't blank.
|
||||||
|
if (!HasChildOutcomes && SelectedTab == "session")
|
||||||
|
SelectedTab = "output";
|
||||||
}
|
}
|
||||||
|
|
||||||
public string TurnsText => $"{Turns}/{EffectiveMaxTurns}";
|
public string TurnsText => $"{Turns}/{EffectiveMaxTurns}";
|
||||||
@@ -176,6 +186,53 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
IsFailed ? "The session ended with an error." :
|
IsFailed ? "The session ended with an error." :
|
||||||
IsCancelled ? "The session was cancelled." : "";
|
IsCancelled ? "The session was cancelled." : "";
|
||||||
|
|
||||||
|
// The session's outcome summary — the task's Result minus any roadblock
|
||||||
|
// section (those get their own card), falling back to the run's
|
||||||
|
// ErrorMarkdown for hard failures. Shown once a run has finished.
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(ShowSessionOutcome))]
|
||||||
|
private string? _sessionOutcome;
|
||||||
|
|
||||||
|
public bool ShowSessionOutcome =>
|
||||||
|
!string.IsNullOrWhiteSpace(SessionOutcome)
|
||||||
|
&& (IsWaitingForReview || IsDone || IsFailed || IsCancelled);
|
||||||
|
|
||||||
|
// The roadblocks the agent emitted (CLAUDEDO_BLOCKED), parsed out of the
|
||||||
|
// run result so they can surface as a distinct colored card.
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(ShowRoadblockCard))]
|
||||||
|
private string? _roadblocks;
|
||||||
|
|
||||||
|
public bool ShowRoadblockCard =>
|
||||||
|
!string.IsNullOrWhiteSpace(Roadblocks)
|
||||||
|
&& (IsWaitingForReview || IsDone || IsFailed || IsCancelled);
|
||||||
|
|
||||||
|
// Worker writes roadblocks into the result under this header
|
||||||
|
// (TaskRunner.ComposeReviewResult). Split it back out for display.
|
||||||
|
private const string RoadblockMarker = "Roadblocks reported during the run:";
|
||||||
|
|
||||||
|
private void ApplyOutcome(string? result, string? errorFallback)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(result))
|
||||||
|
{
|
||||||
|
SessionOutcome = errorFallback;
|
||||||
|
Roadblocks = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var idx = result.IndexOf(RoadblockMarker, StringComparison.Ordinal);
|
||||||
|
if (idx < 0)
|
||||||
|
{
|
||||||
|
SessionOutcome = result;
|
||||||
|
Roadblocks = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var summary = result[..idx].TrimEnd().TrimEnd('⚠').TrimEnd();
|
||||||
|
SessionOutcome = string.IsNullOrWhiteSpace(summary) ? null : summary;
|
||||||
|
Roadblocks = result[(idx + RoadblockMarker.Length)..].Trim();
|
||||||
|
}
|
||||||
|
|
||||||
public string SessionLabel => "claude-session";
|
public string SessionLabel => "claude-session";
|
||||||
|
|
||||||
// Short task-id badge, e.g. "#T1A"
|
// Short task-id badge, e.g. "#T1A"
|
||||||
@@ -222,6 +279,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
OnPropertyChanged(nameof(IsAgentSectionEnabled));
|
OnPropertyChanged(nameof(IsAgentSectionEnabled));
|
||||||
OnPropertyChanged(nameof(ShowRoadblock));
|
OnPropertyChanged(nameof(ShowRoadblock));
|
||||||
OnPropertyChanged(nameof(RoadblockMessage));
|
OnPropertyChanged(nameof(RoadblockMessage));
|
||||||
|
OnPropertyChanged(nameof(ShowSessionOutcome));
|
||||||
|
OnPropertyChanged(nameof(ShowRoadblockCard));
|
||||||
NotifySessionSections();
|
NotifySessionSections();
|
||||||
}
|
}
|
||||||
[ObservableProperty] private string? _model;
|
[ObservableProperty] private string? _model;
|
||||||
@@ -296,7 +355,11 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
|
|
||||||
[ObservableProperty] private string? _worktreePath;
|
[ObservableProperty] private string? _worktreePath;
|
||||||
[ObservableProperty] private string? _worktreeBaseCommit;
|
[ObservableProperty] private string? _worktreeBaseCommit;
|
||||||
|
[ObservableProperty] private string? _worktreeHeadCommit;
|
||||||
[ObservableProperty] private string? _worktreeStateLabel;
|
[ObservableProperty] private string? _worktreeStateLabel;
|
||||||
|
// Repo working dir of the selected task's list — used to diff a merged task's
|
||||||
|
// commit range after its worktree directory is gone.
|
||||||
|
private string? _listWorkingDir;
|
||||||
[ObservableProperty] private string? _branchLine;
|
[ObservableProperty] private string? _branchLine;
|
||||||
[ObservableProperty] private int _turns;
|
[ObservableProperty] private int _turns;
|
||||||
[ObservableProperty] private int _tokens;
|
[ObservableProperty] private int _tokens;
|
||||||
@@ -330,16 +393,32 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
public ObservableCollection<ChildOutcomeRowViewModel> ChildOutcomes { get; } = new();
|
public ObservableCollection<ChildOutcomeRowViewModel> ChildOutcomes { get; } = new();
|
||||||
public bool HasChildOutcomes => ChildOutcomes.Count > 0;
|
public bool HasChildOutcomes => ChildOutcomes.Count > 0;
|
||||||
|
|
||||||
|
// Children that need the user's attention before the parent can be approved:
|
||||||
|
// failed, cancelled, still awaiting their own review, or that reported roadblocks.
|
||||||
|
// The parent deliberately stays in WaitingForChildren until these are resolved;
|
||||||
|
// this surfaces a flag so the roadblock is visible on the parent.
|
||||||
|
public int ChildrenNeedingAttention => ChildOutcomes.Count(c =>
|
||||||
|
c.Status == ClaudeDo.Data.Models.TaskStatus.Failed
|
||||||
|
|| c.Status == ClaudeDo.Data.Models.TaskStatus.Cancelled
|
||||||
|
|| c.Status == ClaudeDo.Data.Models.TaskStatus.WaitingForReview
|
||||||
|
|| c.RoadblockCount > 0);
|
||||||
|
public bool HasChildrenNeedingAttention => ChildrenNeedingAttention > 0;
|
||||||
|
public string ChildrenAttentionText => ChildrenNeedingAttention == 1
|
||||||
|
? "1 child needs attention"
|
||||||
|
: $"{ChildrenNeedingAttention} children need attention";
|
||||||
|
|
||||||
|
private void NotifyAttention()
|
||||||
|
{
|
||||||
|
OnPropertyChanged(nameof(ChildrenNeedingAttention));
|
||||||
|
OnPropertyChanged(nameof(HasChildrenNeedingAttention));
|
||||||
|
OnPropertyChanged(nameof(ChildrenAttentionText));
|
||||||
|
}
|
||||||
|
|
||||||
[ObservableProperty] private string _newSubtaskTitle = "";
|
[ObservableProperty] private string _newSubtaskTitle = "";
|
||||||
|
|
||||||
// Planning merge controls
|
// Planning merge controls
|
||||||
[ObservableProperty] private ObservableCollection<string> _mergeTargetBranches = new();
|
[ObservableProperty] private ObservableCollection<string> _mergeTargetBranches = new();
|
||||||
[ObservableProperty] private string? _selectedMergeTarget;
|
[ObservableProperty] private string? _selectedMergeTarget;
|
||||||
[ObservableProperty]
|
|
||||||
[NotifyCanExecuteChangedFor(nameof(MergeAllCommand))]
|
|
||||||
private bool _canMergeAll;
|
|
||||||
[ObservableProperty] private string? _mergeAllDisabledReason;
|
|
||||||
[ObservableProperty] private string? _mergeAllError;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(ShowMergePreviewMuted))]
|
[NotifyPropertyChangedFor(nameof(ShowMergePreviewMuted))]
|
||||||
@@ -356,9 +435,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
public bool ShowMergePreviewMuted =>
|
public bool ShowMergePreviewMuted =>
|
||||||
!MergeIsClean && !MergeIsConflict && !string.IsNullOrEmpty(MergePreviewText);
|
!MergeIsClean && !MergeIsConflict && !string.IsNullOrEmpty(MergePreviewText);
|
||||||
|
|
||||||
public bool ShowSingleMerge =>
|
|
||||||
WorktreePath != null && Task?.IsPlanningParent != true;
|
|
||||||
|
|
||||||
// Claude CLI stream-json parser + buffer for partial text deltas
|
// Claude CLI stream-json parser + buffer for partial text deltas
|
||||||
private readonly StreamLineFormatter _formatter = new();
|
private readonly StreamLineFormatter _formatter = new();
|
||||||
private readonly StringBuilder _claudeBuf = new();
|
private readonly StringBuilder _claudeBuf = new();
|
||||||
@@ -390,6 +466,10 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
// Set by the view so DeleteTaskCommand can show an error message
|
// Set by the view so DeleteTaskCommand can show an error message
|
||||||
public Func<string, System.Threading.Tasks.Task>? ShowErrorAsync { get; set; }
|
public Func<string, System.Threading.Tasks.Task>? ShowErrorAsync { get; set; }
|
||||||
|
|
||||||
|
// Invoked when a single-task merge/approve hits a conflict. Wired by the
|
||||||
|
// integrator to Layer C's conflict resolver. Args: (taskId, targetBranch).
|
||||||
|
public Func<string, string, System.Threading.Tasks.Task>? RequestConflictResolution { get; set; }
|
||||||
|
|
||||||
private static string StatusToStateKey(ClaudeDo.Data.Models.TaskStatus status) => status switch
|
private static string StatusToStateKey(ClaudeDo.Data.Models.TaskStatus status) => status switch
|
||||||
{
|
{
|
||||||
ClaudeDo.Data.Models.TaskStatus.Queued => "queued",
|
ClaudeDo.Data.Models.TaskStatus.Queued => "queued",
|
||||||
@@ -427,6 +507,21 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
catch { }
|
catch { }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reload the session outcome (task Result incl. roadblocks, or the run's
|
||||||
|
// error) so it appears as soon as a run finishes.
|
||||||
|
private async System.Threading.Tasks.Task RefreshOutcomeAsync(string taskId)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||||
|
var entity = await ctx.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == taskId);
|
||||||
|
var latestRun = await new TaskRunRepository(ctx).GetLatestByTaskIdAsync(taskId);
|
||||||
|
if (Task?.Id != taskId) return;
|
||||||
|
ApplyOutcome(entity?.Result, latestRun?.ErrorMarkdown);
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
public DetailsIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient worker, IServiceProvider services, INotesApi notesApi)
|
public DetailsIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient worker, IServiceProvider services, INotesApi notesApi)
|
||||||
{
|
{
|
||||||
_dbFactory = dbFactory;
|
_dbFactory = dbFactory;
|
||||||
@@ -435,13 +530,14 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
_notesApi = notesApi;
|
_notesApi = notesApi;
|
||||||
Notes = new NotesEditorViewModel(_notesApi);
|
Notes = new NotesEditorViewModel(_notesApi);
|
||||||
Subtasks.CollectionChanged += (_, _) => NotifyStepsChanged();
|
Subtasks.CollectionChanged += (_, _) => NotifyStepsChanged();
|
||||||
Loc.LanguageChanged += (_, _) =>
|
_langChangedHandler = (_, _) =>
|
||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(AgentStatusLabel));
|
OnPropertyChanged(nameof(AgentStatusLabel));
|
||||||
RecomputeModelBadge();
|
RecomputeModelBadge();
|
||||||
RecomputeTurnsBadge();
|
RecomputeTurnsBadge();
|
||||||
RecomputeAgentBadge();
|
RecomputeAgentBadge();
|
||||||
};
|
};
|
||||||
|
Loc.LanguageChanged += _langChangedHandler;
|
||||||
|
|
||||||
// Subscribe once; filter by current task id inside the handler
|
// Subscribe once; filter by current task id inside the handler
|
||||||
_worker.TaskMessageEvent += OnTaskMessage;
|
_worker.TaskMessageEvent += OnTaskMessage;
|
||||||
@@ -450,7 +546,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
_worker.PrepFinishedEvent += OnPrepFinished;
|
_worker.PrepFinishedEvent += OnPrepFinished;
|
||||||
|
|
||||||
// Re-evaluate CanExecute when worker connection flips.
|
// Re-evaluate CanExecute when worker connection flips.
|
||||||
_worker.PropertyChanged += (_, e) =>
|
_workerPropertyChangedHandler = (_, e) =>
|
||||||
{
|
{
|
||||||
if (e.PropertyName == nameof(WorkerClient.IsConnected))
|
if (e.PropertyName == nameof(WorkerClient.IsConnected))
|
||||||
{
|
{
|
||||||
@@ -460,14 +556,17 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
ContinueCommand.NotifyCanExecuteChanged();
|
ContinueCommand.NotifyCanExecuteChanged();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
_worker.PropertyChanged += _workerPropertyChangedHandler;
|
||||||
|
|
||||||
// If the task row's live status changes (e.g. TaskStarted/Finished), mirror it.
|
// If the task row's live status changes (e.g. TaskStarted/Finished), mirror it.
|
||||||
_worker.TaskStartedEvent += (slot, taskId, startedAt) =>
|
_workerTaskStartedHandler = (slot, taskId, startedAt) =>
|
||||||
{
|
{
|
||||||
if (Task?.Id == taskId) AgentState = "running";
|
if (Task?.Id == taskId) AgentState = "running";
|
||||||
_ = RefreshChildOutcomeAsync(taskId);
|
_ = RefreshChildOutcomeAsync(taskId);
|
||||||
};
|
};
|
||||||
_worker.TaskFinishedEvent += (slot, taskId, status, finishedAt) =>
|
_worker.TaskStartedEvent += _workerTaskStartedHandler;
|
||||||
|
|
||||||
|
_workerTaskFinishedHandler = (slot, taskId, status, finishedAt) =>
|
||||||
{
|
{
|
||||||
if (Task?.Id != taskId) return;
|
if (Task?.Id != taskId) return;
|
||||||
FlushClaudeBuffer();
|
FlushClaudeBuffer();
|
||||||
@@ -480,31 +579,33 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
// Re-query to pick up worktree created during the run.
|
// Re-query to pick up worktree created during the run.
|
||||||
_ = RefreshWorktreeAsync(taskId);
|
_ = RefreshWorktreeAsync(taskId);
|
||||||
_ = RefreshChildOutcomeAsync(taskId);
|
_ = RefreshChildOutcomeAsync(taskId);
|
||||||
|
_ = RefreshOutcomeAsync(taskId);
|
||||||
};
|
};
|
||||||
|
_worker.TaskFinishedEvent += _workerTaskFinishedHandler;
|
||||||
|
|
||||||
_worker.WorktreeUpdatedEvent += taskId =>
|
_workerWorktreeUpdatedHandler = taskId =>
|
||||||
{
|
{
|
||||||
if (Task?.Id == taskId) _ = RefreshWorktreeAsync(taskId);
|
if (Task?.Id == taskId) _ = RefreshWorktreeAsync(taskId);
|
||||||
if (Task?.IsPlanningParent == true) _ = RefreshPlanningChildAsync(taskId);
|
if (Task?.IsPlanningParent == true) _ = RefreshPlanningChildAsync(taskId);
|
||||||
_ = RefreshChildOutcomeAsync(taskId);
|
_ = RefreshChildOutcomeAsync(taskId);
|
||||||
};
|
};
|
||||||
|
_worker.WorktreeUpdatedEvent += _workerWorktreeUpdatedHandler;
|
||||||
|
|
||||||
_worker.TaskUpdatedEvent += taskId =>
|
_workerTaskUpdatedHandler = taskId =>
|
||||||
{
|
{
|
||||||
if (Task?.Id == taskId) _ = RefreshStatusAsync(taskId);
|
if (Task?.Id == taskId) _ = RefreshStatusAsync(taskId);
|
||||||
if (Task?.IsPlanningParent == true) _ = RefreshPlanningChildAsync(taskId);
|
if (Task?.IsPlanningParent == true) _ = RefreshPlanningChildAsync(taskId);
|
||||||
_ = RefreshChildOutcomeAsync(taskId);
|
_ = RefreshChildOutcomeAsync(taskId);
|
||||||
};
|
};
|
||||||
|
_worker.TaskUpdatedEvent += _workerTaskUpdatedHandler;
|
||||||
|
|
||||||
Subtasks.CollectionChanged += (_, _) =>
|
Subtasks.CollectionChanged += (_, _) =>
|
||||||
{
|
{
|
||||||
RecomputeCanMergeAll();
|
|
||||||
ReviewCombinedDiffCommand.NotifyCanExecuteChanged();
|
ReviewCombinedDiffCommand.NotifyCanExecuteChanged();
|
||||||
};
|
};
|
||||||
|
|
||||||
ChildOutcomes.CollectionChanged += (_, _) =>
|
ChildOutcomes.CollectionChanged += (_, _) =>
|
||||||
{
|
{
|
||||||
RecomputeCanMergeAll();
|
|
||||||
ReviewCombinedDiffCommand.NotifyCanExecuteChanged();
|
ReviewCombinedDiffCommand.NotifyCanExecuteChanged();
|
||||||
NotifySessionSections();
|
NotifySessionSections();
|
||||||
};
|
};
|
||||||
@@ -512,6 +613,20 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
PrepLog.CollectionChanged += (_, _) => OnPropertyChanged(nameof(ShowPrepEmptyState));
|
PrepLog.CollectionChanged += (_, _) => OnPropertyChanged(nameof(ShowPrepEmptyState));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Loc.LanguageChanged -= _langChangedHandler;
|
||||||
|
_worker.PropertyChanged -= _workerPropertyChangedHandler;
|
||||||
|
_worker.TaskStartedEvent -= _workerTaskStartedHandler;
|
||||||
|
_worker.TaskFinishedEvent -= _workerTaskFinishedHandler;
|
||||||
|
_worker.WorktreeUpdatedEvent -= _workerWorktreeUpdatedHandler;
|
||||||
|
_worker.TaskUpdatedEvent -= _workerTaskUpdatedHandler;
|
||||||
|
_worker.TaskMessageEvent -= OnTaskMessage;
|
||||||
|
_worker.PrepStartedEvent -= OnPrepStarted;
|
||||||
|
_worker.PrepLineEvent -= OnPrepLine;
|
||||||
|
_worker.PrepFinishedEvent -= OnPrepFinished;
|
||||||
|
}
|
||||||
|
|
||||||
private void OnTaskMessage(string taskId, string line)
|
private void OnTaskMessage(string taskId, string line)
|
||||||
{
|
{
|
||||||
if (taskId != _subscribedTaskId) return;
|
if (taskId != _subscribedTaskId) return;
|
||||||
@@ -740,9 +855,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
OnPropertyChanged(nameof(HasChildOutcomes));
|
OnPropertyChanged(nameof(HasChildOutcomes));
|
||||||
MergeTargetBranches.Clear();
|
MergeTargetBranches.Clear();
|
||||||
SelectedMergeTarget = null;
|
SelectedMergeTarget = null;
|
||||||
CanMergeAll = false;
|
SessionOutcome = null;
|
||||||
MergeAllDisabledReason = null;
|
Roadblocks = null;
|
||||||
MergeAllError = null;
|
|
||||||
_claudeBuf.Clear();
|
_claudeBuf.Clear();
|
||||||
|
|
||||||
if (row == null)
|
if (row == null)
|
||||||
@@ -752,6 +866,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
EditableDescription = "";
|
EditableDescription = "";
|
||||||
Model = null;
|
Model = null;
|
||||||
WorktreePath = null;
|
WorktreePath = null;
|
||||||
|
WorktreeHeadCommit = null;
|
||||||
|
_listWorkingDir = null;
|
||||||
WorktreeStateLabel = null;
|
WorktreeStateLabel = null;
|
||||||
BranchLine = null;
|
BranchLine = null;
|
||||||
DiffAdditions = 0;
|
DiffAdditions = 0;
|
||||||
@@ -788,6 +904,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
var entity = await ctx.Tasks
|
var entity = await ctx.Tasks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(t => t.Worktree)
|
.Include(t => t.Worktree)
|
||||||
|
.Include(t => t.List)
|
||||||
.FirstOrDefaultAsync(t => t.Id == row.Id, ct);
|
.FirstOrDefaultAsync(t => t.Id == row.Id, ct);
|
||||||
ct.ThrowIfCancellationRequested();
|
ct.ThrowIfCancellationRequested();
|
||||||
if (entity == null) return;
|
if (entity == null) return;
|
||||||
@@ -797,8 +914,10 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
try { EditableDescription = entity.Description ?? ""; }
|
try { EditableDescription = entity.Description ?? ""; }
|
||||||
finally { _suppressDescSave = false; }
|
finally { _suppressDescSave = false; }
|
||||||
Model = entity.Model;
|
Model = entity.Model;
|
||||||
|
_listWorkingDir = entity.List?.WorkingDir;
|
||||||
WorktreePath = entity.Worktree?.Path;
|
WorktreePath = entity.Worktree?.Path;
|
||||||
WorktreeBaseCommit = entity.Worktree?.BaseCommit;
|
WorktreeBaseCommit = entity.Worktree?.BaseCommit;
|
||||||
|
WorktreeHeadCommit = entity.Worktree?.HeadCommit;
|
||||||
WorktreeStateLabel = entity.Worktree?.State.ToString();
|
WorktreeStateLabel = entity.Worktree?.State.ToString();
|
||||||
BranchLine = entity.Worktree is { } w ? $"{w.BranchName} \u2190 main" : null;
|
BranchLine = entity.Worktree is { } w ? $"{w.BranchName} \u2190 main" : null;
|
||||||
var (add, del) = ParseDiffStat(entity.Worktree?.DiffStat);
|
var (add, del) = ParseDiffStat(entity.Worktree?.DiffStat);
|
||||||
@@ -812,6 +931,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
var latestRun = await runRepo.GetLatestByTaskIdAsync(row.Id, ct);
|
var latestRun = await runRepo.GetLatestByTaskIdAsync(row.Id, ct);
|
||||||
ct.ThrowIfCancellationRequested();
|
ct.ThrowIfCancellationRequested();
|
||||||
LatestRunSessionId = latestRun?.SessionId;
|
LatestRunSessionId = latestRun?.SessionId;
|
||||||
|
ApplyOutcome(entity.Result, latestRun?.ErrorMarkdown);
|
||||||
|
|
||||||
// Subscribe only after DB load confirms the task exists
|
// Subscribe only after DB load confirms the task exists
|
||||||
_subscribedTaskId = row.Id;
|
_subscribedTaskId = row.Id;
|
||||||
@@ -825,13 +945,12 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
Subtasks.Add(new SubtaskRowViewModel { Id = s.Id, Title = s.Title, Done = s.Completed });
|
Subtasks.Add(new SubtaskRowViewModel { Id = s.Id, Title = s.Title, Done = s.Completed });
|
||||||
|
|
||||||
if (entity.PlanningPhase != ClaudeDo.Data.Models.PlanningPhase.None)
|
if (entity.PlanningPhase != ClaudeDo.Data.Models.PlanningPhase.None)
|
||||||
{
|
|
||||||
await LoadPlanningChildrenAsync(row.Id, ct);
|
await LoadPlanningChildrenAsync(row.Id, ct);
|
||||||
}
|
// Surface every parent's children — planning or improvement — in the
|
||||||
else
|
// Session tab with their live status + roadblock count. This is what
|
||||||
{
|
// makes the Session tab appear for planning parents and lets a child's
|
||||||
await LoadChildOutcomesAsync(row.Id, ct);
|
// roadblock register on the parent.
|
||||||
}
|
await LoadChildOutcomesAsync(row.Id, ct);
|
||||||
|
|
||||||
if (entity.Worktree != null
|
if (entity.Worktree != null
|
||||||
&& entity.PlanningPhase == ClaudeDo.Data.Models.PlanningPhase.None
|
&& entity.PlanningPhase == ClaudeDo.Data.Models.PlanningPhase.None
|
||||||
@@ -894,7 +1013,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
RecomputeCanMergeAll();
|
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) { }
|
catch (OperationCanceledException) { }
|
||||||
catch { /* best-effort */ }
|
catch { /* best-effort */ }
|
||||||
@@ -991,7 +1109,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
RecomputeCanMergeAll();
|
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) { }
|
catch (OperationCanceledException) { }
|
||||||
catch { /* best-effort */ }
|
catch { /* best-effort */ }
|
||||||
@@ -1016,7 +1133,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
existing.WorktreeState = child.Worktree?.State ?? ClaudeDo.Data.Models.WorktreeState.Active;
|
existing.WorktreeState = child.Worktree?.State ?? ClaudeDo.Data.Models.WorktreeState.Active;
|
||||||
}
|
}
|
||||||
|
|
||||||
RecomputeCanMergeAll();
|
|
||||||
}
|
}
|
||||||
catch { /* best-effort */ }
|
catch { /* best-effort */ }
|
||||||
}
|
}
|
||||||
@@ -1038,54 +1154,12 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
row.Status = child.Status;
|
row.Status = child.Status;
|
||||||
row.RoadblockCount = child.RoadblockCount;
|
row.RoadblockCount = child.RoadblockCount;
|
||||||
row.WorktreeState = child.Worktree?.State ?? ClaudeDo.Data.Models.WorktreeState.Active;
|
row.WorktreeState = child.Worktree?.State ?? ClaudeDo.Data.Models.WorktreeState.Active;
|
||||||
RecomputeCanMergeAll();
|
|
||||||
MergeAllCommand.NotifyCanExecuteChanged();
|
|
||||||
ReviewCombinedDiffCommand.NotifyCanExecuteChanged();
|
ReviewCombinedDiffCommand.NotifyCanExecuteChanged();
|
||||||
|
NotifyAttention();
|
||||||
}
|
}
|
||||||
catch { /* best-effort */ }
|
catch { /* best-effort */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
internal void RecomputeCanMergeAll()
|
|
||||||
{
|
|
||||||
// Improvement parent: merge is allowed once every child is terminal. The
|
|
||||||
// orchestrator folds the parent's own branch and skips failed/cancelled children.
|
|
||||||
if (ChildOutcomes.Count > 0)
|
|
||||||
{
|
|
||||||
var unfinished = ChildOutcomes.Count(c =>
|
|
||||||
c.Status != ClaudeDo.Data.Models.TaskStatus.Done
|
|
||||||
&& c.Status != ClaudeDo.Data.Models.TaskStatus.Failed
|
|
||||||
&& c.Status != ClaudeDo.Data.Models.TaskStatus.Cancelled);
|
|
||||||
if (unfinished > 0)
|
|
||||||
{
|
|
||||||
CanMergeAll = false;
|
|
||||||
MergeAllDisabledReason = $"{unfinished} improvement(s) not finished";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
CanMergeAll = true;
|
|
||||||
MergeAllDisabledReason = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var notDone = Subtasks.Count(c => c.Status != ClaudeDo.Data.Models.TaskStatus.Done);
|
|
||||||
if (notDone > 0)
|
|
||||||
{
|
|
||||||
CanMergeAll = false;
|
|
||||||
MergeAllDisabledReason = $"{notDone} subtask(s) not done";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var badWt = Subtasks.FirstOrDefault(c =>
|
|
||||||
c.WorktreeState == ClaudeDo.Data.Models.WorktreeState.Discarded ||
|
|
||||||
c.WorktreeState == ClaudeDo.Data.Models.WorktreeState.Kept);
|
|
||||||
if (badWt is not null)
|
|
||||||
{
|
|
||||||
CanMergeAll = false;
|
|
||||||
MergeAllDisabledReason = "at least one worktree was discarded/kept";
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
CanMergeAll = true;
|
|
||||||
MergeAllDisabledReason = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(CanReviewDiff))]
|
[RelayCommand(CanExecute = nameof(CanReviewDiff))]
|
||||||
private async System.Threading.Tasks.Task ReviewCombinedDiffAsync()
|
private async System.Threading.Tasks.Task ReviewCombinedDiffAsync()
|
||||||
{
|
{
|
||||||
@@ -1097,20 +1171,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
|
|
||||||
private bool CanReviewDiff() => (Task?.IsPlanningParent == true && Subtasks.Any()) || HasChildOutcomes;
|
private bool CanReviewDiff() => (Task?.IsPlanningParent == true && Subtasks.Any()) || HasChildOutcomes;
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(CanMergeAll))]
|
|
||||||
private async System.Threading.Tasks.Task MergeAllAsync()
|
|
||||||
{
|
|
||||||
MergeAllError = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _worker.MergeAllPlanningAsync(Task!.Id, SelectedMergeTarget ?? "main");
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
MergeAllError = ex.Message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async System.Threading.Tasks.Task RefreshWorktreeAsync(string taskId)
|
private async System.Threading.Tasks.Task RefreshWorktreeAsync(string taskId)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -1119,11 +1179,14 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
var entity = await ctx.Tasks
|
var entity = await ctx.Tasks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(t => t.Worktree)
|
.Include(t => t.Worktree)
|
||||||
|
.Include(t => t.List)
|
||||||
.FirstOrDefaultAsync(t => t.Id == taskId);
|
.FirstOrDefaultAsync(t => t.Id == taskId);
|
||||||
if (entity == null || Task?.Id != taskId) return;
|
if (entity == null || Task?.Id != taskId) return;
|
||||||
|
|
||||||
|
_listWorkingDir = entity.List?.WorkingDir;
|
||||||
WorktreePath = entity.Worktree?.Path;
|
WorktreePath = entity.Worktree?.Path;
|
||||||
WorktreeBaseCommit = entity.Worktree?.BaseCommit;
|
WorktreeBaseCommit = entity.Worktree?.BaseCommit;
|
||||||
|
WorktreeHeadCommit = entity.Worktree?.HeadCommit;
|
||||||
WorktreeStateLabel = entity.Worktree?.State.ToString();
|
WorktreeStateLabel = entity.Worktree?.State.ToString();
|
||||||
BranchLine = entity.Worktree is { } w ? $"{w.BranchName} ← main" : null;
|
BranchLine = entity.Worktree is { } w ? $"{w.BranchName} ← main" : null;
|
||||||
AgentState = StatusToStateKey(entity.Status);
|
AgentState = StatusToStateKey(entity.Status);
|
||||||
@@ -1158,45 +1221,56 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
MergePreviewText = text; MergeIsClean = clean; MergeIsConflict = conflict;
|
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))]
|
[RelayCommand(CanExecute = nameof(CanOpenDiff))]
|
||||||
private async System.Threading.Tasks.Task OpenDiffAsync()
|
private async System.Threading.Tasks.Task OpenDiffAsync()
|
||||||
{
|
{
|
||||||
if (WorktreePath == null || ShowDiffModal == null) return;
|
if (ShowDiffModal == null) return;
|
||||||
var diffVm = new DiffModalViewModel(_services.GetRequiredService<ClaudeDo.Data.Git.GitService>())
|
var git = _services.GetRequiredService<ClaudeDo.Data.Git.GitService>();
|
||||||
|
|
||||||
|
// Active worktree on disk → diff the worktree live (and allow merging from it).
|
||||||
|
var hasLiveWorktree =
|
||||||
|
WorktreePath != null
|
||||||
|
&& WorktreeStateLabel == "Active"
|
||||||
|
&& System.IO.Directory.Exists(WorktreePath);
|
||||||
|
|
||||||
|
DiffModalViewModel diffVm;
|
||||||
|
if (hasLiveWorktree)
|
||||||
{
|
{
|
||||||
WorktreePath = WorktreePath,
|
diffVm = new DiffModalViewModel(git)
|
||||||
BaseRef = WorktreeBaseCommit,
|
{
|
||||||
TaskId = Task?.Id,
|
WorktreePath = WorktreePath!,
|
||||||
TaskTitle = Task?.Title ?? "",
|
BaseRef = WorktreeBaseCommit,
|
||||||
ShowMergeModal = ShowMergeModal,
|
TaskId = Task?.Id,
|
||||||
ResolveMergeVm = () => _services.GetRequiredService<MergeModalViewModel>(),
|
TaskTitle = Task?.Title ?? "",
|
||||||
};
|
ShowMergeModal = ShowMergeModal,
|
||||||
|
ResolveMergeVm = () => _services.GetRequiredService<MergeModalViewModel>(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else if (CanDiffMergedRange)
|
||||||
|
{
|
||||||
|
// Worktree is gone (merged/discarded) but the commits survive on the
|
||||||
|
// target branch — diff the captured base..head range in the repo. No
|
||||||
|
// merge action: the work is already integrated.
|
||||||
|
diffVm = new DiffModalViewModel(git)
|
||||||
|
{
|
||||||
|
WorktreePath = _listWorkingDir!,
|
||||||
|
BaseRef = WorktreeBaseCommit,
|
||||||
|
HeadCommit = WorktreeHeadCommit,
|
||||||
|
FromCommitRange = true,
|
||||||
|
TaskId = Task?.Id,
|
||||||
|
TaskTitle = Task?.Title ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
else return;
|
||||||
|
|
||||||
await diffVm.LoadAsync();
|
await diffVm.LoadAsync();
|
||||||
await ShowDiffModal(diffVm);
|
await ShowDiffModal(diffVm);
|
||||||
}
|
}
|
||||||
|
|
||||||
private bool CanOpenDiff() => WorktreePath != null;
|
private bool CanDiffMergedRange =>
|
||||||
|
WorktreeBaseCommit != null && WorktreeHeadCommit != null && _listWorkingDir != null;
|
||||||
|
|
||||||
|
private bool CanOpenDiff() => WorktreePath != null || CanDiffMergedRange;
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(CanOpenWorktree))]
|
[RelayCommand(CanExecute = nameof(CanOpenWorktree))]
|
||||||
private void OpenWorktree()
|
private void OpenWorktree()
|
||||||
@@ -1225,9 +1299,11 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
OpenDiffCommand.NotifyCanExecuteChanged();
|
OpenDiffCommand.NotifyCanExecuteChanged();
|
||||||
OpenWorktreeCommand.NotifyCanExecuteChanged();
|
OpenWorktreeCommand.NotifyCanExecuteChanged();
|
||||||
NotifySessionSections();
|
NotifySessionSections();
|
||||||
OnPropertyChanged(nameof(ShowSingleMerge));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
partial void OnWorktreeHeadCommitChanged(string? value) =>
|
||||||
|
OpenDiffCommand.NotifyCanExecuteChanged();
|
||||||
|
|
||||||
partial void OnTaskChanged(TaskRowViewModel? value) => NotifySessionSections();
|
partial void OnTaskChanged(TaskRowViewModel? value) => NotifySessionSections();
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -1364,8 +1440,10 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async System.Threading.Tasks.Task StopAsync()
|
private async System.Threading.Tasks.Task StopAsync()
|
||||||
{
|
{
|
||||||
if (Task == null) return;
|
if (Task == null || !IsRunning) return;
|
||||||
await _worker.CancelTaskAsync(Task.Id);
|
if (!_worker.IsConnected) return;
|
||||||
|
try { await _worker.CancelTaskAsync(Task.Id); }
|
||||||
|
catch { /* offline */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(CanEnqueue))]
|
[RelayCommand(CanExecute = nameof(CanEnqueue))]
|
||||||
@@ -1447,15 +1525,30 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
if (Task is null || !_worker.IsConnected) return;
|
if (Task is null || !_worker.IsConnected) return;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
var hasChildren = Subtasks.Count > 0 || ChildOutcomes.Count > 0;
|
||||||
var result = await _worker.ApproveReviewAsync(Task.Id, SelectedMergeTarget ?? "");
|
var result = await _worker.ApproveReviewAsync(Task.Id, SelectedMergeTarget ?? "");
|
||||||
if (result?.Status == "conflict")
|
if (!hasChildren && result?.Status == "conflict")
|
||||||
{
|
{
|
||||||
var (text, _, _) = MergePreviewPresenter.Describe(
|
if (RequestConflictResolution is not null)
|
||||||
new MergePreviewDto("conflict", result.ConflictFiles, 0));
|
{
|
||||||
MergePreviewText = text; MergeIsClean = false; MergeIsConflict = true;
|
await RequestConflictResolution(Task.Id, SelectedMergeTarget ?? "");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var (text, _, _) = MergePreviewPresenter.Describe(
|
||||||
|
new MergePreviewDto("conflict", result.ConflictFiles, 0));
|
||||||
|
MergePreviewText = text; MergeIsClean = false; MergeIsConflict = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// hasChildren: conflicts arrive via PlanningMergeConflictEvent → conflict dialog
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// A real failure (e.g. a child still needs attention, so the unit can't
|
||||||
|
// be approved yet) must not vanish — tell the user why nothing happened.
|
||||||
|
if (ShowErrorAsync != null)
|
||||||
|
await ShowErrorAsync(ex.Message);
|
||||||
}
|
}
|
||||||
catch { /* stale review action; broadcast reconciles */ }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -1470,10 +1563,14 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async System.Threading.Tasks.Task ParkReviewAsync()
|
private async System.Threading.Tasks.Task ResetReviewAsync()
|
||||||
{
|
{
|
||||||
if (Task is null || !_worker.IsConnected) return;
|
if (Task is null || !_worker.IsConnected || ConfirmAsync is null) return;
|
||||||
try { await _worker.RejectReviewToIdleAsync(Task.Id); }
|
var branchName = $"claudedo/{Task.Id.Replace("-", "")}";
|
||||||
|
var ok = await ConfirmAsync(
|
||||||
|
$"Reset working tree?\nThis discards branch {branchName} (and all changes) and returns the task to Idle.");
|
||||||
|
if (!ok) return;
|
||||||
|
try { await _worker.ResetTaskAsync(Task.Id); }
|
||||||
catch { /* stale review action; broadcast reconciles */ }
|
catch { /* stale review action; broadcast reconciles */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ namespace ClaudeDo.Ui.ViewModels.Islands;
|
|||||||
|
|
||||||
public enum ListKind { Smart, Virtual, User }
|
public enum ListKind { Smart, Virtual, User }
|
||||||
|
|
||||||
public sealed partial class ListsIslandViewModel : ViewModelBase
|
public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
||||||
{
|
{
|
||||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
private readonly IServiceProvider? _services;
|
private readonly IServiceProvider? _services;
|
||||||
@@ -141,6 +141,8 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
|
|||||||
public string MachineNameLocal => Loc.T("vm.lists.localSuffix", MachineName);
|
public string MachineNameLocal => Loc.T("vm.lists.localSuffix", MachineName);
|
||||||
public string UserInitials { get; }
|
public string UserInitials { get; }
|
||||||
|
|
||||||
|
private readonly EventHandler _langChangedHandler;
|
||||||
|
|
||||||
public ListsIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IServiceProvider? services = null, WorkerClient? worker = null)
|
public ListsIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IServiceProvider? services = null, WorkerClient? worker = null)
|
||||||
{
|
{
|
||||||
_dbFactory = dbFactory;
|
_dbFactory = dbFactory;
|
||||||
@@ -163,7 +165,13 @@ public sealed partial class ListsIslandViewModel : ViewModelBase
|
|||||||
_worker.ConnectionRestoredEvent += () => _ = RefreshCountsAsync();
|
_worker.ConnectionRestoredEvent += () => _ = RefreshCountsAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
Loc.LanguageChanged += (_, _) => RefreshLocalizedLabels();
|
_langChangedHandler = (_, _) => RefreshLocalizedLabels();
|
||||||
|
Loc.LanguageChanged += _langChangedHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Loc.LanguageChanged -= _langChangedHandler;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string? SmartListNameKey(string id) => id switch
|
private static string? SmartListNameKey(string id) => id switch
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|||||||
|
|
||||||
namespace ClaudeDo.Ui.ViewModels.Islands;
|
namespace ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
|
||||||
public sealed partial class TasksIslandViewModel : ViewModelBase
|
public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
||||||
{
|
{
|
||||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
private readonly IWorkerClient? _worker;
|
private readonly IWorkerClient? _worker;
|
||||||
@@ -71,6 +71,8 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
|||||||
|
|
||||||
public Func<UnfinishedPlanningModalViewModel, Task>? ShowUnfinishedPlanningModal { get; set; }
|
public Func<UnfinishedPlanningModalViewModel, Task>? ShowUnfinishedPlanningModal { get; set; }
|
||||||
|
|
||||||
|
private readonly EventHandler _langChangedHandler;
|
||||||
|
|
||||||
public TasksIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient? worker = null)
|
public TasksIslandViewModel(IDbContextFactory<ClaudeDoDbContext> dbFactory, IWorkerClient? worker = null)
|
||||||
{
|
{
|
||||||
_dbFactory = dbFactory;
|
_dbFactory = dbFactory;
|
||||||
@@ -85,7 +87,13 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
|||||||
_worker.RefineStartedEvent += OnRefineStarted;
|
_worker.RefineStartedEvent += OnRefineStarted;
|
||||||
_worker.RefineFinishedEvent += OnRefineFinished;
|
_worker.RefineFinishedEvent += OnRefineFinished;
|
||||||
}
|
}
|
||||||
Loc.LanguageChanged += (_, _) => RefreshLocalizedText();
|
_langChangedHandler = (_, _) => RefreshLocalizedText();
|
||||||
|
Loc.LanguageChanged += _langChangedHandler;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
Loc.LanguageChanged -= _langChangedHandler;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RefreshLocalizedText()
|
private void RefreshLocalizedText()
|
||||||
@@ -178,7 +186,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase
|
|||||||
ListKind.Smart when list.Id == "smart:my-day" => t.IsMyDay,
|
ListKind.Smart when list.Id == "smart:my-day" => t.IsMyDay,
|
||||||
ListKind.Smart when list.Id == "smart:important" => t.IsStarred,
|
ListKind.Smart when list.Id == "smart:important" => t.IsStarred,
|
||||||
ListKind.Smart when list.Id == "smart:planned" => t.ScheduledFor != null,
|
ListKind.Smart when list.Id == "smart:planned" => t.ScheduledFor != null,
|
||||||
ListKind.Virtual when list.Id == "virtual:review" => t.Status == TaskStatus.Done && t.Worktree?.State == WorktreeState.Active && t.ParentTaskId == null,
|
ListKind.Virtual when list.Id == "virtual:review" => t.Status == TaskStatus.WaitingForReview,
|
||||||
ListKind.User => $"user:{t.ListId}" == list.Id,
|
ListKind.User => $"user:{t.ListId}" == list.Id,
|
||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
|
|
||||||
namespace ClaudeDo.Ui.ViewModels;
|
namespace ClaudeDo.Ui.ViewModels;
|
||||||
|
|
||||||
public sealed partial class IslandsShellViewModel : ViewModelBase
|
public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
||||||
{
|
{
|
||||||
public ListsIslandViewModel? Lists { get; }
|
public ListsIslandViewModel? Lists { get; }
|
||||||
public TasksIslandViewModel? Tasks { get; }
|
public TasksIslandViewModel? Tasks { get; }
|
||||||
@@ -44,6 +44,20 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
|||||||
// Set by MainWindow to open the conflict resolution dialog.
|
// Set by MainWindow to open the conflict resolution dialog.
|
||||||
public Func<ConflictResolutionViewModel, Task>? ShowConflictDialog { get; set; }
|
public Func<ConflictResolutionViewModel, Task>? ShowConflictDialog { get; set; }
|
||||||
|
|
||||||
|
// Layer C seam: composition root sets the factory; MainWindow sets the dialog opener.
|
||||||
|
// The integrator connects Layer A/B's RequestConflictResolution(taskId, target) to this method.
|
||||||
|
public Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>? ConflictResolverFactory { get; set; }
|
||||||
|
public Func<ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel, Task>? ShowConflictResolver { get; set; }
|
||||||
|
|
||||||
|
public async Task RequestConflictResolutionAsync(string taskId, string targetBranch)
|
||||||
|
{
|
||||||
|
if (ConflictResolverFactory is null || ShowConflictResolver is null) return;
|
||||||
|
var vm = ConflictResolverFactory(taskId);
|
||||||
|
var hasConflicts = await vm.OpenAsync(targetBranch);
|
||||||
|
if (hasConflicts)
|
||||||
|
await ShowConflictResolver(vm);
|
||||||
|
}
|
||||||
|
|
||||||
// Set by MainWindow to open the About dialog.
|
// Set by MainWindow to open the About dialog.
|
||||||
public Func<AboutModalViewModel, Task>? ShowAboutModal { get; set; }
|
public Func<AboutModalViewModel, Task>? ShowAboutModal { get; set; }
|
||||||
|
|
||||||
@@ -140,21 +154,23 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
|||||||
if (ShowConflictDialog == null || _dbFactory == null) return;
|
if (ShowConflictDialog == null || _dbFactory == null) return;
|
||||||
|
|
||||||
string subtaskTitle = subtaskId;
|
string subtaskTitle = subtaskId;
|
||||||
string worktreePath = System.Environment.CurrentDirectory;
|
// The conflict lives in the list's working dir (the repo being merged into),
|
||||||
string targetBranch = Worker?.LastMergeAllTarget ?? "main";
|
// not the subtask worktree. VS Code must open this folder to show the merge UI.
|
||||||
|
string repoDirectory = System.Environment.CurrentDirectory;
|
||||||
|
string targetBranch = Worker?.LastApproveTarget ?? "main";
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
await using var ctx = await _dbFactory.CreateDbContextAsync();
|
||||||
var entity = await ctx.Tasks
|
var entity = await ctx.Tasks
|
||||||
.Include(t => t.Worktree)
|
.Include(t => t.List)
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.FirstOrDefaultAsync(t => t.Id == subtaskId);
|
.FirstOrDefaultAsync(t => t.Id == subtaskId);
|
||||||
if (entity != null)
|
if (entity != null)
|
||||||
{
|
{
|
||||||
subtaskTitle = entity.Title;
|
subtaskTitle = entity.Title;
|
||||||
if (entity.Worktree?.Path is { } p)
|
if (entity.List?.WorkingDir is { } dir && !string.IsNullOrWhiteSpace(dir))
|
||||||
worktreePath = p;
|
repoDirectory = dir;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch { /* Non-fatal: fall back to subtaskId and cwd */ }
|
catch { /* Non-fatal: fall back to subtaskId and cwd */ }
|
||||||
@@ -165,7 +181,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
|||||||
subtaskTitle,
|
subtaskTitle,
|
||||||
targetBranch,
|
targetBranch,
|
||||||
conflictedFiles,
|
conflictedFiles,
|
||||||
worktreePath);
|
repoDirectory);
|
||||||
|
|
||||||
await ShowConflictDialog(vm);
|
await ShowConflictDialog(vm);
|
||||||
}
|
}
|
||||||
@@ -213,6 +229,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
|||||||
_ = Lists.RefreshCountsAsync();
|
_ = Lists.RefreshCountsAsync();
|
||||||
return System.Threading.Tasks.Task.CompletedTask;
|
return System.Threading.Tasks.Task.CompletedTask;
|
||||||
};
|
};
|
||||||
|
Details.RequestConflictResolution = RequestConflictResolutionAsync;
|
||||||
Worker.PropertyChanged += (_, e) =>
|
Worker.PropertyChanged += (_, e) =>
|
||||||
{
|
{
|
||||||
if (e.PropertyName is nameof(WorkerClient.IsConnected) or nameof(WorkerClient.IsReconnecting))
|
if (e.PropertyName is nameof(WorkerClient.IsConnected) or nameof(WorkerClient.IsReconnecting))
|
||||||
@@ -253,6 +270,16 @@ public sealed partial class IslandsShellViewModel : ViewModelBase
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_clearTimer.Stop();
|
||||||
|
_clearTimer.Dispose();
|
||||||
|
_connectTimer.Stop();
|
||||||
|
_connectTimer.Dispose();
|
||||||
|
_primeStatusTimer.Stop();
|
||||||
|
_primeStatusTimer.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
private void RefreshBannerFromStatus()
|
private void RefreshBannerFromStatus()
|
||||||
{
|
{
|
||||||
switch (_updateCheck.LastCheckStatus)
|
switch (_updateCheck.LastCheckStatus)
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ namespace ClaudeDo.Ui.ViewModels.Modals;
|
|||||||
|
|
||||||
public enum DiffLineKind { Add, Del, Ctx, File }
|
public enum DiffLineKind { Add, Del, Ctx, File }
|
||||||
|
|
||||||
|
public enum DiffFileStatus { Modified, Added, Deleted, Renamed }
|
||||||
|
|
||||||
public sealed class DiffLineViewModel
|
public sealed class DiffLineViewModel
|
||||||
{
|
{
|
||||||
public required DiffLineKind Kind { get; init; }
|
public required DiffLineKind Kind { get; init; }
|
||||||
@@ -32,10 +34,27 @@ public sealed class DiffLineViewModel
|
|||||||
|
|
||||||
public sealed class DiffFileViewModel
|
public sealed class DiffFileViewModel
|
||||||
{
|
{
|
||||||
public required string Path { get; init; }
|
public required string Path { get; set; }
|
||||||
|
public string? OldPath { get; set; }
|
||||||
|
public DiffFileStatus Status { get; set; } = DiffFileStatus.Modified;
|
||||||
|
public bool IsBinary { get; set; }
|
||||||
public int Additions { get; set; }
|
public int Additions { get; set; }
|
||||||
public int Deletions { get; set; }
|
public int Deletions { get; set; }
|
||||||
public ObservableCollection<DiffLineViewModel> Lines { get; } = new();
|
public ObservableCollection<DiffLineViewModel> Lines { get; } = new();
|
||||||
|
|
||||||
|
/// Single-letter badge for the file's change kind (A/M/D/R).
|
||||||
|
public string StatusCode => Status switch
|
||||||
|
{
|
||||||
|
DiffFileStatus.Added => "A",
|
||||||
|
DiffFileStatus.Deleted => "D",
|
||||||
|
DiffFileStatus.Renamed => "R",
|
||||||
|
_ => "M",
|
||||||
|
};
|
||||||
|
|
||||||
|
public bool HasLines => Lines.Count > 0;
|
||||||
|
|
||||||
|
/// A text file that produced no diff hunks (e.g. a newly added empty file).
|
||||||
|
public bool IsEmptyContent => !IsBinary && Lines.Count == 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed partial class DiffModalViewModel : ViewModelBase
|
public sealed partial class DiffModalViewModel : ViewModelBase
|
||||||
@@ -44,6 +63,11 @@ public sealed partial class DiffModalViewModel : ViewModelBase
|
|||||||
|
|
||||||
public required string WorktreePath { get; init; }
|
public required string WorktreePath { get; init; }
|
||||||
public string? BaseRef { get; init; }
|
public string? BaseRef { get; init; }
|
||||||
|
/// When set together with <see cref="FromCommitRange"/>, the diff is computed as
|
||||||
|
/// <c>BaseRef..HeadCommit</c> inside <see cref="WorktreePath"/> (used as the repo
|
||||||
|
/// dir) — lets a merged task's diff be viewed after its worktree is gone.
|
||||||
|
public string? HeadCommit { get; init; }
|
||||||
|
public bool FromCommitRange { get; init; }
|
||||||
public string? TaskId { get; init; }
|
public string? TaskId { get; init; }
|
||||||
public string TaskTitle { get; init; } = "";
|
public string TaskTitle { get; init; } = "";
|
||||||
public Func<MergeModalViewModel, Task>? ShowMergeModal { get; set; }
|
public Func<MergeModalViewModel, Task>? ShowMergeModal { get; set; }
|
||||||
@@ -77,6 +101,8 @@ public sealed partial class DiffModalViewModel : ViewModelBase
|
|||||||
var vm = ResolveMergeVm();
|
var vm = ResolveMergeVm();
|
||||||
await vm.InitializeAsync(TaskId, TaskTitle);
|
await vm.InitializeAsync(TaskId, TaskTitle);
|
||||||
await ShowMergeModal(vm);
|
await ShowMergeModal(vm);
|
||||||
|
// The diff is stale once the worktree has been merged away — close it too.
|
||||||
|
if (vm.Merged) CloseAction?.Invoke();
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task LoadAsync(CancellationToken ct = default)
|
public async Task LoadAsync(CancellationToken ct = default)
|
||||||
@@ -87,9 +113,11 @@ public sealed partial class DiffModalViewModel : ViewModelBase
|
|||||||
string raw;
|
string raw;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
raw = BaseRef is not null
|
raw = FromCommitRange && BaseRef is not null && HeadCommit is not null
|
||||||
? await _git.GetBranchDiffAsync(WorktreePath, BaseRef, ct)
|
? await _git.GetCommitRangeDiffAsync(WorktreePath, BaseRef, HeadCommit, ct)
|
||||||
: await _git.GetDiffAsync(WorktreePath, ct);
|
: BaseRef is not null
|
||||||
|
? await _git.GetBranchDiffAsync(WorktreePath, BaseRef, ct)
|
||||||
|
: await _git.GetDiffAsync(WorktreePath, ct);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ public sealed partial class MergeModalViewModel : ViewModelBase
|
|||||||
|
|
||||||
public Action? CloseAction { get; set; }
|
public Action? CloseAction { get; set; }
|
||||||
|
|
||||||
|
/// True once a merge has succeeded — lets the caller (e.g. the diff window)
|
||||||
|
/// close itself after this modal closes.
|
||||||
|
public bool Merged { get; private set; }
|
||||||
|
|
||||||
public MergeModalViewModel(WorkerClient worker)
|
public MergeModalViewModel(WorkerClient worker)
|
||||||
{
|
{
|
||||||
_worker = worker;
|
_worker = worker;
|
||||||
@@ -80,6 +84,7 @@ public sealed partial class MergeModalViewModel : ViewModelBase
|
|||||||
switch (result.Status)
|
switch (result.Status)
|
||||||
{
|
{
|
||||||
case "merged":
|
case "merged":
|
||||||
|
Merged = true;
|
||||||
SuccessMessage = result.ErrorMessage is not null
|
SuccessMessage = result.ErrorMessage is not null
|
||||||
? $"Merged with warning: {result.ErrorMessage}"
|
? $"Merged with warning: {result.ErrorMessage}"
|
||||||
: Loc.T("vm.merge.merged");
|
: Loc.T("vm.merge.merged");
|
||||||
|
|||||||
@@ -27,6 +27,36 @@ public static class UnifiedDiffParser
|
|||||||
|
|
||||||
if (current == null) continue;
|
if (current == null) continue;
|
||||||
|
|
||||||
|
// File-level metadata that carries the change kind.
|
||||||
|
if (line.StartsWith("new file", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
current.Status = DiffFileStatus.Added;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.StartsWith("deleted file", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
current.Status = DiffFileStatus.Deleted;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.StartsWith("rename from ", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
current.Status = DiffFileStatus.Renamed;
|
||||||
|
current.OldPath = line["rename from ".Length..];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.StartsWith("rename to ", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
current.Status = DiffFileStatus.Renamed;
|
||||||
|
current.Path = line["rename to ".Length..];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line.StartsWith("Binary files", StringComparison.Ordinal) ||
|
||||||
|
line.StartsWith("GIT binary patch", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
current.IsBinary = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (line.StartsWith("@@ ", StringComparison.Ordinal))
|
if (line.StartsWith("@@ ", StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
// e.g. "@@ -10,7 +10,9 @@"
|
// e.g. "@@ -10,7 +10,9 @@"
|
||||||
@@ -34,13 +64,15 @@ public static class UnifiedDiffParser
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip diff metadata lines
|
// Skip remaining diff metadata lines
|
||||||
if (line.StartsWith("--- ", StringComparison.Ordinal) ||
|
if (line.StartsWith("--- ", StringComparison.Ordinal) ||
|
||||||
line.StartsWith("+++ ", StringComparison.Ordinal) ||
|
line.StartsWith("+++ ", StringComparison.Ordinal) ||
|
||||||
line.StartsWith("index ", StringComparison.Ordinal) ||
|
line.StartsWith("index ", StringComparison.Ordinal) ||
|
||||||
line.StartsWith("new file", StringComparison.Ordinal) ||
|
line.StartsWith("old mode", StringComparison.Ordinal) ||
|
||||||
line.StartsWith("deleted file", StringComparison.Ordinal) ||
|
line.StartsWith("new mode", StringComparison.Ordinal) ||
|
||||||
line.StartsWith("Binary ", StringComparison.Ordinal))
|
line.StartsWith("similarity index", StringComparison.Ordinal) ||
|
||||||
|
line.StartsWith("copy from", StringComparison.Ordinal) ||
|
||||||
|
line.StartsWith("copy to", StringComparison.Ordinal))
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
if (line.StartsWith('+'))
|
if (line.StartsWith('+'))
|
||||||
|
|||||||
@@ -5,14 +5,6 @@ using ClaudeDo.Data.Git;
|
|||||||
|
|
||||||
namespace ClaudeDo.Ui.ViewModels.Modals;
|
namespace ClaudeDo.Ui.ViewModels.Modals;
|
||||||
|
|
||||||
public enum WorktreeDiffLineKind { Header, Hunk, Added, Removed, Context }
|
|
||||||
|
|
||||||
public sealed partial class WorktreeDiffLineViewModel : ViewModelBase
|
|
||||||
{
|
|
||||||
public required string Text { get; init; }
|
|
||||||
public required WorktreeDiffLineKind Kind { get; init; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed partial class WorktreeNodeViewModel : ViewModelBase
|
public sealed partial class WorktreeNodeViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
public required string Name { get; init; }
|
public required string Name { get; init; }
|
||||||
@@ -28,7 +20,7 @@ public sealed partial class WorktreeModalViewModel : ViewModelBase
|
|||||||
private readonly GitService _git;
|
private readonly GitService _git;
|
||||||
|
|
||||||
public ObservableCollection<WorktreeNodeViewModel> Root { get; } = new();
|
public ObservableCollection<WorktreeNodeViewModel> Root { get; } = new();
|
||||||
public ObservableCollection<WorktreeDiffLineViewModel> SelectedFileDiffLines { get; } = new();
|
public ObservableCollection<DiffLineViewModel> SelectedFileDiffLines { get; } = new();
|
||||||
|
|
||||||
[ObservableProperty] private string _worktreePath = "";
|
[ObservableProperty] private string _worktreePath = "";
|
||||||
[ObservableProperty] private string? _baseCommit;
|
[ObservableProperty] private string? _baseCommit;
|
||||||
@@ -64,19 +56,8 @@ public sealed partial class WorktreeModalViewModel : ViewModelBase
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var line in diff.Split('\n'))
|
foreach (var line in UnifiedDiffParser.Flatten(UnifiedDiffParser.Parse(diff)))
|
||||||
{
|
SelectedFileDiffLines.Add(line);
|
||||||
var kind = line switch
|
|
||||||
{
|
|
||||||
_ when line.StartsWith("+++") || line.StartsWith("---") => WorktreeDiffLineKind.Header,
|
|
||||||
_ when line.StartsWith("@@") => WorktreeDiffLineKind.Hunk,
|
|
||||||
_ when line.StartsWith('+') => WorktreeDiffLineKind.Added,
|
|
||||||
_ when line.StartsWith('-') => WorktreeDiffLineKind.Removed,
|
|
||||||
_ when line.StartsWith("diff ") || line.StartsWith("index ") || line.StartsWith("\\ ") => WorktreeDiffLineKind.Header,
|
|
||||||
_ => WorktreeDiffLineKind.Context,
|
|
||||||
};
|
|
||||||
SelectedFileDiffLines.Add(new WorktreeDiffLineViewModel { Text = line, Kind = kind });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|||||||
|
|
||||||
namespace ClaudeDo.Ui.ViewModels.Modals;
|
namespace ClaudeDo.Ui.ViewModels.Modals;
|
||||||
|
|
||||||
|
public enum BatchMergeOutcome { None, Merging, Merged, Conflict, Blocked, Failed }
|
||||||
|
|
||||||
public sealed partial class WorktreeOverviewRowViewModel : ViewModelBase
|
public sealed partial class WorktreeOverviewRowViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
[ObservableProperty] private string _taskId = "";
|
[ObservableProperty] private string _taskId = "";
|
||||||
@@ -27,6 +29,14 @@ public sealed partial class WorktreeOverviewRowViewModel : ViewModelBase
|
|||||||
[ObservableProperty][NotifyPropertyChangedFor(nameof(AgeText))] private DateTime _createdAt;
|
[ObservableProperty][NotifyPropertyChangedFor(nameof(AgeText))] private DateTime _createdAt;
|
||||||
[ObservableProperty] private bool _pathExistsOnDisk;
|
[ObservableProperty] private bool _pathExistsOnDisk;
|
||||||
[ObservableProperty] private bool _isSelected;
|
[ObservableProperty] private bool _isSelected;
|
||||||
|
[ObservableProperty] private bool _isChecked;
|
||||||
|
[ObservableProperty]
|
||||||
|
[NotifyPropertyChangedFor(nameof(IsConflict))]
|
||||||
|
[NotifyPropertyChangedFor(nameof(HasOutcome))]
|
||||||
|
private BatchMergeOutcome _mergeOutcome;
|
||||||
|
|
||||||
|
public bool IsConflict => MergeOutcome == BatchMergeOutcome.Conflict;
|
||||||
|
public bool HasOutcome => MergeOutcome != BatchMergeOutcome.None;
|
||||||
|
|
||||||
public string AgeText => FormatAge(DateTime.UtcNow - CreatedAt);
|
public string AgeText => FormatAge(DateTime.UtcNow - CreatedAt);
|
||||||
public bool IsActive => State == WorktreeState.Active;
|
public bool IsActive => State == WorktreeState.Active;
|
||||||
@@ -59,9 +69,18 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
|
|||||||
[ObservableProperty] private bool _isBusy;
|
[ObservableProperty] private bool _isBusy;
|
||||||
[ObservableProperty] private string? _statusMessage;
|
[ObservableProperty] private string? _statusMessage;
|
||||||
[ObservableProperty] private WorktreeOverviewRowViewModel? _selectedRow;
|
[ObservableProperty] private WorktreeOverviewRowViewModel? _selectedRow;
|
||||||
|
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(MergeAllCommand))] private string? _selectedTarget;
|
||||||
|
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(MergeAllCommand))] private int _selectedCount;
|
||||||
|
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(MergeAllCommand))] private bool _isMerging;
|
||||||
|
[ObservableProperty] private string? _batchProgress;
|
||||||
|
|
||||||
public ObservableCollection<WorktreeOverviewRowViewModel> Rows { get; } = new();
|
public ObservableCollection<WorktreeOverviewRowViewModel> Rows { get; } = new();
|
||||||
public ObservableCollection<WorktreesGroupViewModel> Groups { get; } = new();
|
public ObservableCollection<WorktreesGroupViewModel> Groups { get; } = new();
|
||||||
|
public ObservableCollection<string> MergeTargets { get; } = new();
|
||||||
|
public ObservableCollection<WorktreeOverviewRowViewModel> ConflictRows { get; } = new();
|
||||||
|
|
||||||
|
/// Inert seam wired by the integrator to Layer C's resolver at merge time. (taskId, targetBranch)
|
||||||
|
public Func<string, string, Task>? RequestConflictResolution { get; set; }
|
||||||
|
|
||||||
public Action? CloseAction { get; set; }
|
public Action? CloseAction { get; set; }
|
||||||
public Action<WorktreeModalViewModel>? ShowDiffAction { get; set; }
|
public Action<WorktreeModalViewModel>? ShowDiffAction { get; set; }
|
||||||
@@ -106,20 +125,24 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
|
|||||||
|
|
||||||
Rows.Clear();
|
Rows.Clear();
|
||||||
Groups.Clear();
|
Groups.Clear();
|
||||||
|
ConflictRows.Clear();
|
||||||
|
SelectedCount = 0;
|
||||||
|
BatchProgress = null;
|
||||||
if (IsGlobal)
|
if (IsGlobal)
|
||||||
{
|
{
|
||||||
foreach (var grp in ordered.GroupBy(r => (r.ListId, r.ListName))
|
foreach (var grp in ordered.GroupBy(r => (r.ListId, r.ListName))
|
||||||
.OrderBy(g => g.Key.ListName, StringComparer.OrdinalIgnoreCase))
|
.OrderBy(g => g.Key.ListName, StringComparer.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
var group = new WorktreesGroupViewModel { ListId = grp.Key.ListId, ListName = grp.Key.ListName };
|
var group = new WorktreesGroupViewModel { ListId = grp.Key.ListId, ListName = grp.Key.ListName };
|
||||||
foreach (var row in grp) group.Rows.Add(row);
|
foreach (var row in grp) { HookRow(row); group.Rows.Add(row); }
|
||||||
Groups.Add(group);
|
Groups.Add(group);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
foreach (var row in ordered) Rows.Add(row);
|
foreach (var row in ordered) { HookRow(row); Rows.Add(row); }
|
||||||
}
|
}
|
||||||
|
await LoadMergeTargetsAsync();
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -255,4 +278,125 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
|
|||||||
Path = d.Path, BranchName = d.BranchName, BaseCommit = d.BaseCommit, State = d.State,
|
Path = d.Path, BranchName = d.BranchName, BaseCommit = d.BaseCommit, State = d.State,
|
||||||
DiffStat = d.DiffStat, CreatedAt = d.CreatedAt, PathExistsOnDisk = d.PathExistsOnDisk,
|
DiffStat = d.DiffStat, CreatedAt = d.CreatedAt, PathExistsOnDisk = d.PathExistsOnDisk,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
public IEnumerable<WorktreeOverviewRowViewModel> AllRows =>
|
||||||
|
IsGlobal ? Groups.SelectMany(g => g.Rows) : Rows;
|
||||||
|
|
||||||
|
private void HookRow(WorktreeOverviewRowViewModel row)
|
||||||
|
{
|
||||||
|
row.PropertyChanged += (_, e) =>
|
||||||
|
{
|
||||||
|
if (e.PropertyName is nameof(WorktreeOverviewRowViewModel.IsChecked)
|
||||||
|
or nameof(WorktreeOverviewRowViewModel.State))
|
||||||
|
RecomputeSelected();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RecomputeSelected() =>
|
||||||
|
SelectedCount = AllRows.Count(r => r.IsChecked && r.IsActive);
|
||||||
|
|
||||||
|
// Test seam: adds a row to the flat list with selection tracking wired up.
|
||||||
|
internal void AddRowForTest(WorktreeOverviewRowViewModel row)
|
||||||
|
{
|
||||||
|
HookRow(row);
|
||||||
|
Rows.Add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task LoadMergeTargetsAsync()
|
||||||
|
{
|
||||||
|
var anchor = AllRows.FirstOrDefault(r => r.IsActive);
|
||||||
|
if (anchor is null) { MergeTargets.Clear(); SelectedTarget = null; return; }
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var targets = await _worker.GetMergeTargetsAsync(anchor.TaskId);
|
||||||
|
MergeTargets.Clear();
|
||||||
|
if (targets is null) { SelectedTarget = null; return; }
|
||||||
|
foreach (var b in targets.LocalBranches) MergeTargets.Add(b);
|
||||||
|
SelectedTarget = MergeTargets.Contains(targets.DefaultBranch)
|
||||||
|
? targets.DefaultBranch
|
||||||
|
: MergeTargets.FirstOrDefault();
|
||||||
|
}
|
||||||
|
catch { MergeTargets.Clear(); SelectedTarget = null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private bool CanMergeAll() => !IsMerging && SelectedCount > 0 && !string.IsNullOrWhiteSpace(SelectedTarget);
|
||||||
|
|
||||||
|
[RelayCommand(CanExecute = nameof(CanMergeAll))]
|
||||||
|
private Task MergeAll() => MergeSelectedAsync(_worker.MergeTaskAsync);
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void ResolveConflict(WorktreeOverviewRowViewModel? row)
|
||||||
|
{
|
||||||
|
if (row is null) return;
|
||||||
|
RequestConflictResolution?.Invoke(row.TaskId, SelectedTarget ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void ToggleSelectAll()
|
||||||
|
{
|
||||||
|
var actives = AllRows.Where(r => r.IsActive).ToList();
|
||||||
|
var allChecked = actives.Count > 0 && actives.All(r => r.IsChecked);
|
||||||
|
foreach (var r in actives) r.IsChecked = !allChecked;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task MergeSelectedAsync(
|
||||||
|
Func<string, string, bool, string, Task<MergeResultDto>> mergeFn,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var target = SelectedTarget;
|
||||||
|
if (string.IsNullOrWhiteSpace(target)) return;
|
||||||
|
|
||||||
|
var selected = AllRows.Where(r => r.IsChecked && r.IsActive).ToList();
|
||||||
|
if (selected.Count == 0) return;
|
||||||
|
|
||||||
|
IsMerging = true;
|
||||||
|
ConflictRows.Clear();
|
||||||
|
var done = 0;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
foreach (var row in selected)
|
||||||
|
{
|
||||||
|
ct.ThrowIfCancellationRequested();
|
||||||
|
row.MergeOutcome = BatchMergeOutcome.Merging;
|
||||||
|
BatchProgress = Loc.T("vm.worktreesOverview.batchProgress", ++done, selected.Count);
|
||||||
|
|
||||||
|
MergeResultDto result;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
result = await mergeFn(row.TaskId, target!, false,
|
||||||
|
Loc.T("vm.merge.commitMessage", row.TaskTitle));
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
row.MergeOutcome = BatchMergeOutcome.Failed;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (result.Status)
|
||||||
|
{
|
||||||
|
case "merged":
|
||||||
|
row.MergeOutcome = BatchMergeOutcome.Merged;
|
||||||
|
row.State = WorktreeState.Merged;
|
||||||
|
row.IsChecked = false;
|
||||||
|
break;
|
||||||
|
case "conflict":
|
||||||
|
row.MergeOutcome = BatchMergeOutcome.Conflict;
|
||||||
|
ConflictRows.Add(row);
|
||||||
|
break;
|
||||||
|
case "blocked":
|
||||||
|
row.MergeOutcome = BatchMergeOutcome.Blocked;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
row.MergeOutcome = BatchMergeOutcome.Failed;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BatchProgress = Loc.T("vm.worktreesOverview.batchDone",
|
||||||
|
selected.Count(r => r.MergeOutcome == BatchMergeOutcome.Merged), ConflictRows.Count);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
IsMerging = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ public sealed partial class ConflictResolutionViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
private readonly IWorkerClient _worker;
|
private readonly IWorkerClient _worker;
|
||||||
private readonly string _planningTaskId;
|
private readonly string _planningTaskId;
|
||||||
private readonly string _worktreePath;
|
// The repository directory that is currently mid-merge (the list's working dir),
|
||||||
|
// NOT the subtask worktree. Opening this folder is what makes VS Code show its
|
||||||
|
// merge-conflict resolution UI.
|
||||||
|
private readonly string _repoDirectory;
|
||||||
|
|
||||||
public string SubtaskTitle { get; }
|
public string SubtaskTitle { get; }
|
||||||
public string TargetBranch { get; }
|
public string TargetBranch { get; }
|
||||||
@@ -29,11 +32,11 @@ public sealed partial class ConflictResolutionViewModel : ObservableObject
|
|||||||
string subtaskTitle,
|
string subtaskTitle,
|
||||||
string targetBranch,
|
string targetBranch,
|
||||||
IReadOnlyList<string> conflictedFiles,
|
IReadOnlyList<string> conflictedFiles,
|
||||||
string worktreePath)
|
string repoDirectory)
|
||||||
{
|
{
|
||||||
_worker = worker;
|
_worker = worker;
|
||||||
_planningTaskId = planningTaskId;
|
_planningTaskId = planningTaskId;
|
||||||
_worktreePath = worktreePath;
|
_repoDirectory = repoDirectory;
|
||||||
SubtaskTitle = subtaskTitle;
|
SubtaskTitle = subtaskTitle;
|
||||||
TargetBranch = targetBranch;
|
TargetBranch = targetBranch;
|
||||||
ConflictedFiles = conflictedFiles;
|
ConflictedFiles = conflictedFiles;
|
||||||
@@ -44,12 +47,13 @@ public sealed partial class ConflictResolutionViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var args = string.Join(" ", ConflictedFiles.Select(f => $"\"{f}\""));
|
// Open the folder that is mid-merge so VS Code shows the Source Control
|
||||||
|
// merge-conflict UI for every conflicted file. Opening individual files
|
||||||
|
// gives only a plain editor with no conflict resolution affordances.
|
||||||
Process.Start(new ProcessStartInfo
|
Process.Start(new ProcessStartInfo
|
||||||
{
|
{
|
||||||
FileName = "code",
|
FileName = "code",
|
||||||
Arguments = args,
|
Arguments = $"\"{_repoDirectory}\"",
|
||||||
WorkingDirectory = _worktreePath,
|
|
||||||
UseShellExecute = true,
|
UseShellExecute = true,
|
||||||
});
|
});
|
||||||
VsCodeError = null;
|
VsCodeError = null;
|
||||||
|
|||||||
82
src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml
Normal file
82
src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Conflicts"
|
||||||
|
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
|
||||||
|
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
||||||
|
x:DataType="vm:ConflictResolverViewModel"
|
||||||
|
x:Class="ClaudeDo.Ui.Views.Conflicts.ConflictResolverView"
|
||||||
|
Title="{loc:Tr conflictResolver.windowTitle}"
|
||||||
|
Width="760" Height="640" MinWidth="560" MinHeight="420"
|
||||||
|
CanResize="True"
|
||||||
|
WindowDecorations="BorderOnly"
|
||||||
|
ExtendClientAreaToDecorationsHint="True"
|
||||||
|
ExtendClientAreaTitleBarHeightHint="-1"
|
||||||
|
WindowStartupLocation="CenterOwner"
|
||||||
|
Background="{DynamicResource SurfaceBrush}">
|
||||||
|
|
||||||
|
<Window.KeyBindings>
|
||||||
|
<KeyBinding Gesture="Escape" Command="{Binding AbortCommand}"/>
|
||||||
|
</Window.KeyBindings>
|
||||||
|
|
||||||
|
<ctl:ModalShell Title="{loc:Tr conflictResolver.modalTitle}" CloseCommand="{Binding AbortCommand}">
|
||||||
|
<ctl:ModalShell.Footer>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8"
|
||||||
|
HorizontalAlignment="Right" VerticalAlignment="Center">
|
||||||
|
<Button Classes="btn" Content="{loc:Tr conflictResolver.continue}"
|
||||||
|
Command="{Binding ContinueCommand}" IsEnabled="{Binding CanContinue}"/>
|
||||||
|
<Button Classes="btn" Content="{loc:Tr conflictResolver.abort}" Command="{Binding AbortCommand}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</ctl:ModalShell.Footer>
|
||||||
|
|
||||||
|
<Grid RowDefinitions="Auto,*" Margin="16,12">
|
||||||
|
<TextBlock Grid.Row="0" Classes="meta" Margin="0,0,0,8"
|
||||||
|
Text="{loc:Tr conflictResolver.loading}"
|
||||||
|
IsVisible="{Binding IsBusy}"/>
|
||||||
|
<TextBlock Grid.Row="0" Classes="meta" Foreground="{DynamicResource BloodBrush}"
|
||||||
|
Text="{Binding Error}" TextWrapping="Wrap"
|
||||||
|
IsVisible="{Binding Error, Converter={x:Static ObjectConverters.IsNotNull}}"/>
|
||||||
|
|
||||||
|
<ScrollViewer Grid.Row="1">
|
||||||
|
<ItemsControl ItemsSource="{Binding Files}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:ConflictFile">
|
||||||
|
<StackPanel Spacing="8" Margin="0,0,0,16">
|
||||||
|
<TextBlock Classes="path-mono heading" Text="{Binding Path}"/>
|
||||||
|
<ItemsControl ItemsSource="{Binding Hunks}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:ConflictHunk">
|
||||||
|
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="1"
|
||||||
|
CornerRadius="6" Padding="10" Margin="0,0,0,8">
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<TextBlock Classes="meta" Text="{loc:Tr conflictResolver.current}"/>
|
||||||
|
<TextBox Text="{Binding Ours, Mode=OneWay}" IsReadOnly="True"
|
||||||
|
AcceptsReturn="True" MaxHeight="120"/>
|
||||||
|
<TextBlock Classes="meta" Text="{loc:Tr conflictResolver.incoming}"/>
|
||||||
|
<TextBox Text="{Binding Theirs, Mode=OneWay}" IsReadOnly="True"
|
||||||
|
AcceptsReturn="True" MaxHeight="120"/>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
|
<Button Classes="btn" Content="{loc:Tr conflictResolver.acceptCurrent}"
|
||||||
|
Command="{Binding AcceptCurrentCommand}"/>
|
||||||
|
<Button Classes="btn" Content="{loc:Tr conflictResolver.acceptIncoming}"
|
||||||
|
Command="{Binding AcceptIncomingCommand}"/>
|
||||||
|
<Button Classes="btn" Content="{loc:Tr conflictResolver.acceptBoth}"
|
||||||
|
Command="{Binding AcceptBothCommand}"/>
|
||||||
|
<Button Classes="btn" Content="{loc:Tr conflictResolver.editManually}"
|
||||||
|
Command="{Binding EditManuallyCommand}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Classes="meta" Text="{loc:Tr conflictResolver.mergedResult}"/>
|
||||||
|
<TextBox Text="{Binding Resolution, Mode=TwoWay}"
|
||||||
|
AcceptsReturn="True" MinHeight="80" MaxHeight="200"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Grid>
|
||||||
|
</ctl:ModalShell>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Conflicts;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Views.Conflicts;
|
||||||
|
|
||||||
|
public partial class ConflictResolverView : Window
|
||||||
|
{
|
||||||
|
public ConflictResolverView()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnDataContextChanged(System.EventArgs e)
|
||||||
|
{
|
||||||
|
base.OnDataContextChanged(e);
|
||||||
|
if (DataContext is ConflictResolverViewModel vm)
|
||||||
|
vm.CloseRequested = Close;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
|
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
|
||||||
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
|
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
|
||||||
|
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
||||||
x:Class="ClaudeDo.Ui.Views.Islands.Detail.DescriptionStepsCard"
|
x:Class="ClaudeDo.Ui.Views.Islands.Detail.DescriptionStepsCard"
|
||||||
x:DataType="vm:DetailsIslandViewModel">
|
x:DataType="vm:DetailsIslandViewModel">
|
||||||
|
|
||||||
@@ -21,7 +22,7 @@
|
|||||||
<Button Grid.Column="2"
|
<Button Grid.Column="2"
|
||||||
Classes="icon-btn"
|
Classes="icon-btn"
|
||||||
Margin="0,0,4,0"
|
Margin="0,0,4,0"
|
||||||
ToolTip.Tip="Copy formatted (title + description + open steps)"
|
ToolTip.Tip="{loc:Tr details.copyFormattedTip}"
|
||||||
Click="OnCopyClick">
|
Click="OnCopyClick">
|
||||||
<PathIcon Data="{StaticResource Icon.Copy}" Width="11" Height="11"/>
|
<PathIcon Data="{StaticResource Icon.Copy}" Width="11" Height="11"/>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -30,6 +31,7 @@
|
|||||||
<Button Grid.Column="3"
|
<Button Grid.Column="3"
|
||||||
Classes="btn"
|
Classes="btn"
|
||||||
Padding="8,3"
|
Padding="8,3"
|
||||||
|
ToolTip.Tip="{loc:Tr details.toggleEditPreviewTip}"
|
||||||
Command="{Binding ToggleEditDescriptionCommand}">
|
Command="{Binding ToggleEditDescriptionCommand}">
|
||||||
<Panel>
|
<Panel>
|
||||||
<TextBlock Text="Preview" IsVisible="{Binding IsEditingDescription}"/>
|
<TextBlock Text="Preview" IsVisible="{Binding IsEditingDescription}"/>
|
||||||
@@ -40,7 +42,8 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- Body -->
|
<!-- Body (scrolls inside the card so the card fills its row to the divider) -->
|
||||||
|
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||||
<StackPanel Margin="14" Spacing="10">
|
<StackPanel Margin="14" Spacing="10">
|
||||||
|
|
||||||
<!-- Description (always visible) -->
|
<!-- Description (always visible) -->
|
||||||
@@ -159,6 +162,7 @@
|
|||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
<!-- Column 1: trash button (not running) -->
|
<!-- Column 1: trash button (not running) -->
|
||||||
<Button Grid.Column="1" Classes="icon-btn"
|
<Button Grid.Column="1" Classes="icon-btn"
|
||||||
Command="{Binding DeleteTaskCommand}"
|
Command="{Binding DeleteTaskCommand}"
|
||||||
ToolTip.Tip="Delete task"
|
ToolTip.Tip="{loc:Tr details.deleteTaskTip}"
|
||||||
IsVisible="{Binding !IsRunning}"
|
IsVisible="{Binding !IsRunning}"
|
||||||
VerticalAlignment="Top"
|
VerticalAlignment="Top"
|
||||||
Margin="6,0,0,0">
|
Margin="6,0,0,0">
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
<!-- Column 1: skull button (running) -->
|
<!-- Column 1: skull button (running) -->
|
||||||
<Button Grid.Column="1" Classes="icon-btn"
|
<Button Grid.Column="1" Classes="icon-btn"
|
||||||
Command="{Binding StopCommand}"
|
Command="{Binding StopCommand}"
|
||||||
ToolTip.Tip="Kill session"
|
ToolTip.Tip="{loc:Tr details.killSessionTip}"
|
||||||
IsVisible="{Binding IsRunning}"
|
IsVisible="{Binding IsRunning}"
|
||||||
VerticalAlignment="Top"
|
VerticalAlignment="Top"
|
||||||
Margin="6,0,0,0">
|
Margin="6,0,0,0">
|
||||||
|
|||||||
@@ -24,6 +24,18 @@
|
|||||||
<Setter Property="TextElement.Foreground" Value="{StaticResource AccentBrush}" />
|
<Setter Property="TextElement.Foreground" Value="{StaticResource AccentBrush}" />
|
||||||
</Style>
|
</Style>
|
||||||
|
|
||||||
|
<!-- Traffic-light dot button: no chrome, just the ellipse -->
|
||||||
|
<Style Selector="Button.dot-btn">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="BorderThickness" Value="0" />
|
||||||
|
<Setter Property="Padding" Value="0" />
|
||||||
|
<Setter Property="CornerRadius" Value="0" />
|
||||||
|
<Setter Property="Cursor" Value="Hand" />
|
||||||
|
</Style>
|
||||||
|
<Style Selector="Button.dot-btn /template/ ContentPresenter">
|
||||||
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
</Style>
|
||||||
|
|
||||||
<!-- Terminal prompt action: bracketed text, no button chrome -->
|
<!-- Terminal prompt action: bracketed text, no button chrome -->
|
||||||
<Style Selector="Button.prompt-action">
|
<Style Selector="Button.prompt-action">
|
||||||
<Setter Property="Background" Value="Transparent" />
|
<Setter Property="Background" Value="Transparent" />
|
||||||
@@ -36,14 +48,15 @@
|
|||||||
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
|
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Button.prompt-action /template/ ContentPresenter">
|
<Style Selector="Button.prompt-action /template/ ContentPresenter">
|
||||||
<Setter Property="Background" Value="Transparent" />
|
<Setter Property="Background" Value="Transparent" />
|
||||||
|
<Setter Property="TextElement.Foreground" Value="{StaticResource TextMuteBrush}" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Button.prompt-action:pointerover /template/ ContentPresenter">
|
<Style Selector="Button.prompt-action:pointerover /template/ ContentPresenter">
|
||||||
<Setter Property="Background" Value="Transparent" />
|
<Setter Property="Background" Value="Transparent" />
|
||||||
<Setter Property="TextElement.Foreground" Value="{StaticResource TextBrush}" />
|
<Setter Property="TextElement.Foreground" Value="{StaticResource TextBrush}" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Button.prompt-action.accent">
|
<Style Selector="Button.prompt-action.accent /template/ ContentPresenter">
|
||||||
<Setter Property="Foreground" Value="{StaticResource AccentBrush}" />
|
<Setter Property="TextElement.Foreground" Value="{StaticResource AccentBrush}" />
|
||||||
</Style>
|
</Style>
|
||||||
<Style Selector="Button.prompt-action.accent:pointerover /template/ ContentPresenter">
|
<Style Selector="Button.prompt-action.accent:pointerover /template/ ContentPresenter">
|
||||||
<Setter Property="TextElement.Foreground" Value="{StaticResource MossBrightBrush}" />
|
<Setter Property="TextElement.Foreground" Value="{StaticResource MossBrightBrush}" />
|
||||||
@@ -59,7 +72,7 @@
|
|||||||
<Grid DockPanel.Dock="Top" ColumnDefinitions="Auto,*,Auto"
|
<Grid DockPanel.Dock="Top" ColumnDefinitions="Auto,*,Auto"
|
||||||
Background="{DynamicResource Surface2Brush}" Height="28">
|
Background="{DynamicResource Surface2Brush}" Height="28">
|
||||||
|
|
||||||
<!-- Traffic-light dots -->
|
<!-- Traffic-light dots (decorative) -->
|
||||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="6"
|
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="6"
|
||||||
Margin="12,0" VerticalAlignment="Center">
|
Margin="12,0" VerticalAlignment="Center">
|
||||||
<Ellipse Classes="dot-red" />
|
<Ellipse Classes="dot-red" />
|
||||||
@@ -160,6 +173,7 @@
|
|||||||
<Button Classes="tab-btn"
|
<Button Classes="tab-btn"
|
||||||
Classes.active="{Binding IsSessionTab}"
|
Classes.active="{Binding IsSessionTab}"
|
||||||
Content="Session"
|
Content="Session"
|
||||||
|
IsVisible="{Binding HasChildOutcomes}"
|
||||||
Command="{Binding SelectTabCommand}"
|
Command="{Binding SelectTabCommand}"
|
||||||
CommandParameter="session" />
|
CommandParameter="session" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
@@ -171,6 +185,26 @@
|
|||||||
<!-- Output: log + review footer, both gated on IsOutputTab -->
|
<!-- Output: log + review footer, both gated on IsOutputTab -->
|
||||||
<DockPanel IsVisible="{Binding IsOutputTab}" LastChildFill="True">
|
<DockPanel IsVisible="{Binding IsOutputTab}" LastChildFill="True">
|
||||||
|
|
||||||
|
<!-- Session outcome: the run's result summary, incl. any roadblocks
|
||||||
|
reported (or the error for a hard failure). -->
|
||||||
|
<Border DockPanel.Dock="Top"
|
||||||
|
Margin="12,8,12,4" Padding="10,8"
|
||||||
|
IsVisible="{Binding ShowSessionOutcome}"
|
||||||
|
Background="{DynamicResource Surface2Brush}"
|
||||||
|
BorderBrush="{DynamicResource LineBrush}"
|
||||||
|
BorderThickness="1" CornerRadius="8">
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<TextBlock Classes="section-label" Text="OUTCOME" />
|
||||||
|
<ScrollViewer MaxHeight="160" VerticalScrollBarVisibility="Auto">
|
||||||
|
<SelectableTextBlock Text="{Binding SessionOutcome}"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Foreground="{DynamicResource TextDimBrush}"
|
||||||
|
FontFamily="{StaticResource MonoFont}"
|
||||||
|
FontSize="{StaticResource FontSizeMono}" />
|
||||||
|
</ScrollViewer>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!-- Review prompt — sits directly on the terminal, like a shell input line;
|
<!-- 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. -->
|
only while awaiting review. No border/fill so it reads as part of the log. -->
|
||||||
<Grid DockPanel.Dock="Bottom"
|
<Grid DockPanel.Dock="Bottom"
|
||||||
@@ -198,10 +232,12 @@
|
|||||||
FontSize="{StaticResource FontSizeMono}" />
|
FontSize="{StaticResource FontSizeMono}" />
|
||||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="10"
|
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="10"
|
||||||
VerticalAlignment="Top" Margin="12,2,0,0">
|
VerticalAlignment="Top" Margin="12,2,0,0">
|
||||||
<Button Classes="prompt-action accent" Content="[Retry]"
|
<Button Classes="prompt-action accent" Content="[Continue]"
|
||||||
|
ToolTip.Tip="{loc:Tr session.reviewContinueTip}"
|
||||||
Command="{Binding RejectReviewCommand}" />
|
Command="{Binding RejectReviewCommand}" />
|
||||||
<Button Classes="prompt-action" Content="[Reset]"
|
<Button Classes="prompt-action" Content="[Reset]"
|
||||||
Command="{Binding ParkReviewCommand}" />
|
ToolTip.Tip="{loc:Tr session.reviewResetTip}"
|
||||||
|
Command="{Binding ResetReviewCommand}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
@@ -228,64 +264,48 @@
|
|||||||
|
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|
||||||
<!-- Git: merge target, approve, diff, worktree -->
|
<!-- Git: one Approve + merge cockpit -->
|
||||||
<ScrollViewer IsVisible="{Binding IsGitTab}" Padding="14,10">
|
<ScrollViewer IsVisible="{Binding IsGitTab}" Padding="14,10">
|
||||||
<StackPanel Spacing="14">
|
<StackPanel Spacing="12" IsVisible="{Binding ShowMergeSection}">
|
||||||
|
<TextBlock Classes="section-label" Text="MERGE" />
|
||||||
|
|
||||||
<!-- Approve (review-gated) -->
|
<StackPanel Spacing="4">
|
||||||
<StackPanel Spacing="8" IsVisible="{Binding IsWaitingForReview}">
|
<TextBlock Classes="field-label" Text="Target branch" />
|
||||||
<TextBlock Classes="section-label" Text="REVIEW" />
|
<ComboBox ItemsSource="{Binding MergeTargetBranches}"
|
||||||
<Button Classes="btn accent" Content="Approve"
|
SelectedItem="{Binding SelectedMergeTarget, Mode=TwoWay}"
|
||||||
Command="{Binding ApproveReviewCommand}" />
|
HorizontalAlignment="Stretch" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- Merge & worktree management (moved from Session tab) -->
|
<StackPanel Spacing="0">
|
||||||
<StackPanel Spacing="10" IsVisible="{Binding ShowMergeSection}">
|
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
|
||||||
<TextBlock Classes="section-label" Text="MERGE & WORKTREE" />
|
Foreground="{DynamicResource MossBrush}"
|
||||||
<StackPanel Spacing="4">
|
IsVisible="{Binding MergeIsClean}" />
|
||||||
<TextBlock Classes="field-label" Text="Merge target" />
|
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
|
||||||
<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}"
|
Foreground="{DynamicResource BloodBrush}"
|
||||||
TextWrapping="Wrap"
|
IsVisible="{Binding MergeIsConflict}" />
|
||||||
IsVisible="{Binding MergeAllError,
|
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
|
||||||
Converter={x:Static ObjectConverters.IsNotNull}}" />
|
Foreground="{DynamicResource TextMuteBrush}"
|
||||||
|
IsVisible="{Binding ShowMergePreviewMuted}" />
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
|
<!-- Primary action: Approve flows straight into the merge. -->
|
||||||
|
<WrapPanel Orientation="Horizontal">
|
||||||
|
<Button Classes="btn accent" Content="Approve & Merge" Margin="0,0,8,8"
|
||||||
|
Command="{Binding ApproveReviewCommand}"
|
||||||
|
IsVisible="{Binding IsWaitingForReview}" />
|
||||||
|
<Button Classes="btn" Content="Open Diff" Margin="0,0,8,8"
|
||||||
|
Command="{Binding OpenDiffCommand}" />
|
||||||
|
<Button Classes="btn" Margin="0,0,8,8"
|
||||||
|
ToolTip.Tip="{loc:Tr agent.openWorktreeTip}"
|
||||||
|
Command="{Binding OpenWorktreeCommand}">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="5">
|
||||||
|
<TextBlock Text="Worktree" />
|
||||||
|
<PathIcon Data="{StaticResource Icon.ArrowOut}" Width="11" Height="11" />
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
<Button Classes="btn" Content="Review Combined Diff" Margin="0,0,8,8"
|
||||||
|
Command="{Binding ReviewCombinedDiffCommand}" />
|
||||||
|
</WrapPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
|
|
||||||
@@ -293,6 +313,22 @@
|
|||||||
<ScrollViewer IsVisible="{Binding IsSessionTab}" Padding="14,10">
|
<ScrollViewer IsVisible="{Binding IsSessionTab}" Padding="14,10">
|
||||||
<StackPanel Spacing="14">
|
<StackPanel Spacing="14">
|
||||||
|
|
||||||
|
<!-- Attention band: a child failed, was cancelled, still needs its own
|
||||||
|
review, or reported roadblocks. The parent stays waiting until resolved. -->
|
||||||
|
<Border IsVisible="{Binding HasChildrenNeedingAttention}"
|
||||||
|
Background="{DynamicResource ErrorTintBrush}"
|
||||||
|
BorderBrush="{DynamicResource BloodBrush}"
|
||||||
|
BorderThickness="1" CornerRadius="8" Padding="10,8">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<PathIcon Data="{StaticResource Icon.Warning}"
|
||||||
|
Foreground="{DynamicResource BloodBrush}"
|
||||||
|
Width="14" Height="14" VerticalAlignment="Center" />
|
||||||
|
<TextBlock Classes="meta" Text="{Binding ChildrenAttentionText}"
|
||||||
|
Foreground="{DynamicResource BloodBrush}"
|
||||||
|
VerticalAlignment="Center" />
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!-- Child outcomes -->
|
<!-- Child outcomes -->
|
||||||
<StackPanel Spacing="6" IsVisible="{Binding HasChildOutcomes}">
|
<StackPanel Spacing="6" IsVisible="{Binding HasChildOutcomes}">
|
||||||
<TextBlock Classes="section-label" Text="OUTCOMES" />
|
<TextBlock Classes="section-label" Text="OUTCOMES" />
|
||||||
@@ -315,13 +351,6 @@
|
|||||||
</ItemsControl>
|
</ItemsControl>
|
||||||
</StackPanel>
|
</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>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
|
|
||||||
|
|||||||
@@ -39,21 +39,59 @@
|
|||||||
<Grid>
|
<Grid>
|
||||||
|
|
||||||
<!-- Task detail: description/steps card (upper) + pinned work console (lower) -->
|
<!-- Task detail: description/steps card (upper) + pinned work console (lower) -->
|
||||||
<Grid IsVisible="{Binding IsTaskDetailVisible}"
|
<Grid x:Name="DetailBodyGrid"
|
||||||
Margin="14,12,14,12"
|
IsVisible="{Binding IsTaskDetailVisible}"
|
||||||
RowDefinitions="2*,*">
|
Margin="14,12,14,12">
|
||||||
<ScrollViewer Grid.Row="0" VerticalScrollBarVisibility="Auto">
|
<Grid.RowDefinitions>
|
||||||
<detail:DescriptionStepsCard VerticalAlignment="Top"/>
|
<!-- Auto: the description sizes to its content so the console takes
|
||||||
</ScrollViewer>
|
every spare pixel when it's short. Row limits are proportional
|
||||||
<detail:WorkConsole Grid.Row="1" Margin="0,10,0,0"/>
|
and set in code-behind (UpdateRowLimits): the description row is
|
||||||
|
capped at 2/3 of the island and the console row floored at 1/3,
|
||||||
|
so the console can be dragged down to (but not below) 1/3 and a
|
||||||
|
long description never spills over the footer. -->
|
||||||
|
<RowDefinition Height="Auto" MinHeight="90"/>
|
||||||
|
<RowDefinition Height="*"/>
|
||||||
|
</Grid.RowDefinitions>
|
||||||
|
<detail:DescriptionStepsCard x:Name="DescriptionCard" Grid.Row="0"/>
|
||||||
|
|
||||||
|
<!-- Console row also hosts the roadblock card (docked above the console)
|
||||||
|
so it surfaces at a glance between Details and Output. Keeping it
|
||||||
|
inside row 1 leaves the desc/console resize model untouched. -->
|
||||||
|
<DockPanel Grid.Row="1" Margin="0,10,0,0">
|
||||||
|
<Border DockPanel.Dock="Top"
|
||||||
|
IsVisible="{Binding ShowRoadblockCard}"
|
||||||
|
Margin="0,0,0,10" Padding="12,10"
|
||||||
|
Background="{DynamicResource ReviewTintBrush}"
|
||||||
|
BorderBrush="{DynamicResource ReviewTintBorderBrush}"
|
||||||
|
BorderThickness="1" CornerRadius="10">
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<PathIcon Data="{StaticResource Icon.Warning}"
|
||||||
|
Foreground="{DynamicResource StatusReviewBrush}"
|
||||||
|
Width="14" Height="14" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Classes="section-label" Text="ROADBLOCK"
|
||||||
|
Foreground="{DynamicResource StatusReviewBrush}"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
</StackPanel>
|
||||||
|
<SelectableTextBlock Text="{Binding Roadblocks}"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Foreground="{DynamicResource TextDimBrush}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
<detail:WorkConsole/>
|
||||||
|
</DockPanel>
|
||||||
|
|
||||||
<!-- Resize by dragging the console's top edge — a transparent splitter
|
<!-- Resize by dragging the console's top edge — a transparent splitter
|
||||||
over the gap above the console; no standalone separator bar. -->
|
over the gap above the console; no standalone separator bar.
|
||||||
<GridSplitter Grid.Row="1"
|
Stays draggable while maximized. -->
|
||||||
|
<GridSplitter x:Name="DetailSplitter" Grid.Row="1"
|
||||||
VerticalAlignment="Top"
|
VerticalAlignment="Top"
|
||||||
Height="10"
|
Height="10"
|
||||||
HorizontalAlignment="Stretch"
|
HorizontalAlignment="Stretch"
|
||||||
ResizeDirection="Rows"
|
ResizeDirection="Rows"
|
||||||
Background="Transparent"/>
|
Background="Transparent"
|
||||||
|
DragStarted="OnSplitterDragStarted"
|
||||||
|
DragCompleted="OnSplitterDragCompleted"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
<!-- Notes mode -->
|
<!-- Notes mode -->
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Input;
|
||||||
using Avalonia.Layout;
|
using Avalonia.Layout;
|
||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
|
using Avalonia.Reactive;
|
||||||
using ClaudeDo.Ui.ViewModels.Islands;
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
using ClaudeDo.Ui.Views.Modals;
|
using ClaudeDo.Ui.Views.Modals;
|
||||||
using ClaudeDo.Ui.Views.Planning;
|
using ClaudeDo.Ui.Views.Planning;
|
||||||
@@ -10,16 +12,42 @@ namespace ClaudeDo.Ui.Views.Islands;
|
|||||||
|
|
||||||
public partial class DetailsIslandView : UserControl
|
public partial class DetailsIslandView : UserControl
|
||||||
{
|
{
|
||||||
|
// Per-task description height (pixels) once the user drags the splitter.
|
||||||
|
// Keyed by task id so each task keeps its own resize; tasks that were
|
||||||
|
// never dragged stay dynamic (Auto-sized description).
|
||||||
|
private readonly Dictionary<string, double> _descriptionHeights = new();
|
||||||
|
private DetailsIslandViewModel? _vm;
|
||||||
|
|
||||||
public DetailsIslandView()
|
public DetailsIslandView()
|
||||||
{
|
{
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
DataContextChanged += OnDataContextChanged;
|
DataContextChanged += OnDataContextChanged;
|
||||||
|
// Keep the row limits proportional to the island height: description
|
||||||
|
// capped at 2/3, console floored at 1/3. The GridSplitter honours these
|
||||||
|
// row Min/Max during a drag, so the console stops shrinking at 1/3.
|
||||||
|
DetailBodyGrid.GetObservable(BoundsProperty)
|
||||||
|
.Subscribe(new AnonymousObserver<Rect>(_ => UpdateRowLimits()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateRowLimits()
|
||||||
|
{
|
||||||
|
var h = DetailBodyGrid.Bounds.Height;
|
||||||
|
if (h <= 0) return;
|
||||||
|
DetailBodyGrid.RowDefinitions[0].MaxHeight = h * 2.0 / 3.0;
|
||||||
|
DetailBodyGrid.RowDefinitions[1].MinHeight = h / 3.0;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnDataContextChanged(object? sender, EventArgs e)
|
private void OnDataContextChanged(object? sender, EventArgs e)
|
||||||
{
|
{
|
||||||
|
if (_vm != null)
|
||||||
|
_vm.PropertyChanged -= OnViewModelPropertyChanged;
|
||||||
|
|
||||||
if (DataContext is DetailsIslandViewModel vm)
|
if (DataContext is DetailsIslandViewModel vm)
|
||||||
{
|
{
|
||||||
|
_vm = vm;
|
||||||
|
vm.PropertyChanged += OnViewModelPropertyChanged;
|
||||||
|
ApplyResizeStateForCurrentTask();
|
||||||
|
|
||||||
vm.ShowDiffModal = async (diffVm) =>
|
vm.ShowDiffModal = async (diffVm) =>
|
||||||
{
|
{
|
||||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||||
@@ -49,6 +77,41 @@ public partial class DetailsIslandView : UserControl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Restores the resize state for the currently-selected task: a task the
|
||||||
|
// user has dragged before gets its pinned pixel height (cap lifted); a task
|
||||||
|
// never dragged falls back to dynamic sizing (Auto row + the bound cap).
|
||||||
|
private void OnViewModelPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.PropertyName == nameof(DetailsIslandViewModel.Task))
|
||||||
|
ApplyResizeStateForCurrentTask();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ApplyResizeStateForCurrentTask()
|
||||||
|
{
|
||||||
|
// A task dragged before keeps its pixel height (clamped by the row's
|
||||||
|
// 2/3 MaxHeight); a task never dragged stays Auto-sized.
|
||||||
|
DetailBodyGrid.RowDefinitions[0].Height = _vm?.Task?.Id is string id && _descriptionHeights.TryGetValue(id, out var h)
|
||||||
|
? new GridLength(h, GridUnitType.Pixel)
|
||||||
|
: GridLength.Auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pin the (until now Auto-sized) description row to its current pixel
|
||||||
|
// height so the splitter resizes smoothly from there.
|
||||||
|
private void OnSplitterDragStarted(object? sender, VectorEventArgs e)
|
||||||
|
{
|
||||||
|
var descRow = DetailBodyGrid.RowDefinitions[0];
|
||||||
|
if (descRow.Height.IsAuto)
|
||||||
|
descRow.Height = new GridLength(DescriptionCard.Bounds.Height, GridUnitType.Pixel);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remember the dragged height for this task so switching tasks keeps each
|
||||||
|
// task's resize independent.
|
||||||
|
private void OnSplitterDragCompleted(object? sender, VectorEventArgs e)
|
||||||
|
{
|
||||||
|
if (_vm?.Task?.Id is string id)
|
||||||
|
_descriptionHeights[id] = DetailBodyGrid.RowDefinitions[0].Height.Value;
|
||||||
|
}
|
||||||
|
|
||||||
private async System.Threading.Tasks.Task ShowErrorDialogAsync(string message)
|
private async System.Threading.Tasks.Task ShowErrorDialogAsync(string message)
|
||||||
{
|
{
|
||||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||||
|
|||||||
@@ -78,7 +78,8 @@
|
|||||||
CommandParameter="{Binding}"
|
CommandParameter="{Binding}"
|
||||||
Classes="icon-btn"
|
Classes="icon-btn"
|
||||||
Width="18" Height="18"
|
Width="18" Height="18"
|
||||||
VerticalAlignment="Center">
|
VerticalAlignment="Center"
|
||||||
|
ToolTip.Tip="{loc:Tr tasks.toggleSubtasksTip}">
|
||||||
<Panel>
|
<Panel>
|
||||||
<TextBlock Classes="meta" Text="▾" IsVisible="{Binding IsExpanded}"
|
<TextBlock Classes="meta" Text="▾" IsVisible="{Binding IsExpanded}"
|
||||||
VerticalAlignment="Center" HorizontalAlignment="Center"/>
|
VerticalAlignment="Center" HorizontalAlignment="Center"/>
|
||||||
@@ -141,7 +142,7 @@
|
|||||||
Data="{StaticResource Icon.AgentSuggested}"
|
Data="{StaticResource Icon.AgentSuggested}"
|
||||||
Foreground="#5C8FA8"
|
Foreground="#5C8FA8"
|
||||||
IsVisible="{Binding IsAgentSuggested}"
|
IsVisible="{Binding IsAgentSuggested}"
|
||||||
ToolTip.Tip="Suggested by the agent"/>
|
ToolTip.Tip="{loc:Tr tasks.agentSuggestedTip}"/>
|
||||||
|
|
||||||
<!-- Status chip -->
|
<!-- Status chip -->
|
||||||
<Border Classes="chip"
|
<Border Classes="chip"
|
||||||
@@ -197,6 +198,7 @@
|
|||||||
<!-- Refine button -->
|
<!-- Refine button -->
|
||||||
<Button Grid.Column="5" Classes="icon-btn refine-btn"
|
<Button Grid.Column="5" Classes="icon-btn refine-btn"
|
||||||
IsVisible="{Binding CanRefine}"
|
IsVisible="{Binding CanRefine}"
|
||||||
|
VerticalAlignment="Top" Margin="0,2,0,0"
|
||||||
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).RefineTaskCommand}"
|
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).RefineTaskCommand}"
|
||||||
CommandParameter="{Binding}"
|
CommandParameter="{Binding}"
|
||||||
ToolTip.Tip="{loc:Tr tasks.refineTip}">
|
ToolTip.Tip="{loc:Tr tasks.refineTip}">
|
||||||
@@ -210,7 +212,8 @@
|
|||||||
Classes.on="{Binding IsStarred}"
|
Classes.on="{Binding IsStarred}"
|
||||||
VerticalAlignment="Top" Margin="0,2,0,0"
|
VerticalAlignment="Top" Margin="0,2,0,0"
|
||||||
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).ToggleStarCommand}"
|
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).ToggleStarCommand}"
|
||||||
CommandParameter="{Binding}">
|
CommandParameter="{Binding}"
|
||||||
|
ToolTip.Tip="{loc:Tr details.starTip}">
|
||||||
<PathIcon Width="14" Height="14" Data="{StaticResource Icon.Star}"/>
|
<PathIcon Width="14" Height="14" Data="{StaticResource Icon.Star}"/>
|
||||||
</Button>
|
</Button>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ public partial class MainWindow : Window
|
|||||||
var mergeDlg = new MergeModalView { DataContext = mergeVm };
|
var mergeDlg = new MergeModalView { DataContext = mergeVm };
|
||||||
await mergeDlg.ShowDialog(this);
|
await mergeDlg.ShowDialog(this);
|
||||||
};
|
};
|
||||||
|
modal.RequestConflictResolution = (taskId, target) =>
|
||||||
|
DataContext is IslandsShellViewModel s
|
||||||
|
? s.RequestConflictResolutionAsync(taskId, target)
|
||||||
|
: System.Threading.Tasks.Task.CompletedTask;
|
||||||
await dlg.ShowDialog(this);
|
await dlg.ShowDialog(this);
|
||||||
};
|
};
|
||||||
vm.ShowRepoImportModal = async (modal) =>
|
vm.ShowRepoImportModal = async (modal) =>
|
||||||
@@ -95,6 +99,11 @@ public partial class MainWindow : Window
|
|||||||
connVm.CloseAction = () => dlg.Close();
|
connVm.CloseAction = () => dlg.Close();
|
||||||
await dlg.ShowDialog(this);
|
await dlg.ShowDialog(this);
|
||||||
};
|
};
|
||||||
|
vm.ShowConflictResolver = async (resolverVm) =>
|
||||||
|
{
|
||||||
|
var dlg = new ClaudeDo.Ui.Views.Conflicts.ConflictResolverView { DataContext = resolverVm };
|
||||||
|
await dlg.ShowDialog(this);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,51 +26,100 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ctl:ModalShell.Footer>
|
</ctl:ModalShell.Footer>
|
||||||
|
|
||||||
<!-- Body: sidebar + diff content -->
|
<!-- Body: two islands — file list | diff content -->
|
||||||
<Grid ColumnDefinitions="240,*">
|
<Grid ColumnDefinitions="280,12,*" Margin="16">
|
||||||
|
|
||||||
<!-- File sidebar -->
|
<!-- Files island -->
|
||||||
<Border Grid.Column="0"
|
<Border Grid.Column="0" Classes="island">
|
||||||
Classes="sidebar-pane">
|
<DockPanel>
|
||||||
<ListBox ItemsSource="{Binding Files}"
|
<Border DockPanel.Dock="Top" Classes="island-header">
|
||||||
SelectedItem="{Binding SelectedFile, Mode=TwoWay}"
|
<TextBlock Classes="eyebrow" Text="{loc:Tr modals.diff.filesHeader}"/>
|
||||||
Background="Transparent"
|
</Border>
|
||||||
BorderThickness="0"
|
<ListBox ItemsSource="{Binding Files}"
|
||||||
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
|
SelectedItem="{Binding SelectedFile, Mode=TwoWay}"
|
||||||
<ListBox.ItemTemplate>
|
Background="Transparent"
|
||||||
<DataTemplate x:DataType="vm:DiffFileViewModel">
|
BorderThickness="0"
|
||||||
<Border Padding="10,8" Background="Transparent">
|
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
|
||||||
<StackPanel Spacing="4">
|
<ListBox.ItemTemplate>
|
||||||
<TextBlock Classes="path-mono" Text="{Binding Path}"
|
<DataTemplate x:DataType="vm:DiffFileViewModel">
|
||||||
TextTrimming="PrefixCharacterEllipsis"/>
|
<Border Padding="10,8" Background="Transparent">
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
<StackPanel Spacing="4">
|
||||||
<Border Classes="chip" Padding="5,2">
|
<Grid ColumnDefinitions="Auto,*">
|
||||||
<TextBlock Foreground="{DynamicResource MossBrightBrush}"
|
<Border Grid.Column="0" Tag="{Binding StatusCode}"
|
||||||
Text="{Binding Additions, StringFormat='+{0}'}"/>
|
CornerRadius="3" Padding="4,0" Margin="0,0,8,0"
|
||||||
</Border>
|
VerticalAlignment="Center">
|
||||||
<Border Classes="chip" Padding="5,2">
|
<TextBlock Text="{Binding StatusCode}"
|
||||||
<TextBlock Foreground="{DynamicResource BloodBrush}"
|
FontFamily="{DynamicResource MonoFont}"
|
||||||
Text="{Binding Deletions, StringFormat='−{0}'}"/>
|
FontSize="{StaticResource FontSizeEyebrow}"
|
||||||
</Border>
|
Foreground="{DynamicResource TextBrush}"/>
|
||||||
|
</Border>
|
||||||
|
<TextBlock Grid.Column="1" Classes="path-mono" Text="{Binding Path}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
TextTrimming="PrefixCharacterEllipsis"/>
|
||||||
|
</Grid>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6"
|
||||||
|
IsVisible="{Binding !IsBinary}">
|
||||||
|
<Border Classes="chip" Padding="5,2">
|
||||||
|
<TextBlock Foreground="{DynamicResource MossBrightBrush}"
|
||||||
|
Text="{Binding Additions, StringFormat='+{0}'}"/>
|
||||||
|
</Border>
|
||||||
|
<Border Classes="chip" Padding="5,2">
|
||||||
|
<TextBlock Foreground="{DynamicResource BloodBrush}"
|
||||||
|
Text="{Binding Deletions, StringFormat='−{0}'}"/>
|
||||||
|
</Border>
|
||||||
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</Border>
|
||||||
</Border>
|
</DataTemplate>
|
||||||
</DataTemplate>
|
</ListBox.ItemTemplate>
|
||||||
</ListBox.ItemTemplate>
|
</ListBox>
|
||||||
</ListBox>
|
</DockPanel>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- Diff content -->
|
<!-- Diff content island -->
|
||||||
<Grid Grid.Column="1" Background="{DynamicResource VoidBrush}">
|
<Border Grid.Column="2" Classes="island">
|
||||||
<TextBlock Classes="body" Text="{Binding StatusMessage}"
|
<DockPanel>
|
||||||
IsVisible="{Binding StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
|
<Border DockPanel.Dock="Top" Classes="island-header"
|
||||||
HorizontalAlignment="Center"
|
IsVisible="{Binding SelectedFile, Converter={x:Static ObjectConverters.IsNotNull}}">
|
||||||
VerticalAlignment="Center"/>
|
<Grid ColumnDefinitions="Auto,*">
|
||||||
<ScrollViewer HorizontalScrollBarVisibility="Auto"
|
<Border Grid.Column="0" Tag="{Binding SelectedFile.StatusCode}"
|
||||||
VerticalScrollBarVisibility="Auto">
|
CornerRadius="3" Padding="4,0" Margin="0,0,8,0"
|
||||||
<ctl:DiffLinesView Lines="{Binding SelectedFile.Lines}"/>
|
VerticalAlignment="Center">
|
||||||
</ScrollViewer>
|
<TextBlock Text="{Binding SelectedFile.StatusCode}"
|
||||||
</Grid>
|
FontFamily="{DynamicResource MonoFont}"
|
||||||
|
FontSize="{StaticResource FontSizeEyebrow}"
|
||||||
|
Foreground="{DynamicResource TextBrush}"/>
|
||||||
|
</Border>
|
||||||
|
<TextBlock Grid.Column="1" Classes="path-mono" Text="{Binding SelectedFile.Path}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
TextTrimming="PrefixCharacterEllipsis"/>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<Grid Background="{DynamicResource VoidBrush}">
|
||||||
|
<!-- Load / no-changes message -->
|
||||||
|
<TextBlock Classes="body" Text="{Binding StatusMessage}"
|
||||||
|
IsVisible="{Binding StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
<!-- Binary file -->
|
||||||
|
<TextBlock Classes="body" Text="{loc:Tr modals.diff.binary}"
|
||||||
|
Foreground="{DynamicResource TextMuteBrush}"
|
||||||
|
IsVisible="{Binding SelectedFile.IsBinary}"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
<!-- Empty / no-content file -->
|
||||||
|
<TextBlock Classes="body" Text="{loc:Tr modals.diff.empty}"
|
||||||
|
Foreground="{DynamicResource TextMuteBrush}"
|
||||||
|
IsVisible="{Binding SelectedFile.IsEmptyContent}"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center"/>
|
||||||
|
<!-- Diff content -->
|
||||||
|
<ScrollViewer HorizontalScrollBarVisibility="Auto"
|
||||||
|
VerticalScrollBarVisibility="Auto"
|
||||||
|
IsVisible="{Binding SelectedFile.HasLines}">
|
||||||
|
<ctl:DiffLinesView Lines="{Binding SelectedFile.Lines}"/>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Grid>
|
||||||
|
</DockPanel>
|
||||||
|
</Border>
|
||||||
</Grid>
|
</Grid>
|
||||||
</ctl:ModalShell>
|
</ctl:ModalShell>
|
||||||
</Window>
|
</Window>
|
||||||
|
|||||||
@@ -243,6 +243,7 @@
|
|||||||
<TextBlock Classes="meta" Grid.Column="3" Text="{Binding LastRunLabel}" VerticalAlignment="Center"
|
<TextBlock Classes="meta" Grid.Column="3" Text="{Binding LastRunLabel}" VerticalAlignment="Center"
|
||||||
MinWidth="80"/>
|
MinWidth="80"/>
|
||||||
<Button Classes="icon-btn" Grid.Column="4" Content="✕"
|
<Button Classes="icon-btn" Grid.Column="4" Content="✕"
|
||||||
|
ToolTip.Tip="{loc:Tr settings.prime.removeScheduleTip}"
|
||||||
Command="{Binding $parent[ItemsControl].((vm:SettingsModalViewModel)DataContext).Prime.RemoveScheduleCommand}"
|
Command="{Binding $parent[ItemsControl].((vm:SettingsModalViewModel)DataContext).Prime.RemoveScheduleCommand}"
|
||||||
CommandParameter="{Binding}"/>
|
CommandParameter="{Binding}"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
|
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Modals"
|
||||||
xmlns:converters="using:ClaudeDo.Ui.Converters"
|
xmlns:converters="using:ClaudeDo.Ui.Converters"
|
||||||
|
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
|
||||||
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
||||||
x:Class="ClaudeDo.Ui.Views.Modals.WorktreeModalView"
|
x:Class="ClaudeDo.Ui.Views.Modals.WorktreeModalView"
|
||||||
x:DataType="vm:WorktreeModalViewModel"
|
x:DataType="vm:WorktreeModalViewModel"
|
||||||
@@ -16,10 +17,6 @@
|
|||||||
CanResize="True"
|
CanResize="True"
|
||||||
TransparencyLevelHint="AcrylicBlur">
|
TransparencyLevelHint="AcrylicBlur">
|
||||||
|
|
||||||
<Window.Resources>
|
|
||||||
<converters:DiffLineKindToBrushConverter x:Key="DiffLineKindToBrush"/>
|
|
||||||
</Window.Resources>
|
|
||||||
|
|
||||||
<Window.KeyBindings>
|
<Window.KeyBindings>
|
||||||
<KeyBinding Gesture="Escape" Command="{Binding CloseCommand}"/>
|
<KeyBinding Gesture="Escape" Command="{Binding CloseCommand}"/>
|
||||||
</Window.KeyBindings>
|
</Window.KeyBindings>
|
||||||
@@ -89,17 +86,7 @@
|
|||||||
HorizontalScrollBarVisibility="Auto"
|
HorizontalScrollBarVisibility="Auto"
|
||||||
VerticalScrollBarVisibility="Auto"
|
VerticalScrollBarVisibility="Auto"
|
||||||
Margin="4,0,8,8">
|
Margin="4,0,8,8">
|
||||||
<ItemsControl ItemsSource="{Binding SelectedFileDiffLines}">
|
<ctl:DiffLinesView Lines="{Binding SelectedFileDiffLines}"/>
|
||||||
<ItemsControl.ItemTemplate>
|
|
||||||
<DataTemplate DataType="vm:WorktreeDiffLineViewModel">
|
|
||||||
<SelectableTextBlock Text="{Binding Text}"
|
|
||||||
FontFamily="{DynamicResource MonoFont}"
|
|
||||||
FontSize="{StaticResource FontSizeMono}"
|
|
||||||
Foreground="{Binding Kind, Converter={StaticResource DiffLineKindToBrush}}"
|
|
||||||
TextWrapping="NoWrap"/>
|
|
||||||
</DataTemplate>
|
|
||||||
</ItemsControl.ItemTemplate>
|
|
||||||
</ItemsControl>
|
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -60,8 +60,12 @@
|
|||||||
CommandParameter="{Binding}"/>
|
CommandParameter="{Binding}"/>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
</Border.ContextMenu>
|
</Border.ContextMenu>
|
||||||
<Grid ColumnDefinitions="*,90,80,80">
|
<Grid ColumnDefinitions="Auto,*,90,90,80,80">
|
||||||
<StackPanel Grid.Column="0" Orientation="Vertical" Spacing="2">
|
<CheckBox Grid.Column="0" VerticalAlignment="Center" Margin="0,0,8,0"
|
||||||
|
IsChecked="{Binding IsChecked, Mode=TwoWay}"
|
||||||
|
IsEnabled="{Binding IsActive}"
|
||||||
|
IsVisible="{Binding IsActive}"/>
|
||||||
|
<StackPanel Grid.Column="1" Orientation="Vertical" Spacing="2">
|
||||||
<TextBlock Classes="title" Text="{Binding TaskTitle}"/>
|
<TextBlock Classes="title" Text="{Binding TaskTitle}"/>
|
||||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
<StackPanel Orientation="Horizontal" Spacing="4">
|
||||||
<TextBlock Classes="meta" Text="{Binding TaskStatus}"/>
|
<TextBlock Classes="meta" Text="{Binding TaskStatus}"/>
|
||||||
@@ -72,13 +76,16 @@
|
|||||||
ToolTip.Tip="{loc:Tr modals.worktreesOverview.phantomTooltip}"/>
|
ToolTip.Tip="{loc:Tr modals.worktreesOverview.phantomTooltip}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<Border Grid.Column="1" CornerRadius="3" Padding="6,2" VerticalAlignment="Center"
|
<TextBlock Grid.Column="2" Classes="meta" VerticalAlignment="Center"
|
||||||
|
Text="{Binding MergeOutcome}"
|
||||||
|
IsVisible="{Binding HasOutcome}"/>
|
||||||
|
<Border Grid.Column="3" CornerRadius="3" Padding="6,2" VerticalAlignment="Center"
|
||||||
Background="{Binding State, Converter={StaticResource WorktreeStateColor}}">
|
Background="{Binding State, Converter={StaticResource WorktreeStateColor}}">
|
||||||
<TextBlock Classes="meta" Text="{Binding State}" Foreground="{DynamicResource TextBrush}"
|
<TextBlock Classes="meta" Text="{Binding State}" Foreground="{DynamicResource TextBrush}"
|
||||||
HorizontalAlignment="Center"/>
|
HorizontalAlignment="Center"/>
|
||||||
</Border>
|
</Border>
|
||||||
<TextBlock Grid.Column="2" Classes="meta" Text="{Binding DiffStat}" VerticalAlignment="Center"/>
|
<TextBlock Grid.Column="4" Classes="meta" Text="{Binding DiffStat}" VerticalAlignment="Center"/>
|
||||||
<TextBlock Grid.Column="3" Classes="meta" Text="{Binding AgeText}" VerticalAlignment="Center"/>
|
<TextBlock Grid.Column="5" Classes="meta" Text="{Binding AgeText}" VerticalAlignment="Center"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
@@ -98,7 +105,20 @@
|
|||||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
<Button Classes="btn" Content="{loc:Tr modals.worktreesOverview.refresh}" Command="{Binding RefreshCommand}" IsEnabled="{Binding !IsBusy}"/>
|
<Button Classes="btn" Content="{loc:Tr modals.worktreesOverview.refresh}" Command="{Binding RefreshCommand}" IsEnabled="{Binding !IsBusy}"/>
|
||||||
<Button Classes="btn" Content="{loc:Tr modals.worktreesOverview.cleanupFinished}" Command="{Binding CleanupFinishedCommand}" IsEnabled="{Binding !IsBusy}"/>
|
<Button Classes="btn" Content="{loc:Tr modals.worktreesOverview.cleanupFinished}" Command="{Binding CleanupFinishedCommand}" IsEnabled="{Binding !IsBusy}"/>
|
||||||
<TextBlock Text="{Binding StatusMessage}" VerticalAlignment="Center" Margin="12,0,0,0"
|
<Button Classes="btn" Content="{loc:Tr modals.worktreesOverview.selectAll}" Command="{Binding ToggleSelectAllCommand}"/>
|
||||||
|
<Border Width="1" Background="{DynamicResource LineBrush}" Margin="4,2"/>
|
||||||
|
<TextBlock Text="{loc:Tr modals.worktreesOverview.targetLabel}" VerticalAlignment="Center" Foreground="{DynamicResource TextDimBrush}"/>
|
||||||
|
<ComboBox MinWidth="160"
|
||||||
|
ItemsSource="{Binding MergeTargets}"
|
||||||
|
SelectedItem="{Binding SelectedTarget, Mode=TwoWay}"/>
|
||||||
|
<Button Classes="btn accent"
|
||||||
|
Content="{loc:Tr modals.worktreesOverview.mergeAll}"
|
||||||
|
Command="{Binding MergeAllCommand}"/>
|
||||||
|
<TextBlock Text="{Binding SelectedCount, StringFormat='{}{0} selected'}"
|
||||||
|
VerticalAlignment="Center" Foreground="{DynamicResource TextDimBrush}"/>
|
||||||
|
<TextBlock Text="{Binding BatchProgress}" VerticalAlignment="Center" Margin="8,0,0,0"
|
||||||
|
Foreground="{DynamicResource TextDimBrush}"/>
|
||||||
|
<TextBlock Text="{Binding StatusMessage}" VerticalAlignment="Center" Margin="8,0,0,0"
|
||||||
Foreground="{DynamicResource TextDimBrush}"/>
|
Foreground="{DynamicResource TextDimBrush}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</Border>
|
</Border>
|
||||||
@@ -106,12 +126,35 @@
|
|||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<ScrollViewer Padding="20,16">
|
<ScrollViewer Padding="20,16">
|
||||||
<StackPanel>
|
<StackPanel>
|
||||||
|
<Border IsVisible="{Binding ConflictRows.Count}"
|
||||||
|
Background="{DynamicResource ErrorTintBrush}"
|
||||||
|
BorderBrush="{DynamicResource StatusErrorBrush}"
|
||||||
|
BorderThickness="1" CornerRadius="6" Padding="12,8" Margin="0,0,0,12">
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<TextBlock Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.needsResolution}"/>
|
||||||
|
<ItemsControl ItemsSource="{Binding ConflictRows}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:WorktreeOverviewRowViewModel">
|
||||||
|
<Grid ColumnDefinitions="*,Auto" Margin="0,2">
|
||||||
|
<TextBlock Grid.Column="0" Classes="meta" VerticalAlignment="Center"
|
||||||
|
Text="{Binding TaskTitle}"/>
|
||||||
|
<Button Grid.Column="1" Classes="btn"
|
||||||
|
Content="{loc:Tr modals.worktreesOverview.resolve}"
|
||||||
|
Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).ResolveConflictCommand}"
|
||||||
|
CommandParameter="{Binding}"/>
|
||||||
|
</Grid>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
<!-- Column headers -->
|
<!-- Column headers -->
|
||||||
<Grid ColumnDefinitions="*,90,80,80" Margin="12,0,12,4">
|
<Grid ColumnDefinitions="Auto,*,90,90,80,80" Margin="12,0,12,4">
|
||||||
<TextBlock Grid.Column="0" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnTask}"/>
|
<TextBlock Grid.Column="1" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnTask}"/>
|
||||||
<TextBlock Grid.Column="1" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnState}"/>
|
<TextBlock Grid.Column="2" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnOutcome}"/>
|
||||||
<TextBlock Grid.Column="2" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnDiff}"/>
|
<TextBlock Grid.Column="3" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnState}"/>
|
||||||
<TextBlock Grid.Column="3" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnAge}"/>
|
<TextBlock Grid.Column="4" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnDiff}"/>
|
||||||
|
<TextBlock Grid.Column="5" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnAge}"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
<Border Height="1" Background="{DynamicResource LineBrush}" Margin="0,0,0,8"/>
|
<Border Height="1" Background="{DynamicResource LineBrush}" Margin="0,0,0,8"/>
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ Interfaces (e.g. `IQueueWaker`, `IPrimeClock`, `ITaskStateService`) live in an `
|
|||||||
|
|
||||||
| Field | Values | Meaning |
|
| Field | Values | Meaning |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `Status` | `Idle`, `Queued`, `Running`, `WaitingForReview`, `Done`, `Failed`, `Cancelled` | Lifecycle only. |
|
| `Status` | `Idle`, `Queued`, `Running`, `WaitingForChildren`, `WaitingForReview`, `Done`, `Failed`, `Cancelled` | Lifecycle only. `WaitingForChildren` = parent's own work is done, waiting on its children. |
|
||||||
| `PlanningPhase` | `None`, `Active`, `Finalized` | Parent-only marker. `Active` ≈ legacy `Planning`; `Finalized` ≈ legacy `Planned`. |
|
| `PlanningPhase` | `None`, `Active`, `Finalized` | Parent-only marker. `Active` ≈ legacy `Planning`; `Finalized` ≈ legacy `Planned`. |
|
||||||
| `BlockedByTaskId` | nullable FK | Replaces legacy `Waiting`. A queued row with `BlockedByTaskId != NULL` is skipped by the picker. |
|
| `BlockedByTaskId` | nullable FK | Replaces legacy `Waiting`. A queued row with `BlockedByTaskId != NULL` is skipped by the picker. |
|
||||||
| `ReviewFeedback` | nullable string | Reviewer's rejection comment. Set by `RejectToQueueAsync`; consumed and cleared by `QueueService` on the next re-run (resumes the Claude session with it as the next-turn prompt). |
|
| `ReviewFeedback` | nullable string | Reviewer's rejection comment. Set by `RejectToQueueAsync`; consumed and cleared by `QueueService` on the next re-run (resumes the Claude session with it as the next-turn prompt). |
|
||||||
@@ -66,24 +66,46 @@ Interfaces (e.g. `IQueueWaker`, `IPrimeClock`, `ITaskStateService`) live in an `
|
|||||||
Allowed transitions (enforced by `TaskStateService`):
|
Allowed transitions (enforced by `TaskStateService`):
|
||||||
|
|
||||||
```
|
```
|
||||||
Idle → Queued | Running (RunNow)
|
Idle → Queued | Running (RunNow)
|
||||||
Queued → Running | Cancelled | Idle
|
Queued → Running | Cancelled | Idle
|
||||||
Running → WaitingForReview (standalone success) | Done (planning child success) | Failed | Cancelled
|
Running → WaitingForReview (standalone success, no children)
|
||||||
WaitingForReview → Done (approve: merges worktree first; conflicts keep it in WaitingForReview) | Queued (reject-rerun, +feedback) | Idle (reject-park) | Cancelled
|
| WaitingForChildren (parent with pending children)
|
||||||
Done → Idle (re-run)
|
| Done (planning/improvement child success) | Failed | Cancelled
|
||||||
Failed → Idle | Queued
|
WaitingForChildren → WaitingForReview (all children terminal) | Cancelled
|
||||||
Cancelled → Idle | Queued
|
WaitingForReview → Done (approve) | Queued (reject-rerun, +feedback) | Idle (reject-park) | Cancelled
|
||||||
|
Done → Idle (re-run)
|
||||||
|
Failed → Idle | Queued
|
||||||
|
Cancelled → Idle | Queued
|
||||||
```
|
```
|
||||||
|
|
||||||
Only standalone tasks (`ParentTaskId == null`) route to `WaitingForReview` on success. Planning children go straight to `Done` so the sequential chain (which advances on terminal states) is unaffected. `TaskRunner.HandleSuccess` makes this choice; review transitions live in `TaskStateService` (`SubmitForReviewAsync`, `ApproveReviewAsync`, `RejectToQueueAsync`, `RejectToIdleAsync`, `ClearReviewFeedbackAsync`).
|
**Unified parent model.** Every parent — planning *or* improvement — flows
|
||||||
|
`… → WaitingForChildren → WaitingForReview → Done`, advanced by the single
|
||||||
|
`TaskStateService.TryAdvanceParentAsync` (surfaces any `WaitingForChildren` parent for
|
||||||
|
review once all children are terminal; failed/cancelled children are annotated on the
|
||||||
|
result, not wedged). A planning parent enters `WaitingForChildren` at
|
||||||
|
`FinalizePlanningAsync` (or `WaitingForReview` directly if it has no children); an
|
||||||
|
improvement parent enters it from `TaskRunner.HandleSuccess` when its run spawned
|
||||||
|
children. Planning/improvement **children** still go straight to `Done` (no individual
|
||||||
|
review) — only the parent is reviewed.
|
||||||
|
|
||||||
|
**Approve = merge the whole unit.** `ApproveReview`/`review_task` approve, for a task
|
||||||
|
that has children, drives `PlanningMergeOrchestrator` (merges the parent worktree if
|
||||||
|
Active + each `Done` child in order, sets the parent `Done`, and on a mid-merge
|
||||||
|
conflict pauses for `ContinuePlanningMerge`/`AbortPlanningMerge`). Childless tasks use
|
||||||
|
`TaskMergeService.ApproveAndMergeAsync`. There is no separate "Merge all" entry —
|
||||||
|
approve is the single review+merge action. Review transitions live in `TaskStateService`
|
||||||
|
(`SubmitForReviewAsync`, `SubmitForChildrenAsync`, `ApproveReviewAsync`,
|
||||||
|
`RejectToQueueAsync`, `RejectToIdleAsync`, `ClearReviewFeedbackAsync`).
|
||||||
|
|
||||||
## Planning Flow
|
## Planning Flow
|
||||||
|
|
||||||
`PlanningSessionManager.FinalizeAsync` is the single path:
|
`PlanningSessionManager.FinalizeAsync` is the single path:
|
||||||
|
|
||||||
1. `_state.FinalizePlanningAsync(parent)` flips parent `PlanningPhase` to `Finalized`.
|
1. `_state.FinalizePlanningAsync(parent)` flips parent `PlanningPhase` to `Finalized` and sets `Status` to `WaitingForChildren` (or `WaitingForReview` if the parent has no children).
|
||||||
2. `PlanningChainCoordinator.SetupChainAsync` attaches the `agent` tag to every child, enqueues child[0], and `BlockOn`s child[i] → child[i-1].
|
2. `PlanningChainCoordinator.SetupChainAsync(parent, enqueue: false)` establishes the blocked-by chain (`BlockOn`s child[i] → child[i-1]) but **leaves children `Idle`** — finalize never auto-queues. Queueing is a deliberate user action: `QueuePlanAsync` (hub `QueuePlanningSubtasksAsync`, the "Queue plan" button) calls `SetupChainAsync(parent, enqueue: true)`, which sets every non-terminal child `Queued` and re-applies the chain.
|
||||||
3. The first child is woken automatically; successors unblock as their predecessor reaches a terminal state via `OnChildFinishedAsync`.
|
3. Once queued, the first child is woken automatically; successors unblock as their predecessor reaches a terminal state via `OnChildFinishedAsync`.
|
||||||
|
|
||||||
|
A child that hits a roadblock (fails, or reports `CLAUDEDO_BLOCKED` roadblocks) does **not** advance the parent — the parent stays in `WaitingForChildren` until every child is terminal. The UI surfaces blocked children on the parent's Session tab (`ChildOutcomes` + a "children need attention" band) so the roadblock is visible without forcing a transition.
|
||||||
|
|
||||||
`TaskRepository.FinalizePlanningAsync` no longer exists. The `Mark*Async` repository helpers are `internal` — only `TaskStateService` calls them.
|
`TaskRepository.FinalizePlanningAsync` no longer exists. The `Mark*Async` repository helpers are `internal` — only `TaskStateService` calls them.
|
||||||
|
|
||||||
@@ -121,7 +143,7 @@ Each CLI invocation is recorded in the `task_runs` table via `TaskRunRepository`
|
|||||||
|
|
||||||
## SignalR Hub
|
## SignalR Hub
|
||||||
|
|
||||||
**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`
|
**WorkerHub** methods: `Ping`, `GetActive`, `RunNow`, `CancelTask`, `WakeQueue`, `ContinueTask`, `ResetTask`, `ApproveReview(taskId, targetBranch) -> MergeResultDto` (childless task: merges its worktree then Done, conflict stays WaitingForReview; task with children: drives `PlanningMergeOrchestrator` to merge the whole unit), `ContinuePlanningMerge` / `AbortPlanningMerge` (resolve a unit-merge conflict), `PreviewMerge(taskId, targetBranch) -> MergePreviewDto` (non-destructive mergeability check), `RejectReviewToQueue`, `RejectReviewToIdle`, `CancelReview`, `GetAgents`, `RefreshAgents`, `GetAppSettings`, `UpdateAppSettings`, `CleanupFinishedWorktrees`, `ResetAllWorktrees`, `MergeTask`, `GetMergeTargets`, `UpdateList`, `UpdateListConfig`, `GetListConfig`, `UpdateTaskAgentSettings`, `GetWeekReport`, `GenerateWeekReport`, `GetDailyNotes`, `AddDailyNote`, `UpdateDailyNote`, `DeleteDailyNote`, `RunDailyPrepNow`, `ClearMyDay`, `GetLastPrepLog`
|
||||||
|
|
||||||
**HubBroadcaster** events: `TaskStarted`, `TaskFinished`, `TaskMessage`, `WorktreeUpdated`, `TaskUpdated`, `RunCreated`, `ListUpdated`, `PrimeFired`, `PrepStarted`, `PrepLine`, `PrepFinished`
|
**HubBroadcaster** events: `TaskStarted`, `TaskFinished`, `TaskMessage`, `WorktreeUpdated`, `TaskUpdated`, `RunCreated`, `ListUpdated`, `PrimeFired`, `PrepStarted`, `PrepLine`, `PrepFinished`
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
<OutputType>WinExe</OutputType>
|
<OutputType>WinExe</OutputType>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<ApplicationIcon>ClaudeTaskWorker.ico</ApplicationIcon>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
BIN
src/ClaudeDo.Worker/ClaudeTaskWorker.ico
Normal file
BIN
src/ClaudeDo.Worker/ClaudeTaskWorker.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
10
src/ClaudeDo.Worker/External/ConfigMcpTools.cs
vendored
10
src/ClaudeDo.Worker/External/ConfigMcpTools.cs
vendored
@@ -61,4 +61,14 @@ public sealed class ConfigMcpTools
|
|||||||
await _tasks.UpdateAgentSettingsAsync(taskId, model.NullIfBlank(), systemPrompt.NullIfBlank(), agentPath.NullIfBlank(), maxTurns, cancellationToken);
|
await _tasks.UpdateAgentSettingsAsync(taskId, model.NullIfBlank(), systemPrompt.NullIfBlank(), agentPath.NullIfBlank(), maxTurns, cancellationToken);
|
||||||
await _broadcaster.TaskUpdated(taskId);
|
await _broadcaster.TaskUpdated(taskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description("Get per-task config overrides (model/system prompt/agent path/max turns). Returns null if no override is set on this task.")]
|
||||||
|
public async Task<TaskConfigDto?> GetTaskConfig(string taskId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||||
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||||
|
if (task.Model is null && task.SystemPrompt is null && task.AgentPath is null && task.MaxTurns is null)
|
||||||
|
return null;
|
||||||
|
return new TaskConfigDto(task.Model, task.SystemPrompt, task.AgentPath, task.MaxTurns);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ public sealed class ExternalMcpService
|
|||||||
|
|
||||||
[McpServerTool, Description(
|
[McpServerTool, Description(
|
||||||
"List tasks in a given list. Optionally filter by creator (createdBy) and/or status. " +
|
"List tasks in a given list. Optionally filter by creator (createdBy) and/or status. " +
|
||||||
"Valid status values: Idle, Queued, Running, WaitingForReview, Done, Failed, Cancelled.")]
|
"Valid status values: Idle, Queued, Running, WaitingForReview, WaitingForChildren, Done, Failed, Cancelled.")]
|
||||||
public async Task<IReadOnlyList<TaskDto>> ListTasks(
|
public async Task<IReadOnlyList<TaskDto>> ListTasks(
|
||||||
string listId,
|
string listId,
|
||||||
string? createdBy,
|
string? createdBy,
|
||||||
@@ -116,7 +116,7 @@ public sealed class ExternalMcpService
|
|||||||
{
|
{
|
||||||
if (!Enum.TryParse<TaskStatus>(status, ignoreCase: true, out var parsed))
|
if (!Enum.TryParse<TaskStatus>(status, ignoreCase: true, out var parsed))
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"Unknown status '{status}'. Valid values: Idle, Queued, Running, WaitingForReview, Done, Failed, Cancelled.");
|
$"Unknown status '{status}'. Valid values: Idle, Queued, Running, WaitingForReview, WaitingForChildren, Done, Failed, Cancelled.");
|
||||||
statusFilter = parsed;
|
statusFilter = parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -360,13 +360,14 @@ public sealed class ExternalMcpService
|
|||||||
[McpServerTool, Description("Returns all valid task status values and their meanings. Use before filtering by status or interpreting task state.")]
|
[McpServerTool, Description("Returns all valid task status values and their meanings. Use before filtering by status or interpreting task state.")]
|
||||||
public Task<IReadOnlyList<StatusValueDto>> GetTaskStatusValues() =>
|
public Task<IReadOnlyList<StatusValueDto>> GetTaskStatusValues() =>
|
||||||
Task.FromResult<IReadOnlyList<StatusValueDto>>([
|
Task.FromResult<IReadOnlyList<StatusValueDto>>([
|
||||||
new("Idle", "Not yet queued; task is editable and will not run until enqueued."),
|
new("Idle", "Not yet queued; task is editable and will not run until enqueued."),
|
||||||
new("Queued", "Waiting for an agent execution slot. Tasks with a blocker (BlockedByTaskId) are skipped by the queue picker until their predecessor finishes."),
|
new("Queued", "Waiting for an agent execution slot. Tasks with a blocker (BlockedByTaskId) are skipped by the queue picker until their predecessor finishes."),
|
||||||
new("Running", "Agent is actively executing the task; cannot be edited or deleted until cancelled."),
|
new("Running", "Agent is actively executing the task; cannot be edited or deleted until cancelled."),
|
||||||
new("WaitingForReview", "Run finished successfully and awaits review. Use review_task: approve (→ Done), reject_rerun (→ Queued, resumes the session with feedback), reject_park (→ Idle), or cancel (→ Cancelled)."),
|
new("WaitingForReview", "Run finished successfully and awaits review. Use review_task: approve (→ Done), reject_rerun (→ Queued, resumes the session with feedback), reject_park (→ Idle), or cancel (→ Cancelled)."),
|
||||||
new("Done", "Completed successfully and approved; result text is available in the result field. Can be reset to Idle for re-execution."),
|
new("WaitingForChildren", "Planning parent whose child tasks are still running. The parent resumes once all children reach a terminal state."),
|
||||||
new("Failed", "Execution ended with an error; task can be reset to Idle or re-queued directly."),
|
new("Done", "Completed successfully and approved; result text is available in the result field. Can be reset to Idle for re-execution."),
|
||||||
new("Cancelled", "Cancelled by the user; task can be reset to Idle or re-queued directly."),
|
new("Failed", "Execution ended with an error; task can be reset to Idle or re-queued directly."),
|
||||||
|
new("Cancelled", "Cancelled by the user; task can be reset to Idle or re-queued directly."),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// ── Worktree / git tools ──────────────────────────────────────────────────
|
// ── Worktree / git tools ──────────────────────────────────────────────────
|
||||||
@@ -428,7 +429,7 @@ public sealed class ExternalMcpService
|
|||||||
"Merge a task's worktree branch into targetBranch (default: main). " +
|
"Merge a task's worktree branch into targetBranch (default: main). " +
|
||||||
"noFf=true (default): always creates a merge commit (--no-ff). " +
|
"noFf=true (default): always creates a merge commit (--no-ff). " +
|
||||||
"dryRun=true: validates preconditions only, does not perform the merge; merged=false in the result means 'not actually merged'. " +
|
"dryRun=true: validates preconditions only, does not perform the merge; merged=false in the result means 'not actually merged'. " +
|
||||||
"Refuses if task status is not Done (status values: Idle, Queued, Running, Done, Failed, Cancelled). " +
|
"allowWaitingForReview=true: also allows merging a task in WaitingForReview (default false, which only allows Done). " +
|
||||||
"On success: merged=true, mergeCommit contains the new merge commit SHA. " +
|
"On success: merged=true, mergeCommit contains the new merge commit SHA. " +
|
||||||
"On conflict: the merge is cleanly aborted (no half-merged state left); merged=false and conflicts lists the affected files.")]
|
"On conflict: the merge is cleanly aborted (no half-merged state left); merged=false and conflicts lists the affected files.")]
|
||||||
public async Task<MergeTaskResultDto> MergeTask(
|
public async Task<MergeTaskResultDto> MergeTask(
|
||||||
@@ -436,14 +437,17 @@ public sealed class ExternalMcpService
|
|||||||
string targetBranch = "main",
|
string targetBranch = "main",
|
||||||
bool noFf = true,
|
bool noFf = true,
|
||||||
bool dryRun = false,
|
bool dryRun = false,
|
||||||
|
bool allowWaitingForReview = false,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
||||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
||||||
if (task.Status != TaskStatus.Done)
|
var canMerge = task.Status == TaskStatus.Done ||
|
||||||
|
(allowWaitingForReview && task.Status == TaskStatus.WaitingForReview);
|
||||||
|
if (!canMerge)
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"Task must be Done to merge (current status: {task.Status}). " +
|
$"Task must be Done to merge (current status: {task.Status}). " +
|
||||||
"Valid statuses for merge: Done.");
|
"Pass allowWaitingForReview=true to also merge a WaitingForReview task.");
|
||||||
|
|
||||||
var list = await _lists.GetByIdAsync(task.ListId, cancellationToken);
|
var list = await _lists.GetByIdAsync(task.ListId, cancellationToken);
|
||||||
|
|
||||||
@@ -528,7 +532,37 @@ public sealed class ExternalMcpService
|
|||||||
|
|
||||||
var path = wt.Path;
|
var path = wt.Path;
|
||||||
var result = await _maintenance.ForceRemoveAsync(taskId, cancellationToken);
|
var result = await _maintenance.ForceRemoveAsync(taskId, cancellationToken);
|
||||||
return new CleanupWorktreeResult(result.Removed, path, result.Removed);
|
return new CleanupWorktreeResult(result.Removed, path, result.BranchDeleted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerTool, Description(
|
||||||
|
"Send a follow-up prompt to an existing Claude session (multi-turn continuation). " +
|
||||||
|
"The agent resumes using --resume with the session ID from the task's last run. " +
|
||||||
|
"Runs in the override execution slot; throws if the slot is busy — try again later. " +
|
||||||
|
"Returns a status string from the execution slot.")]
|
||||||
|
public async Task<string> ContinueTask(
|
||||||
|
string taskId,
|
||||||
|
string followUpPrompt,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(followUpPrompt))
|
||||||
|
throw new InvalidOperationException("followUpPrompt is required.");
|
||||||
|
|
||||||
|
string result;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
result = await _queue.ContinueTask(taskId, followUpPrompt);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Override slot busy. Try again later.");
|
||||||
|
}
|
||||||
|
catch (KeyNotFoundException)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"Task {taskId} not found.");
|
||||||
|
}
|
||||||
|
await _broadcaster.TaskUpdated(taskId);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Daily prep ───────────────────────────────────────────────────────────
|
// ── Daily prep ───────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -56,6 +56,9 @@ public record ForceRemoveResultDto(bool Removed, string? Reason);
|
|||||||
public record MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage);
|
public record MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage);
|
||||||
public record MergePreviewDto(string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount);
|
public record MergePreviewDto(string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount);
|
||||||
public record MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalBranches);
|
public record MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalBranches);
|
||||||
|
public record MergeConflictsDto(string TaskId, IReadOnlyList<ConflictFileDto> Files);
|
||||||
|
public record ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks);
|
||||||
|
public record ConflictHunkDto(string Ours, string Theirs, string? Base);
|
||||||
public record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType);
|
public record UpdateListDto(string Id, string Name, string? WorkingDir, string DefaultCommitType);
|
||||||
public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
|
public record UpdateListConfigDto(string ListId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
|
||||||
public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
|
public record UpdateTaskAgentSettingsDto(string TaskId, string? Model, string? SystemPrompt, string? AgentPath, int? MaxTurns = null);
|
||||||
@@ -328,6 +331,49 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
return new MergePreviewDto(p.Status, p.ConflictFiles, p.ChangedFileCount);
|
return new MergePreviewDto(p.Status, p.ConflictFiles, p.ChangedFileCount);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
public Task<MergeResultDto> StartConflictMerge(string taskId, string targetBranch)
|
||||||
|
=> HubGuard(async () =>
|
||||||
|
{
|
||||||
|
var r = await _mergeService.MergeAsync(
|
||||||
|
taskId, targetBranch ?? "", removeWorktree: false, "Merge task",
|
||||||
|
leaveConflictsInTree: true, CancellationToken.None);
|
||||||
|
if (r.Status == TaskMergeService.StatusBlocked)
|
||||||
|
throw new HubException(r.ErrorMessage ?? "merge blocked");
|
||||||
|
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
public Task<MergeConflictsDto> GetMergeConflicts(string taskId)
|
||||||
|
=> HubGuard(async () =>
|
||||||
|
{
|
||||||
|
var c = await _mergeService.GetConflictsAsync(taskId, CancellationToken.None);
|
||||||
|
return new MergeConflictsDto(
|
||||||
|
c.TaskId,
|
||||||
|
c.Files.Select(f => new ConflictFileDto(
|
||||||
|
f.Path,
|
||||||
|
new[] { new ConflictHunkDto(f.Ours, f.Theirs, f.Base) })).ToList());
|
||||||
|
});
|
||||||
|
|
||||||
|
public Task WriteConflictResolution(string taskId, string path, string resolvedContent)
|
||||||
|
=> HubGuard(() => _mergeService.WriteResolutionAsync(
|
||||||
|
taskId, path, resolvedContent ?? "", CancellationToken.None));
|
||||||
|
|
||||||
|
public Task<MergeResultDto> ContinueMerge(string taskId)
|
||||||
|
=> HubGuard(async () =>
|
||||||
|
{
|
||||||
|
var r = await _mergeService.ContinueMergeAsync(taskId, CancellationToken.None);
|
||||||
|
if (r.Status == TaskMergeService.StatusBlocked)
|
||||||
|
throw new HubException(r.ErrorMessage ?? "continue failed");
|
||||||
|
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
|
||||||
|
});
|
||||||
|
|
||||||
|
public Task AbortMerge(string taskId)
|
||||||
|
=> HubGuard(async () =>
|
||||||
|
{
|
||||||
|
var r = await _mergeService.AbortMergeAsync(taskId, CancellationToken.None);
|
||||||
|
if (r.Status == TaskMergeService.StatusBlocked)
|
||||||
|
throw new HubException(r.ErrorMessage ?? "abort failed");
|
||||||
|
});
|
||||||
|
|
||||||
public async Task UpdateList(UpdateListDto dto)
|
public async Task UpdateList(UpdateListDto dto)
|
||||||
{
|
{
|
||||||
using var ctx = _dbFactory.CreateDbContext();
|
using var ctx = _dbFactory.CreateDbContext();
|
||||||
@@ -395,6 +441,16 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
public Task<MergeResultDto> ApproveReview(string taskId, string targetBranch)
|
public Task<MergeResultDto> ApproveReview(string taskId, string targetBranch)
|
||||||
=> HubGuard(async () =>
|
=> HubGuard(async () =>
|
||||||
{
|
{
|
||||||
|
bool hasChildren;
|
||||||
|
await using (var ctx = await _dbFactory.CreateDbContextAsync(CancellationToken.None))
|
||||||
|
hasChildren = await ctx.Tasks.AnyAsync(t => t.ParentTaskId == taskId, CancellationToken.None);
|
||||||
|
|
||||||
|
if (hasChildren)
|
||||||
|
{
|
||||||
|
await _planningMergeOrchestrator.StartAsync(taskId, targetBranch ?? "", CancellationToken.None);
|
||||||
|
return new MergeResultDto(TaskMergeService.StatusMerged, Array.Empty<string>(), null);
|
||||||
|
}
|
||||||
|
|
||||||
var r = await _mergeService.ApproveAndMergeAsync(taskId, targetBranch ?? "", CancellationToken.None);
|
var r = await _mergeService.ApproveAndMergeAsync(taskId, targetBranch ?? "", CancellationToken.None);
|
||||||
if (r.Status == TaskMergeService.StatusBlocked)
|
if (r.Status == TaskMergeService.StatusBlocked)
|
||||||
throw new HubException(r.ErrorMessage ?? "approve failed");
|
throw new HubException(r.ErrorMessage ?? "approve failed");
|
||||||
@@ -504,10 +560,6 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
};
|
};
|
||||||
}, "planning task not found");
|
}, "planning task not found");
|
||||||
|
|
||||||
public Task MergeAllPlanning(string planningTaskId, string targetBranch)
|
|
||||||
=> HubGuard(() => _planningMergeOrchestrator.StartAsync(planningTaskId, targetBranch ?? "", CancellationToken.None),
|
|
||||||
"planning task not found");
|
|
||||||
|
|
||||||
public async Task ContinuePlanningMerge(string planningTaskId)
|
public async Task ContinuePlanningMerge(string planningTaskId)
|
||||||
{
|
{
|
||||||
try { await _planningMergeOrchestrator.ContinueAsync(planningTaskId, CancellationToken.None); }
|
try { await _planningMergeOrchestrator.ContinueAsync(planningTaskId, CancellationToken.None); }
|
||||||
|
|||||||
@@ -23,6 +23,16 @@ public sealed record MergePreviewResult(
|
|||||||
IReadOnlyList<string> ConflictFiles,
|
IReadOnlyList<string> ConflictFiles,
|
||||||
int ChangedFileCount);
|
int ChangedFileCount);
|
||||||
|
|
||||||
|
public sealed record MergeConflicts(
|
||||||
|
string TaskId,
|
||||||
|
IReadOnlyList<ConflictFileContent> Files);
|
||||||
|
|
||||||
|
public sealed record ConflictFileContent(
|
||||||
|
string Path,
|
||||||
|
string Ours,
|
||||||
|
string Theirs,
|
||||||
|
string? Base);
|
||||||
|
|
||||||
public sealed class TaskMergeService
|
public sealed class TaskMergeService
|
||||||
{
|
{
|
||||||
public const string StatusMerged = "merged";
|
public const string StatusMerged = "merged";
|
||||||
@@ -75,6 +85,15 @@ public sealed class TaskMergeService
|
|||||||
await _broadcaster.WorktreeUpdated(taskId);
|
await _broadcaster.WorktreeUpdated(taskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task ApproveIfWaitingForReviewAsync(TaskEntity task, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// A merged worktree means the work is integrated, so the task must reach Done.
|
||||||
|
// MarkWorktreeMergedAsync only flips the worktree state; transition the task
|
||||||
|
// itself when it was still awaiting review (a Done task is already terminal).
|
||||||
|
if (task.Status == TaskStatus.WaitingForReview)
|
||||||
|
await _state.ApproveReviewAsync(task.Id, ct);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<MergeResult> MergeAsync(
|
public async Task<MergeResult> MergeAsync(
|
||||||
string taskId,
|
string taskId,
|
||||||
string targetBranch,
|
string targetBranch,
|
||||||
@@ -158,6 +177,7 @@ public sealed class TaskMergeService
|
|||||||
}
|
}
|
||||||
|
|
||||||
await MarkWorktreeMergedAsync(taskId, ct);
|
await MarkWorktreeMergedAsync(taskId, ct);
|
||||||
|
await ApproveIfWaitingForReviewAsync(task, ct);
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"Merged task {TaskId} branch {Branch} into {Target} (remove worktree: {Remove})",
|
"Merged task {TaskId} branch {Branch} into {Target} (remove worktree: {Remove})",
|
||||||
@@ -177,7 +197,7 @@ public sealed class TaskMergeService
|
|||||||
|
|
||||||
public async Task<MergeResult> ContinueMergeAsync(string taskId, CancellationToken ct)
|
public async Task<MergeResult> ContinueMergeAsync(string taskId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var (_, list, wt) = await LoadMergeContextAsync(taskId, ct);
|
var (task, list, wt) = await LoadMergeContextAsync(taskId, ct);
|
||||||
|
|
||||||
if (wt is null) return Blocked("task has no worktree");
|
if (wt is null) return Blocked("task has no worktree");
|
||||||
if (wt.State != WorktreeState.Active) return Blocked($"worktree state is {wt.State}");
|
if (wt.State != WorktreeState.Active) return Blocked($"worktree state is {wt.State}");
|
||||||
@@ -195,6 +215,7 @@ public sealed class TaskMergeService
|
|||||||
catch (Exception ex) { return Blocked($"commit failed: {ex.Message}"); }
|
catch (Exception ex) { return Blocked($"commit failed: {ex.Message}"); }
|
||||||
|
|
||||||
await MarkWorktreeMergedAsync(taskId, ct);
|
await MarkWorktreeMergedAsync(taskId, ct);
|
||||||
|
await ApproveIfWaitingForReviewAsync(task, ct);
|
||||||
_logger.LogInformation("Continued merge of task {TaskId} branch {Branch}", taskId, wt.BranchName);
|
_logger.LogInformation("Continued merge of task {TaskId} branch {Branch}", taskId, wt.BranchName);
|
||||||
|
|
||||||
return new MergeResult(StatusMerged, Array.Empty<string>(), null);
|
return new MergeResult(StatusMerged, Array.Empty<string>(), null);
|
||||||
@@ -217,6 +238,35 @@ public sealed class TaskMergeService
|
|||||||
return new MergeResult(StatusAborted, Array.Empty<string>(), null);
|
return new MergeResult(StatusAborted, Array.Empty<string>(), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<MergeConflicts> GetConflictsAsync(string taskId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var (_, list, _) = await LoadMergeContextAsync(taskId, ct);
|
||||||
|
if (string.IsNullOrWhiteSpace(list.WorkingDir))
|
||||||
|
throw new InvalidOperationException("list has no working directory");
|
||||||
|
|
||||||
|
var files = await _git.ListConflictedFilesAsync(list.WorkingDir, ct);
|
||||||
|
var result = new List<ConflictFileContent>(files.Count);
|
||||||
|
foreach (var path in files)
|
||||||
|
{
|
||||||
|
var ours = await _git.ShowStageAsync(list.WorkingDir, 2, path, ct) ?? "";
|
||||||
|
var theirs = await _git.ShowStageAsync(list.WorkingDir, 3, path, ct) ?? "";
|
||||||
|
var @base = await _git.ShowStageAsync(list.WorkingDir, 1, path, ct);
|
||||||
|
result.Add(new ConflictFileContent(path, ours, theirs, @base));
|
||||||
|
}
|
||||||
|
return new MergeConflicts(taskId, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task WriteResolutionAsync(string taskId, string path, string content, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var (_, list, _) = await LoadMergeContextAsync(taskId, ct);
|
||||||
|
if (string.IsNullOrWhiteSpace(list.WorkingDir))
|
||||||
|
throw new InvalidOperationException("list has no working directory");
|
||||||
|
|
||||||
|
var full = Path.Combine(list.WorkingDir, path.Replace('/', Path.DirectorySeparatorChar));
|
||||||
|
await File.WriteAllTextAsync(full, content, ct);
|
||||||
|
await _git.AddPathAsync(list.WorkingDir, path, ct);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<MergeTargets> GetTargetsAsync(string taskId, CancellationToken ct)
|
public async Task<MergeTargets> GetTargetsAsync(string taskId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var (_, list, _) = await LoadMergeContextAsync(taskId, ct);
|
var (_, list, _) = await LoadMergeContextAsync(taskId, ct);
|
||||||
@@ -274,12 +324,10 @@ public sealed class TaskMergeService
|
|||||||
? await _git.GetCurrentBranchAsync(list.WorkingDir, ct)
|
? await _git.GetCurrentBranchAsync(list.WorkingDir, ct)
|
||||||
: targetBranch;
|
: targetBranch;
|
||||||
|
|
||||||
var merge = await MergeAsync(taskId, target, removeWorktree: false, $"Merge {wt.BranchName}", ct);
|
// MergeAsync transitions the task WaitingForReview -> Done on a successful merge.
|
||||||
if (merge.Status != StatusMerged)
|
// Remove the worktree on approve (matching the unit-merge path) so merged
|
||||||
return merge;
|
// worktrees don't pile up; the merge commit on the target branch is the record.
|
||||||
|
return await MergeAsync(taskId, target, removeWorktree: true, $"Merge {wt.BranchName}", ct);
|
||||||
var approve = await _state.ApproveReviewAsync(taskId, ct);
|
|
||||||
return approve.Ok ? merge : Blocked(approve.Reason ?? "approve failed");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static MergeResult Blocked(string reason) =>
|
private static MergeResult Blocked(string reason) =>
|
||||||
|
|||||||
@@ -19,17 +19,21 @@ public sealed class PlanningChainCoordinator
|
|||||||
_state = state;
|
_state = state;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sets up a sequential queue chain over a planning parent's children.
|
// Sets up a sequential chain over a planning parent's children.
|
||||||
// - First non-terminal child gets Status=Queued, BlockedByTaskId=null.
|
// - First non-terminal child gets BlockedByTaskId=null.
|
||||||
// - Each subsequent non-terminal child gets Status=Queued + BlockedByTaskId=<predecessor>,
|
// - Each subsequent non-terminal child gets BlockedByTaskId=<predecessor>,
|
||||||
// so the picker skips them until the predecessor finishes.
|
// so the picker skips them until the predecessor finishes.
|
||||||
|
// - When enqueue is true, each non-terminal child is also set to Status=Queued
|
||||||
|
// (the user-driven "Queue plan"). When false (finalize), children are left
|
||||||
|
// Idle and only the blocked-by links are established, so nothing runs until
|
||||||
|
// the user queues the plan.
|
||||||
// - Terminal children (Done/Failed/Cancelled) are left untouched; they are
|
// - Terminal children (Done/Failed/Cancelled) are left untouched; they are
|
||||||
// skipped when computing predecessors so a re-run on a partially executed
|
// skipped when computing predecessors so a re-run on a partially executed
|
||||||
// chain leaves history alone but still reshapes the tail.
|
// chain leaves history alone but still reshapes the tail.
|
||||||
// - Running children abort the operation — the chain cannot be reshaped while
|
// - Running children abort the operation — the chain cannot be reshaped while
|
||||||
// one of its members is mid-flight.
|
// one of its members is mid-flight.
|
||||||
// Returns the number of children placed in the chain.
|
// Returns the number of children placed in the chain.
|
||||||
internal async Task<int> SetupChainAsync(string parentTaskId, CancellationToken ct = default)
|
internal async Task<int> SetupChainAsync(string parentTaskId, bool enqueue, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||||
var parent = await ctx.Tasks.FirstOrDefaultAsync(t => t.Id == parentTaskId, ct)
|
var parent = await ctx.Tasks.FirstOrDefaultAsync(t => t.Id == parentTaskId, ct)
|
||||||
@@ -56,7 +60,8 @@ public sealed class PlanningChainCoordinator
|
|||||||
var state = _state();
|
var state = _state();
|
||||||
for (int i = 0; i < sequenceable.Count; i++)
|
for (int i = 0; i < sequenceable.Count; i++)
|
||||||
{
|
{
|
||||||
await state.EnqueueAsync(sequenceable[i].Id, ct);
|
if (enqueue)
|
||||||
|
await state.EnqueueAsync(sequenceable[i].Id, ct);
|
||||||
if (i == 0)
|
if (i == 0)
|
||||||
await state.UnblockAsync(sequenceable[i].Id, ct);
|
await state.UnblockAsync(sequenceable[i].Id, ct);
|
||||||
else
|
else
|
||||||
@@ -81,18 +86,14 @@ public sealed class PlanningChainCoordinator
|
|||||||
if (phase != PlanningPhase.Finalized)
|
if (phase != PlanningPhase.Finalized)
|
||||||
throw new InvalidOperationException("Plan must be finalized before it can be queued.");
|
throw new InvalidOperationException("Plan must be finalized before it can be queued.");
|
||||||
|
|
||||||
return await SetupChainAsync(parentTaskId, ct);
|
return await SetupChainAsync(parentTaskId, enqueue: true, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string?> OnChildFinishedAsync(
|
public async Task<string?> OnChildFinishedAsync(
|
||||||
string childTaskId, TaskStatus finalStatus, CancellationToken ct = default)
|
string childTaskId, TaskStatus finalStatus, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (finalStatus != TaskStatus.Done) return null;
|
|
||||||
|
|
||||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||||
// The successor is whichever sibling explicitly blocks on this child.
|
// The successor is whichever sibling explicitly blocks on this child.
|
||||||
// No status check — UnblockAsync flips legacy Waiting to Queued and is a no-op
|
|
||||||
// for already-Queued rows in the new layout.
|
|
||||||
var nextId = await ctx.Tasks
|
var nextId = await ctx.Tasks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(t => t.BlockedByTaskId == childTaskId)
|
.Where(t => t.BlockedByTaskId == childTaskId)
|
||||||
@@ -101,7 +102,16 @@ public sealed class PlanningChainCoordinator
|
|||||||
.FirstOrDefaultAsync(ct);
|
.FirstOrDefaultAsync(ct);
|
||||||
if (nextId is null) return null;
|
if (nextId is null) return null;
|
||||||
|
|
||||||
await _state().UnblockAsync(nextId, ct);
|
if (finalStatus == TaskStatus.Done)
|
||||||
return nextId;
|
{
|
||||||
|
await _state().UnblockAsync(nextId, ct);
|
||||||
|
return nextId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Child failed or was cancelled: cancel the immediate successor so the chain
|
||||||
|
// is not left wedged. CancelAsync triggers OnChildTerminalAsync → OnChildFinishedAsync
|
||||||
|
// for that successor, cascading cancellation through the rest of the chain.
|
||||||
|
await _state().CancelAsync(nextId, DateTime.UtcNow, ct);
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ public sealed class PlanningMcpService
|
|||||||
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
|
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description("Finalize the planning session, promoting all draft child tasks to queued or manual status.")]
|
[McpServerTool, Description("Finalize the planning session. Child tasks are left idle and chain-linked (each blocked by its predecessor); they are NOT queued automatically — the user queues the plan from the app when ready. The queueAgentTasks argument is accepted for compatibility but ignored.")]
|
||||||
public async Task<int> Finalize(
|
public async Task<int> Finalize(
|
||||||
bool queueAgentTasks,
|
bool queueAgentTasks,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
@@ -149,8 +149,10 @@ public sealed class PlanningMcpService
|
|||||||
|
|
||||||
var children = await _tasks.GetChildrenAsync(ctx.ParentTaskId, cancellationToken);
|
var children = await _tasks.GetChildrenAsync(ctx.ParentTaskId, cancellationToken);
|
||||||
int count = children.Count;
|
int count = children.Count;
|
||||||
if (queueAgentTasks && children.Count > 0)
|
// Establish the blocked-by chain but leave children Idle; queueing is a
|
||||||
count = await _chain.SetupChainAsync(ctx.ParentTaskId, cancellationToken);
|
// deliberate user action ("Queue plan"), never an automatic finalize step.
|
||||||
|
if (children.Count > 0)
|
||||||
|
count = await _chain.SetupChainAsync(ctx.ParentTaskId, enqueue: false, cancellationToken);
|
||||||
|
|
||||||
foreach (var c in children)
|
foreach (var c in children)
|
||||||
await BroadcastTaskUpdatedAsync(c.Id, cancellationToken);
|
await BroadcastTaskUpdatedAsync(c.Id, cancellationToken);
|
||||||
|
|||||||
@@ -199,6 +199,10 @@ public sealed class PlanningMergeOrchestrator
|
|||||||
parent.FinishedAt = DateTime.UtcNow;
|
parent.FinishedAt = DateTime.UtcNow;
|
||||||
await ctx.SaveChangesAsync(ct);
|
await ctx.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
// Surface the Done transition to the UI. Without this the parent row stays
|
||||||
|
// visibly stuck in WaitingForReview even though the unit merge completed.
|
||||||
|
await _broadcaster.TaskUpdated(parentTaskId);
|
||||||
|
|
||||||
// Only planning builds an integration branch via the aggregator; skip cleanup otherwise.
|
// Only planning builds an integration branch via the aggregator; skip cleanup otherwise.
|
||||||
if (isPlanning)
|
if (isPlanning)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -209,12 +209,13 @@ public sealed class PlanningSessionManager
|
|||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
finalizeResult.Reason ?? $"Could not finalize planning for task {taskId}.");
|
finalizeResult.Reason ?? $"Could not finalize planning for task {taskId}.");
|
||||||
|
|
||||||
int count = 0;
|
// Establish the blocked-by chain but leave children Idle; queueing is a
|
||||||
|
// deliberate user action ("Queue plan"), never an automatic finalize step.
|
||||||
|
// queueAgentTasks is accepted for compatibility but no longer auto-queues.
|
||||||
var children = await tasks.GetChildrenAsync(taskId, ct);
|
var children = await tasks.GetChildrenAsync(taskId, ct);
|
||||||
if (queueAgentTasks && children.Count > 0)
|
int count = children.Count;
|
||||||
count = await _chain.SetupChainAsync(taskId, ct);
|
if (children.Count > 0)
|
||||||
else
|
count = await _chain.SetupChainAsync(taskId, enqueue: false, ct);
|
||||||
count = children.Count;
|
|
||||||
|
|
||||||
// Best-effort cleanup — don't block finalization on git state.
|
// Best-effort cleanup — don't block finalization on git state.
|
||||||
await TryCleanupWorktreeAsync(taskId, lists, settings, ct);
|
await TryCleanupWorktreeAsync(taskId, lists, settings, ct);
|
||||||
|
|||||||
@@ -8,10 +8,13 @@ public static class DailyPrepPrompt
|
|||||||
public static string LogPath() =>
|
public static string LogPath() =>
|
||||||
System.IO.Path.Combine(ClaudeDo.Data.Paths.AppDataRoot(), "logs", "daily-prep.log");
|
System.IO.Path.Combine(ClaudeDo.Data.Paths.AppDataRoot(), "logs", "daily-prep.log");
|
||||||
|
|
||||||
public static string BuildArgs(int maxTurns) =>
|
public static IReadOnlyList<string> BuildArgs(int maxTurns) =>
|
||||||
"-p --output-format stream-json --verbose --permission-mode acceptEdits " +
|
[
|
||||||
$"--max-turns {maxTurns} " +
|
"-p", "--output-format", "stream-json", "--verbose",
|
||||||
$"--allowedTools {CandidatesTool} {SetMyDayTool}";
|
"--permission-mode", "acceptEdits",
|
||||||
|
"--max-turns", maxTurns.ToString(),
|
||||||
|
"--allowedTools", CandidatesTool, SetMyDayTool,
|
||||||
|
];
|
||||||
|
|
||||||
public static string BuildPrompt(int maxTasks, DateOnly today) =>
|
public static string BuildPrompt(int maxTasks, DateOnly today) =>
|
||||||
ClaudeDo.Data.PromptFiles.Render(
|
ClaudeDo.Data.PromptFiles.Render(
|
||||||
|
|||||||
@@ -12,13 +12,22 @@ public static class RefinePrompt
|
|||||||
public static string LogPath(string taskId) =>
|
public static string LogPath(string taskId) =>
|
||||||
System.IO.Path.Combine(Paths.AppDataRoot(), "logs", $"refine-{Short(taskId)}.log");
|
System.IO.Path.Combine(Paths.AppDataRoot(), "logs", $"refine-{Short(taskId)}.log");
|
||||||
|
|
||||||
public static string BuildArgs(int maxTurns, bool canReadRepo)
|
public static IReadOnlyList<string> BuildArgs(int maxTurns, bool canReadRepo)
|
||||||
{
|
{
|
||||||
var tools = canReadRepo
|
var args = new List<string>
|
||||||
? $"{GetTaskTool} {UpdateTaskTool} {AddSubtaskTool} Read Grep Glob"
|
{
|
||||||
: $"{GetTaskTool} {UpdateTaskTool} {AddSubtaskTool}";
|
"-p", "--output-format", "stream-json", "--verbose",
|
||||||
return "-p --output-format stream-json --verbose --permission-mode acceptEdits " +
|
"--permission-mode", "acceptEdits",
|
||||||
$"--max-turns {maxTurns} --allowedTools {tools}";
|
"--max-turns", maxTurns.ToString(),
|
||||||
|
"--allowedTools", GetTaskTool, UpdateTaskTool, AddSubtaskTool,
|
||||||
|
};
|
||||||
|
if (canReadRepo)
|
||||||
|
{
|
||||||
|
args.Add("Read");
|
||||||
|
args.Add("Grep");
|
||||||
|
args.Add("Glob");
|
||||||
|
}
|
||||||
|
return args;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string BuildPrompt(TaskEntity task, IEnumerable<SubtaskEntity> subtasks)
|
public static string BuildPrompt(TaskEntity task, IEnumerable<SubtaskEntity> subtasks)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ namespace ClaudeDo.Worker.Refine;
|
|||||||
public sealed class RefineRunner : IRefineRunner
|
public sealed class RefineRunner : IRefineRunner
|
||||||
{
|
{
|
||||||
private static readonly TimeSpan RunTimeout = TimeSpan.FromMinutes(5);
|
private static readonly TimeSpan RunTimeout = TimeSpan.FromMinutes(5);
|
||||||
private const int MaxTurns = 25;
|
private const int MaxTurns = 5;
|
||||||
|
|
||||||
private readonly IClaudeProcess _claude;
|
private readonly IClaudeProcess _claude;
|
||||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
|
|||||||
@@ -69,7 +69,12 @@ public sealed class WeekReportService : IWeekReportService
|
|||||||
// alphanumerics, dashes and dots only.
|
// alphanumerics, dashes and dots only.
|
||||||
var safeModel = new string(model.Where(c => char.IsLetterOrDigit(c) || c is '-' or '.').ToArray());
|
var safeModel = new string(model.Where(c => char.IsLetterOrDigit(c) || c is '-' or '.').ToArray());
|
||||||
if (safeModel.Length == 0) safeModel = "sonnet";
|
if (safeModel.Length == 0) safeModel = "sonnet";
|
||||||
var args = $"-p --output-format stream-json --verbose --permission-mode auto --model {safeModel}";
|
IReadOnlyList<string> args =
|
||||||
|
[
|
||||||
|
"-p", "--output-format", "stream-json", "--verbose",
|
||||||
|
"--permission-mode", "auto",
|
||||||
|
"--model", safeModel,
|
||||||
|
];
|
||||||
var result = await _claude.RunAsync(args, prompt, Path.GetTempPath(), _ => Task.CompletedTask, ct);
|
var result = await _claude.RunAsync(args, prompt, Path.GetTempPath(), _ => Task.CompletedTask, ct);
|
||||||
if (!result.IsSuccess)
|
if (!result.IsSuccess)
|
||||||
throw new InvalidOperationException(result.ErrorMarkdown ?? "Claude could not generate the report.");
|
throw new InvalidOperationException(result.ErrorMarkdown ?? "Claude could not generate the report.");
|
||||||
|
|||||||
@@ -27,12 +27,12 @@ public sealed class ClaudeArgsBuilder
|
|||||||
required = new[] { "summary" },
|
required = new[] { "summary" },
|
||||||
});
|
});
|
||||||
|
|
||||||
public string Build(ClaudeRunConfig config)
|
public IReadOnlyList<string> Build(ClaudeRunConfig config)
|
||||||
{
|
{
|
||||||
var args = new List<string>
|
var args = new List<string>
|
||||||
{
|
{
|
||||||
"-p",
|
"-p",
|
||||||
"--output-format stream-json",
|
"--output-format", "stream-json",
|
||||||
"--verbose",
|
"--verbose",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -40,50 +40,55 @@ public sealed class ClaudeArgsBuilder
|
|||||||
|| config.PermissionMode.Equals("bypassPermissions", StringComparison.OrdinalIgnoreCase)
|
|| config.PermissionMode.Equals("bypassPermissions", StringComparison.OrdinalIgnoreCase)
|
||||||
? "auto"
|
? "auto"
|
||||||
: config.PermissionMode;
|
: config.PermissionMode;
|
||||||
args.Add($"--permission-mode {permissionMode}");
|
args.Add("--permission-mode");
|
||||||
|
args.Add(permissionMode);
|
||||||
|
|
||||||
if (config.Model is not null)
|
if (config.Model is not null)
|
||||||
args.Add($"--model {config.Model}");
|
{
|
||||||
|
args.Add("--model");
|
||||||
|
args.Add(config.Model);
|
||||||
|
}
|
||||||
|
|
||||||
if (config.MaxTurns is int turns && turns > 0)
|
if (config.MaxTurns is int turns && turns > 0)
|
||||||
args.Add($"--max-turns {turns}");
|
{
|
||||||
|
args.Add("--max-turns");
|
||||||
|
args.Add(turns.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
if (config.SystemPrompt is not null)
|
if (config.SystemPrompt is not null)
|
||||||
args.Add($"--append-system-prompt {Escape(config.SystemPrompt)}");
|
{
|
||||||
|
args.Add("--append-system-prompt");
|
||||||
|
args.Add(config.SystemPrompt);
|
||||||
|
}
|
||||||
|
|
||||||
if (config.AgentPath is not null)
|
if (config.AgentPath is not null)
|
||||||
{
|
{
|
||||||
var agentJson = JsonSerializer.Serialize(new[] { new { file = config.AgentPath } });
|
var agentJson = JsonSerializer.Serialize(new[] { new { file = config.AgentPath } });
|
||||||
args.Add($"--agents {Escape(agentJson)}");
|
args.Add("--agents");
|
||||||
|
args.Add(agentJson);
|
||||||
}
|
}
|
||||||
|
|
||||||
args.Add($"--json-schema {Escape(ResultSchema)}");
|
args.Add("--json-schema");
|
||||||
|
args.Add(ResultSchema);
|
||||||
|
|
||||||
if (config.McpConfigPath is not null)
|
if (config.McpConfigPath is not null)
|
||||||
args.Add($"--mcp-config {Escape(config.McpConfigPath)}");
|
{
|
||||||
|
args.Add("--mcp-config");
|
||||||
|
args.Add(config.McpConfigPath);
|
||||||
|
}
|
||||||
|
|
||||||
if (config.AllowedTools is not null)
|
if (config.AllowedTools is not null)
|
||||||
args.Add($"--allowedTools {config.AllowedTools}");
|
{
|
||||||
|
args.Add("--allowedTools");
|
||||||
|
args.Add(config.AllowedTools);
|
||||||
|
}
|
||||||
|
|
||||||
if (config.ResumeSessionId is not null)
|
if (config.ResumeSessionId is not null)
|
||||||
args.Add($"--resume {config.ResumeSessionId}");
|
|
||||||
|
|
||||||
return string.Join(" ", args);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string Escape(string value)
|
|
||||||
{
|
|
||||||
if (value.Contains(' ') || value.Contains('"') || value.Contains('\'')
|
|
||||||
|| value.Contains('\t') || value.Contains('\n') || value.Contains('\r'))
|
|
||||||
{
|
{
|
||||||
var escaped = value
|
args.Add("--resume");
|
||||||
.Replace("\\", "\\\\")
|
args.Add(config.ResumeSessionId);
|
||||||
.Replace("\"", "\\\"")
|
|
||||||
.Replace("\n", "\\n")
|
|
||||||
.Replace("\r", "\\r")
|
|
||||||
.Replace("\t", "\\t");
|
|
||||||
return $"\"{escaped}\"";
|
|
||||||
}
|
}
|
||||||
return value;
|
|
||||||
|
return args;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ public sealed class ClaudeProcess : IClaudeProcess
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async Task<RunResult> RunAsync(
|
public async Task<RunResult> RunAsync(
|
||||||
string arguments,
|
IReadOnlyList<string> arguments,
|
||||||
string prompt,
|
string prompt,
|
||||||
string workingDirectory,
|
string workingDirectory,
|
||||||
Func<string, Task> onStdoutLine,
|
Func<string, Task> onStdoutLine,
|
||||||
@@ -27,7 +27,6 @@ public sealed class ClaudeProcess : IClaudeProcess
|
|||||||
var psi = new ProcessStartInfo
|
var psi = new ProcessStartInfo
|
||||||
{
|
{
|
||||||
FileName = _cfg.ClaudeBin,
|
FileName = _cfg.ClaudeBin,
|
||||||
Arguments = arguments,
|
|
||||||
WorkingDirectory = workingDirectory,
|
WorkingDirectory = workingDirectory,
|
||||||
RedirectStandardInput = true,
|
RedirectStandardInput = true,
|
||||||
RedirectStandardOutput = true,
|
RedirectStandardOutput = true,
|
||||||
@@ -37,6 +36,8 @@ public sealed class ClaudeProcess : IClaudeProcess
|
|||||||
StandardOutputEncoding = Encoding.UTF8,
|
StandardOutputEncoding = Encoding.UTF8,
|
||||||
StandardErrorEncoding = Encoding.UTF8,
|
StandardErrorEncoding = Encoding.UTF8,
|
||||||
};
|
};
|
||||||
|
foreach (var arg in arguments)
|
||||||
|
psi.ArgumentList.Add(arg);
|
||||||
|
|
||||||
using var process = new Process { StartInfo = psi };
|
using var process = new Process { StartInfo = psi };
|
||||||
process.Start();
|
process.Start();
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ namespace ClaudeDo.Worker.Runner;
|
|||||||
public interface IClaudeProcess
|
public interface IClaudeProcess
|
||||||
{
|
{
|
||||||
Task<RunResult> RunAsync(
|
Task<RunResult> RunAsync(
|
||||||
string arguments,
|
IReadOnlyList<string> arguments,
|
||||||
string prompt,
|
string prompt,
|
||||||
string workingDirectory,
|
string workingDirectory,
|
||||||
Func<string, Task> onStdoutLine,
|
Func<string, Task> onStdoutLine,
|
||||||
|
|||||||
@@ -211,19 +211,32 @@ public sealed class TaskRunner
|
|||||||
await _state.StartRunningAsync(taskId, now, ct);
|
await _state.StartRunningAsync(taskId, now, ct);
|
||||||
await _broadcaster.TaskStarted(slot, taskId, now);
|
await _broadcaster.TaskStarted(slot, taskId, now);
|
||||||
|
|
||||||
var nextRunNumber = lastRun.RunNumber + 1;
|
try
|
||||||
var result = await RunOnceAsync(taskId, task.Title, slot, runDir, resolvedConfig, nextRunNumber, false, followUpPrompt, ct);
|
|
||||||
|
|
||||||
if (result.IsSuccess)
|
|
||||||
{
|
{
|
||||||
await HandleSuccess(task, list, slot, wtCtx, result, ct);
|
var nextRunNumber = lastRun.RunNumber + 1;
|
||||||
}
|
var result = await RunOnceAsync(taskId, task.Title, slot, runDir, resolvedConfig, nextRunNumber, false, followUpPrompt, ct);
|
||||||
else
|
|
||||||
{
|
|
||||||
await MarkFailed(taskId, task.Title, slot, result.ErrorMarkdown, result.TurnCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
await _broadcaster.TaskUpdated(taskId);
|
if (result.IsSuccess)
|
||||||
|
{
|
||||||
|
await HandleSuccess(task, list, slot, wtCtx, result, ct);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await MarkFailed(taskId, task.Title, slot, result.ErrorMarkdown, result.TurnCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _broadcaster.TaskUpdated(taskId);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("Task {TaskId} was cancelled during continue", taskId);
|
||||||
|
await MarkFailed(taskId, task.Title, slot, "Task cancelled.");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Unhandled exception continuing task {TaskId}", taskId);
|
||||||
|
await MarkFailed(taskId, task.Title, slot, $"Unhandled error: {ex.Message}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private readonly record struct RunDirResult(string? RunDir, WorktreeContext? WtCtx, string? FailureReason);
|
private readonly record struct RunDirResult(string? RunDir, WorktreeContext? WtCtx, string? FailureReason);
|
||||||
|
|||||||
@@ -196,14 +196,15 @@ public sealed class TaskStateService : ITaskStateService
|
|||||||
await using (var ctx = await _dbFactory.CreateDbContextAsync(ct))
|
await using (var ctx = await _dbFactory.CreateDbContextAsync(ct))
|
||||||
{
|
{
|
||||||
var affected = await ctx.Tasks
|
var affected = await ctx.Tasks
|
||||||
.Where(t => t.Id == taskId && t.Status != TaskStatus.Done)
|
.Where(t => t.Id == taskId &&
|
||||||
|
(t.Status == TaskStatus.Running || t.Status == TaskStatus.Queued))
|
||||||
.ExecuteUpdateAsync(s => s
|
.ExecuteUpdateAsync(s => s
|
||||||
.SetProperty(t => t.Status, TaskStatus.Failed)
|
.SetProperty(t => t.Status, TaskStatus.Failed)
|
||||||
.SetProperty(t => t.FinishedAt, finishedAt)
|
.SetProperty(t => t.FinishedAt, finishedAt)
|
||||||
.SetProperty(t => t.Result, error), ct);
|
.SetProperty(t => t.Result, error), ct);
|
||||||
|
|
||||||
if (affected == 0)
|
if (affected == 0)
|
||||||
return new TransitionResult(false, "Task already done; cannot fail.");
|
return new TransitionResult(false, "Task not in a failable state (must be Running or Queued).");
|
||||||
}
|
}
|
||||||
|
|
||||||
await OnChildTerminalAsync(taskId, TaskStatus.Failed);
|
await OnChildTerminalAsync(taskId, TaskStatus.Failed);
|
||||||
@@ -218,7 +219,8 @@ public sealed class TaskStateService : ITaskStateService
|
|||||||
var affected = await ctx.Tasks
|
var affected = await ctx.Tasks
|
||||||
.Where(t => t.Id == taskId &&
|
.Where(t => t.Id == taskId &&
|
||||||
(t.Status == TaskStatus.Running || t.Status == TaskStatus.Queued
|
(t.Status == TaskStatus.Running || t.Status == TaskStatus.Queued
|
||||||
|| t.Status == TaskStatus.WaitingForReview))
|
|| t.Status == TaskStatus.WaitingForReview
|
||||||
|
|| t.Status == TaskStatus.WaitingForChildren))
|
||||||
.ExecuteUpdateAsync(s => s
|
.ExecuteUpdateAsync(s => s
|
||||||
.SetProperty(t => t.Status, TaskStatus.Cancelled)
|
.SetProperty(t => t.Status, TaskStatus.Cancelled)
|
||||||
.SetProperty(t => t.FinishedAt, finishedAt), ct);
|
.SetProperty(t => t.FinishedAt, finishedAt), ct);
|
||||||
@@ -288,12 +290,16 @@ public sealed class TaskStateService : ITaskStateService
|
|||||||
public async Task<TransitionResult> FinalizePlanningAsync(string parentId, CancellationToken ct)
|
public async Task<TransitionResult> FinalizePlanningAsync(string parentId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||||
|
var hasChildren = await ctx.Tasks.AnyAsync(t => t.ParentTaskId == parentId, ct);
|
||||||
|
var newStatus = hasChildren ? TaskStatus.WaitingForChildren : TaskStatus.WaitingForReview;
|
||||||
|
|
||||||
var affected = await ctx.Tasks
|
var affected = await ctx.Tasks
|
||||||
.Where(t => t.Id == parentId && t.PlanningPhase == PlanningPhase.Active)
|
.Where(t => t.Id == parentId && t.PlanningPhase == PlanningPhase.Active)
|
||||||
.ExecuteUpdateAsync(s => s
|
.ExecuteUpdateAsync(s => s
|
||||||
.SetProperty(t => t.PlanningPhase, PlanningPhase.Finalized)
|
.SetProperty(t => t.PlanningPhase, PlanningPhase.Finalized)
|
||||||
.SetProperty(t => t.PlanningFinalizedAt, DateTime.UtcNow)
|
.SetProperty(t => t.PlanningFinalizedAt, DateTime.UtcNow)
|
||||||
.SetProperty(t => t.PlanningSessionToken, (string?)null), ct);
|
.SetProperty(t => t.PlanningSessionToken, (string?)null)
|
||||||
|
.SetProperty(t => t.Status, newStatus), ct);
|
||||||
|
|
||||||
if (affected == 0)
|
if (affected == 0)
|
||||||
return new TransitionResult(false, "No active planning session.");
|
return new TransitionResult(false, "No active planning session.");
|
||||||
@@ -385,28 +391,18 @@ public sealed class TaskStateService : ITaskStateService
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await using var ctx = await _dbFactory.CreateDbContextAsync(CancellationToken.None);
|
await TryAdvanceParentAsync(parentId);
|
||||||
await new TaskRepository(ctx).TryCompleteParentAsync(parentId, CancellationToken.None);
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "TryCompleteParent failed for {ParentId}", parentId);
|
_logger.LogWarning(ex, "TryAdvanceParent failed for {ParentId}", parentId);
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await TryAdvanceImprovementParentAsync(parentId);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "TryAdvanceImprovementParent failed for {ParentId}", parentId);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Improvement parents sit in WaitingForChildren while their suggested children run.
|
// Any parent (planning or improvement) sitting in WaitingForChildren surfaces for review
|
||||||
// Once every child is terminal (Done/Failed/Cancelled) the parent surfaces for review;
|
// once every child is terminal (Done/Failed/Cancelled). A failed or cancelled child does
|
||||||
// a failed or cancelled child does not wedge the parent — it is flagged on the result.
|
// not wedge the parent — it is flagged on the result.
|
||||||
private async Task TryAdvanceImprovementParentAsync(string parentId)
|
private async Task TryAdvanceParentAsync(string parentId)
|
||||||
{
|
{
|
||||||
string? parentResult;
|
string? parentResult;
|
||||||
List<TaskStatus> childStatuses;
|
List<TaskStatus> childStatuses;
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ public sealed class WorktreeMaintenanceService
|
|||||||
{
|
{
|
||||||
public sealed record CleanupResult(int Removed, IReadOnlyList<string> RemovedTaskIds);
|
public sealed record CleanupResult(int Removed, IReadOnlyList<string> RemovedTaskIds);
|
||||||
public sealed record ResetResult(int Removed, int TasksAffected, bool Blocked, int RunningTasks, IReadOnlyList<string> RemovedTaskIds);
|
public sealed record ResetResult(int Removed, int TasksAffected, bool Blocked, int RunningTasks, IReadOnlyList<string> RemovedTaskIds);
|
||||||
public sealed record ForceRemoveResult(bool Removed, string? Reason);
|
public sealed record ForceRemoveResult(bool Removed, string? Reason, bool BranchDeleted);
|
||||||
|
|
||||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
private readonly GitService _git;
|
private readonly GitService _git;
|
||||||
@@ -43,7 +43,8 @@ public sealed class WorktreeMaintenanceService
|
|||||||
var removedTaskIds = new List<string>();
|
var removedTaskIds = new List<string>();
|
||||||
foreach (var row in rows)
|
foreach (var row in rows)
|
||||||
{
|
{
|
||||||
if (await TryRemoveAsync(row, force: false, ct))
|
var (rowRemoved, _) = await TryRemoveAsync(row, force: false, ct);
|
||||||
|
if (rowRemoved)
|
||||||
{
|
{
|
||||||
removed++;
|
removed++;
|
||||||
removedTaskIds.Add(row.TaskId);
|
removedTaskIds.Add(row.TaskId);
|
||||||
@@ -71,7 +72,8 @@ public sealed class WorktreeMaintenanceService
|
|||||||
var removedTaskIds = new List<string>();
|
var removedTaskIds = new List<string>();
|
||||||
foreach (var row in rows)
|
foreach (var row in rows)
|
||||||
{
|
{
|
||||||
if (await TryRemoveAsync(row, force: true, ct))
|
var (rowRemoved, _) = await TryRemoveAsync(row, force: true, ct);
|
||||||
|
if (rowRemoved)
|
||||||
{
|
{
|
||||||
removed++;
|
removed++;
|
||||||
removedTaskIds.Add(row.TaskId);
|
removedTaskIds.Add(row.TaskId);
|
||||||
@@ -118,16 +120,16 @@ public sealed class WorktreeMaintenanceService
|
|||||||
.FirstOrDefaultAsync(ct);
|
.FirstOrDefaultAsync(ct);
|
||||||
|
|
||||||
if (row is null)
|
if (row is null)
|
||||||
return new ForceRemoveResult(false, "worktree not found");
|
return new ForceRemoveResult(false, "worktree not found", false);
|
||||||
|
|
||||||
if (row.Status == ClaudeDo.Data.Models.TaskStatus.Running)
|
if (row.Status == ClaudeDo.Data.Models.TaskStatus.Running)
|
||||||
return new ForceRemoveResult(false, "task is currently running");
|
return new ForceRemoveResult(false, "task is currently running", false);
|
||||||
|
|
||||||
var ok = await TryRemoveAsync(row.Row, force: true, ct);
|
var (ok, branchDeleted) = await TryRemoveAsync(row.Row, force: true, ct);
|
||||||
return new ForceRemoveResult(ok, ok ? null : "remove failed");
|
return new ForceRemoveResult(ok, ok ? null : "remove failed", branchDeleted);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<bool> TryRemoveAsync(WorktreeRow row, bool force, CancellationToken ct)
|
private async Task<(bool Removed, bool BranchDeleted)> TryRemoveAsync(WorktreeRow row, bool force, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var repoDirExists = !string.IsNullOrWhiteSpace(row.WorkingDir) && Directory.Exists(row.WorkingDir);
|
var repoDirExists = !string.IsNullOrWhiteSpace(row.WorkingDir) && Directory.Exists(row.WorkingDir);
|
||||||
bool dirRemoved;
|
bool dirRemoved;
|
||||||
@@ -163,8 +165,13 @@ public sealed class WorktreeMaintenanceService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drop the DB row only when the on-disk worktree is gone; otherwise we'd silently
|
||||||
|
// strand a directory while reporting success.
|
||||||
|
if (!dirRemoved) return (false, false);
|
||||||
|
|
||||||
// Branch cleanup: otherwise rerunning the task hits "branch already exists".
|
// Branch cleanup: otherwise rerunning the task hits "branch already exists".
|
||||||
// Prune first so git no longer thinks the branch is checked out by a phantom worktree.
|
// Prune first so git no longer thinks the branch is checked out by a phantom worktree.
|
||||||
|
bool branchDeleted = false;
|
||||||
if (repoDirExists)
|
if (repoDirExists)
|
||||||
{
|
{
|
||||||
try { await _git.WorktreePruneAsync(row.WorkingDir!, ct); }
|
try { await _git.WorktreePruneAsync(row.WorkingDir!, ct); }
|
||||||
@@ -175,6 +182,7 @@ public sealed class WorktreeMaintenanceService
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _git.BranchDeleteAsync(row.WorkingDir!, row.BranchName, force: true, ct);
|
await _git.BranchDeleteAsync(row.WorkingDir!, row.BranchName, force: true, ct);
|
||||||
|
branchDeleted = true;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -184,13 +192,9 @@ public sealed class WorktreeMaintenanceService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Drop the DB row only when the on-disk worktree is gone; otherwise we'd silently
|
|
||||||
// strand a directory while reporting success.
|
|
||||||
if (!dirRemoved) return false;
|
|
||||||
|
|
||||||
using var context = _dbFactory.CreateDbContext();
|
using var context = _dbFactory.CreateDbContext();
|
||||||
await context.Worktrees.Where(w => w.TaskId == row.TaskId).ExecuteDeleteAsync(ct);
|
await context.Worktrees.Where(w => w.TaskId == row.TaskId).ExecuteDeleteAsync(ct);
|
||||||
return true;
|
return (true, branchDeleted);
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed record WorktreeRow(string TaskId, string Path, string BranchName, string? WorkingDir);
|
private sealed record WorktreeRow(string TaskId, string Path, string BranchName, string? WorkingDir);
|
||||||
|
|||||||
@@ -78,20 +78,20 @@ public sealed class VirtualFilterTests
|
|||||||
// --- Review ---
|
// --- Review ---
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Review_matches_only_done_with_active_worktree()
|
public void Review_matches_only_waiting_for_review()
|
||||||
{
|
{
|
||||||
var f = new ReviewFilter();
|
var f = new ReviewFilter();
|
||||||
Assert.True (f.Matches(TaskFactory.Make("a", status: TaskStatus.Done, worktreeState: WorktreeState.Active)));
|
Assert.True (f.Matches(TaskFactory.Make("a", status: TaskStatus.WaitingForReview, worktreeState: WorktreeState.Active)));
|
||||||
Assert.False(f.Matches(TaskFactory.Make("b", status: TaskStatus.Done, worktreeState: WorktreeState.Merged)));
|
Assert.True (f.Matches(TaskFactory.Make("b", status: TaskStatus.WaitingForReview, worktreeState: null)));
|
||||||
Assert.False(f.Matches(TaskFactory.Make("c", status: TaskStatus.Done, worktreeState: null)));
|
Assert.False(f.Matches(TaskFactory.Make("c", status: TaskStatus.Done, worktreeState: WorktreeState.Active)));
|
||||||
Assert.False(f.Matches(TaskFactory.Make("d", status: TaskStatus.Failed, worktreeState: WorktreeState.Active)));
|
Assert.False(f.Matches(TaskFactory.Make("d", status: TaskStatus.Failed, worktreeState: WorktreeState.Active)));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Review_count_equals_match()
|
public void Review_count_equals_match()
|
||||||
{
|
{
|
||||||
var f = new ReviewFilter();
|
var f = new ReviewFilter();
|
||||||
var t = TaskFactory.Make("a", status: TaskStatus.Done, worktreeState: WorktreeState.Active);
|
var t = TaskFactory.Make("a", status: TaskStatus.WaitingForReview, worktreeState: WorktreeState.Active);
|
||||||
Assert.Equal(f.Matches(t), f.ShouldCount(t));
|
Assert.Equal(f.Matches(t), f.ShouldCount(t));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
115
tests/ClaudeDo.Data.Tests/ForeignKeyTests.cs
Normal file
115
tests/ClaudeDo.Data.Tests/ForeignKeyTests.cs
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Data.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Proves that FK enforcement (PRAGMA foreign_keys=ON) is active on every
|
||||||
|
/// IDbContextFactory-created context, not just the MigrateAndConfigure context.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ForeignKeyTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _dbPath;
|
||||||
|
private readonly DbContextOptions<ClaudeDoDbContext> _options;
|
||||||
|
|
||||||
|
public ForeignKeyTests()
|
||||||
|
{
|
||||||
|
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_fktest_{Guid.NewGuid():N}.db");
|
||||||
|
_options = new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
||||||
|
.UseSqlite($"Data Source={_dbPath}")
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
// EnsureCreated creates the schema including FK constraints in DDL.
|
||||||
|
using var ctx = new ClaudeDoDbContext(_options);
|
||||||
|
ctx.Database.EnsureCreated();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
foreach (var suffix in new[] { "", "-wal", "-shm" })
|
||||||
|
try { File.Delete(_dbPath + suffix); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private ClaudeDoDbContext Open() => new(_options);
|
||||||
|
|
||||||
|
// ---- FK interceptor: ON DELETE SET NULL ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task BlockedByTaskId_is_nulled_when_predecessor_deleted_on_fresh_context()
|
||||||
|
{
|
||||||
|
var listId = Guid.NewGuid().ToString();
|
||||||
|
var parentId = Guid.NewGuid().ToString();
|
||||||
|
var childId = Guid.NewGuid().ToString();
|
||||||
|
|
||||||
|
await using (var ctx = Open())
|
||||||
|
{
|
||||||
|
ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
|
||||||
|
ctx.Tasks.Add(new TaskEntity { Id = parentId, ListId = listId, Title = "Predecessor", Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow });
|
||||||
|
ctx.Tasks.Add(new TaskEntity { Id = childId, ListId = listId, Title = "Blocked", Status = TaskStatus.Idle, BlockedByTaskId = parentId, CreatedAt = DateTime.UtcNow });
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete predecessor in a brand-new context (simulates factory-created context).
|
||||||
|
// Without the FK interceptor, PRAGMA foreign_keys is OFF and SQLite silently
|
||||||
|
// leaves blocked_by_task_id pointing at the deleted row.
|
||||||
|
await using (var ctx = Open())
|
||||||
|
{
|
||||||
|
var predecessor = await ctx.Tasks.FindAsync(parentId);
|
||||||
|
ctx.Tasks.Remove(predecessor!);
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
await using (var ctx = Open())
|
||||||
|
{
|
||||||
|
var child = await ctx.Tasks.AsNoTracking().FirstAsync(t => t.Id == childId);
|
||||||
|
Assert.Null(child.BlockedByTaskId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- AppSettingsRepository: get-or-create resilience ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAsync_returns_existing_row_when_present()
|
||||||
|
{
|
||||||
|
await using var ctx = Open();
|
||||||
|
var repo = new AppSettingsRepository(ctx);
|
||||||
|
|
||||||
|
var first = await repo.GetAsync();
|
||||||
|
var second = await repo.GetAsync();
|
||||||
|
|
||||||
|
Assert.Equal(AppSettingsEntity.SingletonId, first.Id);
|
||||||
|
Assert.Equal(AppSettingsEntity.SingletonId, second.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetAsync_creates_row_when_absent()
|
||||||
|
{
|
||||||
|
// Use a fresh DB with no HasData seed (EnsureCreated skips HasData seeding).
|
||||||
|
var path = Path.Combine(Path.GetTempPath(), $"claudedo_fktest_empty_{Guid.NewGuid():N}.db");
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var opts = new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
||||||
|
.UseSqlite($"Data Source={path}")
|
||||||
|
.Options;
|
||||||
|
await using var ctx = new ClaudeDoDbContext(opts);
|
||||||
|
ctx.Database.EnsureCreated();
|
||||||
|
|
||||||
|
var repo = new AppSettingsRepository(ctx);
|
||||||
|
var settings = await repo.GetAsync();
|
||||||
|
|
||||||
|
Assert.Equal(AppSettingsEntity.SingletonId, settings.Id);
|
||||||
|
|
||||||
|
// A second call should find the row and not re-insert.
|
||||||
|
var settings2 = await repo.GetAsync();
|
||||||
|
Assert.Equal(AppSettingsEntity.SingletonId, settings2.Id);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
foreach (var suffix in new[] { "", "-wal", "-shm" })
|
||||||
|
try { File.Delete(path + suffix); } catch { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,6 +58,11 @@ public abstract class StubWorkerClient : IWorkerClient
|
|||||||
public virtual Task RejectReviewToQueueAsync(string taskId, string feedback) => Task.CompletedTask;
|
public virtual Task RejectReviewToQueueAsync(string taskId, string feedback) => Task.CompletedTask;
|
||||||
public virtual Task RejectReviewToIdleAsync(string taskId) => Task.CompletedTask;
|
public virtual Task RejectReviewToIdleAsync(string taskId) => Task.CompletedTask;
|
||||||
public virtual Task CancelReviewAsync(string taskId) => Task.CompletedTask;
|
public virtual Task CancelReviewAsync(string taskId) => Task.CompletedTask;
|
||||||
|
public virtual Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch) => Task.FromResult(new MergeResultDto("conflict", System.Array.Empty<string>(), null));
|
||||||
|
public virtual Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId) => Task.FromResult(new MergeConflictsDto(taskId, System.Array.Empty<ConflictFileDto>()));
|
||||||
|
public virtual Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent) => Task.CompletedTask;
|
||||||
|
public virtual Task<MergeResultDto> ContinueMergeAsync(string taskId) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));
|
||||||
|
public virtual Task AbortMergeAsync(string taskId) => Task.CompletedTask;
|
||||||
public virtual Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
public virtual Task StartPlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
public virtual Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
public virtual Task OpenInteractiveTerminalAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
public virtual Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
public virtual Task ResumePlanningSessionAsync(string taskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
@@ -70,7 +75,6 @@ public abstract class StubWorkerClient : IWorkerClient
|
|||||||
=> Task.FromResult<IReadOnlyList<SubtaskDiffDto>>(Array.Empty<SubtaskDiffDto>());
|
=> Task.FromResult<IReadOnlyList<SubtaskDiffDto>>(Array.Empty<SubtaskDiffDto>());
|
||||||
public virtual Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch)
|
public virtual Task<CombinedDiffResultDto?> BuildPlanningIntegrationBranchAsync(string planningTaskId, string targetBranch)
|
||||||
=> Task.FromResult<CombinedDiffResultDto?>(null);
|
=> Task.FromResult<CombinedDiffResultDto?>(null);
|
||||||
public virtual Task MergeAllPlanningAsync(string planningTaskId, string targetBranch) => Task.CompletedTask;
|
|
||||||
public virtual Task ContinuePlanningMergeAsync(string planningTaskId) => Task.CompletedTask;
|
public virtual Task ContinuePlanningMergeAsync(string planningTaskId) => Task.CompletedTask;
|
||||||
public virtual Task AbortPlanningMergeAsync(string planningTaskId) => Task.CompletedTask;
|
public virtual Task AbortPlanningMergeAsync(string planningTaskId) => Task.CompletedTask;
|
||||||
public virtual Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default) => Task.CompletedTask;
|
public virtual Task QueuePlanningSubtasksAsync(string parentTaskId, CancellationToken ct = default) => Task.CompletedTask;
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ public class ConflictResolutionViewModelTests
|
|||||||
subtaskTitle: "My subtask",
|
subtaskTitle: "My subtask",
|
||||||
targetBranch: "main",
|
targetBranch: "main",
|
||||||
conflictedFiles: new[] { "src/Foo.cs", "src/Bar.cs" },
|
conflictedFiles: new[] { "src/Foo.cs", "src/Bar.cs" },
|
||||||
worktreePath: "C:/worktrees/plan-1");
|
repoDirectory: "C:/repos/plan-1");
|
||||||
|
|
||||||
// ------------------------------------------------------------------ tests
|
// ------------------------------------------------------------------ tests
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using ClaudeDo.Ui.Services;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Conflicts;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||||
|
|
||||||
|
public class ConflictResolverViewModelTests
|
||||||
|
{
|
||||||
|
private sealed class FakeWorker : StubWorkerClient
|
||||||
|
{
|
||||||
|
public string? WrittenPath;
|
||||||
|
public string? WrittenContent;
|
||||||
|
public bool Continued;
|
||||||
|
public bool Aborted;
|
||||||
|
public string ContinueStatus = "merged";
|
||||||
|
|
||||||
|
public override Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch)
|
||||||
|
=> Task.FromResult(new MergeResultDto("conflict", new[] { "README.md" }, null));
|
||||||
|
|
||||||
|
public override Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId)
|
||||||
|
=> Task.FromResult(new MergeConflictsDto(taskId, new[]
|
||||||
|
{
|
||||||
|
new ConflictFileDto("README.md", new[] { new ConflictHunkDto("ours\n", "theirs\n", "base\n") })
|
||||||
|
}));
|
||||||
|
|
||||||
|
public override Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent)
|
||||||
|
{
|
||||||
|
WrittenPath = path; WrittenContent = resolvedContent; return Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task<MergeResultDto> ContinueMergeAsync(string taskId)
|
||||||
|
{
|
||||||
|
Continued = true;
|
||||||
|
return Task.FromResult(new MergeResultDto(ContinueStatus, System.Array.Empty<string>(), null));
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task AbortMergeAsync(string taskId) { Aborted = true; return Task.CompletedTask; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OpenAsync_LoadsConflicts_AndBlocksContinueUntilResolved()
|
||||||
|
{
|
||||||
|
var vm = new ConflictResolverViewModel(new FakeWorker(), "task-1");
|
||||||
|
var hasConflicts = await vm.OpenAsync("main");
|
||||||
|
|
||||||
|
Assert.True(hasConflicts);
|
||||||
|
var file = Assert.Single(vm.Files);
|
||||||
|
Assert.Equal("README.md", file.Path);
|
||||||
|
Assert.False(vm.CanContinue);
|
||||||
|
|
||||||
|
file.Hunks[0].AcceptIncomingCommand.Execute(null);
|
||||||
|
Assert.True(vm.CanContinue);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Continue_WritesComposedResolution_AndClosesOnMerged()
|
||||||
|
{
|
||||||
|
var worker = new FakeWorker();
|
||||||
|
var vm = new ConflictResolverViewModel(worker, "task-1");
|
||||||
|
var closed = false;
|
||||||
|
vm.CloseRequested = () => closed = true;
|
||||||
|
|
||||||
|
await vm.OpenAsync("main");
|
||||||
|
vm.Files[0].Hunks[0].AcceptCurrentCommand.Execute(null);
|
||||||
|
await vm.ContinueCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.Equal("README.md", worker.WrittenPath);
|
||||||
|
Assert.Equal("ours\n", worker.WrittenContent);
|
||||||
|
Assert.True(worker.Continued);
|
||||||
|
Assert.True(closed);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Continue_StaysOpenAndReportsError_WhenStillConflicted()
|
||||||
|
{
|
||||||
|
var worker = new FakeWorker { ContinueStatus = "conflict" };
|
||||||
|
var vm = new ConflictResolverViewModel(worker, "task-1");
|
||||||
|
var closed = false;
|
||||||
|
vm.CloseRequested = () => closed = true;
|
||||||
|
|
||||||
|
await vm.OpenAsync("main");
|
||||||
|
vm.Files[0].Hunks[0].AcceptBothCommand.Execute(null);
|
||||||
|
await vm.ContinueCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.False(closed);
|
||||||
|
Assert.NotNull(vm.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Abort_CallsWorkerAndCloses()
|
||||||
|
{
|
||||||
|
var worker = new FakeWorker();
|
||||||
|
var vm = new ConflictResolverViewModel(worker, "task-1");
|
||||||
|
var closed = false;
|
||||||
|
vm.CloseRequested = () => closed = true;
|
||||||
|
|
||||||
|
await vm.AbortCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.True(worker.Aborted);
|
||||||
|
Assert.True(closed);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Ui.Services;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Islands;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||||
|
|
||||||
|
public class DetailsIslandConflictSeamTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _dbPath;
|
||||||
|
|
||||||
|
public DetailsIslandConflictSeamTests()
|
||||||
|
{
|
||||||
|
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_conflict_seam_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 NullServiceProvider : IServiceProvider
|
||||||
|
{
|
||||||
|
public object? GetService(Type serviceType) => null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubNotesApi : ClaudeDo.Ui.Services.Interfaces.INotesApi
|
||||||
|
{
|
||||||
|
public Task<List<ClaudeDo.Ui.Services.DailyNoteDto>> ListAsync(DateOnly day) =>
|
||||||
|
Task.FromResult(new List<ClaudeDo.Ui.Services.DailyNoteDto>());
|
||||||
|
public Task<ClaudeDo.Ui.Services.DailyNoteDto?> AddAsync(DateOnly day, string text) =>
|
||||||
|
Task.FromResult<ClaudeDo.Ui.Services.DailyNoteDto?>(null);
|
||||||
|
public Task UpdateAsync(string id, string text) => Task.CompletedTask;
|
||||||
|
public Task DeleteAsync(string id) => Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ConflictApproveWorkerClient : StubWorkerClient
|
||||||
|
{
|
||||||
|
public override bool IsConnected => true;
|
||||||
|
public override Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) =>
|
||||||
|
Task.FromResult<MergeResultDto?>(new MergeResultDto("conflict", new[] { "a.cs" }, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
private DetailsIslandViewModel BuildVm(StubWorkerClient worker)
|
||||||
|
{
|
||||||
|
var factory = new TestDbFactory(NewContext);
|
||||||
|
return new DetailsIslandViewModel(factory, worker, new NullServiceProvider(), new StubNotesApi());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ApproveReview_OnConflict_InvokesConflictResolutionSeam()
|
||||||
|
{
|
||||||
|
const string taskId = "task-conflict-1";
|
||||||
|
|
||||||
|
var vm = BuildVm(new ConflictApproveWorkerClient());
|
||||||
|
vm.Bind(new TaskRowViewModel { Id = taskId, Status = TaskStatus.WaitingForReview });
|
||||||
|
vm.SelectedMergeTarget = "main";
|
||||||
|
|
||||||
|
string? capturedTaskId = null;
|
||||||
|
string? capturedTarget = null;
|
||||||
|
vm.RequestConflictResolution = (tid, target) =>
|
||||||
|
{
|
||||||
|
capturedTaskId = tid;
|
||||||
|
capturedTarget = target;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
};
|
||||||
|
|
||||||
|
await vm.ApproveReviewCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
Assert.Equal(taskId, capturedTaskId);
|
||||||
|
Assert.Equal("main", capturedTarget);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,72 +78,6 @@ public class DetailsIslandPlanningTests : IDisposable
|
|||||||
Task.FromException<MergeResultDto?>(new InvalidOperationException("Task is not waiting for review; cannot approve."));
|
Task.FromException<MergeResultDto?>(new InvalidOperationException("Task is not waiting for review; cannot approve."));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static SubtaskRowViewModel MakeSubtask(TaskStatus status, WorktreeState wt = WorktreeState.Active) =>
|
|
||||||
new() { Id = Guid.NewGuid().ToString(), Title = "t", Status = status, WorktreeState = wt };
|
|
||||||
|
|
||||||
// ── CanMergeAll tests exercising the real VM ─────────────────────────────
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void CanMergeAll_AllChildrenDoneActiveWorktrees_True()
|
|
||||||
{
|
|
||||||
var vm = BuildVm(new FakeWorkerClient());
|
|
||||||
vm.Subtasks.Add(MakeSubtask(TaskStatus.Done, WorktreeState.Active));
|
|
||||||
vm.Subtasks.Add(MakeSubtask(TaskStatus.Done, WorktreeState.Active));
|
|
||||||
|
|
||||||
vm.RecomputeCanMergeAll();
|
|
||||||
|
|
||||||
Assert.True(vm.CanMergeAll);
|
|
||||||
Assert.Null(vm.MergeAllDisabledReason);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void CanMergeAll_AnyChildNotDone_FalseWithReason()
|
|
||||||
{
|
|
||||||
var vm = BuildVm(new FakeWorkerClient());
|
|
||||||
vm.Subtasks.Add(MakeSubtask(TaskStatus.Done, WorktreeState.Active));
|
|
||||||
vm.Subtasks.Add(MakeSubtask(TaskStatus.Done, WorktreeState.Active));
|
|
||||||
vm.Subtasks.Add(MakeSubtask(TaskStatus.Running, WorktreeState.Active));
|
|
||||||
|
|
||||||
vm.RecomputeCanMergeAll();
|
|
||||||
|
|
||||||
Assert.False(vm.CanMergeAll);
|
|
||||||
Assert.NotNull(vm.MergeAllDisabledReason);
|
|
||||||
Assert.Contains("1 subtask", vm.MergeAllDisabledReason, StringComparison.OrdinalIgnoreCase);
|
|
||||||
Assert.Contains("not done", vm.MergeAllDisabledReason, StringComparison.OrdinalIgnoreCase);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void CanMergeAll_AnyChildDiscarded_FalseWithReason()
|
|
||||||
{
|
|
||||||
var vm = BuildVm(new FakeWorkerClient());
|
|
||||||
vm.Subtasks.Add(MakeSubtask(TaskStatus.Done, WorktreeState.Active));
|
|
||||||
vm.Subtasks.Add(MakeSubtask(TaskStatus.Done, WorktreeState.Discarded));
|
|
||||||
|
|
||||||
vm.RecomputeCanMergeAll();
|
|
||||||
|
|
||||||
Assert.False(vm.CanMergeAll);
|
|
||||||
Assert.NotNull(vm.MergeAllDisabledReason);
|
|
||||||
Assert.True(
|
|
||||||
vm.MergeAllDisabledReason!.Contains("discarded", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
vm.MergeAllDisabledReason.Contains("kept", StringComparison.OrdinalIgnoreCase));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void CanMergeAll_AnyChildKept_FalseWithReason()
|
|
||||||
{
|
|
||||||
var vm = BuildVm(new FakeWorkerClient());
|
|
||||||
vm.Subtasks.Add(MakeSubtask(TaskStatus.Done, WorktreeState.Active));
|
|
||||||
vm.Subtasks.Add(MakeSubtask(TaskStatus.Done, WorktreeState.Kept));
|
|
||||||
|
|
||||||
vm.RecomputeCanMergeAll();
|
|
||||||
|
|
||||||
Assert.False(vm.CanMergeAll);
|
|
||||||
Assert.NotNull(vm.MergeAllDisabledReason);
|
|
||||||
Assert.True(
|
|
||||||
vm.MergeAllDisabledReason!.Contains("kept", StringComparison.OrdinalIgnoreCase) ||
|
|
||||||
vm.MergeAllDisabledReason.Contains("discarded", StringComparison.OrdinalIgnoreCase));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Review-action resilience: a failing hub call must not crash the app ───
|
// ── Review-action resilience: a failing hub call must not crash the app ───
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
109
tests/ClaudeDo.Ui.Tests/ViewModels/UnifiedDiffParserTests.cs
Normal file
109
tests/ClaudeDo.Ui.Tests/ViewModels/UnifiedDiffParserTests.cs
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
using System.Linq;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Modals;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||||
|
|
||||||
|
public class UnifiedDiffParserTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Modified_file_counts_additions_and_deletions()
|
||||||
|
{
|
||||||
|
const string raw =
|
||||||
|
"diff --git a/src/Foo.cs b/src/Foo.cs\n" +
|
||||||
|
"index 111..222 100644\n" +
|
||||||
|
"--- a/src/Foo.cs\n" +
|
||||||
|
"+++ b/src/Foo.cs\n" +
|
||||||
|
"@@ -1,3 +1,3 @@\n" +
|
||||||
|
" ctx\n" +
|
||||||
|
"-old\n" +
|
||||||
|
"+new\n";
|
||||||
|
|
||||||
|
var file = Assert.Single(UnifiedDiffParser.Parse(raw));
|
||||||
|
Assert.Equal("src/Foo.cs", file.Path);
|
||||||
|
Assert.Equal(DiffFileStatus.Modified, file.Status);
|
||||||
|
Assert.Equal("M", file.StatusCode);
|
||||||
|
Assert.Equal(1, file.Additions);
|
||||||
|
Assert.Equal(1, file.Deletions);
|
||||||
|
Assert.True(file.HasLines);
|
||||||
|
Assert.False(file.IsBinary);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void New_file_is_marked_added()
|
||||||
|
{
|
||||||
|
const string raw =
|
||||||
|
"diff --git a/New.cs b/New.cs\n" +
|
||||||
|
"new file mode 100644\n" +
|
||||||
|
"index 000..abc\n" +
|
||||||
|
"--- /dev/null\n" +
|
||||||
|
"+++ b/New.cs\n" +
|
||||||
|
"@@ -0,0 +1,1 @@\n" +
|
||||||
|
"+hello\n";
|
||||||
|
|
||||||
|
var file = Assert.Single(UnifiedDiffParser.Parse(raw));
|
||||||
|
Assert.Equal(DiffFileStatus.Added, file.Status);
|
||||||
|
Assert.Equal("A", file.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Deleted_file_is_marked_deleted()
|
||||||
|
{
|
||||||
|
const string raw =
|
||||||
|
"diff --git a/Gone.cs b/Gone.cs\n" +
|
||||||
|
"deleted file mode 100644\n" +
|
||||||
|
"index abc..000\n" +
|
||||||
|
"--- a/Gone.cs\n" +
|
||||||
|
"+++ /dev/null\n" +
|
||||||
|
"@@ -1,1 +0,0 @@\n" +
|
||||||
|
"-bye\n";
|
||||||
|
|
||||||
|
var file = Assert.Single(UnifiedDiffParser.Parse(raw));
|
||||||
|
Assert.Equal(DiffFileStatus.Deleted, file.Status);
|
||||||
|
Assert.Equal("D", file.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rename_captures_old_and_new_path()
|
||||||
|
{
|
||||||
|
const string raw =
|
||||||
|
"diff --git a/Old.cs b/New.cs\n" +
|
||||||
|
"similarity index 100%\n" +
|
||||||
|
"rename from Old.cs\n" +
|
||||||
|
"rename to New.cs\n";
|
||||||
|
|
||||||
|
var file = Assert.Single(UnifiedDiffParser.Parse(raw));
|
||||||
|
Assert.Equal(DiffFileStatus.Renamed, file.Status);
|
||||||
|
Assert.Equal("R", file.StatusCode);
|
||||||
|
Assert.Equal("Old.cs", file.OldPath);
|
||||||
|
Assert.Equal("New.cs", file.Path);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Binary_file_is_flagged_with_no_lines()
|
||||||
|
{
|
||||||
|
const string raw =
|
||||||
|
"diff --git a/img.png b/img.png\n" +
|
||||||
|
"new file mode 100644\n" +
|
||||||
|
"index 000..abc\n" +
|
||||||
|
"Binary files /dev/null and b/img.png differ\n";
|
||||||
|
|
||||||
|
var file = Assert.Single(UnifiedDiffParser.Parse(raw));
|
||||||
|
Assert.True(file.IsBinary);
|
||||||
|
Assert.False(file.HasLines);
|
||||||
|
Assert.False(file.IsEmptyContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Empty_new_file_reports_empty_content()
|
||||||
|
{
|
||||||
|
const string raw =
|
||||||
|
"diff --git a/Empty.txt b/Empty.txt\n" +
|
||||||
|
"new file mode 100644\n" +
|
||||||
|
"index 000..000\n";
|
||||||
|
|
||||||
|
var file = Assert.Single(UnifiedDiffParser.Parse(raw));
|
||||||
|
Assert.Equal(DiffFileStatus.Added, file.Status);
|
||||||
|
Assert.False(file.HasLines);
|
||||||
|
Assert.True(file.IsEmptyContent);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Ui.Services;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Modals;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Tests.ViewModels;
|
||||||
|
|
||||||
|
public class WorktreesOverviewBatchMergeTests
|
||||||
|
{
|
||||||
|
private static WorktreeOverviewRowViewModel ActiveRow(string id) => new()
|
||||||
|
{
|
||||||
|
TaskId = id,
|
||||||
|
TaskTitle = $"Task {id}",
|
||||||
|
TaskStatus = TaskStatus.WaitingForReview,
|
||||||
|
State = WorktreeState.Active,
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Row_outcome_helpers_reflect_state()
|
||||||
|
{
|
||||||
|
var row = ActiveRow("a");
|
||||||
|
Assert.Equal(BatchMergeOutcome.None, row.MergeOutcome);
|
||||||
|
Assert.False(row.IsConflict);
|
||||||
|
|
||||||
|
row.MergeOutcome = BatchMergeOutcome.Conflict;
|
||||||
|
Assert.True(row.IsConflict);
|
||||||
|
|
||||||
|
row.MergeOutcome = BatchMergeOutcome.Merged;
|
||||||
|
Assert.False(row.IsConflict);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorktreesOverviewModalViewModel NewVm() =>
|
||||||
|
new(new ClaudeDo.Ui.Services.WorkerClient("http://127.0.0.1:1/hub"), () => null!);
|
||||||
|
|
||||||
|
private static MergeResultDto Merged() => new("merged", System.Array.Empty<string>(), null);
|
||||||
|
private static MergeResultDto Conflict() => new("conflict", new[] { "f.cs" }, null);
|
||||||
|
private static MergeResultDto Blocked() => new("blocked", System.Array.Empty<string>(), "blocked");
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async System.Threading.Tasks.Task MergeSelected_only_processes_checked_active_rows()
|
||||||
|
{
|
||||||
|
var vm = NewVm();
|
||||||
|
var a = ActiveRow("a"); a.IsChecked = true;
|
||||||
|
var b = ActiveRow("b"); b.IsChecked = false;
|
||||||
|
var c = ActiveRow("c"); c.IsChecked = true; c.State = WorktreeState.Merged;
|
||||||
|
vm.Rows.Add(a); vm.Rows.Add(b); vm.Rows.Add(c);
|
||||||
|
vm.SelectedTarget = "main";
|
||||||
|
|
||||||
|
var seen = new System.Collections.Generic.List<string>();
|
||||||
|
await vm.MergeSelectedAsync((id, target, remove, msg) =>
|
||||||
|
{
|
||||||
|
seen.Add(id);
|
||||||
|
Assert.Equal("main", target);
|
||||||
|
Assert.False(remove);
|
||||||
|
return System.Threading.Tasks.Task.FromResult(Merged());
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal(new[] { "a" }, seen);
|
||||||
|
Assert.Equal(BatchMergeOutcome.Merged, a.MergeOutcome);
|
||||||
|
Assert.False(a.IsChecked);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async System.Threading.Tasks.Task MergeSelected_continues_past_conflict_and_collects_it()
|
||||||
|
{
|
||||||
|
var vm = NewVm();
|
||||||
|
var a = ActiveRow("a"); a.IsChecked = true;
|
||||||
|
var b = ActiveRow("b"); b.IsChecked = true;
|
||||||
|
var c = ActiveRow("c"); c.IsChecked = true;
|
||||||
|
vm.Rows.Add(a); vm.Rows.Add(b); vm.Rows.Add(c);
|
||||||
|
vm.SelectedTarget = "main";
|
||||||
|
|
||||||
|
await vm.MergeSelectedAsync((id, target, remove, msg) =>
|
||||||
|
System.Threading.Tasks.Task.FromResult(id == "b" ? Conflict() : Merged()));
|
||||||
|
|
||||||
|
Assert.Equal(BatchMergeOutcome.Merged, a.MergeOutcome);
|
||||||
|
Assert.Equal(BatchMergeOutcome.Conflict, b.MergeOutcome);
|
||||||
|
Assert.Equal(BatchMergeOutcome.Merged, c.MergeOutcome);
|
||||||
|
Assert.Contains(b, vm.ConflictRows);
|
||||||
|
Assert.Single(vm.ConflictRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async System.Threading.Tasks.Task MergeSelected_maps_blocked_and_exception_to_failure_outcomes()
|
||||||
|
{
|
||||||
|
var vm = NewVm();
|
||||||
|
var a = ActiveRow("a"); a.IsChecked = true;
|
||||||
|
var b = ActiveRow("b"); b.IsChecked = true;
|
||||||
|
vm.Rows.Add(a); vm.Rows.Add(b);
|
||||||
|
vm.SelectedTarget = "main";
|
||||||
|
|
||||||
|
await vm.MergeSelectedAsync((id, target, remove, msg) => id == "a"
|
||||||
|
? System.Threading.Tasks.Task.FromResult(Blocked())
|
||||||
|
: throw new System.InvalidOperationException("boom"));
|
||||||
|
|
||||||
|
Assert.Equal(BatchMergeOutcome.Blocked, a.MergeOutcome);
|
||||||
|
Assert.Equal(BatchMergeOutcome.Failed, b.MergeOutcome);
|
||||||
|
Assert.Empty(vm.ConflictRows);
|
||||||
|
Assert.False(vm.IsMerging);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async System.Threading.Tasks.Task MergeSelected_noop_when_no_target()
|
||||||
|
{
|
||||||
|
var vm = NewVm();
|
||||||
|
var a = ActiveRow("a"); a.IsChecked = true;
|
||||||
|
vm.Rows.Add(a);
|
||||||
|
vm.SelectedTarget = null;
|
||||||
|
|
||||||
|
var called = false;
|
||||||
|
await vm.MergeSelectedAsync((id, t, r, m) => { called = true; return System.Threading.Tasks.Task.FromResult(Merged()); });
|
||||||
|
|
||||||
|
Assert.False(called);
|
||||||
|
Assert.Equal(BatchMergeOutcome.None, a.MergeOutcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SelectedCount_tracks_checked_active_rows()
|
||||||
|
{
|
||||||
|
var vm = NewVm();
|
||||||
|
var a = ActiveRow("a");
|
||||||
|
var b = ActiveRow("b");
|
||||||
|
var merged = ActiveRow("c"); merged.State = WorktreeState.Merged;
|
||||||
|
vm.AddRowForTest(a); vm.AddRowForTest(b); vm.AddRowForTest(merged);
|
||||||
|
|
||||||
|
Assert.Equal(0, vm.SelectedCount);
|
||||||
|
a.IsChecked = true;
|
||||||
|
Assert.Equal(1, vm.SelectedCount);
|
||||||
|
b.IsChecked = true;
|
||||||
|
merged.IsChecked = true;
|
||||||
|
Assert.Equal(2, vm.SelectedCount);
|
||||||
|
a.IsChecked = false;
|
||||||
|
Assert.Equal(1, vm.SelectedCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ResolveConflict_invokes_seam_with_task_and_target()
|
||||||
|
{
|
||||||
|
var vm = NewVm();
|
||||||
|
vm.SelectedTarget = "release";
|
||||||
|
var row = ActiveRow("x"); row.MergeOutcome = BatchMergeOutcome.Conflict;
|
||||||
|
|
||||||
|
(string Task, string Target)? captured = null;
|
||||||
|
vm.RequestConflictResolution = (taskId, target) => { captured = (taskId, target); return System.Threading.Tasks.Task.CompletedTask; };
|
||||||
|
|
||||||
|
vm.ResolveConflictCommand.Execute(row);
|
||||||
|
|
||||||
|
Assert.Equal(("x", "release"), captured);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MergeAll_canExecute_requires_target_selection_and_idle()
|
||||||
|
{
|
||||||
|
var vm = NewVm();
|
||||||
|
var a = ActiveRow("a");
|
||||||
|
vm.AddRowForTest(a);
|
||||||
|
|
||||||
|
Assert.False(vm.MergeAllCommand.CanExecute(null));
|
||||||
|
a.IsChecked = true;
|
||||||
|
Assert.False(vm.MergeAllCommand.CanExecute(null));
|
||||||
|
vm.SelectedTarget = "main";
|
||||||
|
Assert.True(vm.MergeAllCommand.CanExecute(null));
|
||||||
|
vm.IsMerging = true;
|
||||||
|
Assert.False(vm.MergeAllCommand.CanExecute(null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -515,7 +515,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
|||||||
var sut = BuildSut(CreateQueue());
|
var sut = BuildSut(CreateQueue());
|
||||||
|
|
||||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
() => sut.MergeTask(task.Id, "main", true, false, CancellationToken.None));
|
() => sut.MergeTask(task.Id, "main", true, false, false, CancellationToken.None));
|
||||||
Assert.Contains("Done", ex.Message);
|
Assert.Contains("Done", ex.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -527,7 +527,7 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
|||||||
var (task, _, _) = await SeedWorktreeAsync(TaskStatus.Done);
|
var (task, _, _) = await SeedWorktreeAsync(TaskStatus.Done);
|
||||||
var sut = BuildSut(CreateQueue());
|
var sut = BuildSut(CreateQueue());
|
||||||
|
|
||||||
var result = await sut.MergeTask(task.Id, "main", true, dryRun: true, CancellationToken.None);
|
var result = await sut.MergeTask(task.Id, "main", true, dryRun: true, cancellationToken: CancellationToken.None);
|
||||||
|
|
||||||
Assert.False(result.Merged);
|
Assert.False(result.Merged);
|
||||||
Assert.Null(result.MergeCommit);
|
Assert.Null(result.MergeCommit);
|
||||||
@@ -595,4 +595,150 @@ public sealed class ExternalMcpServiceTests : IDisposable
|
|||||||
Assert.True(result.Removed);
|
Assert.True(result.Removed);
|
||||||
Assert.False(Directory.Exists(wt.WorktreePath));
|
Assert.False(Directory.Exists(wt.WorktreePath));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── GetTaskConfig ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private ConfigMcpTools BuildConfigSut() => new(_lists, _tasks, _broadcaster);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetTaskConfig_NotFound_Throws()
|
||||||
|
{
|
||||||
|
var sut = BuildConfigSut();
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => sut.GetTaskConfig("does-not-exist", CancellationToken.None));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetTaskConfig_NoOverrides_ReturnsNull()
|
||||||
|
{
|
||||||
|
var listId = await SeedListAsync();
|
||||||
|
var task = await SeedTaskAsync(listId);
|
||||||
|
var sut = BuildConfigSut();
|
||||||
|
|
||||||
|
var result = await sut.GetTaskConfig(task.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetTaskConfig_WithOverrides_ReturnsValues()
|
||||||
|
{
|
||||||
|
var listId = await SeedListAsync();
|
||||||
|
var task = await SeedTaskAsync(listId);
|
||||||
|
await _tasks.UpdateAgentSettingsAsync(task.Id, "claude-sonnet-4-6", "be concise", null, 10, CancellationToken.None);
|
||||||
|
var sut = BuildConfigSut();
|
||||||
|
|
||||||
|
var result = await sut.GetTaskConfig(task.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.Equal("claude-sonnet-4-6", result.Model);
|
||||||
|
Assert.Equal("be concise", result.SystemPrompt);
|
||||||
|
Assert.Null(result.AgentPath);
|
||||||
|
Assert.Equal(10, result.MaxTurns);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── GetTaskStatusValues ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetTaskStatusValues_ContainsAllStatuses()
|
||||||
|
{
|
||||||
|
var sut = NewService();
|
||||||
|
var values = await sut.GetTaskStatusValues();
|
||||||
|
var names = values.Select(v => v.Status).ToHashSet();
|
||||||
|
|
||||||
|
foreach (var status in Enum.GetValues<TaskStatus>())
|
||||||
|
Assert.Contains(status.ToString(), names);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ListTasks status filter ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ListTasks_FilterByWaitingForReview_ReturnsMatchingTasks()
|
||||||
|
{
|
||||||
|
var listId = await SeedListAsync();
|
||||||
|
await SeedTaskAsync(listId, "wfr", TaskStatus.WaitingForReview);
|
||||||
|
await SeedTaskAsync(listId, "idle", TaskStatus.Idle);
|
||||||
|
var sut = NewService();
|
||||||
|
|
||||||
|
var result = await sut.ListTasks(listId, null, "WaitingForReview", CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Single(result);
|
||||||
|
Assert.Equal("WaitingForReview", result[0].Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ListTasks_FilterByWaitingForChildren_ReturnsMatchingTasks()
|
||||||
|
{
|
||||||
|
var listId = await SeedListAsync();
|
||||||
|
await SeedTaskAsync(listId, "wfc", TaskStatus.WaitingForChildren);
|
||||||
|
await SeedTaskAsync(listId, "done", TaskStatus.Done);
|
||||||
|
var sut = NewService();
|
||||||
|
|
||||||
|
var result = await sut.ListTasks(listId, null, "WaitingForChildren", CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Single(result);
|
||||||
|
Assert.Equal("WaitingForChildren", result[0].Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── MergeTask allowWaitingForReview ───────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MergeTask_WaitingForReview_WithoutFlag_Throws()
|
||||||
|
{
|
||||||
|
var listId = await SeedListAsync();
|
||||||
|
var task = await SeedTaskAsync(listId, status: TaskStatus.WaitingForReview);
|
||||||
|
var sut = BuildSut(CreateQueue());
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => sut.MergeTask(task.Id, "main", true, false, false, CancellationToken.None));
|
||||||
|
Assert.Contains("Done", ex.Message);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MergeTask_WaitingForReview_WithFlag_DryRun_Succeeds()
|
||||||
|
{
|
||||||
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var (task, _, _) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
|
||||||
|
var sut = BuildSut(CreateQueue());
|
||||||
|
|
||||||
|
var result = await sut.MergeTask(task.Id, "main", true, dryRun: true, allowWaitingForReview: true, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.False(result.Merged);
|
||||||
|
Assert.Null(result.MergeCommit);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MergeTask_WaitingForReview_WithFlag_MarksTaskDone()
|
||||||
|
{
|
||||||
|
if (!GitAvailable) { Assert.True(true, "git not available -- skipping"); return; }
|
||||||
|
|
||||||
|
var (task, list, wt) = await SeedWorktreeAsync(TaskStatus.WaitingForReview);
|
||||||
|
File.WriteAllText(Path.Combine(wt.WorktreePath, "added.txt"), "content");
|
||||||
|
var cfg = new WorkerConfig { WorktreeRootStrategy = "sibling" };
|
||||||
|
var mgr = new WorktreeManager(new GitService(), _db.CreateFactory(), cfg, NullLogger<WorktreeManager>.Instance);
|
||||||
|
await mgr.CommitIfChangedAsync(wt, task, list, CancellationToken.None);
|
||||||
|
|
||||||
|
var target = await new GitService().GetCurrentBranchAsync(list.WorkingDir, CancellationToken.None);
|
||||||
|
var sut = BuildSut(CreateQueue());
|
||||||
|
|
||||||
|
var result = await sut.MergeTask(task.Id, target, true, dryRun: false, allowWaitingForReview: true, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.True(result.Merged);
|
||||||
|
var reloaded = await new TaskRepository(_db.CreateContext()).GetByIdAsync(task.Id);
|
||||||
|
Assert.Equal(TaskStatus.Done, reloaded!.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ContinueTask validation ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ContinueTask_EmptyPrompt_Throws()
|
||||||
|
{
|
||||||
|
var listId = await SeedListAsync();
|
||||||
|
var task = await SeedTaskAsync(listId);
|
||||||
|
var sut = NewService();
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => sut.ContinueTask(task.Id, " ", CancellationToken.None));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ public sealed class PlanningChainCoordinatorTests : IDisposable
|
|||||||
{
|
{
|
||||||
await SeedPlanningFamilyAsync("P", 3);
|
await SeedPlanningFamilyAsync("P", 3);
|
||||||
|
|
||||||
var count = await _sut.SetupChainAsync("P", default);
|
var count = await _sut.SetupChainAsync("P", enqueue: true, default);
|
||||||
|
|
||||||
Assert.Equal(3, count);
|
Assert.Equal(3, count);
|
||||||
var kids = await GetChildrenAsync("P");
|
var kids = await GetChildrenAsync("P");
|
||||||
@@ -92,7 +92,7 @@ public sealed class PlanningChainCoordinatorTests : IDisposable
|
|||||||
{
|
{
|
||||||
await SeedPlanningFamilyAsync("P", 2, childStatus: TaskStatus.Idle);
|
await SeedPlanningFamilyAsync("P", 2, childStatus: TaskStatus.Idle);
|
||||||
|
|
||||||
var count = await _sut.SetupChainAsync("P", default);
|
var count = await _sut.SetupChainAsync("P", enqueue: true, default);
|
||||||
|
|
||||||
Assert.Equal(2, count);
|
Assert.Equal(2, count);
|
||||||
}
|
}
|
||||||
@@ -101,7 +101,7 @@ public sealed class PlanningChainCoordinatorTests : IDisposable
|
|||||||
public async Task OnChildDone_UnblocksTheSuccessor()
|
public async Task OnChildDone_UnblocksTheSuccessor()
|
||||||
{
|
{
|
||||||
await SeedPlanningFamilyAsync("P", 3);
|
await SeedPlanningFamilyAsync("P", 3);
|
||||||
await _sut.SetupChainAsync("P", default);
|
await _sut.SetupChainAsync("P", enqueue: true, default);
|
||||||
|
|
||||||
// Mark the head child Done before announcing.
|
// Mark the head child Done before announcing.
|
||||||
await using (var ctx = _factory.CreateDbContext())
|
await using (var ctx = _factory.CreateDbContext())
|
||||||
@@ -125,10 +125,10 @@ public sealed class PlanningChainCoordinatorTests : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task OnChildFailed_DoesNotAdvanceChain()
|
public async Task OnChildFailed_CancelsPendingSuccessors_ChainIsNotWedged()
|
||||||
{
|
{
|
||||||
await SeedPlanningFamilyAsync("P", 3);
|
await SeedPlanningFamilyAsync("P", 3);
|
||||||
await _sut.SetupChainAsync("P", default);
|
await _sut.SetupChainAsync("P", enqueue: true, default);
|
||||||
|
|
||||||
await using (var ctx = _factory.CreateDbContext())
|
await using (var ctx = _factory.CreateDbContext())
|
||||||
{
|
{
|
||||||
@@ -139,19 +139,50 @@ public sealed class PlanningChainCoordinatorTests : IDisposable
|
|||||||
|
|
||||||
var advanced = await _sut.OnChildFinishedAsync("P-c0", TaskStatus.Failed, default);
|
var advanced = await _sut.OnChildFinishedAsync("P-c0", TaskStatus.Failed, default);
|
||||||
|
|
||||||
|
// No "advancement" — the chain does not continue on failure.
|
||||||
Assert.Null(advanced);
|
Assert.Null(advanced);
|
||||||
var kids = await GetChildrenAsync("P");
|
var kids = await GetChildrenAsync("P");
|
||||||
Assert.Equal(TaskStatus.Failed, kids[0].Status);
|
Assert.Equal(TaskStatus.Failed, kids[0].Status);
|
||||||
// Successors remain blocked on the failed predecessor.
|
// Successors must be Cancelled, not left stuck as Queued.
|
||||||
Assert.Equal(kids[0].Id, kids[1].BlockedByTaskId);
|
Assert.Equal(TaskStatus.Cancelled, kids[1].Status);
|
||||||
Assert.Equal(kids[1].Id, kids[2].BlockedByTaskId);
|
Assert.Equal(TaskStatus.Cancelled, kids[2].Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OnChildFailed_MidChain_CancelsAllDownstreamSuccessors()
|
||||||
|
{
|
||||||
|
// Chain: c0 → c1 → c2 → c3. c1 fails mid-chain; c2 and c3 must be cancelled.
|
||||||
|
await SeedPlanningFamilyAsync("P", 4);
|
||||||
|
await _sut.SetupChainAsync("P", enqueue: true, default);
|
||||||
|
|
||||||
|
// Mark c0 Done so c1 was unblocked; c1 ran and failed.
|
||||||
|
await using (var ctx = _factory.CreateDbContext())
|
||||||
|
{
|
||||||
|
var c0 = await ctx.Tasks.FirstAsync(t => t.Id == "P-c0");
|
||||||
|
c0.Status = TaskStatus.Done;
|
||||||
|
c0.BlockedByTaskId = null;
|
||||||
|
var c1 = await ctx.Tasks.FirstAsync(t => t.Id == "P-c1");
|
||||||
|
c1.Status = TaskStatus.Failed;
|
||||||
|
c1.BlockedByTaskId = null;
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Announce that c1 finished as Failed — the coordinator must cascade to c2/c3.
|
||||||
|
var advanced = await _sut.OnChildFinishedAsync("P-c1", TaskStatus.Failed, default);
|
||||||
|
|
||||||
|
Assert.Null(advanced);
|
||||||
|
var kids = await GetChildrenAsync("P");
|
||||||
|
Assert.Equal(TaskStatus.Done, kids[0].Status);
|
||||||
|
Assert.Equal(TaskStatus.Failed, kids[1].Status);
|
||||||
|
Assert.Equal(TaskStatus.Cancelled, kids[2].Status);
|
||||||
|
Assert.Equal(TaskStatus.Cancelled, kids[3].Status);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task OnChildDone_LastChild_ReturnsNull()
|
public async Task OnChildDone_LastChild_ReturnsNull()
|
||||||
{
|
{
|
||||||
await SeedPlanningFamilyAsync("P", 2);
|
await SeedPlanningFamilyAsync("P", 2);
|
||||||
await _sut.SetupChainAsync("P", default);
|
await _sut.SetupChainAsync("P", enqueue: true, default);
|
||||||
|
|
||||||
await using (var ctx = _factory.CreateDbContext())
|
await using (var ctx = _factory.CreateDbContext())
|
||||||
{
|
{
|
||||||
@@ -177,7 +208,7 @@ public sealed class PlanningChainCoordinatorTests : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
() => _sut.SetupChainAsync("P", default));
|
() => _sut.SetupChainAsync("P", enqueue: true, default));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -199,7 +230,7 @@ public sealed class PlanningChainCoordinatorTests : IDisposable
|
|||||||
await ctx.SaveChangesAsync();
|
await ctx.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
var count = await _sut.SetupChainAsync("P", default);
|
var count = await _sut.SetupChainAsync("P", enqueue: true, default);
|
||||||
|
|
||||||
Assert.Equal(4, count);
|
Assert.Equal(4, count);
|
||||||
var kids = await GetChildrenAsync("P");
|
var kids = await GetChildrenAsync("P");
|
||||||
@@ -226,7 +257,7 @@ public sealed class PlanningChainCoordinatorTests : IDisposable
|
|||||||
await ctx.SaveChangesAsync();
|
await ctx.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
var count = await _sut.SetupChainAsync("P", default);
|
var count = await _sut.SetupChainAsync("P", enqueue: true, default);
|
||||||
|
|
||||||
// Only the two non-terminal tail children get chained.
|
// Only the two non-terminal tail children get chained.
|
||||||
Assert.Equal(2, count);
|
Assert.Equal(2, count);
|
||||||
@@ -272,4 +303,21 @@ public sealed class PlanningChainCoordinatorTests : IDisposable
|
|||||||
var kids = await GetChildrenAsync("P");
|
var kids = await GetChildrenAsync("P");
|
||||||
Assert.All(kids, k => Assert.Equal(TaskStatus.Idle, k.Status));
|
Assert.All(kids, k => Assert.Equal(TaskStatus.Idle, k.Status));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetupChain_LinkOnly_LeavesChildrenIdle_ButEstablishesChain()
|
||||||
|
{
|
||||||
|
// Finalize path: children must stay Idle (nothing auto-queues) but still
|
||||||
|
// get the blocked-by chain so a later "Queue plan" runs them in order.
|
||||||
|
await SeedPlanningFamilyAsync("P", 3);
|
||||||
|
|
||||||
|
var count = await _sut.SetupChainAsync("P", enqueue: false, default);
|
||||||
|
|
||||||
|
Assert.Equal(3, count);
|
||||||
|
var kids = await GetChildrenAsync("P");
|
||||||
|
Assert.All(kids, k => Assert.Equal(TaskStatus.Idle, k.Status));
|
||||||
|
Assert.Null(kids[0].BlockedByTaskId);
|
||||||
|
Assert.Equal(kids[0].Id, kids[1].BlockedByTaskId);
|
||||||
|
Assert.Equal(kids[1].Id, kids[2].BlockedByTaskId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,19 +121,20 @@ public sealed class PlanningEndToEndTests : IDisposable
|
|||||||
Assert.Equal(PlanningPhase.Finalized, reload!.PlanningPhase);
|
Assert.Equal(PlanningPhase.Finalized, reload!.PlanningPhase);
|
||||||
|
|
||||||
var kids = await _tasks.GetChildrenAsync(parent.Id);
|
var kids = await _tasks.GetChildrenAsync(parent.Id);
|
||||||
// SetupChainAsync auto-attaches agent tag and queues all children;
|
// Finalize no longer auto-queues. Children stay Idle and chain-linked
|
||||||
// the first one is unblocked, the rest are BlockedBy their predecessor.
|
// (head unblocked, rest BlockedBy their predecessor) until the user
|
||||||
Assert.Equal(TaskStatus.Queued, kids[0].Status);
|
// explicitly queues the plan.
|
||||||
|
Assert.Equal(TaskStatus.Idle, kids[0].Status);
|
||||||
Assert.Null(kids[0].BlockedByTaskId);
|
Assert.Null(kids[0].BlockedByTaskId);
|
||||||
Assert.Equal(TaskStatus.Queued, kids[1].Status);
|
Assert.Equal(TaskStatus.Idle, kids[1].Status);
|
||||||
Assert.Equal(kids[0].Id, kids[1].BlockedByTaskId);
|
Assert.Equal(kids[0].Id, kids[1].BlockedByTaskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Regression: original bug was "queue never picks up planning tasks". After Finalize
|
// Regression: original bug was "queue never picks up planning tasks". Finalize
|
||||||
// with queueAgentTasks=true, the first child must be claimable by the queue picker
|
// leaves children Idle; queueing the plan (the explicit user gate) must make the
|
||||||
// automatically — without anyone calling WakeQueue() manually.
|
// first child claimable by the picker automatically — without a manual WakeQueue().
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task FinalizeAsync_FirstChildIsClaimedByPicker_WithinDeadline()
|
public async Task QueuePlanAfterFinalize_FirstChildIsClaimedByPicker_WithinDeadline()
|
||||||
{
|
{
|
||||||
var listId = Guid.NewGuid().ToString();
|
var listId = Guid.NewGuid().ToString();
|
||||||
var wd = Path.Combine(Path.GetTempPath(), $"cd_e2e_wd_{Guid.NewGuid():N}");
|
var wd = Path.Combine(Path.GetTempPath(), $"cd_e2e_wd_{Guid.NewGuid():N}");
|
||||||
@@ -160,12 +161,18 @@ public sealed class PlanningEndToEndTests : IDisposable
|
|||||||
|
|
||||||
var kidsBefore = await _tasks.GetChildrenAsync(parent.Id);
|
var kidsBefore = await _tasks.GetChildrenAsync(parent.Id);
|
||||||
var firstChildId = kidsBefore[0].Id;
|
var firstChildId = kidsBefore[0].Id;
|
||||||
var wakesBefore = _built.WakeCount();
|
|
||||||
|
|
||||||
await _manager.FinalizeAsync(parent.Id, queueAgentTasks: true, CancellationToken.None);
|
await _manager.FinalizeAsync(parent.Id, queueAgentTasks: true, CancellationToken.None);
|
||||||
|
|
||||||
// The picker should pick the first child immediately. Auto-wake fires inside
|
// Finalize leaves every child Idle — nothing is claimable yet.
|
||||||
// _state.EnqueueAsync; we don't need a manual WakeQueue() for the bug to be fixed.
|
var afterFinalize = await _tasks.GetChildrenAsync(parent.Id);
|
||||||
|
Assert.All(afterFinalize, k => Assert.Equal(TaskStatus.Idle, k.Status));
|
||||||
|
|
||||||
|
// Queueing the plan is the gate that enqueues + auto-wakes. The picker should
|
||||||
|
// then pick the first child immediately, without a manual WakeQueue().
|
||||||
|
var wakesBefore = _built.WakeCount();
|
||||||
|
await _built.Chain.QueuePlanAsync(parent.Id, CancellationToken.None);
|
||||||
|
|
||||||
var picker = new QueuePicker(_db.CreateFactory());
|
var picker = new QueuePicker(_db.CreateFactory());
|
||||||
|
|
||||||
TaskEntity? claimed = null;
|
TaskEntity? claimed = null;
|
||||||
@@ -181,6 +188,6 @@ public sealed class PlanningEndToEndTests : IDisposable
|
|||||||
Assert.Equal(firstChildId, claimed!.Id);
|
Assert.Equal(firstChildId, claimed!.Id);
|
||||||
Assert.Equal(TaskStatus.Running, claimed.Status);
|
Assert.Equal(TaskStatus.Running, claimed.Status);
|
||||||
Assert.True(_built.WakeCount() > wakesBefore,
|
Assert.True(_built.WakeCount() > wakesBefore,
|
||||||
"TaskStateService.EnqueueAsync should auto-wake the queue.");
|
"QueuePlanAsync → EnqueueAsync should auto-wake the queue.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -352,4 +352,78 @@ public sealed class PlanningMergeOrchestratorTests : IDisposable
|
|||||||
await ctx.SaveChangesAsync();
|
await ctx.SaveChangesAsync();
|
||||||
return (parentId, running);
|
return (parentId, running);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── ApproveReview routing ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Improvement parent (PlanningPhase.None) in WaitingForReview with two Done children
|
||||||
|
/// that each have an Active worktree → orchestrator merges both and marks the parent Done.
|
||||||
|
/// This mirrors the ApproveReview hub path for a parent-with-children.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task StartAsync_ImprovementParentInWaitingForReview_MergesBothChildrenAndLeavesDone()
|
||||||
|
{
|
||||||
|
var db = NewDb();
|
||||||
|
var repo = NewRepo();
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||||
|
|
||||||
|
var (parentId, subA, subB) = await SeedImprovementParentWithTwoDoneChildrenAsync(db, repo);
|
||||||
|
|
||||||
|
var (orch, calls) = BuildOrchestrator(db);
|
||||||
|
|
||||||
|
await orch.StartAsync(parentId, "main", CancellationToken.None);
|
||||||
|
|
||||||
|
using var ctx = db.CreateContext();
|
||||||
|
var parent = ctx.Tasks.Single(t => t.Id == parentId);
|
||||||
|
Assert.Equal(TaskStatus.Done, parent.Status);
|
||||||
|
Assert.NotNull(parent.FinishedAt);
|
||||||
|
|
||||||
|
Assert.Equal(WorktreeState.Merged, ctx.Worktrees.Single(w => w.TaskId == subA).State);
|
||||||
|
Assert.Equal(WorktreeState.Merged, ctx.Worktrees.Single(w => w.TaskId == subB).State);
|
||||||
|
|
||||||
|
Assert.Contains(calls, c => c.Method == "PlanningMergeStarted");
|
||||||
|
Assert.Equal(2, calls.Count(c => c.Method == "PlanningSubtaskMerged"));
|
||||||
|
Assert.Contains(calls, c => c.Method == "PlanningCompleted" && (string)c.Args[0]! == parentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(string parentId, string subA, string subB)> SeedImprovementParentWithTwoDoneChildrenAsync(
|
||||||
|
DbFixture db, GitRepoFixture repo)
|
||||||
|
{
|
||||||
|
using var ctx = db.CreateContext();
|
||||||
|
|
||||||
|
var listId = Guid.NewGuid().ToString();
|
||||||
|
ctx.Lists.Add(new ListEntity
|
||||||
|
{
|
||||||
|
Id = listId, Name = "test", CreatedAt = DateTime.UtcNow,
|
||||||
|
WorkingDir = repo.RepoDir,
|
||||||
|
});
|
||||||
|
|
||||||
|
var parentId = Guid.NewGuid().ToString();
|
||||||
|
// Improvement parent: PlanningPhase.None, status WaitingForReview (after children finished)
|
||||||
|
ctx.Tasks.Add(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = parentId, ListId = listId, Title = "improve", CreatedAt = DateTime.UtcNow,
|
||||||
|
Status = TaskStatus.WaitingForReview, PlanningPhase = PlanningPhase.None, SortOrder = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
var subA = Guid.NewGuid().ToString();
|
||||||
|
var subB = Guid.NewGuid().ToString();
|
||||||
|
ctx.Tasks.Add(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = subA, ListId = listId, Title = "child A", CreatedAt = DateTime.UtcNow,
|
||||||
|
ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 1,
|
||||||
|
});
|
||||||
|
ctx.Tasks.Add(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = subB, ListId = listId, Title = "child B", CreatedAt = DateTime.UtcNow,
|
||||||
|
ParentTaskId = parentId, Status = TaskStatus.Done, SortOrder = 2,
|
||||||
|
});
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
SeedWorktree(ctx, repo, subA, "fileA.txt", "content A");
|
||||||
|
SeedWorktree(ctx, repo, subB, "fileB.txt", "content B");
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
return (parentId, subA, subB);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,8 +19,10 @@ public class DailyPrepPromptTests
|
|||||||
public void Build_args_allows_only_the_two_tools()
|
public void Build_args_allows_only_the_two_tools()
|
||||||
{
|
{
|
||||||
var args = DailyPrepPrompt.BuildArgs(maxTurns: 30);
|
var args = DailyPrepPrompt.BuildArgs(maxTurns: 30);
|
||||||
Assert.Contains("--output-format stream-json", args);
|
Assert.Contains("--output-format", args);
|
||||||
Assert.Contains("--max-turns 30", args);
|
Assert.Contains("stream-json", args);
|
||||||
|
Assert.Contains("--max-turns", args);
|
||||||
|
Assert.Contains("30", args);
|
||||||
Assert.Contains("--allowedTools", args);
|
Assert.Contains("--allowedTools", args);
|
||||||
Assert.Contains("mcp__claudedo__get_daily_prep_candidates", args);
|
Assert.Contains("mcp__claudedo__get_daily_prep_candidates", args);
|
||||||
Assert.Contains("mcp__claudedo__set_my_day", args);
|
Assert.Contains("mcp__claudedo__set_my_day", args);
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ public class PrimeRunnerTests : IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async Task<RunResult> RunAsync(
|
public async Task<RunResult> RunAsync(
|
||||||
string arguments,
|
IReadOnlyList<string> arguments,
|
||||||
string prompt,
|
string prompt,
|
||||||
string workingDirectory,
|
string workingDirectory,
|
||||||
Func<string, Task> onStdoutLine,
|
Func<string, Task> onStdoutLine,
|
||||||
|
|||||||
@@ -11,9 +11,12 @@ public sealed class RefinePromptTests
|
|||||||
{
|
{
|
||||||
var args = RefinePrompt.BuildArgs(20, canReadRepo: true);
|
var args = RefinePrompt.BuildArgs(20, canReadRepo: true);
|
||||||
|
|
||||||
Assert.Contains("--permission-mode acceptEdits", args);
|
Assert.Contains("--permission-mode", args);
|
||||||
|
Assert.Contains("acceptEdits", args);
|
||||||
Assert.Contains("mcp__claudedo__add_subtask", args);
|
Assert.Contains("mcp__claudedo__add_subtask", args);
|
||||||
Assert.Contains(" Read Grep Glob", args);
|
Assert.Contains("Read", args);
|
||||||
|
Assert.Contains("Grep", args);
|
||||||
|
Assert.Contains("Glob", args);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ internal sealed class RecordingClaudeProcess : IClaudeProcess
|
|||||||
|
|
||||||
public RecordingClaudeProcess(bool success) => _success = success;
|
public RecordingClaudeProcess(bool success) => _success = success;
|
||||||
|
|
||||||
public Task<RunResult> RunAsync(string arguments, string prompt, string workingDirectory,
|
public Task<RunResult> RunAsync(IReadOnlyList<string> arguments, string prompt, string workingDirectory,
|
||||||
Func<string, Task> onStdoutLine, CancellationToken ct)
|
Func<string, Task> onStdoutLine, CancellationToken ct)
|
||||||
{
|
{
|
||||||
Interlocked.Increment(ref _callCount);
|
Interlocked.Increment(ref _callCount);
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ public class WeekReportServiceTests : IDisposable
|
|||||||
{
|
{
|
||||||
public int Calls;
|
public int Calls;
|
||||||
public RunResult Next = new() { ExitCode = 0, ResultMarkdown = "## Bericht" };
|
public RunResult Next = new() { ExitCode = 0, ResultMarkdown = "## Bericht" };
|
||||||
public Task<RunResult> RunAsync(string args, string prompt, string wd, Func<string, Task> onLine, CancellationToken ct)
|
public Task<RunResult> RunAsync(IReadOnlyList<string> args, string prompt, string wd, Func<string, Task> onLine, CancellationToken ct)
|
||||||
{ Calls++; return Task.FromResult(Next); }
|
{ Calls++; return Task.FromResult(Next); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,139 +0,0 @@
|
|||||||
using ClaudeDo.Data;
|
|
||||||
using ClaudeDo.Data.Models;
|
|
||||||
using ClaudeDo.Data.Repositories;
|
|
||||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
||||||
|
|
||||||
namespace ClaudeDo.Worker.Tests.Repositories;
|
|
||||||
|
|
||||||
public sealed class TaskRepositoryParentCompletionTests : IDisposable
|
|
||||||
{
|
|
||||||
private readonly DbFixture _db = new();
|
|
||||||
private readonly ClaudeDoDbContext _ctx;
|
|
||||||
private readonly TaskRepository _tasks;
|
|
||||||
private readonly ListRepository _lists;
|
|
||||||
|
|
||||||
public TaskRepositoryParentCompletionTests()
|
|
||||||
{
|
|
||||||
_ctx = _db.CreateContext();
|
|
||||||
_tasks = new TaskRepository(_ctx);
|
|
||||||
_lists = new ListRepository(_ctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
|
|
||||||
|
|
||||||
private async Task<string> ListAsync()
|
|
||||||
{
|
|
||||||
var id = Guid.NewGuid().ToString();
|
|
||||||
await _lists.AddAsync(new ListEntity { Id = id, Name = "L", CreatedAt = DateTime.UtcNow });
|
|
||||||
return id;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<TaskEntity> PlannedParentAsync(string listId)
|
|
||||||
{
|
|
||||||
var parent = new TaskEntity
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid().ToString(),
|
|
||||||
ListId = listId,
|
|
||||||
Title = "p",
|
|
||||||
Status = TaskStatus.Idle,
|
|
||||||
PlanningPhase = PlanningPhase.Finalized,
|
|
||||||
CreatedAt = DateTime.UtcNow,
|
|
||||||
CommitType = "chore",
|
|
||||||
};
|
|
||||||
await _tasks.AddAsync(parent);
|
|
||||||
return parent;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<TaskEntity> ChildAsync(string listId, string parentId, TaskStatus status)
|
|
||||||
{
|
|
||||||
var child = new TaskEntity
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid().ToString(),
|
|
||||||
ListId = listId,
|
|
||||||
Title = "c",
|
|
||||||
Status = status,
|
|
||||||
CreatedAt = DateTime.UtcNow,
|
|
||||||
CommitType = "chore",
|
|
||||||
ParentTaskId = parentId,
|
|
||||||
};
|
|
||||||
await _tasks.AddAsync(child);
|
|
||||||
return child;
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task TryCompleteParentAsync_AllChildrenDone_ParentBecomesDone()
|
|
||||||
{
|
|
||||||
var listId = await ListAsync();
|
|
||||||
var parent = await PlannedParentAsync(listId);
|
|
||||||
await ChildAsync(listId, parent.Id, TaskStatus.Done);
|
|
||||||
await ChildAsync(listId, parent.Id, TaskStatus.Done);
|
|
||||||
|
|
||||||
await _tasks.TryCompleteParentAsync(parent.Id);
|
|
||||||
|
|
||||||
var loaded = await _tasks.GetByIdAsync(parent.Id);
|
|
||||||
Assert.Equal(TaskStatus.Done, loaded!.Status);
|
|
||||||
Assert.NotNull(loaded.FinishedAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task TryCompleteParentAsync_OneFailedRestDone_ParentBecomesFailed()
|
|
||||||
{
|
|
||||||
var listId = await ListAsync();
|
|
||||||
var parent = await PlannedParentAsync(listId);
|
|
||||||
await ChildAsync(listId, parent.Id, TaskStatus.Done);
|
|
||||||
await ChildAsync(listId, parent.Id, TaskStatus.Failed);
|
|
||||||
|
|
||||||
await _tasks.TryCompleteParentAsync(parent.Id);
|
|
||||||
|
|
||||||
var loaded = await _tasks.GetByIdAsync(parent.Id);
|
|
||||||
Assert.Equal(TaskStatus.Failed, loaded!.Status);
|
|
||||||
Assert.NotNull(loaded.FinishedAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task TryCompleteParentAsync_OneStillRunning_ParentStaysPlanned()
|
|
||||||
{
|
|
||||||
var listId = await ListAsync();
|
|
||||||
var parent = await PlannedParentAsync(listId);
|
|
||||||
await ChildAsync(listId, parent.Id, TaskStatus.Done);
|
|
||||||
await ChildAsync(listId, parent.Id, TaskStatus.Running);
|
|
||||||
|
|
||||||
await _tasks.TryCompleteParentAsync(parent.Id);
|
|
||||||
|
|
||||||
var loaded = await _tasks.GetByIdAsync(parent.Id);
|
|
||||||
Assert.Equal(TaskStatus.Idle, loaded!.Status);
|
|
||||||
Assert.Equal(PlanningPhase.Finalized, loaded.PlanningPhase);
|
|
||||||
Assert.Null(loaded.FinishedAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task TryCompleteParentAsync_ChildStillIdle_ParentStaysFinalized()
|
|
||||||
{
|
|
||||||
var listId = await ListAsync();
|
|
||||||
var parent = await PlannedParentAsync(listId);
|
|
||||||
await ChildAsync(listId, parent.Id, TaskStatus.Done);
|
|
||||||
await ChildAsync(listId, parent.Id, TaskStatus.Idle);
|
|
||||||
|
|
||||||
await _tasks.TryCompleteParentAsync(parent.Id);
|
|
||||||
|
|
||||||
var loaded = await _tasks.GetByIdAsync(parent.Id);
|
|
||||||
Assert.Equal(PlanningPhase.Finalized, loaded!.PlanningPhase);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task TryCompleteParentAsync_ParentIsNotFinalized_NoChange()
|
|
||||||
{
|
|
||||||
var listId = await ListAsync();
|
|
||||||
var parent = await PlannedParentAsync(listId);
|
|
||||||
await _ctx.Database.ExecuteSqlRawAsync(
|
|
||||||
"UPDATE tasks SET planning_phase = 'active' WHERE id = {0}", parent.Id);
|
|
||||||
await ChildAsync(listId, parent.Id, TaskStatus.Done);
|
|
||||||
|
|
||||||
await _tasks.TryCompleteParentAsync(parent.Id);
|
|
||||||
|
|
||||||
var loaded = await _tasks.GetByIdAsync(parent.Id);
|
|
||||||
Assert.Equal(PlanningPhase.Active, loaded!.PlanningPhase);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,9 +11,11 @@ public sealed class ClaudeArgsBuilderTests
|
|||||||
{
|
{
|
||||||
var args = _builder.Build(new ClaudeRunConfig(null, null, null, null));
|
var args = _builder.Build(new ClaudeRunConfig(null, null, null, null));
|
||||||
Assert.Contains("-p", args);
|
Assert.Contains("-p", args);
|
||||||
Assert.Contains("--output-format stream-json", args);
|
Assert.Contains("--output-format", args);
|
||||||
|
Assert.Contains("stream-json", args);
|
||||||
Assert.Contains("--verbose", args);
|
Assert.Contains("--verbose", args);
|
||||||
Assert.Contains("--permission-mode auto", args);
|
Assert.Contains("--permission-mode", args);
|
||||||
|
Assert.Contains("auto", args);
|
||||||
Assert.DoesNotContain("--dangerously-skip-permissions", args);
|
Assert.DoesNotContain("--dangerously-skip-permissions", args);
|
||||||
Assert.Contains("--json-schema", args);
|
Assert.Contains("--json-schema", args);
|
||||||
Assert.DoesNotContain("--model", args);
|
Assert.DoesNotContain("--model", args);
|
||||||
@@ -26,7 +28,8 @@ public sealed class ClaudeArgsBuilderTests
|
|||||||
public void Model_Adds_Model_Flag()
|
public void Model_Adds_Model_Flag()
|
||||||
{
|
{
|
||||||
var args = _builder.Build(new ClaudeRunConfig("sonnet-4-6", null, null, null));
|
var args = _builder.Build(new ClaudeRunConfig("sonnet-4-6", null, null, null));
|
||||||
Assert.Contains("--model sonnet-4-6", args);
|
Assert.Contains("--model", args);
|
||||||
|
Assert.Contains("sonnet-4-6", args);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -42,63 +45,74 @@ public sealed class ClaudeArgsBuilderTests
|
|||||||
{
|
{
|
||||||
var args = _builder.Build(new ClaudeRunConfig(null, null, "/path/to/agent.md", null));
|
var args = _builder.Build(new ClaudeRunConfig(null, null, "/path/to/agent.md", null));
|
||||||
Assert.Contains("--agents", args);
|
Assert.Contains("--agents", args);
|
||||||
Assert.Contains("/path/to/agent.md", args);
|
Assert.Contains(args, a => a.Contains("/path/to/agent.md"));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ResumeSessionId_Adds_Resume_Flag()
|
public void ResumeSessionId_Adds_Resume_Flag()
|
||||||
{
|
{
|
||||||
var args = _builder.Build(new ClaudeRunConfig(null, null, null, "sess-abc-123"));
|
var args = _builder.Build(new ClaudeRunConfig(null, null, null, "sess-abc-123"));
|
||||||
Assert.Contains("--resume sess-abc-123", args);
|
Assert.Contains("--resume", args);
|
||||||
|
Assert.Contains("sess-abc-123", args);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void All_Options_Set_Includes_All_Flags()
|
public void All_Options_Set_Includes_All_Flags()
|
||||||
{
|
{
|
||||||
var args = _builder.Build(new ClaudeRunConfig("opus-4-6", "Be thorough.", "/agents/dev.md", "sess-xyz"));
|
var args = _builder.Build(new ClaudeRunConfig("opus-4-6", "Be thorough.", "/agents/dev.md", "sess-xyz"));
|
||||||
Assert.Contains("--model opus-4-6", args);
|
Assert.Contains("--model", args);
|
||||||
|
Assert.Contains("opus-4-6", args);
|
||||||
Assert.Contains("--append-system-prompt", args);
|
Assert.Contains("--append-system-prompt", args);
|
||||||
Assert.Contains("--agents", args);
|
Assert.Contains("--agents", args);
|
||||||
Assert.Contains("--resume sess-xyz", args);
|
Assert.Contains("--resume", args);
|
||||||
|
Assert.Contains("sess-xyz", args);
|
||||||
Assert.Contains("--json-schema", args);
|
Assert.Contains("--json-schema", args);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SystemPrompt_With_Quotes_Is_Escaped()
|
public void SystemPrompt_With_Quotes_Is_Passed_Verbatim()
|
||||||
{
|
{
|
||||||
var args = _builder.Build(new ClaudeRunConfig(null, """Don't say "hello".""", null, null));
|
var prompt = """Don't say "hello".""";
|
||||||
|
var args = _builder.Build(new ClaudeRunConfig(null, prompt, null, null));
|
||||||
Assert.Contains("--append-system-prompt", args);
|
Assert.Contains("--append-system-prompt", args);
|
||||||
|
Assert.Contains(prompt, args);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Build_quotes_system_prompt_with_newline()
|
public void SystemPrompt_With_Newline_Is_Passed_As_Single_Element()
|
||||||
{
|
{
|
||||||
|
var prompt = "line1\nline2";
|
||||||
var args = _builder.Build(new ClaudeRunConfig(
|
var args = _builder.Build(new ClaudeRunConfig(
|
||||||
Model: null,
|
Model: null,
|
||||||
SystemPrompt: "line1\nline2",
|
SystemPrompt: prompt,
|
||||||
AgentPath: null,
|
AgentPath: null,
|
||||||
ResumeSessionId: null));
|
ResumeSessionId: null));
|
||||||
|
|
||||||
Assert.Contains("--append-system-prompt \"line1\\nline2\"", args);
|
var list = args.ToList();
|
||||||
|
var idx = list.IndexOf("--append-system-prompt");
|
||||||
|
Assert.True(idx >= 0);
|
||||||
|
Assert.Equal(prompt, list[idx + 1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Build_quotes_system_prompt_with_tab()
|
public void SystemPrompt_With_Tab_Is_Passed_As_Single_Element()
|
||||||
{
|
{
|
||||||
|
var prompt = "col1\tcol2";
|
||||||
var args = _builder.Build(new ClaudeRunConfig(
|
var args = _builder.Build(new ClaudeRunConfig(
|
||||||
Model: null,
|
Model: null,
|
||||||
SystemPrompt: "col1\tcol2",
|
SystemPrompt: prompt,
|
||||||
AgentPath: null,
|
AgentPath: null,
|
||||||
ResumeSessionId: null));
|
ResumeSessionId: null));
|
||||||
|
|
||||||
Assert.Contains("\"col1", args);
|
Assert.Contains(prompt, args);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void MaxTurns_Adds_Flag_When_Positive()
|
public void MaxTurns_Adds_Flag_When_Positive()
|
||||||
{
|
{
|
||||||
var args = _builder.Build(new ClaudeRunConfig(null, null, null, null, MaxTurns: 25));
|
var args = _builder.Build(new ClaudeRunConfig(null, null, null, null, MaxTurns: 25));
|
||||||
Assert.Contains("--max-turns 25", args);
|
Assert.Contains("--max-turns", args);
|
||||||
|
Assert.Contains("25", args);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -114,7 +128,8 @@ public sealed class ClaudeArgsBuilderTests
|
|||||||
public void PermissionMode_bypass_Maps_To_Auto()
|
public void PermissionMode_bypass_Maps_To_Auto()
|
||||||
{
|
{
|
||||||
var args = _builder.Build(new ClaudeRunConfig(null, null, null, null, PermissionMode: "bypassPermissions"));
|
var args = _builder.Build(new ClaudeRunConfig(null, null, null, null, PermissionMode: "bypassPermissions"));
|
||||||
Assert.Contains("--permission-mode auto", args);
|
Assert.Contains("--permission-mode", args);
|
||||||
|
Assert.Contains("auto", args);
|
||||||
Assert.DoesNotContain("--dangerously-skip-permissions", args);
|
Assert.DoesNotContain("--dangerously-skip-permissions", args);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +137,8 @@ public sealed class ClaudeArgsBuilderTests
|
|||||||
public void PermissionMode_acceptEdits_Emits_PermissionMode_Flag()
|
public void PermissionMode_acceptEdits_Emits_PermissionMode_Flag()
|
||||||
{
|
{
|
||||||
var args = _builder.Build(new ClaudeRunConfig(null, null, null, null, PermissionMode: "acceptEdits"));
|
var args = _builder.Build(new ClaudeRunConfig(null, null, null, null, PermissionMode: "acceptEdits"));
|
||||||
Assert.Contains("--permission-mode acceptEdits", args);
|
Assert.Contains("--permission-mode", args);
|
||||||
|
Assert.Contains("acceptEdits", args);
|
||||||
Assert.DoesNotContain("--dangerously-skip-permissions", args);
|
Assert.DoesNotContain("--dangerously-skip-permissions", args);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +146,8 @@ public sealed class ClaudeArgsBuilderTests
|
|||||||
public void PermissionMode_Null_Defaults_To_Auto()
|
public void PermissionMode_Null_Defaults_To_Auto()
|
||||||
{
|
{
|
||||||
var args = _builder.Build(new ClaudeRunConfig(null, null, null, null));
|
var args = _builder.Build(new ClaudeRunConfig(null, null, null, null));
|
||||||
Assert.Contains("--permission-mode auto", args);
|
Assert.Contains("--permission-mode", args);
|
||||||
|
Assert.Contains("auto", args);
|
||||||
Assert.DoesNotContain("--dangerously-skip-permissions", args);
|
Assert.DoesNotContain("--dangerously-skip-permissions", args);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,8 +159,26 @@ public sealed class ClaudeArgsBuilderTests
|
|||||||
McpConfigPath: "C:\\tmp\\t_mcp.json",
|
McpConfigPath: "C:\\tmp\\t_mcp.json",
|
||||||
AllowedTools: "mcp__claudedo_run__SuggestImprovement"));
|
AllowedTools: "mcp__claudedo_run__SuggestImprovement"));
|
||||||
Assert.Contains("--mcp-config", args);
|
Assert.Contains("--mcp-config", args);
|
||||||
Assert.Contains("t_mcp.json", args);
|
Assert.Contains(args, a => a.Contains("t_mcp.json"));
|
||||||
Assert.Contains("--allowedTools mcp__claudedo_run__SuggestImprovement", args);
|
Assert.Contains("--allowedTools", args);
|
||||||
|
Assert.Contains("mcp__claudedo_run__SuggestImprovement", args);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SystemPrompt_ContainingDangerousFlag_IsPassedAsLiteral_NotAsFlag()
|
||||||
|
{
|
||||||
|
// If the system prompt contains "--dangerously-skip-permissions", it must arrive
|
||||||
|
// as the VALUE of --append-system-prompt, not as a standalone CLI flag.
|
||||||
|
const string dangerousPrompt = "--dangerously-skip-permissions";
|
||||||
|
var args = _builder.Build(new ClaudeRunConfig(null, dangerousPrompt, null, null));
|
||||||
|
|
||||||
|
var list = args.ToList();
|
||||||
|
var flagIdx = list.IndexOf("--append-system-prompt");
|
||||||
|
Assert.True(flagIdx >= 0, "--append-system-prompt flag must be present");
|
||||||
|
// The dangerous string sits immediately after the flag as its value.
|
||||||
|
Assert.Equal(dangerousPrompt, list[flagIdx + 1]);
|
||||||
|
// It does NOT appear as a separate element (i.e., not treated as its own flag).
|
||||||
|
Assert.Equal(1, list.Count(a => a == dangerousPrompt));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Worker.Config;
|
||||||
|
using ClaudeDo.Worker.Hub;
|
||||||
|
using ClaudeDo.Worker.Runner;
|
||||||
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||||
|
using ClaudeDo.Worker.Tests.Services;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Runner;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Verifies that ContinueAsync wraps RunOnceAsync exceptions so the task
|
||||||
|
/// is never left stuck in Running status on an unexpected error.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ContinueAsyncExceptionTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly DbFixture _db = new();
|
||||||
|
private readonly string _tempDir;
|
||||||
|
private readonly WorkerConfig _cfg;
|
||||||
|
|
||||||
|
public ContinueAsyncExceptionTests()
|
||||||
|
{
|
||||||
|
_tempDir = Path.Combine(Path.GetTempPath(), $"cd_continue_{Guid.NewGuid():N}");
|
||||||
|
Directory.CreateDirectory(_tempDir);
|
||||||
|
_cfg = new WorkerConfig { SandboxRoot = _tempDir, LogRoot = _tempDir };
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() { _db.Dispose(); try { Directory.Delete(_tempDir, true); } catch { } }
|
||||||
|
|
||||||
|
private TaskRunner BuildRunner(IClaudeProcess claude, ClaudeDoDbContext ctx)
|
||||||
|
{
|
||||||
|
var dbFactory = _db.CreateFactory();
|
||||||
|
var broadcaster = new HubBroadcaster(new FakeHubContext());
|
||||||
|
var state = TaskStateServiceBuilder.Build(dbFactory).State;
|
||||||
|
var wt = new WorktreeManager(new ClaudeDo.Data.Git.GitService(), dbFactory, _cfg, NullLogger<WorktreeManager>.Instance);
|
||||||
|
return new TaskRunner(claude, dbFactory, broadcaster, wt, new ClaudeArgsBuilder(), _cfg,
|
||||||
|
NullLogger<TaskRunner>.Instance, state, new TaskRunTokenRegistry());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ContinueAsync_UnhandledException_MarksTaskFailed_NotStuckRunning()
|
||||||
|
{
|
||||||
|
var dbFactory = _db.CreateFactory();
|
||||||
|
string listId, taskId;
|
||||||
|
|
||||||
|
using (var ctx = _db.CreateContext())
|
||||||
|
{
|
||||||
|
listId = Guid.NewGuid().ToString();
|
||||||
|
ctx.Lists.Add(new ListEntity { Id = listId, Name = "L", WorkingDir = null, CreatedAt = DateTime.UtcNow });
|
||||||
|
|
||||||
|
taskId = Guid.NewGuid().ToString();
|
||||||
|
ctx.Tasks.Add(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = taskId,
|
||||||
|
ListId = listId,
|
||||||
|
Title = "Continue me",
|
||||||
|
Status = TaskStatus.WaitingForReview,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
});
|
||||||
|
await ctx.SaveChangesAsync();
|
||||||
|
|
||||||
|
// A prior run with a session ID is required for ContinueAsync to proceed.
|
||||||
|
await new TaskRunRepository(ctx).AddAsync(new TaskRunEntity
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(),
|
||||||
|
TaskId = taskId,
|
||||||
|
RunNumber = 1,
|
||||||
|
IsRetry = false,
|
||||||
|
Prompt = "original prompt",
|
||||||
|
SessionId = "sess-continue-test",
|
||||||
|
StartedAt = DateTime.UtcNow.AddMinutes(-5),
|
||||||
|
FinishedAt = DateTime.UtcNow.AddMinutes(-1),
|
||||||
|
ExitCode = 0,
|
||||||
|
ResultMarkdown = "first result",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// This process throws a non-cancellation exception to simulate an unexpected failure.
|
||||||
|
var throwingProcess = new ThrowingClaudeProcess(new InvalidOperationException("disk full"));
|
||||||
|
|
||||||
|
using var ctx2 = _db.CreateContext();
|
||||||
|
var runner = BuildRunner(throwingProcess, ctx2);
|
||||||
|
|
||||||
|
// ContinueAsync must not propagate the exception and must leave the task in Failed.
|
||||||
|
await runner.ContinueAsync(taskId, "please continue", "slot-1", CancellationToken.None);
|
||||||
|
|
||||||
|
using var verify = _db.CreateContext();
|
||||||
|
var task = await new TaskRepository(verify).GetByIdAsync(taskId);
|
||||||
|
Assert.NotNull(task);
|
||||||
|
Assert.Equal(TaskStatus.Failed, task.Status);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class ThrowingClaudeProcess : IClaudeProcess
|
||||||
|
{
|
||||||
|
private readonly Exception _ex;
|
||||||
|
public ThrowingClaudeProcess(Exception ex) => _ex = ex;
|
||||||
|
|
||||||
|
public Task<RunResult> RunAsync(
|
||||||
|
IReadOnlyList<string> arguments, string prompt, string workingDirectory,
|
||||||
|
Func<string, Task> onStdoutLine, CancellationToken ct)
|
||||||
|
=> throw _ex;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
using ClaudeDo.Data;
|
|
||||||
using ClaudeDo.Data.Models;
|
|
||||||
using ClaudeDo.Data.Repositories;
|
|
||||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
|
||||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
||||||
|
|
||||||
namespace ClaudeDo.Worker.Tests.Runner;
|
|
||||||
|
|
||||||
public sealed class TaskRunnerParentCompletionTests : IDisposable
|
|
||||||
{
|
|
||||||
private readonly DbFixture _db = new();
|
|
||||||
private readonly ClaudeDoDbContext _ctx;
|
|
||||||
private readonly TaskRepository _tasks;
|
|
||||||
private readonly ListRepository _lists;
|
|
||||||
|
|
||||||
public TaskRunnerParentCompletionTests()
|
|
||||||
{
|
|
||||||
_ctx = _db.CreateContext();
|
|
||||||
_tasks = new TaskRepository(_ctx);
|
|
||||||
_lists = new ListRepository(_ctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose() { _ctx.Dispose(); _db.Dispose(); }
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ChildMarkedDone_LastOne_ParentFinalized()
|
|
||||||
{
|
|
||||||
var listId = Guid.NewGuid().ToString();
|
|
||||||
await _lists.AddAsync(new ListEntity { Id = listId, Name = "L", CreatedAt = DateTime.UtcNow });
|
|
||||||
|
|
||||||
var parent = new TaskEntity
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid().ToString(),
|
|
||||||
ListId = listId,
|
|
||||||
Title = "p",
|
|
||||||
Status = TaskStatus.Idle,
|
|
||||||
PlanningPhase = PlanningPhase.Finalized,
|
|
||||||
CreatedAt = DateTime.UtcNow,
|
|
||||||
CommitType = "chore",
|
|
||||||
};
|
|
||||||
await _tasks.AddAsync(parent);
|
|
||||||
|
|
||||||
var c1 = new TaskEntity
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid().ToString(),
|
|
||||||
ListId = listId,
|
|
||||||
Title = "c1",
|
|
||||||
Status = TaskStatus.Done,
|
|
||||||
CreatedAt = DateTime.UtcNow,
|
|
||||||
CommitType = "chore",
|
|
||||||
ParentTaskId = parent.Id,
|
|
||||||
};
|
|
||||||
var c2 = new TaskEntity
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid().ToString(),
|
|
||||||
ListId = listId,
|
|
||||||
Title = "c2",
|
|
||||||
Status = TaskStatus.Running,
|
|
||||||
CreatedAt = DateTime.UtcNow,
|
|
||||||
CommitType = "chore",
|
|
||||||
ParentTaskId = parent.Id,
|
|
||||||
};
|
|
||||||
await _tasks.AddAsync(c1);
|
|
||||||
await _tasks.AddAsync(c2);
|
|
||||||
|
|
||||||
// Simulate the runner finishing the second child:
|
|
||||||
await _tasks.MarkDoneAsync(c2.Id, DateTime.UtcNow, "done");
|
|
||||||
if (c2.ParentTaskId is not null)
|
|
||||||
await _tasks.TryCompleteParentAsync(c2.ParentTaskId);
|
|
||||||
|
|
||||||
var parentLoaded = await _tasks.GetByIdAsync(parent.Id);
|
|
||||||
Assert.Equal(TaskStatus.Done, parentLoaded!.Status);
|
|
||||||
Assert.NotNull(parentLoaded.FinishedAt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -46,7 +46,7 @@ public sealed class QueueServiceSlotGuardTests : IDisposable
|
|||||||
private QueueWaker _waker = null!;
|
private QueueWaker _waker = null!;
|
||||||
|
|
||||||
private (QueueService service, FakeClaudeProcess fakeProcess) CreateService(
|
private (QueueService service, FakeClaudeProcess fakeProcess) CreateService(
|
||||||
Func<string, string, string, Func<string, Task>, CancellationToken, Task<RunResult>>? handler = null)
|
Func<string, string, IReadOnlyList<string>, Func<string, Task>, CancellationToken, Task<RunResult>>? handler = null)
|
||||||
{
|
{
|
||||||
var fake = new FakeClaudeProcess(handler);
|
var fake = new FakeClaudeProcess(handler);
|
||||||
var broadcaster = new HubBroadcaster(new FakeHubContext());
|
var broadcaster = new HubBroadcaster(new FakeHubContext());
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ public sealed class QueueServiceTests : IDisposable
|
|||||||
private QueueWaker _waker = null!;
|
private QueueWaker _waker = null!;
|
||||||
|
|
||||||
private (QueueService service, FakeClaudeProcess fakeProcess) CreateService(
|
private (QueueService service, FakeClaudeProcess fakeProcess) CreateService(
|
||||||
Func<string, string, string, Func<string, Task>, CancellationToken, Task<RunResult>>? handler = null)
|
Func<string, string, IReadOnlyList<string>, Func<string, Task>, CancellationToken, Task<RunResult>>? handler = null)
|
||||||
{
|
{
|
||||||
var fake = new FakeClaudeProcess(handler);
|
var fake = new FakeClaudeProcess(handler);
|
||||||
var broadcaster = new HubBroadcaster(new FakeHubContext());
|
var broadcaster = new HubBroadcaster(new FakeHubContext());
|
||||||
@@ -118,7 +118,7 @@ public sealed class QueueServiceTests : IDisposable
|
|||||||
{
|
{
|
||||||
var (listId, _) = await SeedListWithAgentTag();
|
var (listId, _) = await SeedListWithAgentTag();
|
||||||
|
|
||||||
string? capturedArgs = null;
|
IReadOnlyList<string>? capturedArgs = null;
|
||||||
string? capturedPrompt = null;
|
string? capturedPrompt = null;
|
||||||
var done = new TaskCompletionSource();
|
var done = new TaskCompletionSource();
|
||||||
|
|
||||||
@@ -157,7 +157,9 @@ public sealed class QueueServiceTests : IDisposable
|
|||||||
_waker.Wake();
|
_waker.Wake();
|
||||||
await done.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
await done.Task.WaitAsync(TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
Assert.Contains("--resume sess-1", capturedArgs);
|
Assert.NotNull(capturedArgs);
|
||||||
|
Assert.Contains("--resume", capturedArgs);
|
||||||
|
Assert.Contains("sess-1", capturedArgs);
|
||||||
Assert.Equal("fix the bug", capturedPrompt);
|
Assert.Equal("fix the bug", capturedPrompt);
|
||||||
|
|
||||||
// Feedback is cleared after the run reaches a successful terminal state (post-run),
|
// Feedback is cleared after the run reaches a successful terminal state (post-run),
|
||||||
@@ -346,19 +348,19 @@ public sealed class QueueServiceTests : IDisposable
|
|||||||
|
|
||||||
internal sealed class FakeClaudeProcess : IClaudeProcess
|
internal sealed class FakeClaudeProcess : IClaudeProcess
|
||||||
{
|
{
|
||||||
private readonly Func<string, string, string, Func<string, Task>, CancellationToken, Task<RunResult>> _handler;
|
private readonly Func<string, string, IReadOnlyList<string>, Func<string, Task>, CancellationToken, Task<RunResult>> _handler;
|
||||||
private int _callCount;
|
private int _callCount;
|
||||||
|
|
||||||
public int CallCount => _callCount;
|
public int CallCount => _callCount;
|
||||||
|
|
||||||
public FakeClaudeProcess(
|
public FakeClaudeProcess(
|
||||||
Func<string, string, string, Func<string, Task>, CancellationToken, Task<RunResult>>? handler = null)
|
Func<string, string, IReadOnlyList<string>, Func<string, Task>, CancellationToken, Task<RunResult>>? handler = null)
|
||||||
{
|
{
|
||||||
_handler = handler ?? ((_, _, _, _, _) =>
|
_handler = handler ?? ((_, _, _, _, _) =>
|
||||||
Task.FromResult(new RunResult { ExitCode = 0, ResultMarkdown = "ok" }));
|
Task.FromResult(new RunResult { ExitCode = 0, ResultMarkdown = "ok" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<RunResult> RunAsync(string arguments, string prompt, string workingDirectory,
|
public async Task<RunResult> RunAsync(IReadOnlyList<string> arguments, string prompt, string workingDirectory,
|
||||||
Func<string, Task> onStdoutLine, CancellationToken ct)
|
Func<string, Task> onStdoutLine, CancellationToken ct)
|
||||||
{
|
{
|
||||||
Interlocked.Increment(ref _callCount);
|
Interlocked.Increment(ref _callCount);
|
||||||
|
|||||||
@@ -531,6 +531,8 @@ public class TaskMergeServiceTests : IDisposable
|
|||||||
Assert.Equal(TaskStatus.Done, updated!.Status);
|
Assert.Equal(TaskStatus.Done, updated!.Status);
|
||||||
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
|
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
|
||||||
Assert.Equal(WorktreeState.Merged, wt!.State);
|
Assert.Equal(WorktreeState.Merged, wt!.State);
|
||||||
|
// Approve removes the worktree so merged worktrees don't pile up.
|
||||||
|
Assert.False(Directory.Exists(wtCtx.WorktreePath));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -621,6 +623,72 @@ public class TaskMergeServiceTests : IDisposable
|
|||||||
// Cleanup
|
// Cleanup
|
||||||
GitRepoFixture.RunGit(repo.RepoDir, "merge", "--abort");
|
GitRepoFixture.RunGit(repo.RepoDir, "merge", "--abort");
|
||||||
}
|
}
|
||||||
|
[Fact]
|
||||||
|
public async Task GetConflictsAsync_AfterConflictMerge_ReturnsOursAndTheirs()
|
||||||
|
{
|
||||||
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||||
|
|
||||||
|
var db = NewDb();
|
||||||
|
var repo = NewRepo();
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||||
|
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n");
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change");
|
||||||
|
|
||||||
|
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
||||||
|
_wtCleanups.Add((repo.RepoDir, wtPath));
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/c1", wtPath, repo.BaseCommit);
|
||||||
|
File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n");
|
||||||
|
GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch change");
|
||||||
|
|
||||||
|
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.WaitingForReview);
|
||||||
|
await SeedWorktree(db, task.Id, wtPath, "claudedo/c1", repo.BaseCommit);
|
||||||
|
|
||||||
|
var (svc, _) = BuildService(db);
|
||||||
|
var start = await svc.MergeAsync(task.Id, "main", false, "msg", leaveConflictsInTree: true, CancellationToken.None);
|
||||||
|
Assert.Equal(TaskMergeService.StatusConflict, start.Status);
|
||||||
|
|
||||||
|
var conflicts = await svc.GetConflictsAsync(task.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(task.Id, conflicts.TaskId);
|
||||||
|
var file = Assert.Single(conflicts.Files);
|
||||||
|
Assert.Equal("README.md", file.Path);
|
||||||
|
Assert.Contains("main change", file.Ours);
|
||||||
|
Assert.Contains("branch change", file.Theirs);
|
||||||
|
Assert.NotNull(file.Base);
|
||||||
|
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "merge", "--abort");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WriteResolutionAsync_ThenContinue_CompletesMerge()
|
||||||
|
{
|
||||||
|
if (!GitRepoFixture.IsGitAvailable()) return;
|
||||||
|
|
||||||
|
var db = NewDb();
|
||||||
|
var repo = NewRepo();
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
||||||
|
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n");
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change");
|
||||||
|
|
||||||
|
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
||||||
|
_wtCleanups.Add((repo.RepoDir, wtPath));
|
||||||
|
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/c2", wtPath, repo.BaseCommit);
|
||||||
|
File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n");
|
||||||
|
GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch change");
|
||||||
|
|
||||||
|
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.WaitingForReview);
|
||||||
|
await SeedWorktree(db, task.Id, wtPath, "claudedo/c2", repo.BaseCommit);
|
||||||
|
|
||||||
|
var (svc, _) = BuildService(db);
|
||||||
|
await svc.MergeAsync(task.Id, "main", false, "msg", leaveConflictsInTree: true, CancellationToken.None);
|
||||||
|
|
||||||
|
await svc.WriteResolutionAsync(task.Id, "README.md", "# resolved by user\n", CancellationToken.None);
|
||||||
|
var result = await svc.ContinueMergeAsync(task.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
|
||||||
|
Assert.Equal("# resolved by user\n", File.ReadAllText(Path.Combine(repo.RepoDir, "README.md")));
|
||||||
|
Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#region Test doubles
|
#region Test doubles
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user