52 Commits

Author SHA1 Message Date
mika kuns
f21c65be18 feat(ui): richer diff viewer + surface child roadblocks on parents
All checks were successful
Changelog / changelog (push) Successful in 1s
Release / release (push) Successful in 38s
- UnifiedDiffParser detects added/deleted/renamed/binary files; diff
  modal shows a file list, binary/empty placeholders, and can diff a
  merged task by commit range after its worktree is gone
- DetailsIslandViewModel flags children needing attention (failed,
  cancelled, awaiting review, or with roadblocks) on the parent
- GitService gains worktree head-commit/range support; planning chain,
  merge orchestration, and session manager tweaks with updated tests
- refresh app/installer/worker icons

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 16:40:59 +02:00
mika kuns
c300f8c313 docs: document the unified parent-task model
Add WaitingForChildren to the status tables, document the single parent
lifecycle (planning + improvement) and approve-merges-the-whole-unit across
the root, Worker, and Data CLAUDE.md files.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:46:02 +02:00
mika kuns
d6e0953293 feat(worker): allow cancelling a WaitingForChildren parent
Add WaitingForChildren to the CancelAsync guard so a parent waiting on its
children can be cancelled.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:44:18 +02:00
mika kuns
a8b86e25e6 feat(ui): single approve action merges the whole unit
Approve & Merge is now the only review+merge entry. For a parent with
children it drives the unit merge via the worker (conflicts still surface
through the existing PlanningMergeConflict dialog); the separate Merge All
Subtasks button, MergeAllCommand, CanMergeAll plumbing, and the dead
MergeAllPlanningAsync client method are removed. Combined-diff preview and
conflict continue/abort are kept.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:43:04 +02:00
mika kuns
1abb429f12 feat(worker): approve drives the unit merge for parents with children
ApproveReview routes a parent that has children through
PlanningMergeOrchestrator (merge parent + each Done child, set parent Done,
conflict continue/abort) instead of the parent-only ApproveAndMergeAsync.
Childless tasks are unchanged. Removes the now-redundant MergeAllPlanning hub
method (UI rewiring follows separately).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:32:33 +02:00
mika kuns
803c04d9e0 docs(worker): Task 4 = full approve/merge UX consolidation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:27:35 +02:00
mika kuns
12732d6dc9 feat(worker): planning finalize enters WaitingForChildren
A finalized planning parent now joins the unified parent lifecycle:
WaitingForChildren while its child chain runs (or WaitingForReview directly
if it has no children), advancing to review like an improvement parent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:19:29 +02:00
mika kuns
b3a2daf40d refactor(worker): single parent-advance path for planning + improvement
Collapse TryCompleteParentAsync (planning -> Done) and
TryAdvanceImprovementParentAsync (improvement -> WaitingForReview) into one
TryAdvanceParentAsync that surfaces any WaitingForChildren parent for review
once all children are terminal. Planning parents no longer auto-complete.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 11:14:43 +02:00
mika kuns
8f49ebb248 docs(worker): spec + plan for unifying the parent-task model
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:58:45 +02:00
mika kuns
f56cc617c3 fix(worker): mark task Done on every successful merge path, not just approve
Generalizes the previous merge_task fix: the WaitingForReview->Done transition
now lives in TaskMergeService.MergeAsync/ContinueMergeAsync, so the UI Merge
button (WorkerHub.MergeTask), conflict-merge, continue-merge and the external
MCP all land a merged task in Done. ApproveAndMergeAsync no longer double-approves.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:41:08 +02:00
mika kuns
ca8326c4c5 fix(mcp): merge_task marks the task Done after a successful merge
merge_task only flipped the worktree to Merged; it never transitioned the task
status. With allowWaitingForReview this left a merged task stuck in
WaitingForReview. Approve it to Done on a successful merge (a Done task is
already terminal). Mirrors the ApproveAndMergeAsync review flow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:36:26 +02:00
mika kuns
f5d165baae fix(data): drop unique index on lists.name (allow duplicate list names)
The startup-race hardening added a global unique index on lists.name, but
duplicate list names are legitimate and the index broke 8 Worker tests that
seed same-named lists. The seeder race is already handled by the atomic
INSERT...WHERE NOT EXISTS, so the index is redundant. Keep the de-dup migration
step, remove the unique index from config, migration and model snapshot.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 10:15:42 +02:00
mika kuns
61a40d549b Merge task branch for: Data hardening: per-connection FK pragma + startup seed/appsettings race 2026-06-09 10:07:05 +02:00
mika kuns
5723b81992 Merge task branch for: Worker hardening: CLI arg injection, stuck-Running, planning-chain wedge, Fail guard 2026-06-09 10:06:59 +02:00
mika kuns
7f1a14ab80 fix(data): harden FK pragma per-connection and seed concurrency
- Add SqliteForeignKeyInterceptor (DbConnectionInterceptor) registered via
  OnConfiguring so every IDbContextFactory-created context runs
  PRAGMA foreign_keys=ON, not only the MigrateAndConfigure context.
- DefaultListsSeeder: replace TOCTOU read-then-insert with atomic
  INSERT … SELECT … WHERE NOT EXISTS — one SQLite writer lock, no race.
- AppSettingsRepository.GetAsync: catch DbUpdateException on the
  get-or-create path and re-read so concurrent startup cannot throw.
- Migration 20260609000000_UniqueListName: de-duplicates empty list rows
  (startup-race leftovers) then adds a UNIQUE index on lists.name.
- ForeignKeyTests: verifies ON DELETE SET NULL (blocked_by_task_id) is
  enforced on a fresh DbContext with no manual PRAGMA call.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 10:05:41 +02:00
mika kuns
33bdff8a6e fix(worker): harden CLI injection, stuck-Running, chain wedge, and Fail guard
1. ArgumentList (fix injection): ClaudeArgsBuilder.Build() now returns
   IReadOnlyList<string>; ClaudeProcess populates ProcessStartInfo.ArgumentList
   instead of Arguments, so values like system prompts are never shell-split.
   DailyPrepPrompt, RefinePrompt, and WeekReportService migrated similarly.
   All IClaudeProcess fakes updated.

2. ContinueAsync exception guard: wrap RunOnceAsync in try/catch matching
   the RunAsync pattern so an unexpected exception never leaves the task
   stuck in Running status.

3. Planning chain cascade: OnChildFinishedAsync now calls CancelAsync on
   the immediate blocked successor when a child fails or is cancelled,
   triggering a recursive cascade that clears the entire remaining chain
   instead of leaving it wedged.

4. FailAsync guard: restrict valid source states to Running and Queued;
   WaitingForReview -> Failed is now rejected, preventing an invalid
   transition that could corrupt the review workflow.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 10:05:40 +02:00
mika kuns
b5cf19b19a Merge task branch for: MCP: add missing external tools + fix status enum, branchDeleted, merge-from-review 2026-06-09 10:00:11 +02:00
mika kuns
9f19a714f7 feat(mcp): add get_task_config, continue_task; fix status enum, branchDeleted, merge-from-review
- ConfigMcpTools: add get_task_config read-back (was write-only)
- ExternalMcpService: add WaitingForChildren to ListTasks filter and GetTaskStatusValues
- ExternalMcpService: add continue_task tool wrapping QueueService.ContinueTask
- ExternalMcpService: add allowWaitingForReview param to merge_task (default false)
- ExternalMcpService: fix CleanupTaskWorktree branchDeleted — now uses real branch-delete outcome
- WorktreeMaintenanceService: TryRemoveAsync returns (Removed, BranchDeleted) tuple; ForceRemoveResult gains BranchDeleted field
- Tests: 9 new cases covering all five changes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 09:57:47 +02:00
mika kuns
b672c9aaf3 fix(git): serialize concurrent worktree add to prevent commondir race
Parallel task starts called 'git worktree add' simultaneously; git's shared
.git/worktrees metadata mutation isn't concurrency-safe and one add failed with
'failed to read .git/worktrees/<other>/commondir'. Serialize adds behind a
process-wide gate plus a bounded retry on the transient error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 09:55:39 +02:00
mika kuns
384e058812 docs: add CHANGELOG (Keep a Changelog format)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 09:54:32 +02:00
mika kuns
01e0c1d794 fix(ui): dispose VM subscriptions/timers, guard offline Stop, align review delta-path
- DetailsIslandViewModel/TasksIslandViewModel/ListsIslandViewModel: implement
  IDisposable, unsubscribe Loc.LanguageChanged and worker events (memory leaks).
- IslandsShellViewModel: dispose the three System.Timers.Timer instances.
- StopAsync: guard on Task/IsRunning/IsConnected and wrap CancelTask in try/catch.
- TaskMatchesList virtual:review now matches WaitingForReview (aligns with ReviewFilter).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 09:53:58 +02:00
mika kuns
00a065bf7f fix(review): populate review queue from WaitingForReview tasks
ReviewFilter matched Status==Done && active worktree, but a successful run
lands a task in WaitingForReview, so the Review virtual list was always empty.
Match WaitingForReview instead; update VirtualFilterTests accordingly.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 09:53:57 +02:00
mika kuns
763732a9b3 feat(ui): surface agent roadblocks and run outcome in the detail pane
- Parse CLAUDEDO_BLOCKED roadblocks out of the run result and show them in a
  colored card between Details and Output (ApplyOutcome / ShowRoadblockCard).
- Show the run outcome summary as an OUTCOME card in the Output tab, loaded from
  the task result (falls back to the run's ErrorMarkdown) and refreshed on finish.
- Guard the Session tab so it only appears when there are child outcomes.
- Make console resize per-task and proportional (description capped at 2/3,
  console floored at ~1/3) so a long description no longer spills over the footer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 09:34:37 +02:00
mika kuns
a41b8de47a feat(i18n): localize task-header, task-row and prime-schedule tooltips
Replace hardcoded tooltips with loc keys (kill-session, delete-task, toggle-subtasks, agent-suggested, star, remove-schedule) and drop the unused console.maximizeTip key; en/de kept in parity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 09:34:26 +02:00
mika kuns
18b777a712 ci: add dependency-audit and changelog Gitea workflows
- audit.yml: weekly `dotnet list package --vulnerable` scan that files an issue on findings
- changelog.yml: generate a changelog on `v*` tag pushes

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 09:34:18 +02:00
mika kuns
7f173daecb feat(ui): wire layer A/B conflict seams to the inline resolver 2026-06-05 11:12:42 +02:00
mika kuns
e71c0ed24f merge(layer-b): multi-worktree batch-merge cockpit 2026-06-05 11:09:09 +02:00
mika kuns
d450153183 merge(layer-c): inline conflict resolver + worker conflict plumbing 2026-06-05 11:09:02 +02:00
mika kuns
72687e9b30 feat(ui): expose conflict-resolver factory and dialog seam for integrator 2026-06-05 11:00:37 +02:00
mika kuns
d52243ccd1 refactor(ui): render worktree modal diff via canonical DiffLinesView
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 11:00:19 +02:00
mika kuns
8cafad370e feat(ui): add inline conflict resolver view and localization 2026-06-05 10:58:19 +02:00
mika kuns
d8a973d0e1 feat(ui): add inline conflict resolver view-model 2026-06-05 10:56:47 +02:00
mika kuns
0b623b8e4a feat(ui): add inline conflict model (file/hunk with resolution) 2026-06-05 10:55:20 +02:00
mika kuns
5edb433755 feat(ui): batch-merge cockpit view with checkboxes and conflicts panel 2026-06-05 10:54:34 +02:00
mika kuns
c8f82ed3c2 feat(i18n): add batch-merge cockpit strings (en/de) 2026-06-05 10:52:28 +02:00
mika kuns
1aa06077a8 feat(ui): wire batch selection, target loading and resolve seam
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-05 10:50:38 +02:00
mika kuns
cb20877620 feat(hub): expose conflict-resolution merge methods 2026-06-05 10:50:04 +02:00
mika kuns
dcbf67c63b feat(merge): read conflict stages and write user resolutions 2026-06-05 10:49:07 +02:00
mika kuns
02b11c727c feat(ui): add skip-and-continue batch merge orchestration 2026-06-05 10:47:17 +02:00
mika kuns
74afc46909 feat(git): add conflict-stage blob reads and single-path staging 2026-06-05 10:47:14 +02:00
mika kuns
ef3fba1690 feat(ui): add batch-merge row state to worktrees cockpit VM 2026-06-05 10:44:18 +02:00
mika kuns
ef2f5c51e4 docs(plan): Layer C inline conflict resolver 2026-06-05 10:44:18 +02:00
mika kuns
3060cb0242 docs(plan): Layer B multi-worktree merge cockpit plan 2026-06-05 10:42:02 +02:00
mika kuns
3596053512 feat(ui): fuse git tab into one approve+merge cockpit 2026-06-05 10:32:02 +02:00
mika kuns
4bf4a27036 feat(ui): route single-task merge conflicts into a resolution seam 2026-06-05 10:30:43 +02:00
mika kuns
de4ad5dcf3 feat(ui): maximize work console via green traffic-light dot 2026-06-05 10:27:47 +02:00
mika kuns
2dfc4559b1 feat(ui): add conflict-resolution worker contract (foundation for merge rework) 2026-06-05 10:20:42 +02:00
mika kuns
dd3b03b9e4 docs(plan): foundation + Layer A plan and Layer B/C parallel kickoff prompts
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 10:15:52 +02:00
mika kuns
f4416ee1c3 docs(design): git tab merge & review rework — shared foundation + 3 layers
Design for simplifying single-task review/merge (Layer A), multi-worktree
batch merge cockpit (Layer B), and inline conflict resolver (Layer C),
with frozen shared contracts so B/C build in parallel worktrees.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 10:09:10 +02:00
mika kuns
42bb79e2b7 feat(ui): rename review Retry to Continue and make Reset discard the worktree
[Continue] keeps the reject-to-queue + resume behaviour. [Reset] now calls
ResetTaskAsync (discards the task worktree and returns it to Idle) behind a
confirmation, replacing the old park-to-idle action.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 09:03:47 +02:00
mika kuns
561028e67b fix(ui): set prompt-action resting color on ContentPresenter
The Fluent theme sets text color on the inner ContentPresenter, so setting
Foreground on the Button only took effect on hover. Move the normal-state
color onto the ContentPresenter so [Retry] shows green at rest.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 08:58:26 +02:00
mika kuns
07a9d07cf6 style(ui): align refine button with star and update refine icon
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-05 08:57:50 +02:00
103 changed files with 6782 additions and 922 deletions

101
.gitea/workflows/audit.yml Normal file
View 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

View 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
View 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

View File

@@ -35,7 +35,7 @@ Two-process system communicating over SignalR (`127.0.0.1:47821`):
- EF Core migrations manage schema (Migrations/ folder in ClaudeDo.Data) - 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)

View File

@@ -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 387390), add:
```csharp
// Invoked when a single-task merge/approve hits a conflict. Wired by the
// integrator to Layer C's conflict resolver. Args: (taskId, targetBranch).
public Func<string, string, System.Threading.Tasks.Task>? RequestConflictResolution { get; set; }
```
- [ ] **Step 4: Route the Approve conflict branch into the seam**
In `ApproveReviewAsync` (around line 1453), replace the conflict branch body so it
prefers the seam, falling back to the current preview-text behavior:
```csharp
var result = await _worker.ApproveReviewAsync(Task.Id, SelectedMergeTarget ?? "");
if (result?.Status == "conflict")
{
if (RequestConflictResolution is not null)
{
await RequestConflictResolution(Task.Id, SelectedMergeTarget ?? "");
}
else
{
var (text, _, _) = MergePreviewPresenter.Describe(
new MergePreviewDto("conflict", result.ConflictFiles, 0));
MergePreviewText = text; MergeIsClean = false; MergeIsConflict = true;
}
}
```
- [ ] **Step 5: Route the manual Merge conflict branch into the seam**
In `MergeAsync` (around line 1170), apply the same pattern to its conflict branch:
```csharp
var result = await _worker.MergeTaskAsync(Task.Id, SelectedMergeTarget ?? "", false, "Merge task");
if (result.Status == "conflict")
{
if (RequestConflictResolution is not null)
{
await RequestConflictResolution(Task.Id, SelectedMergeTarget ?? "");
}
else
{
var (text, _, _) = MergePreviewPresenter.Describe(
new MergePreviewDto("conflict", result.ConflictFiles, 0));
MergePreviewText = text; MergeIsClean = false; MergeIsConflict = true;
}
}
else
{
await RefreshMergePreviewAsync();
}
```
- [ ] **Step 6: Run the test to verify it passes**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter ApproveReview_OnConflict_InvokesConflictResolutionSeam`
Expected: PASS.
- [ ] **Step 7: Run the full UI suite (no regressions)**
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
Expected: PASS.
- [ ] **Step 8: Commit**
```bash
git add src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandConflictSeamTests.cs
git commit -m "feat(ui): route single-task merge conflicts into a resolution seam"
```
### Task A.2: Fuse the Git tab into one Approve+merge cockpit
**Files:**
- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml`
- [ ] **Step 1: Replace the two Git-tab sections with one cockpit block**
In `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml`, replace the entire Git
`ScrollViewer` body (lines 255313 — the `<!-- Git: ... -->` block containing the
separate `REVIEW` `StackPanel` and the `MERGE & WORKTREE` `StackPanel`) with a single
cockpit where Approve sits with the merge target/preview/actions. Keep the existing
control class names (`section-label`, `field-label`, `btn`, `btn accent`, `meta`) and
the existing bindings (`SelectedMergeTarget`, `MergeTargetBranches`, `MergePreviewText`,
`MergeIsClean`, `MergeIsConflict`, `ShowMergePreviewMuted`, `OpenDiffCommand`,
`ApproveReviewCommand`, `MergeCommand`, `ShowSingleMerge`, `OpenWorktreeCommand`,
`ReviewCombinedDiffCommand`, `MergeAllCommand`, `CanMergeAll`, `MergeAllDisabledReason`,
`MergeAllError`):
```xml
<!-- Git: one Approve + merge cockpit -->
<ScrollViewer IsVisible="{Binding IsGitTab}" Padding="14,10">
<StackPanel Spacing="12" IsVisible="{Binding ShowMergeSection}">
<TextBlock Classes="section-label" Text="MERGE" />
<StackPanel Spacing="4">
<TextBlock Classes="field-label" Text="Target branch" />
<ComboBox ItemsSource="{Binding MergeTargetBranches}"
SelectedItem="{Binding SelectedMergeTarget, Mode=TwoWay}"
HorizontalAlignment="Stretch" />
</StackPanel>
<StackPanel Spacing="0">
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
Foreground="{DynamicResource MossBrush}"
IsVisible="{Binding MergeIsClean}" />
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
Foreground="{DynamicResource BloodBrush}"
IsVisible="{Binding MergeIsConflict}" />
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
Foreground="{DynamicResource TextMuteBrush}"
IsVisible="{Binding ShowMergePreviewMuted}" />
</StackPanel>
<!-- Primary action: Approve flows straight into the merge.
Approve is the review-gated path; the plain Merge button covers
already-reviewed / kept worktrees. -->
<WrapPanel Orientation="Horizontal">
<Button Classes="btn accent" Content="Approve &amp; Merge" Margin="0,0,8,8"
Command="{Binding ApproveReviewCommand}"
IsVisible="{Binding IsWaitingForReview}" />
<Button Classes="btn accent" Content="Merge" Margin="0,0,8,8"
Command="{Binding MergeCommand}"
IsVisible="{Binding ShowSingleMerge}" />
<Button Classes="btn" Content="Open Diff" Margin="0,0,8,8"
Command="{Binding OpenDiffCommand}" />
<Button Classes="btn" Margin="0,0,8,8"
Command="{Binding OpenWorktreeCommand}">
<StackPanel Orientation="Horizontal" Spacing="5">
<TextBlock Text="Worktree" />
<PathIcon Data="{StaticResource Icon.ArrowOut}" Width="11" Height="11" />
</StackPanel>
</Button>
<Button Classes="btn" Content="Review Combined Diff" Margin="0,0,8,8"
Command="{Binding ReviewCombinedDiffCommand}" />
<Button Classes="btn accent" Content="Merge All Subtasks" Margin="0,0,0,8"
Command="{Binding MergeAllCommand}"
IsEnabled="{Binding CanMergeAll}"
ToolTip.Tip="{Binding MergeAllDisabledReason}" />
</WrapPanel>
<TextBlock Text="{Binding MergeAllError}"
Foreground="{DynamicResource BloodBrush}"
TextWrapping="Wrap"
IsVisible="{Binding MergeAllError,
Converter={x:Static ObjectConverters.IsNotNull}}" />
</StackPanel>
</ScrollViewer>
```
Note: the cockpit now shows whenever `ShowMergeSection` is true. `ShowMergeSection`
(DetailsIslandViewModel line 161) must be true while `IsWaitingForReview` so the
Approve button appears. Check its expression in Step 2.
- [ ] **Step 2: Verify `ShowMergeSection` covers the review state**
Read `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` line 161. If
`ShowMergeSection` is false while `IsWaitingForReview` (e.g. it requires a non-review
state), widen it to also be true when `IsWaitingForReview && WorktreePath != null`, and
ensure `OnPropertyChanged(nameof(ShowMergeSection))` already fires on the relevant state
transitions (it is notified via `NotifySessionSections`). Make the minimal change needed
so the Approve button is visible in review state. If it already covers review, change
nothing.
- [ ] **Step 3: Build the app project**
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
Expected: BUILD succeeds (pulls in Ui + Data).
- [ ] **Step 4: Visual verification (manual — flag for the user)**
This is an AXAML layout change with no automated coverage. Launch the app, open a task
in `WaitingForReview`, open the Git tab, and confirm: the single MERGE block shows the
target combo, the colored preview line, an "Approve & Merge" button (review state), and
the diff/worktree/combined/merge-all actions. **Explicitly tell the user this needs a
visual pass — do not claim it works without running it.**
- [ ] **Step 5: Commit**
```bash
git add src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs
git commit -m "feat(ui): fuse git tab into one approve+merge cockpit"
```
### Task A.3: Verify diff-renderer consolidation
**Files:** none modified (verification only).
- [ ] **Step 1: Confirm DiffModal + Planning already use the canonical renderer**
Run: `rg -l "DiffLinesView" src/ClaudeDo.Ui/Views`
Expected: matches in `Modals/DiffModalView.axaml` and `Planning/PlanningDiffView.axaml`.
If `PlanningDiffView.axaml` does NOT use `DiffLinesView`, change its diff `ItemsControl`
to a `<controls:DiffLinesView Lines="{Binding SelectedFile.Lines}" />` (matching
`DiffModalView.axaml`'s usage) and rebuild the App project. If both already use it, this
task is a no-op — record that and move on. (`WorktreeModalView`'s bespoke diff is
intentionally left for Layer B.)
---
## Self-Review
- **Spec coverage:** Foundation contract (spec §"Frozen worker conflict contract") →
Task 0.1. Test fakes (spec parallel-boundaries row) → Task 0.2. Branch point (spec
§"built & pushed this session") → Task 0.3. Layer A cockpit + Approve/merge flow
together (spec §"Layer A") → Task A.2. Single-task approve-on-conflict opens resolver
via seam (spec §"Layer A" + §"integration seams") → Task A.1. Diff consolidation
(spec §"One diff model") → Task A.3. Output-footer feedback unchanged → not touched
(correct). No spec requirement left unmapped for this session's scope.
- **Placeholder scan:** none — every code step has concrete code; the only "mirror the
existing harness" reference (Task A.1 Step 1) points at a real file with a working
pattern, not a TODO.
- **Type consistency:** `MergeConflictsDto`/`ConflictFileDto`/`ConflictHunkDto` and the
5 method names match between `IWorkerClient` (0.1 Step 1), `WorkerClient` (0.1 Steps
23), and both fakes (0.2). The seam `RequestConflictResolution` is
`Func<string,string,Task>?` everywhere (A.1 Steps 1, 35). DTO field names match the
spec.
---
## Integration notes (for the integrator merging A + B + C)
- Wire `DetailsIslandViewModel.RequestConflictResolution` and Layer B's equivalent
callback to Layer C's `ConflictResolverViewModel` factory + `ShowConflictResolver`
dialog delegate.
- Layer C implements the worker hub methods `StartConflictMerge`, `GetMergeConflicts`,
`WriteConflictResolution`, `ContinueMerge`, `AbortMerge`; the client side from Task
0.1 already calls them by name.

View File

@@ -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.
```

View File

@@ -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.

View 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 14), skip-and-continue + conflict collection (Task 2), single target picker (Tasks 34), Resolve → `RequestConflictResolution(taskId, targetBranch)` seam left unwired (Tasks 34), `WorktreeModalView` diff migration to `DiffLinesView` (Task 6), no worker files touched, no `IWorkerClient` change, locales in parity (Task 5). ✔
- **No ConflictResolver reference:** the seam is a bare `Func<string,string,Task>?`; no Layer C type is named. ✔
- **Type consistency:** `BatchMergeOutcome`, `MergeOutcome`, `IsConflict`, `HasOutcome`, `MergeSelectedAsync`, `ConflictRows`, `SelectedTarget`, `SelectedCount`, `IsMerging`, `BatchProgress`, `RequestConflictResolution`, `MergeAllCommand`, `ResolveConflictCommand`, `ToggleSelectAllCommand`, `AddRowForTest`, `AllRows` are used consistently across tasks. ✔

View File

@@ -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.

View File

@@ -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).

View File

@@ -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

View File

@@ -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();
} }

View File

@@ -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**

View File

@@ -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>();

View File

@@ -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;
} }

View File

@@ -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());
} }
} }

View 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
}
}
}

View File

@@ -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)
{
}
}
}

View File

@@ -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;
} }

View File

@@ -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
} }

View File

@@ -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

View File

@@ -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" }
} }

View File

@@ -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" }
} }

View File

@@ -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();
}

View File

@@ -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 -->

View File

@@ -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);

View File

@@ -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);

View 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));
}

View File

@@ -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();
}
}
}

View File

@@ -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 */ }
} }

View File

@@ -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

View File

@@ -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,
}; };

View File

@@ -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)

View File

@@ -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)
{ {

View File

@@ -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");

View File

@@ -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('+'))

View File

@@ -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]

View File

@@ -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;
}
}
} }

View File

@@ -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;

View 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>

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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">

View File

@@ -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 &amp; 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 &amp; Merge" Margin="0,0,8,8"
Command="{Binding ApproveReviewCommand}"
IsVisible="{Binding IsWaitingForReview}" />
<Button Classes="btn" Content="Open Diff" Margin="0,0,8,8"
Command="{Binding OpenDiffCommand}" />
<Button Classes="btn" Margin="0,0,8,8"
ToolTip.Tip="{loc:Tr agent.openWorktreeTip}"
Command="{Binding OpenWorktreeCommand}">
<StackPanel Orientation="Horizontal" Spacing="5">
<TextBlock Text="Worktree" />
<PathIcon Data="{StaticResource Icon.ArrowOut}" Width="11" Height="11" />
</StackPanel>
</Button>
<Button Classes="btn" Content="Review Combined Diff" Margin="0,0,8,8"
Command="{Binding ReviewCombinedDiffCommand}" />
</WrapPanel>
</StackPanel> </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>

View File

@@ -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 -->

View File

@@ -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;

View File

@@ -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>

View File

@@ -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);
};
} }
} }

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"/>

View File

@@ -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`

View File

@@ -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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

View File

@@ -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);
}
} }

View File

@@ -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 ───────────────────────────────────────────────────────────

View File

@@ -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); }

View File

@@ -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) =>

View File

@@ -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;
} }
} }

View File

@@ -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);

View File

@@ -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)
{ {

View File

@@ -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);

View File

@@ -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(

View File

@@ -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)

View File

@@ -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;

View File

@@ -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.");

View File

@@ -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;
} }
} }

View File

@@ -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();

View File

@@ -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,

View File

@@ -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);

View File

@@ -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;

View File

@@ -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);

View File

@@ -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));
} }
} }

View 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 { }
}
}
}

View File

@@ -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;

View File

@@ -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

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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]

View 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);
}
}

View File

@@ -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));
}
}

View File

@@ -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));
}
} }

View File

@@ -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);
}
} }

View File

@@ -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.");
} }
} }

View File

@@ -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);
}
} }

View File

@@ -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);

View File

@@ -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,

View File

@@ -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]

View File

@@ -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);

View File

@@ -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); }
} }

View File

@@ -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);
}
}

View File

@@ -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));
} }
} }

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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());

View File

@@ -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);

View File

@@ -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