Compare commits
8 Commits
f8f20bf6ed
...
feature/de
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
50c10b6e75 | ||
|
|
075b6d13af | ||
|
|
324f1d9c7c | ||
|
|
c592ca32fb | ||
|
|
7ce418d474 | ||
|
|
ab260ad0a6 | ||
|
|
b3b87df320 | ||
|
|
da73324e3a |
@@ -1,101 +0,0 @@
|
|||||||
name: Dependency Audit
|
|
||||||
|
|
||||||
on:
|
|
||||||
schedule:
|
|
||||||
- cron: '0 6 * * 1' # Mondays 06:00 UTC
|
|
||||||
workflow_dispatch: {}
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
audit:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
env:
|
|
||||||
DOTNET_ROOT: /home/mika/.dotnet
|
|
||||||
GITEA_API: https://git.kuns.dev/api/v1
|
|
||||||
REPO: releases/ClaudeDo
|
|
||||||
ISSUE_TITLE: 'Dependency audit: vulnerable packages detected'
|
|
||||||
steps:
|
|
||||||
- name: Checkout main
|
|
||||||
env:
|
|
||||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
git clone --depth 1 --branch main \
|
|
||||||
"https://oauth2:${TOKEN}@git.kuns.dev/${REPO}.git" src
|
|
||||||
|
|
||||||
- name: Scan for vulnerable / outdated packages
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
export PATH="$DOTNET_ROOT:$PATH"
|
|
||||||
cd src
|
|
||||||
|
|
||||||
: > audit.log
|
|
||||||
: > vuln.md
|
|
||||||
found=0
|
|
||||||
|
|
||||||
# .slnx tooling needs .NET 9; iterate per-project to stay on .NET 8.
|
|
||||||
while IFS= read -r proj; do
|
|
||||||
echo "==== $proj ====" | tee -a audit.log
|
|
||||||
dotnet restore "$proj" >/dev/null
|
|
||||||
|
|
||||||
vuln="$(dotnet list "$proj" package --vulnerable --include-transitive 2>&1)"
|
|
||||||
echo "$vuln" | tee -a audit.log
|
|
||||||
if echo "$vuln" | grep -qi "has the following vulnerable"; then
|
|
||||||
found=1
|
|
||||||
{
|
|
||||||
printf '#### `%s`\n\n```\n' "$proj"
|
|
||||||
echo "$vuln"
|
|
||||||
printf '```\n\n'
|
|
||||||
} >> vuln.md
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Outdated is informational only — never fails the run.
|
|
||||||
dotnet list "$proj" package --outdated 2>&1 | tee -a audit.log || true
|
|
||||||
echo "" | tee -a audit.log
|
|
||||||
done < <(find . -name '*.csproj' | sort)
|
|
||||||
|
|
||||||
if [ "$found" -ne 0 ]; then
|
|
||||||
echo "::error::Vulnerable packages detected — see log above." >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "No vulnerable packages found."
|
|
||||||
|
|
||||||
- name: Report vulnerabilities to a Gitea issue
|
|
||||||
if: failure()
|
|
||||||
env:
|
|
||||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
cd src
|
|
||||||
|
|
||||||
if [ -s vuln.md ]; then
|
|
||||||
DETAILS="$(cat vuln.md)"
|
|
||||||
else
|
|
||||||
DETAILS="The audit job failed before producing findings — check the run log."
|
|
||||||
fi
|
|
||||||
BODY="$(printf 'Automated weekly dependency audit found vulnerable packages.\n\n%s\n\n[View workflow run](%s)' \
|
|
||||||
"$DETAILS" "$RUN_URL")"
|
|
||||||
|
|
||||||
# Reuse an existing open issue if one is already tracking this.
|
|
||||||
EXISTING="$(curl -sS \
|
|
||||||
-H "Authorization: token ${TOKEN}" \
|
|
||||||
"${GITEA_API}/repos/${REPO}/issues?state=open&type=issues&limit=50" \
|
|
||||||
| jq -r --arg t "$ISSUE_TITLE" '.[] | select(.title==$t) | .number' | head -n1)"
|
|
||||||
|
|
||||||
if [ -n "$EXISTING" ]; then
|
|
||||||
echo "Commenting on existing issue #$EXISTING"
|
|
||||||
jq -n --arg body "$BODY" '{body:$body}' \
|
|
||||||
| curl -sS --fail-with-body -X POST \
|
|
||||||
-H "Authorization: token ${TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d @- \
|
|
||||||
"${GITEA_API}/repos/${REPO}/issues/${EXISTING}/comments" >/dev/null
|
|
||||||
else
|
|
||||||
echo "Creating new issue"
|
|
||||||
jq -n --arg title "$ISSUE_TITLE" --arg body "$BODY" '{title:$title, body:$body}' \
|
|
||||||
| curl -sS --fail-with-body -X POST \
|
|
||||||
-H "Authorization: token ${TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d @- \
|
|
||||||
"${GITEA_API}/repos/${REPO}/issues" >/dev/null
|
|
||||||
fi
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
name: Changelog
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- 'v*'
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
changelog:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
env:
|
|
||||||
REPO: releases/ClaudeDo
|
|
||||||
steps:
|
|
||||||
- name: Checkout main (full history)
|
|
||||||
env:
|
|
||||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
git clone "https://oauth2:${TOKEN}@git.kuns.dev/${REPO}.git" src
|
|
||||||
cd src
|
|
||||||
git fetch --tags --force
|
|
||||||
git checkout main
|
|
||||||
|
|
||||||
- name: Regenerate CHANGELOG.md
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
cd src
|
|
||||||
|
|
||||||
emit_group() {
|
|
||||||
# $1 range, $2 conventional-type, $3 heading
|
|
||||||
local range="$1" type="$2" title="$3" lines
|
|
||||||
lines="$(git log "$range" --no-merges --pretty=format:'%s|%h' \
|
|
||||||
| grep -E "^${type}(\([^)]*\))?(!)?: " || true)"
|
|
||||||
[ -z "$lines" ] && return 0
|
|
||||||
printf '### %s\n\n' "$title"
|
|
||||||
while IFS='|' read -r subject hash; do
|
|
||||||
printf -- '- %s (%s)\n' "${subject#*: }" "$hash"
|
|
||||||
done <<< "$lines"
|
|
||||||
printf '\n'
|
|
||||||
}
|
|
||||||
|
|
||||||
emit_section() {
|
|
||||||
# $1 range, $2 tag, $3 date
|
|
||||||
printf '## %s — %s\n\n' "$2" "$3"
|
|
||||||
emit_group "$1" feat "Features"
|
|
||||||
emit_group "$1" fix "Fixes"
|
|
||||||
emit_group "$1" perf "Performance"
|
|
||||||
emit_group "$1" refactor "Refactoring"
|
|
||||||
emit_group "$1" docs "Documentation"
|
|
||||||
}
|
|
||||||
|
|
||||||
# Tags ascending by semver so we can pair each with its predecessor.
|
|
||||||
mapfile -t TAGS < <(git tag --sort=v:refname | grep -E '^v' || true)
|
|
||||||
|
|
||||||
{
|
|
||||||
printf '# Changelog\n\n'
|
|
||||||
for ((i=${#TAGS[@]}-1; i>=0; i--)); do
|
|
||||||
TAG="${TAGS[$i]}"
|
|
||||||
DATE="$(git log -1 --format=%ad --date=short "$TAG")"
|
|
||||||
if (( i > 0 )); then
|
|
||||||
RANGE="${TAGS[$((i-1))]}..${TAG}"
|
|
||||||
else
|
|
||||||
RANGE="$TAG"
|
|
||||||
fi
|
|
||||||
emit_section "$RANGE" "$TAG" "$DATE"
|
|
||||||
done
|
|
||||||
} > CHANGELOG.md
|
|
||||||
|
|
||||||
cat CHANGELOG.md
|
|
||||||
|
|
||||||
- name: Commit and push if changed
|
|
||||||
env:
|
|
||||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
cd src
|
|
||||||
if git diff --quiet -- CHANGELOG.md; then
|
|
||||||
echo "CHANGELOG.md unchanged; nothing to commit."
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
git config user.name "ClaudeDo CI"
|
|
||||||
git config user.email "ci@kuns.dev"
|
|
||||||
git add CHANGELOG.md
|
|
||||||
git commit -m "docs(changelog): update for ${GITHUB_REF_NAME}"
|
|
||||||
git push origin main
|
|
||||||
@@ -5,10 +5,6 @@ on:
|
|||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: release-${{ github.ref_name }}
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
release:
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -42,52 +38,11 @@ jobs:
|
|||||||
TAG: ${{ steps.ver.outputs.tag }}
|
TAG: ${{ steps.ver.outputs.tag }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
# Full clone (with tags) so release notes can diff against the previous tag.
|
git clone --depth 1 --branch "$TAG" \
|
||||||
git clone --branch "$TAG" \
|
|
||||||
"https://oauth2:${TOKEN}@git.kuns.dev/${REPO}.git" \
|
"https://oauth2:${TOKEN}@git.kuns.dev/${REPO}.git" \
|
||||||
"$WORK/src"
|
"$WORK/src"
|
||||||
git -C "$WORK/src" log -1 --oneline
|
git -C "$WORK/src" log -1 --oneline
|
||||||
|
|
||||||
- name: Generate release notes
|
|
||||||
env:
|
|
||||||
WORK: ${{ steps.ws.outputs.dir }}
|
|
||||||
TAG: ${{ steps.ver.outputs.tag }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
cd "$WORK/src"
|
|
||||||
|
|
||||||
PREV="$(git tag --sort=v:refname | grep -E '^v' \
|
|
||||||
| awk -v t="$TAG" '$0==t{print prev} {prev=$0}')"
|
|
||||||
if [ -n "$PREV" ]; then
|
|
||||||
RANGE="${PREV}..${TAG}"
|
|
||||||
else
|
|
||||||
RANGE="$TAG"
|
|
||||||
fi
|
|
||||||
|
|
||||||
emit_group() {
|
|
||||||
# $1 conventional-type, $2 heading
|
|
||||||
local lines
|
|
||||||
lines="$(git log "$RANGE" --no-merges --pretty=format:'%s|%h' \
|
|
||||||
| grep -E "^${1}(\([^)]*\))?(!)?: " || true)"
|
|
||||||
[ -z "$lines" ] && return 0
|
|
||||||
printf '### %s\n\n' "$2"
|
|
||||||
while IFS='|' read -r subject hash; do
|
|
||||||
printf -- '- %s (%s)\n' "${subject#*: }" "$hash"
|
|
||||||
done <<< "$lines"
|
|
||||||
printf '\n'
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
emit_group feat "Features"
|
|
||||||
emit_group fix "Fixes"
|
|
||||||
emit_group perf "Performance"
|
|
||||||
emit_group refactor "Refactoring"
|
|
||||||
emit_group docs "Documentation"
|
|
||||||
} > RELEASE_NOTES.md
|
|
||||||
|
|
||||||
echo "--- release notes ---"
|
|
||||||
cat RELEASE_NOTES.md
|
|
||||||
|
|
||||||
- name: Publish ClaudeDo.App (win-x64, self-contained)
|
- name: Publish ClaudeDo.App (win-x64, self-contained)
|
||||||
env:
|
env:
|
||||||
WORK: ${{ steps.ws.outputs.dir }}
|
WORK: ${{ steps.ws.outputs.dir }}
|
||||||
@@ -173,8 +128,7 @@ jobs:
|
|||||||
BODY=$(jq -n \
|
BODY=$(jq -n \
|
||||||
--arg tag "$TAG" \
|
--arg tag "$TAG" \
|
||||||
--arg name "$TAG" \
|
--arg name "$TAG" \
|
||||||
--rawfile body "$WORK/src/RELEASE_NOTES.md" \
|
'{tag_name:$tag, name:$name, body:"", draft:false, prerelease:false, target_commitish:"main"}')
|
||||||
'{tag_name:$tag, name:$name, body:$body, draft:true, prerelease:false, target_commitish:"main"}')
|
|
||||||
RESP=$(curl -sS -X POST \
|
RESP=$(curl -sS -X POST \
|
||||||
-H "Authorization: token ${TOKEN}" \
|
-H "Authorization: token ${TOKEN}" \
|
||||||
-H "Content-Type: application/json" \
|
-H "Content-Type: application/json" \
|
||||||
@@ -212,32 +166,6 @@ jobs:
|
|||||||
done
|
done
|
||||||
echo "All assets uploaded."
|
echo "All assets uploaded."
|
||||||
|
|
||||||
- name: Publish release
|
|
||||||
env:
|
|
||||||
RELEASE_ID: ${{ steps.release.outputs.release_id }}
|
|
||||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
curl -sS --fail-with-body -X PATCH \
|
|
||||||
-H "Authorization: token ${TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"draft":false}' \
|
|
||||||
"${GITEA_API}/repos/${REPO}/releases/${RELEASE_ID}" \
|
|
||||||
> /dev/null
|
|
||||||
echo "Release ${RELEASE_ID} published."
|
|
||||||
|
|
||||||
- name: Delete draft release on failure
|
|
||||||
if: failure() && steps.release.outputs.release_id != ''
|
|
||||||
env:
|
|
||||||
RELEASE_ID: ${{ steps.release.outputs.release_id }}
|
|
||||||
TOKEN: ${{ secrets.GITEA_TOKEN }}
|
|
||||||
run: |
|
|
||||||
curl -sS -X DELETE \
|
|
||||||
-H "Authorization: token ${TOKEN}" \
|
|
||||||
"${GITEA_API}/repos/${REPO}/releases/${RELEASE_ID}" \
|
|
||||||
> /dev/null || true
|
|
||||||
echo "Cleaned up draft release ${RELEASE_ID}."
|
|
||||||
|
|
||||||
- name: Cleanup workspace
|
- name: Cleanup workspace
|
||||||
if: always()
|
if: always()
|
||||||
env:
|
env:
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,9 +1,6 @@
|
|||||||
# Local dev worktrees (created by using-git-worktrees skill)
|
# Local dev worktrees (created by using-git-worktrees skill)
|
||||||
.worktrees/
|
.worktrees/
|
||||||
|
|
||||||
# Brainstorming visual companion artifacts
|
|
||||||
.superpowers/
|
|
||||||
|
|
||||||
# .NET build output
|
# .NET build output
|
||||||
bin/
|
bin/
|
||||||
obj/
|
obj/
|
||||||
|
|||||||
920
CHANGELOG.md
920
CHANGELOG.md
@@ -1,920 +0,0 @@
|
|||||||
# Changelog
|
|
||||||
|
|
||||||
## v1.8.0 — 2026-06-09
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
- richer diff viewer + surface child roadblocks on parents (f21c65b)
|
|
||||||
- allow cancelling a WaitingForChildren parent (d6e0953)
|
|
||||||
- single approve action merges the whole unit (a8b86e2)
|
|
||||||
- approve drives the unit merge for parents with children (1abb429)
|
|
||||||
- planning finalize enters WaitingForChildren (12732d6)
|
|
||||||
- add get_task_config, continue_task; fix status enum, branchDeleted, merge-from-review (9f19a71)
|
|
||||||
- surface agent roadblocks and run outcome in the detail pane (763732a)
|
|
||||||
- localize task-header, task-row and prime-schedule tooltips (a41b8de)
|
|
||||||
- wire layer A/B conflict seams to the inline resolver (7f173da)
|
|
||||||
- expose conflict-resolver factory and dialog seam for integrator (72687e9)
|
|
||||||
- add inline conflict resolver view and localization (8cafad3)
|
|
||||||
- add inline conflict resolver view-model (d8a973d)
|
|
||||||
- add inline conflict model (file/hunk with resolution) (0b623b8)
|
|
||||||
- batch-merge cockpit view with checkboxes and conflicts panel (5edb433)
|
|
||||||
- add batch-merge cockpit strings (en/de) (c8f82ed)
|
|
||||||
- wire batch selection, target loading and resolve seam (1aa0607)
|
|
||||||
- expose conflict-resolution merge methods (cb20877)
|
|
||||||
- read conflict stages and write user resolutions (dcbf67c)
|
|
||||||
- add skip-and-continue batch merge orchestration (02b11c7)
|
|
||||||
- add conflict-stage blob reads and single-path staging (74afc46)
|
|
||||||
- add batch-merge row state to worktrees cockpit VM (ef3fba1)
|
|
||||||
- fuse git tab into one approve+merge cockpit (3596053)
|
|
||||||
- route single-task merge conflicts into a resolution seam (4bf4a27)
|
|
||||||
- maximize work console via green traffic-light dot (de4ad5d)
|
|
||||||
- add conflict-resolution worker contract (foundation for merge rework) (2dfc455)
|
|
||||||
- rename review Retry to Continue and make Reset discard the worktree (42bb79e)
|
|
||||||
- send Retry on Enter in the review prompt (9c5872e)
|
|
||||||
- rework review into terminal footer and add Git tab (8819a56)
|
|
||||||
- add IsGitTab flag to work console view model (6c65158)
|
|
||||||
- add mergeability indicator and Merge button to work console (de01579)
|
|
||||||
- show mergeability and surface approve conflicts in the work console (0d8999d)
|
|
||||||
- wire merge-aware approve and preview into the worker client (3202c76)
|
|
||||||
- expose PreviewMerge hub method and merge-on-approve (43f8f7f)
|
|
||||||
- approve merges worktree before marking task done (b817c87)
|
|
||||||
- add Refine button, icon, and command to task card (2a6781f)
|
|
||||||
- add non-destructive merge-tree conflict probe (4098f7f)
|
|
||||||
- add RefineTask client call and refine events (8239004)
|
|
||||||
- wire RefineTask hub method, broadcaster events, and DI (e523ed8)
|
|
||||||
- add RefineRunner, prompt/args helper, and interfaces (0460d7b)
|
|
||||||
- add Refine prompt kind and default (eca6813)
|
|
||||||
- add add_subtask tool to claudedo MCP (22830d3)
|
|
||||||
- resize detail split by dragging the console's top edge (b840655)
|
|
||||||
- rework work console — single Session tab, right-aligned header, turns x/y (ac9bae9)
|
|
||||||
- make steps visible at a glance; lift details card off background (99c6bf4)
|
|
||||||
- wire redesigned detail island (header + description/steps card + work console) (c71026d)
|
|
||||||
- add WorkConsole detail component (ce50f9f)
|
|
||||||
- add DescriptionStepsCard detail component (c323953)
|
|
||||||
- add TaskHeaderBar detail component (9f95942)
|
|
||||||
- compose task prompt from title + description + open steps only (299867d)
|
|
||||||
- fold parent branch into combined-diff for improvement parents (469e68b)
|
|
||||||
- focused custom prompt for improvement children so they stay narrow (176b985)
|
|
||||||
- show improvement-child outcomes on the parent review card + enable tree-merge (5d34f95)
|
|
||||||
- mark agent-suggested improvement children in the task tree (0e13017)
|
|
||||||
- surface WaitingForChildren status (chip, color, agent-strip, labels) (5363570)
|
|
||||||
- instruct agents to offload out-of-scope work via SuggestImprovement (f60beca)
|
|
||||||
- fold parent branch into tree-merge for improvement parents (519bfbe)
|
|
||||||
- mint per-run MCP token + emit run-scoped --mcp-config (06e3acd)
|
|
||||||
- resolve per-run tokens in MCP auth + register TaskRunMcpService (f3052dc)
|
|
||||||
- add SuggestImprovement tool (server-stamped, one layer deep) (9d133e2)
|
|
||||||
- add TaskRunMcpContext + accessor (7542bc2)
|
|
||||||
- add per-run TaskRunTokenRegistry (ef86a8c)
|
|
||||||
- base improvement-child worktree on parent HEAD (da23b6c)
|
|
||||||
- route standalone success with children to WaitingForChildren + enqueue them (c10f564)
|
|
||||||
- advance WaitingForChildren parent to review when children terminal (7873e60)
|
|
||||||
- add SubmitForChildrenAsync (Running -> WaitingForChildren) (6f4b5d5)
|
|
||||||
- generalize CreateChildAsync for any parent + CreatedBy stamp (6fdf04d)
|
|
||||||
- add WaitingForChildren task status value (ee0d125)
|
|
||||||
- roadblock badge on the task card; relocate review actions off the row (2455eac)
|
|
||||||
- host review actions in the details panel; show review state and diff meter (d8b86e3)
|
|
||||||
- persist roadblock count on the task (49b9f1f)
|
|
||||||
- surface reported roadblocks in the review result (1e547de)
|
|
||||||
- carry blocks through RunResult (56ebc28)
|
|
||||||
- collect and strip CLAUDEDO_BLOCKED markers in StreamAnalyzer (cf7f0da)
|
|
||||||
- weekly-report instructions from file, point at data sections (ac1e9b0)
|
|
||||||
- daily-prep prompt from file, English default (79bfc79)
|
|
||||||
- expose all editable prompt files, drop agent prompt (bd1e3db)
|
|
||||||
- retry prompt from file, append only real captured errors (edc9f77)
|
|
||||||
- externalize prompt kinds with defaults and token renderer (9bdf99d)
|
|
||||||
- show inherited markers and max-turns override in task flyout (cd683ba)
|
|
||||||
- show inherited markers and max-turns override in list settings (d0ab382)
|
|
||||||
- add reusable inherited-source badge control (3e3041c)
|
|
||||||
- add inheritance resolver returning value and source (92cee12)
|
|
||||||
- add inherited-marker, turns, and prepended-prompt strings (bba3c55)
|
|
||||||
- mirror max-turns field on signalr config dtos (26f5936)
|
|
||||||
- expose max-turns override over signalr and mcp config tools (b72a788)
|
|
||||||
- resolve max-turns from task then list then global default (beae2d6)
|
|
||||||
- persist max_turns in list and task repositories (ac137f7)
|
|
||||||
- add nullable max_turns override to list_config and tasks (97e38fb)
|
|
||||||
- replace Plan-My-Day header icon with a stroked sun icon (52e3980)
|
|
||||||
- trigger planning from inside the prep-log window with an empty-state hint (7d743f1)
|
|
||||||
- load persisted prep log into the terminal on open (914095d)
|
|
||||||
- persist last prep run to a log file and serve it via GetLastPrepLog (4d82079)
|
|
||||||
- move Clear-day and Prep-log into MyDay header icon row (c764b2b)
|
|
||||||
- reuse SessionTerminal for prep log; fix invisible Sort icon; add Broom/List icons (f7d1b37)
|
|
||||||
- clear textbox focus on click outside any text box (fab1772)
|
|
||||||
- add Prep-log and Clear-day buttons to MyDay header (c45f892)
|
|
||||||
- add live prep-output mode to the Details island (a8670ee)
|
|
||||||
- expose prep stream events and ClearMyDay on the UI worker client (7676ecf)
|
|
||||||
- add ClearMyDay hub method (fa83d7f)
|
|
||||||
- stream prep output via PrepStarted/PrepLine/PrepFinished (e48475d)
|
|
||||||
- add Prepare-day button to MyDay header (46ac3fc)
|
|
||||||
- add DailyPrepMaxTasks editor to Prime Claude settings (5e0859f)
|
|
||||||
- add RunDailyPrepNow hub method and expose DailyPrepMaxTasks (2d00160)
|
|
||||||
- run daily prep from PrimeRunner via allowed MCP tools (20b3a29)
|
|
||||||
- add set_my_day MCP tool with cap-guard (fd7f8ac)
|
|
||||||
- add get_daily_prep_candidates MCP tool (0bb8094)
|
|
||||||
- add DailyPrepMaxTasks app setting (3c66d65)
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
|
|
||||||
- mark task Done on every successful merge path, not just approve (f56cc61)
|
|
||||||
- merge_task marks the task Done after a successful merge (ca8326c)
|
|
||||||
- drop unique index on lists.name (allow duplicate list names) (f5d165b)
|
|
||||||
- harden FK pragma per-connection and seed concurrency (7f1a14a)
|
|
||||||
- harden CLI injection, stuck-Running, chain wedge, and Fail guard (33bdff8)
|
|
||||||
- serialize concurrent worktree add to prevent commondir race (b672c9a)
|
|
||||||
- dispose VM subscriptions/timers, guard offline Stop, align review delta-path (01e0c1d)
|
|
||||||
- populate review queue from WaitingForReview tasks (00a065b)
|
|
||||||
- set prompt-action resting color on ContentPresenter (561028e)
|
|
||||||
- discard stale mergeability probe after task or target switch (6e3f90d)
|
|
||||||
- guard blank working dir in approve-merge before resolving target (f1cf29b)
|
|
||||||
- update TaskMergeService ctor calls after ITaskStateService injection (98b0d58)
|
|
||||||
- stop the console clipping the last log line (1603be0)
|
|
||||||
- render Output log directly on the console, not as a nested card (71a3765)
|
|
||||||
- stop app crash when approving review after Merge all (cc7355e)
|
|
||||||
- live-update child outcomes + enable Review combined diff for improvement parents (a3f407b)
|
|
||||||
- only planning-active children are drafts; allow improvement children to queue (8036de1)
|
|
||||||
- exempt improvement children from orphan-dequeue sweep (f25c759)
|
|
||||||
- populate diff meter when selecting a finished task (c035720)
|
|
||||||
- warning icon fill-rule and dedicated review section header (4522ac9)
|
|
||||||
- apply system default on every run; dedupe roadblocks (9a117a5)
|
|
||||||
- clean up orphaned worktree when the DB row insert fails (71ac481)
|
|
||||||
- hide task header, footer and agent strip in prep/notes mode (39fa83a)
|
|
||||||
- register IWorkerClient mapping for WeeklyReportModalViewModel (46f42a4)
|
|
||||||
|
|
||||||
### Refactoring
|
|
||||||
|
|
||||||
- single parent-advance path for planning + improvement (b3a2daf)
|
|
||||||
- render worktree modal diff via canonical DiffLinesView (d52243c)
|
|
||||||
- blend review prompt into the terminal instead of a boxed footer (e22a326)
|
|
||||||
- remove dead inline-layout handlers from DetailsIslandView (3e84871)
|
|
||||||
- share color-coded diff rendering between per-task and combined diff viewers (22a1ba7)
|
|
||||||
- planning prompts read from editable files (1b3c6bd)
|
|
||||||
- collapse agent prompt into system prompt (883dbc6)
|
|
||||||
- address code smells (run-dir helper, App DI injection) (3756b81)
|
|
||||||
- remove unused Sort button from MyDay header (3a40e39)
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
|
|
||||||
- document the unified parent-task model (c300f8c)
|
|
||||||
- Task 4 = full approve/merge UX consolidation (803c04d)
|
|
||||||
- spec + plan for unifying the parent-task model (8f49ebb)
|
|
||||||
- add CHANGELOG (Keep a Changelog format) (384e058)
|
|
||||||
- Layer C inline conflict resolver (ef2f5c5)
|
|
||||||
- Layer B multi-worktree merge cockpit plan (3060cb0)
|
|
||||||
- foundation + Layer A plan and Layer B/C parallel kickoff prompts (dd3b03b)
|
|
||||||
- git tab merge & review rework — shared foundation + 3 layers (f4416ee)
|
|
||||||
- add implementation plan for terminal-style review controls (096519b)
|
|
||||||
- spec terminal-style review with Git tab and footer actions (266e6d1)
|
|
||||||
- document real git merge on approve, PreviewMerge hub method, and new GitService/WorkerClient members (cb4c396)
|
|
||||||
- add approve-merge + conflict-preview implementation plan (75ad7b1)
|
|
||||||
- add approve-merge + conflict-preview design spec (66a7b23)
|
|
||||||
- add Refine Task implementation plan (3573548)
|
|
||||||
- add Refine Task design spec (0867bc8)
|
|
||||||
- add ClaudeDo distribution website design spec (a2c339c)
|
|
||||||
- task-detail island redesign spec + component build prompts (8f7e289)
|
|
||||||
- implementation plan for build-config logging + traceability (c5a4e35)
|
|
||||||
- runtime build-config detection, Warning in Release, retain 2 (e547921)
|
|
||||||
- design for build-config debug logging + task traceability (f1316df)
|
|
||||||
- align Task 6 with rebased HandleSuccess (preserve SetRoadblockCount) (204b089)
|
|
||||||
- child tasks + agent improvement loop implementation plan (da4ab0c)
|
|
||||||
- plan for review & roadblock UX follow-up (4d52845)
|
|
||||||
- refresh prompt inventory for externalized prompts + roadblock marker (202e8de)
|
|
||||||
- implementation plan for bundled-prompts overhaul (c8f468f)
|
|
||||||
- child base off parent HEAD, shared planning-style tree merge (84fd2c1)
|
|
||||||
- design for reusable child tasks + agent improvement loop (30b49d1)
|
|
||||||
- design for bundled-prompts overhaul (ad7d748)
|
|
||||||
- note max-turns override and inherited markers in module docs (75aa42b)
|
|
||||||
- implementation plan for inherited markers, overrides, and Turns (b63c78c)
|
|
||||||
- spec for inherited-settings display, overrides, and Turns (37ce673)
|
|
||||||
- slim open.md down to open items only (b9741ef)
|
|
||||||
- park mailbox proposal; skip architecture.md and ADRs (0a0d7e8)
|
|
||||||
- drop CI-pipeline item (push-to-main + release workflow makes it redundant) (72a86fc)
|
|
||||||
- regenerate open.md against verified current state (bcf5e2f)
|
|
||||||
- document daily-prep across area CLAUDE.md files; add Installer CLAUDE.md (fb055ce)
|
|
||||||
- add autonomous working-style loop and agent gotchas to CLAUDE.md (9e7f37b)
|
|
||||||
- add plan-day-in-log-window plan (53d897a)
|
|
||||||
- add prep-log persistence plan (26758b6)
|
|
||||||
- add MyDay icons + terminal-reuse plan (2e73d33)
|
|
||||||
- add design specs and implementation plans (9470c5b)
|
|
||||||
|
|
||||||
## v1.7.0 — 2026-06-03
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
- localize installer with language picker and config write-through (364a037)
|
|
||||||
- add WPF localization primitives and Language config to installer (2fbf054)
|
|
||||||
- localize ViewModel-built strings via ambient Loc accessor (350a89f)
|
|
||||||
- localize Avalonia view strings via loc:Tr markup (086c6f6)
|
|
||||||
- add language dropdown to settings and persist selection (070f5de)
|
|
||||||
- initialize Localizer at app startup from config/OS culture (f529a5f)
|
|
||||||
- add Language preference and Save() to AppSettings (6a85d82)
|
|
||||||
- add Avalonia loc:Tr markup extension and LocalizedString (35ad171)
|
|
||||||
- seed en.json and wire locale copy to app output (3c40bb5)
|
|
||||||
- add CultureResolver for OS-culture mapping (d95d55e)
|
|
||||||
- add Localizer with fallback chain and change event (d22b50e)
|
|
||||||
- add LocaleStore folder discovery (a83a0c4)
|
|
||||||
- add ClaudeDo.Localization project with nested-JSON locale parser (9efde2b)
|
|
||||||
- pinned Notes row in My Day opens the notes editor (a8943a9)
|
|
||||||
- notes mode in the Details island (eccd06e)
|
|
||||||
- NotesEditorView (731c291)
|
|
||||||
- NotesEditorViewModel with day navigation and bullet CRUD (c8b5ed3)
|
|
||||||
- INotesApi wrapper for daily notes (9bf44da)
|
|
||||||
- open Weekly Report modal from the menu (b748c15)
|
|
||||||
- WeeklyReportModalView (74fc39f)
|
|
||||||
- WeeklyReportModalViewModel with default-range logic (ccd2ee2)
|
|
||||||
- persist report excluded paths and standup weekday (5b89e3d)
|
|
||||||
- WorkerClient methods for week report and daily notes (e106b00)
|
|
||||||
- hub methods for week report and daily notes (d7558ef)
|
|
||||||
- register report reader and service in DI (4aa4353)
|
|
||||||
- WeekReportService orchestrates generate + store (50d84f1)
|
|
||||||
- week report prompt builder (day-major pivot) (e2271b5)
|
|
||||||
- ClaudeHistoryReader distills session logs (bec87b3)
|
|
||||||
- report activity models and reader interface (4cb7ad8)
|
|
||||||
- add WeekReportRepository with tests (992fbf0)
|
|
||||||
- add DailyNoteRepository with tests (1d7b86d)
|
|
||||||
- migration for daily notes and week reports (036586e)
|
|
||||||
- configure daily note + week report tables (d9e5d26)
|
|
||||||
- add daily note + week report entities and report settings (10d86b4)
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
|
|
||||||
- live-refresh smart/virtual list names on language change (00ef11a)
|
|
||||||
- notes add row stays visible, English 'Add' label, Enter to add (2d55f88)
|
|
||||||
- sanitize report model arg, fix multi-repo summary attribution and standup-weekday sentinel (a8d8a8b)
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
|
|
||||||
- localization implementation plan (8dc8b8b)
|
|
||||||
- localization (i18n) design spec (baeea9c)
|
|
||||||
- document weekly report and daily notes feature (0bc3d2a)
|
|
||||||
- add weekly report implementation plan (f72cfae)
|
|
||||||
- add report prompt and day-major pivot to weekly report spec (e5a2ed2)
|
|
||||||
- add weekly report feature design spec (536d819)
|
|
||||||
|
|
||||||
## v1.6.0 — 2026-06-02
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
- use a 24h TimePicker for prime schedule time entry (869cf72)
|
|
||||||
- replace prime date range with weekday toggle buttons (7db8f21)
|
|
||||||
- drive prime schedule rows from weekday toggles (37738e3)
|
|
||||||
- map prime schedule weekday bitmask over the hub (81fd186)
|
|
||||||
- compute prime due-time from weekday bitmask (bed4255)
|
|
||||||
- migrate prime schedules to days_of_week bitmask (dff06d9)
|
|
||||||
- persist weekday bitmask in prime schedule repo (0efad7a)
|
|
||||||
- model Prime schedule as weekday bitmask (eaf27e8)
|
|
||||||
- surface review actions and WaitingForReview status in task rows (6c27ffb)
|
|
||||||
- add review hub methods and worker client wrappers (21f1cf2)
|
|
||||||
- add review_task MCP tool and status reference updates (c88ed9d)
|
|
||||||
- route standalone success to review and resume on re-queue (9c1f20f)
|
|
||||||
- add review state transitions to TaskStateService (e8d018d)
|
|
||||||
- add WaitingForReview status and review_feedback column (1ca32a6)
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
|
|
||||||
- manual modal dragging, maximize/restore icon, day-toggle style (f1715a3)
|
|
||||||
- harden review re-run, timestamps, and queue affordance (1cb5171)
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
|
|
||||||
- describe recurring-weekday Prime schedule (26998f0)
|
|
||||||
- implementation plan for recurring-weekday Prime (13c3393)
|
|
||||||
- spec for recurring-weekday Prime schedules (4704a28)
|
|
||||||
- document WaitingForReview state across project CLAUDE.md files (4684a0a)
|
|
||||||
- waiting-for-review implementation plan (b86677d)
|
|
||||||
- waiting-for-review task state design (3e072fa)
|
|
||||||
|
|
||||||
## v1.5.0 — 2026-06-01
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
- replay run log in session terminal, drop per-row live tail (4a36fbe)
|
|
||||||
- optionally register ClaudeDo MCP server with Claude (5170914)
|
|
||||||
- configurable max parallel task executions (b1f4349)
|
|
||||||
- list reordering, quick actions, and resizable modals (ab44ba5)
|
|
||||||
|
|
||||||
## v1.4.2 — 2026-06-01
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
|
|
||||||
- stop the running app before updating, not just the worker (4148dcd)
|
|
||||||
- keep step badges green and reset state on re-run (5783790)
|
|
||||||
|
|
||||||
## v1.4.1 — 2026-06-01
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
|
|
||||||
- track EF migration Designer files (were gitignored) (edfb702)
|
|
||||||
|
|
||||||
## v1.4.0 — 2026-06-01
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
- wire worker connection modal and make status pill clickable (1246bf7)
|
|
||||||
- prompt once on worker connection failure with grace timer (00dc7eb)
|
|
||||||
- add worker connection help modal (0139607)
|
|
||||||
- remove Startup worker shortcut on uninstall (759d905)
|
|
||||||
- start worker via Process.Start, drop schtasks stop (2f1dcdc)
|
|
||||||
- register autostart via Startup shortcut, drop scheduled task (133f2d2)
|
|
||||||
- add AutostartShortcut helper for Startup-folder lnk (e2bb43a)
|
|
||||||
- unify type scale to 11/13/18/24 and add canonical text classes (b00e4d9)
|
|
||||||
- add reusable ModalShell control (c20fbe3)
|
|
||||||
- set global Inter Tight font default on all windows (5a25818)
|
|
||||||
- add named tint and hairline overlay brush tokens (f0f8cd1)
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
|
|
||||||
- restore resize and full-width rows in WorktreesOverview modal (16717ab)
|
|
||||||
- unclip Edit/Preview buttons; enlarge section labels and use mono field labels (e86464e)
|
|
||||||
- correct SettingsModal font snap (11px is Mono, not Body) (b1006ac)
|
|
||||||
- use LineBrush for schedule flyout border and tokenize TaskRowView (3d4a64a)
|
|
||||||
|
|
||||||
### Refactoring
|
|
||||||
|
|
||||||
- rename StopWorkerStep.TaskName to LegacyTaskName (400a078)
|
|
||||||
- stop auto-spawning the worker on app start (4ecd855)
|
|
||||||
- extract ShortcutFactory COM helper (867dc37)
|
|
||||||
- migrate PlanningDiffView to ModalShell (926471d)
|
|
||||||
- drop double padding in Tasks island header (9be8e6b)
|
|
||||||
- drop double padding in Lists island header (b9e5dfc)
|
|
||||||
- class schedule-flyout cancel in TaskRowView (c669370)
|
|
||||||
- class merge-section buttons in DetailsIslandView (4688e88)
|
|
||||||
- class update-banner buttons in MainWindow (8b21b0e)
|
|
||||||
- normalize buttons/footer/padding in ConflictResolutionView (4a786eb)
|
|
||||||
- normalize buttons/footer/padding in DiffModal (cd64f28)
|
|
||||||
- normalize buttons/footer/padding in WorktreesOverviewModal (3585ad5)
|
|
||||||
- normalize buttons/footer/padding in RepoImportModal (990935e)
|
|
||||||
- normalize buttons/footer/padding in UnfinishedPlanningModal (1b5a928)
|
|
||||||
- normalize buttons/footer/padding in AboutModal (e8f880e)
|
|
||||||
- normalize buttons/footer/padding in MergeModal (3228a08)
|
|
||||||
- normalize buttons/footer/padding in ListSettingsModal (ccec791)
|
|
||||||
- normalize buttons/footer/padding in SettingsModal (187fb64)
|
|
||||||
- make primary/danger buttons self-contained, drop unused btn.primary (0a71956)
|
|
||||||
- inherit terminal font for SelectableTextBlock (ccec591)
|
|
||||||
- use sidebar-pane in PlanningDiffView (a4cb03b)
|
|
||||||
- use diff-lineno and sidebar-pane in DiffModal (f53292e)
|
|
||||||
- use danger-box in MergeModal (539ebec)
|
|
||||||
- use danger-box in SettingsModal (dff5651)
|
|
||||||
- use shared section style in ListSettingsModal (9f49b01)
|
|
||||||
- reuse task-row style for worktree rows (fb3a6ac)
|
|
||||||
- use section-divider in DetailsIslandView (4f84b15)
|
|
||||||
- drop duplicate converters and normalize binding in ListsIslandView (27b0d51)
|
|
||||||
- merge task-row styles and add shared section/danger-box/sidebar/accent styles (2a38104)
|
|
||||||
- unify text and close button in ThemedDatePicker (bddef5a)
|
|
||||||
- unify text and close button in ConflictResolutionView (51d3ea2)
|
|
||||||
- unify text and close button in PlanningDiffView (335b422)
|
|
||||||
- unify text and close button in DiffModalView (08f3bab)
|
|
||||||
- unify text and close button in WorktreeModalView (9082f2e)
|
|
||||||
- unify text and close button in WorktreesOverviewModalView (0f64b1c)
|
|
||||||
- unify text and close button in RepoImportModalView (dd45387)
|
|
||||||
- unify text and close button in UnfinishedPlanningModalView (00e1d2d)
|
|
||||||
- unify text and close button in AboutModalView (9a91135)
|
|
||||||
- unify text and close button in MergeModalView (8e595a1)
|
|
||||||
- unify text and close button in ListSettingsModalView (97fc715)
|
|
||||||
- unify text and close button in SettingsModalView (ed8607d)
|
|
||||||
- apply text classes to SessionTerminalView (929e0ca)
|
|
||||||
- apply text classes to AgentStripView (40a3630)
|
|
||||||
- apply text classes to TaskRowView (b9f5d82)
|
|
||||||
- apply text classes to DetailsIslandView (e0dda3e)
|
|
||||||
- apply text classes to TasksIslandView (d4c66de)
|
|
||||||
- apply text classes to ListsIslandView (a132127)
|
|
||||||
- apply text classes to MainWindow (6e3125e)
|
|
||||||
- consolidate list-section-label into shared section-label (7af892f)
|
|
||||||
- fold selected-day White to TextBrush token (8944074)
|
|
||||||
- tokenize WorktreeModalView font sizes (fbd5d9f)
|
|
||||||
- tokenize and dynamic-ize PlanningDiffView (5fdd9f0)
|
|
||||||
- migrate ConflictResolutionView to ModalShell and use dynamic resources (bce4e0a)
|
|
||||||
- migrate DiffModal to ModalShell and use dynamic resources (229f865)
|
|
||||||
- migrate WorktreesOverviewModal to ModalShell (a444033)
|
|
||||||
- migrate RepoImportModal to ModalShell (2265829)
|
|
||||||
- migrate UnfinishedPlanningModal to ModalShell (50e05b9)
|
|
||||||
- migrate AboutModal to ModalShell (538839c)
|
|
||||||
- migrate MergeModal to ModalShell (8d07fc2)
|
|
||||||
- migrate ListSettingsModal to ModalShell (e1bfbb0)
|
|
||||||
- migrate SettingsModal to ModalShell (4f5db36)
|
|
||||||
- tokenize ThemedDatePicker (16b0d11)
|
|
||||||
- tokenize SessionTerminalView (a1f05da)
|
|
||||||
- tokenize AgentStripView (0c0c73b)
|
|
||||||
- tokenize DetailsIslandView (bff15c9)
|
|
||||||
- tokenize TasksIslandView (f40de4b)
|
|
||||||
- tokenize ListsIslandView (e120b0f)
|
|
||||||
- tokenize MainWindow (e8ce725)
|
|
||||||
- tokenize IslandStyles values and add shared modal styles (7a6bfbe)
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
|
|
||||||
- reflect Startup-shortcut worker autostart (549b87b)
|
|
||||||
- add worker lifecycle implementation plan (5baa1d7)
|
|
||||||
- add worker lifecycle redesign spec (4963a72)
|
|
||||||
- add visual-check checklist for normalization pass (df73378)
|
|
||||||
- add UI normalization design spec and implementation plan (d52f23f)
|
|
||||||
|
|
||||||
## v1.3.0 — 2026-05-30
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
- register new external MCP tool classes (b41a78e)
|
|
||||||
- add external MCP app-settings read tool (9ea6070)
|
|
||||||
- add external MCP reset-failed-task tool (5a592c4)
|
|
||||||
- add external MCP agent-listing tool (7196aab)
|
|
||||||
- add external MCP run-history and log tools (3afe29d)
|
|
||||||
- add external MCP list/task config tools (c3493a3)
|
|
||||||
- add external MCP list-management tools (53f4e2d)
|
|
||||||
- run worker as per-user logon task instead of Windows service (26c4e57)
|
|
||||||
- repo-import modal — remember folders, search, compact rows, no auto-select (6d0973c)
|
|
||||||
- add delete-list button to List Settings modal (128fb7d)
|
|
||||||
- add 'Add repos as lists' Help-menu entry point (9c638e7)
|
|
||||||
- add repo import button to Lists island (c43b06d)
|
|
||||||
- add RepoImportModalView (e4d958d)
|
|
||||||
- add RepoImportModalViewModel with candidate merge logic (50b1589)
|
|
||||||
- add RepoImportItemViewModel (1c689a8)
|
|
||||||
- add RepoScanner for git repo discovery (03617ee)
|
|
||||||
- gate subtask queueing behind plan finalization (ce79a2d)
|
|
||||||
- merge action and robust jump-to-task in worktrees overview (967e0cd)
|
|
||||||
- hide list chip outside virtual list views (2223839)
|
|
||||||
- auto-select first changed file in diff modal (3587703)
|
|
||||||
- polish worktrees overview modal (ca71275)
|
|
||||||
- wire worktree overview modal entry points (789094f)
|
|
||||||
- add WorktreesOverviewModalView (9f70f67)
|
|
||||||
- add WorktreesOverviewModalViewModel (182a9df)
|
|
||||||
- add WorktreeStateColorConverter (79131f8)
|
|
||||||
- expose worktree overview client methods (b888a5f)
|
|
||||||
- expose worktree overview, state mutation, force-remove (046da0f)
|
|
||||||
- add ForceRemoveAsync for targeted removal (b095a29)
|
|
||||||
- add GetOverviewAsync for overview modal (ce30d01)
|
|
||||||
- allow CleanupFinishedAsync to filter by list (89f6b83)
|
|
||||||
- add Restart worker menu entry under Help (8d34db3)
|
|
||||||
- prevent orphaned subtasks via guards + startup repair (d094a21)
|
|
||||||
- add Claude CLI preflight on startup (df66c4a)
|
|
||||||
- cascade dequeue to queued children for any parent (4c92da5)
|
|
||||||
- refine planning chain re-shape on re-run (d4d5a4b)
|
|
||||||
- status/tag context menu + ThemedDatePicker in task row (9ba238f)
|
|
||||||
- editable task status and tags from details panel (c185665)
|
|
||||||
- add ThemedDatePicker control and adopt in Prime settings (47b0737)
|
|
||||||
- add hub methods to set task status and tags freely (121e8cd)
|
|
||||||
- drop 'agent' tag gate from queue claim (cfbe2fd)
|
|
||||||
- show transient prime status in footer (5079a5f)
|
|
||||||
- add About modal opened from Help menu (618235d)
|
|
||||||
- refactor Settings to TabControl + add Prime Claude tab (bca8c9e)
|
|
||||||
- split SettingsModalViewModel into per-tab VMs + add PrimeClaudeTabViewModel (8b02b63)
|
|
||||||
- add Prime schedule client + PrimeFired event (f890fa8)
|
|
||||||
- register Prime services in DI (71c6c68)
|
|
||||||
- add Prime schedule hub methods (507f59f)
|
|
||||||
- broadcast PrimeFired SignalR event (13c280f)
|
|
||||||
- add PrimeScheduler hosted service (09e3e7e)
|
|
||||||
- add NextDueCalculator with workday + catch-up logic (975db8a)
|
|
||||||
- add Prime scheduler abstractions + runner (f383645)
|
|
||||||
- add PrimeScheduleDto (4e90828)
|
|
||||||
- add PrimeScheduleRepository (a335a3b)
|
|
||||||
- add AddPrimeSchedules migration (0b90df6)
|
|
||||||
- add PrimeScheduleEntity + configuration (6c9ccf6)
|
|
||||||
- consolidate finalize+chain via TaskStateService, fix queue pickup (4ab906f)
|
|
||||||
- add Idle/Cancelled status, PlanningPhase enum, BlockedByTaskId field (7b737e6)
|
|
||||||
- show dequeue affordance on planning parents with queued children (bdb709b)
|
|
||||||
- allow status changes and post-finalize edits in active session (2d7f825)
|
|
||||||
- add SetTaskTags (59dc1e2)
|
|
||||||
- add DeleteTask (31a394e)
|
|
||||||
- add UpdateTask for content/tag patching (d99cb68)
|
|
||||||
- AddTask accepts tags on creation (1a74e1c)
|
|
||||||
- add ListTags + inject TagRepository (e6846b7)
|
|
||||||
- add TaskRepository.SetTagsAsync for full tag-set replacement (2549352)
|
|
||||||
- default permission mode to auto and surface it in UI (14cc9fb)
|
|
||||||
- add editable system/planning/agent prompt files (7f96ae9)
|
|
||||||
- add Run interactively action to task context menu (6c54759)
|
|
||||||
- make island layout user-resizable with grid splitters (e192285)
|
|
||||||
- add MarkdownView control and editable description in details island (a6ca1c0)
|
|
||||||
- queue planning subtasks sequentially and surface waiting status (8f94ddd)
|
|
||||||
- add external MCP endpoint with API-key auth (4532042)
|
|
||||||
- add PlanningChainCoordinator for sequential subtask execution (16e1ddd)
|
|
||||||
- add Waiting task status and CreatedBy column (288d2ec)
|
|
||||||
- run planning agent in plan permission mode and enforce brainstorming skill (8e9f09a)
|
|
||||||
- register planning services and add Merge-all hub methods (3008c36)
|
|
||||||
- add pre-flight checks and idempotent restart to PlanningMergeOrchestrator (e58cac2)
|
|
||||||
- add PlanningMergeOrchestrator.AbortAsync (b989639)
|
|
||||||
- add PlanningMergeOrchestrator.ContinueAsync to resume merge after conflict (7d87c03)
|
|
||||||
- add PlanningMergeOrchestrator happy path with merge event broadcasts (3142ba2)
|
|
||||||
- add conflict resolution dialog for planning merge-all (bc788e1)
|
|
||||||
- add aggregated diff viewer for planning tasks (a6ebff3)
|
|
||||||
- add PlanningAggregator.CleanupIntegrationBranchAsync (389d904)
|
|
||||||
- add merge-target dropdown and merge-all controls to planning detail (4c6fd9f)
|
|
||||||
- add PlanningAggregator.BuildIntegrationBranchAsync (2cab33d)
|
|
||||||
- add PlanningAggregator.GetAggregatedDiffAsync (a1727b6)
|
|
||||||
- add AbortMergeAsync to cancel a conflicted merge (bc0f1e3)
|
|
||||||
- add ContinueMergeAsync to resume a conflicted merge (62106ff)
|
|
||||||
- add leaveConflictsInTree option to TaskMergeService.MergeAsync (e77ba35)
|
|
||||||
- broadcast child TaskUpdated events on planning CRUD (5a03dc8)
|
|
||||||
- launcher passes planning token via env, drops --mcp-config (6800852)
|
|
||||||
- cleanup planning worktree and branch on finalize/discard (48899b3)
|
|
||||||
- create ephemeral worktree and write .mcp.json in StartAsync (fce91bc)
|
|
||||||
- live task updates from worker events + planning polish (b7c60f5)
|
|
||||||
- SignalR hub endpoints for planning sessions (7b67e35)
|
|
||||||
- map MCP HTTP endpoint and broadcast TaskUpdated (6cb20a9)
|
|
||||||
- MCP tools update_planning_task and finalize (99c6a71)
|
|
||||||
- MCP tools for child-task CRUD (0088d6e)
|
|
||||||
- MCP bearer-token auth middleware (b115a4c)
|
|
||||||
- WindowsTerminalPlanningLauncher with pre-flight checks (43a3740)
|
|
||||||
- PlanningSessionManager.GetPendingDraftCountAsync (d28164c)
|
|
||||||
- PlanningSessionManager.FinalizeAsync (77f7cf1)
|
|
||||||
- PlanningSessionManager.DiscardAsync (84e6c2d)
|
|
||||||
- PlanningSessionManager.ResumeAsync (84b0ba8)
|
|
||||||
- PlanningSessionManager.StartAsync (b6bec1e)
|
|
||||||
- friendly error when deleting task with children (0e116be)
|
|
||||||
- unfinished planning session dialog (47b4974)
|
|
||||||
- draft and planning badge styles (506caa2)
|
|
||||||
- planning entries in task context menu (388a8c1)
|
|
||||||
- TaskRowView hierarchy indentation, chevron, badges, draft italic (42b208f)
|
|
||||||
- planning commands and expand/collapse in TasksIslandViewModel (309f84b)
|
|
||||||
- WorkerClient planning-session methods (0060840)
|
|
||||||
- TaskRowViewModel gains planning hierarchy flags (229d4bb)
|
|
||||||
- hook TryCompleteParentAsync after MarkDone/MarkFailed (d4a4642)
|
|
||||||
- TaskRepository.TryCompleteParentAsync (b7464c9)
|
|
||||||
- TaskRepository.DiscardPlanningAsync (524aaf8)
|
|
||||||
- TaskRepository.FinalizePlanningAsync (a9e7479)
|
|
||||||
- TaskRepository.FindByPlanningTokenAsync (2e80cc6)
|
|
||||||
- TaskRepository.UpdatePlanningSessionIdAsync (d099138)
|
|
||||||
- TaskRepository.SetPlanningStartedAsync (2278d97)
|
|
||||||
- TaskRepository.CreateChildAsync (74255dd)
|
|
||||||
- TaskRepository.GetChildrenAsync (b466246)
|
|
||||||
- migration AddPlanningSupport (b3eb39a)
|
|
||||||
- configure planning columns and self-ref FK with Restrict (253e6f0)
|
|
||||||
- add planning columns and self-ref navigations to TaskEntity (042a1b4)
|
|
||||||
- add Planning, Planned, Draft task statuses (7a20534)
|
|
||||||
- move list-settings access from lists pane to tasks header (ee2cbc9)
|
|
||||||
- add update banner and Help menu to MainWindow (00c6217)
|
|
||||||
- wire update-check state and commands into shell VM (bbe7d73)
|
|
||||||
- register UpdateCheckService and InstallerLocator in DI (0934b29)
|
|
||||||
- show worker log line in footer (b28d8f2)
|
|
||||||
- add worker log state and 30s timer to shell VM (ec4ec44)
|
|
||||||
- add InstallerLocator (ee09706)
|
|
||||||
- add UpdateCheckService (c06d1d6)
|
|
||||||
- add WorkerLogLevelToBrushConverter with tests (f906e70)
|
|
||||||
- self-update pre-flight before wizard (caf900b)
|
|
||||||
- subscribe to WorkerLog SignalR event (e80e3fc)
|
|
||||||
- emit WorkerLog for merge, discard, reset (e805655)
|
|
||||||
- emit WorkerLog events from TaskRunner (ea4d2d7)
|
|
||||||
- add SelfUpdater.DownloadAndVerifyAsync (98c188a)
|
|
||||||
- add SelfUpdater.HandleReplaceSelfAsync (0c3dcb0)
|
|
||||||
- add SelfUpdater.DecideUpdateAsync (e017d66)
|
|
||||||
- add SelfUpdater installer-asset matching (ba0b38b)
|
|
||||||
- add VersionComparer (7c0f8d8)
|
|
||||||
- add WorkerLog SignalR event (0a7fcae)
|
|
||||||
- add WorkerLogLevel enum (80f6669)
|
|
||||||
- add empty ClaudeDo.Releases library (86012e0)
|
|
||||||
- replay persisted task log when selecting a task (c8c8bb4)
|
|
||||||
- add queueing and scheduling from task row context menu (6f725d1)
|
|
||||||
- use ClaudeTask icon for window and taskbar (9952ff9)
|
|
||||||
- show version info and offer worker restart in settings (4a6d96b)
|
|
||||||
- record data directory in install manifest (2690332)
|
|
||||||
- harden database init and service setup steps (31218fc)
|
|
||||||
- add Restore default agents button to Settings modal (e70ae7f)
|
|
||||||
- add RestoreDefaultAgentsAsync to WorkerClient (1830273)
|
|
||||||
- expose RestoreDefaultAgents hub method (1a10e6f)
|
|
||||||
- seed default agents on startup (df57c2b)
|
|
||||||
- add DefaultAgentSeeder for first-launch agent seeding (990be09)
|
|
||||||
- add bundled default agent definitions (ff3de1d)
|
|
||||||
- always-visible Steps section at top of DetailsIsland with add-step input (b0b15e4)
|
|
||||||
- per-task agent settings in DetailsIsland (bba5778)
|
|
||||||
- open ListSettingsModal via context menu and gear button (5784dbe)
|
|
||||||
- add ListSettingsModalView (5348220)
|
|
||||||
- add ListSettingsModalViewModel (cd0b95e)
|
|
||||||
- WorkerClient supports list/task agent settings + ListUpdated event (fc1cfe5)
|
|
||||||
- add hub methods for list and task agent settings (7c31216)
|
|
||||||
- add TaskRepository.UpdateAgentSettingsAsync (480eb08)
|
|
||||||
- add ListRepository.DeleteConfigAsync (1b94fa5)
|
|
||||||
- show status messages and real diff-stats in DiffModal (3142057)
|
|
||||||
- add Merge button to DiffModal (1bc7fcc)
|
|
||||||
- add Merge command to DiffModal (c911717)
|
|
||||||
- attach MergeModal to DetailsIsland (949911f)
|
|
||||||
- wire DetailsIsland ApproveMerge through MergeModal (f3a58a6)
|
|
||||||
- add MergeModalView (e11b019)
|
|
||||||
- add MergeModalViewModel (3d0cc4f)
|
|
||||||
- add MergeTaskAsync and GetMergeTargetsAsync to WorkerClient (4585b20)
|
|
||||||
- expose MergeTask and GetMergeTargets on WorkerHub (c53b587)
|
|
||||||
- implement TaskMergeService happy path (3331c24)
|
|
||||||
- scaffold TaskMergeService with pre-flight checks (1c20d8f)
|
|
||||||
- add ListConflictedFilesAsync (77a1460)
|
|
||||||
- add MergeAbortAsync (21a1870)
|
|
||||||
- add MergeNoFfAsync returning (exitCode, stderr) (3ebbdb3)
|
|
||||||
- add IsMidMergeAsync (535d0c5)
|
|
||||||
- add ListLocalBranchesAsync (2d807aa)
|
|
||||||
- add GetCurrentBranchAsync (93ee7b7)
|
|
||||||
- add Continue and Reset buttons to agent strip (2ce6b7b)
|
|
||||||
- add Continue and Reset commands to DetailsIslandViewModel (b03e858)
|
|
||||||
- add ContinueTaskAsync and ResetTaskAsync to WorkerClient (2278b51)
|
|
||||||
- expose ResetTask hub method (219a231)
|
|
||||||
- add TaskResetService for discard + reset flow (74eb36d)
|
|
||||||
- add TaskRepository.ResetToManualAsync (202236a)
|
|
||||||
- add WorktreeManager.DiscardAsync for task reset (44203f3)
|
|
||||||
- add settings modal and wire to worker hub (e6b3762)
|
|
||||||
- extend ClaudeArgsBuilder with MaxTurns and PermissionMode (fca5d57)
|
|
||||||
- add WorktreeMaintenanceService for idle-worktree cleanup (cfb9ca1)
|
|
||||||
- add AppSettings entity, migration, and repository (62a1121)
|
|
||||||
- render user tool_result blocks as one-line summaries (374e811)
|
|
||||||
- render assistant tool_use blocks with per-tool args (3a67fe8)
|
|
||||||
- render assistant text blocks, skip thinking (dc6e3fe)
|
|
||||||
- format system init message in StreamLineFormatter (b525498)
|
|
||||||
- keyboard shortcuts (/ Ctrl+N Space Esc) (6dade01)
|
|
||||||
- pulse, hover, modal, and row-add animations (47e8e1f)
|
|
||||||
- worktree modal with tree view and M/A badges (abd7733)
|
|
||||||
- diff modal with file sidebar and tinted hunks (4d68543)
|
|
||||||
- tasks island with rows, chips, add-task, selection (f94bb35)
|
|
||||||
- details island with agent strip, terminal, subtasks, notes (4f41b08)
|
|
||||||
- DetailsIslandViewModel with agent state and log (fcf53ab)
|
|
||||||
- TasksIslandViewModel with smart/virtual/user filtering (0034acc)
|
|
||||||
- Lists island view with search and nav items (f167120)
|
|
||||||
- TaskRowViewModel with status chip mapping (dc1b648)
|
|
||||||
- ListsIslandViewModel with smart/virtual/user lists (06cc141)
|
|
||||||
- chromeless three-island shell (05404f4)
|
|
||||||
- scaffold islands shell and child VMs (8909119)
|
|
||||||
- merge Tokens and IslandStyles into App (55917c9)
|
|
||||||
- embed Inter Tight and JetBrains Mono fonts (1893576)
|
|
||||||
- add design Tokens resource dictionary (92a6e06)
|
|
||||||
- add island control styles (579b527)
|
|
||||||
- seed default Lists (My Day, Important, Planned) (bd8a4d0)
|
|
||||||
- migration for IsStarred/IsMyDay/Notes columns (928dde1)
|
|
||||||
- add IsStarred, IsMyDay, Notes to TaskEntity (a1190a3)
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
|
|
||||||
- cap run-log read size and harden run-history tests (fec2fe2)
|
|
||||||
- reuse shared hub fake and guard blank list name (ac2f1d8)
|
|
||||||
- apply blue PLANNED badge for finalized planning, drop dead converter statics (7a88e8a)
|
|
||||||
- strip prerelease and build metadata before version compare (b84716f)
|
|
||||||
- narrow delete-list FK catch to SqliteException (6e3947c)
|
|
||||||
- narrow RepoScanner catch to filesystem exceptions (4877c11)
|
|
||||||
- widen About modal so folder Open buttons are not clipped (c1c7862)
|
|
||||||
- restore Ui.Tests build by implementing ListUpdatedEvent in fakes (12668f6)
|
|
||||||
- dispatch WorkerLog events to UI thread (7d61d38)
|
|
||||||
- wire details-island buttons and drop dead handlers (e55367a)
|
|
||||||
- default-expand diff tree; reliable row-click toggle (7e3ae70)
|
|
||||||
- toggle expand on full folder row click (232d7cb)
|
|
||||||
- use BorderOnly chrome; color diff +/- lines (6c8048d)
|
|
||||||
- make overview modal resizable; add diff content pane (6670771)
|
|
||||||
- resizable modal, drop branch column, show committed diff (bc15c16)
|
|
||||||
- preserve status message after cleanup; English label (8f4e37e)
|
|
||||||
- restore green test suite across all projects (8eafa71)
|
|
||||||
- attach agent tag to chained children for queue pickup (721c36a)
|
|
||||||
- emit PlanningMergeAborted (not Conflict) on non-conflict merge failures (ce23f64)
|
|
||||||
- prevent PlanningMergeOrchestrator double-drain race and orphaned state (ef070dd)
|
|
||||||
- reorder PlanningAggregator checkout/delete and kill git on cancel (9d04d1d)
|
|
||||||
- align virtual list semantics and complete planning roll-up coverage (6bdfa73)
|
|
||||||
- wrap MergeAbortAsync in AbortMergeAsync for consistent error handling (ada4d9f)
|
|
||||||
- planning parents roll up child status; children stay nested until parent Done (6d460ea)
|
|
||||||
- tighten ContinueMergeAsync guards and commit error handling (63759ee)
|
|
||||||
- derive planning MCP URL from configured SignalRPort (e62485d)
|
|
||||||
- register TaskRepository in DI and guard null WorkingDir (c048264)
|
|
||||||
- planning launcher — avoid cmd shell to prevent prompt injection (9e09ae6)
|
|
||||||
- enable foreign_keys pragma in MigrateAndConfigure (7821106)
|
|
||||||
- select task on left-click even when reorder is disabled (1344beb)
|
|
||||||
- session terminal scrolls to end after layout so last line is fully visible (7de5510)
|
|
||||||
- pin AgentStrip above metadata footer, terminal sits above it (5e54275)
|
|
||||||
- session terminal auto-sizes to output, caps at 420px before scrolling (6ac8823)
|
|
||||||
- move agent-settings expander out of capped scroller so it expands properly (839f862)
|
|
||||||
- use PlaceholderText instead of obsolete Watermark in ListSettingsModalView (2901a76)
|
|
||||||
- use UTF-8 encoding for git process stdio (07dee31)
|
|
||||||
- disable Merge button after worktree is no longer Active (4debd5c)
|
|
||||||
- return Blocked when MergeAbortAsync fails to avoid stuck repo (1495c63)
|
|
||||||
- honour targetBranch in MergeAsync by checking out before merge (953d931)
|
|
||||||
- correct Reset button tooltip wording (58c8210)
|
|
||||||
- early-return in ResetAsync when ConfirmAsync is unwired (f90d3d8)
|
|
||||||
- prefix broadcast lines with [stdout] so UI parser routes them (4283c67)
|
|
||||||
- truncate WebFetch URL in tool_use arg (ec679e4)
|
|
||||||
- filled window icons, boxed task rows, proper explorer button (e19a9d3)
|
|
||||||
- NAVIGATOR eyebrow — drop broken converter binding (42fb7ce)
|
|
||||||
- wire delete confirm, close-details, uppercase eyebrow, explorer button (5acc896)
|
|
||||||
- drop icon-btn sizing from AgentStrip text buttons (27c6a4b)
|
|
||||||
- use Tag-attribute selectors for terminal log colors (2d1a488)
|
|
||||||
- guard Bind/LoadForList against interleaved DbContext awaits (62aac7e)
|
|
||||||
- wire modal delegates from DetailsIslandView owner (279f2c7)
|
|
||||||
- remove stale brush overrides in App.axaml (9514651)
|
|
||||||
- restore ViewModels using for IslandsShellViewModel (eee98b7)
|
|
||||||
|
|
||||||
### Refactoring
|
|
||||||
|
|
||||||
- merge TaskRunner failure handlers and reuse NullIfBlank (1856943)
|
|
||||||
- fold single-consumer helper types into their owners (ce9fadc)
|
|
||||||
- remove dead PlanningMergeEvents records and unused RunNowRequestedEvent (25ee623)
|
|
||||||
- extract interfaces to Interfaces folders and consolidate filters (41da124)
|
|
||||||
- consolidate commit types into CommitTypeRegistry (5da69ee)
|
|
||||||
- consolidate permission modes into PermissionModeRegistry (5308ba3)
|
|
||||||
- consolidate model aliases into ModelRegistry (a62ef24)
|
|
||||||
- remove tag entity and all references (623ebf1)
|
|
||||||
- dequeue orphans instead of promoting, restore lost lineage (0d55002)
|
|
||||||
- consolidate task list filters into single strategy registry (e68bb73)
|
|
||||||
- retire legacy TaskStatus values and backfill existing rows (dc3fc44)
|
|
||||||
- extract OverrideSlotService and reorganize Worker/Services into domain folders (ff7c239)
|
|
||||||
- split queue waker and picker, auto-wake on enqueue (064a903)
|
|
||||||
- introduce TaskStateService and route mutations through it (8823265)
|
|
||||||
- use --permission-mode auto instead of --dangerously-skip-permissions (b2eb5fc)
|
|
||||||
- test planning detail pane via real ViewModel and restore merge-all IsEnabled binding (1aead9d)
|
|
||||||
- switch MCP config to env-var token expansion (975e1ce)
|
|
||||||
- add worktree path and token file helpers (1d61df8)
|
|
||||||
- inject GitService and WorkerConfig into PlanningSessionManager (1370bf3)
|
|
||||||
- drop McpConfigPath from PlanningSessionFiles (f2db5f4)
|
|
||||||
- extend planning contexts with token and worktree (fd2ac48)
|
|
||||||
- use shared VersionComparer in InstallModeDetector (5b4cdd3)
|
|
||||||
- move release-API + checksum types to ClaudeDo.Releases (46e01ae)
|
|
||||||
- redesign list settings and merge modals with custom chrome (5ced1b9)
|
|
||||||
- single scrollable DetailsIsland body with agent-settings gear flyout, remove Notes (c599fdc)
|
|
||||||
- skeleton dispatch for StreamLineFormatter rewrite (668087c)
|
|
||||||
- centralize list seeding in MigrateAndConfigure, add default-value test (9a05907)
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
|
|
||||||
- sync CLAUDE.md files with current architecture (cfc4511)
|
|
||||||
- correct external MCP tool inventory, drop removed tag tools (32daa4a)
|
|
||||||
- clarify SetTaskConfig null-clears-override wording (f3f8af4)
|
|
||||||
- add external MCP UI-parity spec and plan (99dc084)
|
|
||||||
- clarify repo-import checkbox default intent (2f7f00d)
|
|
||||||
- add repo import list helper implementation plan (5b15e30)
|
|
||||||
- document RepoImportModalView (e5bce07)
|
|
||||||
- add repo import list helper design spec (7869c2a)
|
|
||||||
- add planning draft/planned queue gate design spec (09a930e)
|
|
||||||
- add worktree overview modal spec and plan (b944597)
|
|
||||||
- regenerate against current code state (a6608bf)
|
|
||||||
- add design + plan for tabbed settings + Prime Claude (2ff0971)
|
|
||||||
- add session prompts for worker state consolidation slices 2-6 (cf7a6e4)
|
|
||||||
- add worker state and queue consolidation spec (43af17e)
|
|
||||||
- add external MCP CRUD extensions spec and plan (10b2ca8)
|
|
||||||
- document new external MCP tools (1b9f2d4)
|
|
||||||
- add planning UX spec/plan and prompts/mailbox proposals (615c1da)
|
|
||||||
- add spec and plan for planning merge-all feature (8afbf20)
|
|
||||||
- add worktree-isolated MCP session design and plan (4de2dea)
|
|
||||||
- add planning-session manual verification checklist (450e685)
|
|
||||||
- add planning sessions implementation plans A, B, C (43d517d)
|
|
||||||
- add planning sessions design (8891d48)
|
|
||||||
- add self-update manual verification checklist (a41e5b5)
|
|
||||||
- add worker-log footer implementation plan (ea76945)
|
|
||||||
- add worker-log footer implementation plan (41e0bea)
|
|
||||||
- add worker-log footer design spec (da19eb8)
|
|
||||||
- add implementation plan (0d37473)
|
|
||||||
- add design spec for app + installer self-update (6a4bf67)
|
|
||||||
- add default-agents plan and design spec (a135485)
|
|
||||||
- refresh CLAUDE.md files for agent settings UI (e74e7ee)
|
|
||||||
- agent settings UI implementation plan (02464b7)
|
|
||||||
- agent settings per list and per task UI reimplementation (68f461d)
|
|
||||||
- add 2026-04-21 open-items consolidation (cb43bcd)
|
|
||||||
- clarify merged-with-cleanup-warning result shape (32ef1b3)
|
|
||||||
- add worktree merge implementation plan (0885518)
|
|
||||||
- add worktree merge design spec (944d3bd)
|
|
||||||
- note ResetTask hub method and TaskResetService (fb89e02)
|
|
||||||
- add implementation plan for continue and reset buttons (133774c)
|
|
||||||
- add spec for continue and reset buttons on failed tasks (a3bb557)
|
|
||||||
- add UI-rewrite notes, plans, and stream-formatter spec (23f8fdd)
|
|
||||||
- add design spec (b474113)
|
|
||||||
|
|
||||||
## v1.2.0 — 2026-04-17
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
- add subtask tree view with expand/collapse in task list (32bb528)
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
|
|
||||||
- expand ~ in UiDbPath (2a8cd97)
|
|
||||||
- init editor TCS before dialog can complete (09e8b1f)
|
|
||||||
- reset stale worktree state on TaskDetail reload (92d8d90)
|
|
||||||
- capture CurrentListId before await in AddTask (aa1008d)
|
|
||||||
- make user-data deletion on uninstall opt-in (5f3d41e)
|
|
||||||
- rollback-safe extract with .bak stash (7d48f34)
|
|
||||||
- move service start out of RegisterServiceStep (51a1bbe)
|
|
||||||
- escape newline/tab in CLI args (ad7c9fa)
|
|
||||||
- guard against same task in queue and override slot (11a4376)
|
|
||||||
- reject CurrentUser service account without password (f10ad69)
|
|
||||||
- swallow DB errors in TaskListViewModel.OnTaskUpdated (dc4571a)
|
|
||||||
- emit RunCreated after run row exists (4fb6ba6)
|
|
||||||
- resolve critical bugs and improve reliability across worker, data, UI (3423919)
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
|
|
||||||
- add subtask tree view design spec (4f25c3d)
|
|
||||||
|
|
||||||
## v1.1.0 — 2026-04-16
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
- wire EF Core into DI and update all consumers to IDbContextFactory (36484ed)
|
|
||||||
- rewrite all repositories to use EF Core ClaudeDoDbContext (34ca1b0)
|
|
||||||
- add ClaudeDoDbContext with Fluent API configurations (51a5dcb)
|
|
||||||
- add navigation properties to all entity models (f8f1386)
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
|
|
||||||
- address code review findings (611454d)
|
|
||||||
|
|
||||||
### Refactoring
|
|
||||||
|
|
||||||
- switch InitDatabaseStep to EF Core migrations (7d0ca45)
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
|
|
||||||
- update CLAUDE.md files for EF Core migration (8d61b05)
|
|
||||||
- add EF Core migration implementation plan (9236ca6)
|
|
||||||
- add EF Core migration design spec (9e1f137)
|
|
||||||
|
|
||||||
## v1.0.0 — 2026-04-15
|
|
||||||
|
|
||||||
### Features
|
|
||||||
|
|
||||||
- add app and installer icons (3b1f148)
|
|
||||||
- agent config inline in detail panel, file picker, subtask UI (9a407bd)
|
|
||||||
- add subtasks table, repository and prompt integration (8c051d8)
|
|
||||||
- remove MaxWidth on main columns to use full window width (8577c55)
|
|
||||||
- mode-aware wizard page list + Update-mode step pipeline (b5455a1)
|
|
||||||
- Config view — Save/Repair/Uninstall commands + footer buttons (2898bec)
|
|
||||||
- add UninstallRunner (service + shortcuts + dirs) (ac38ea8)
|
|
||||||
- rewrite WelcomePage for download-mode + update heading (da1fe21)
|
|
||||||
- async mode detection + mode-aware DI wiring (01c29bb)
|
|
||||||
- add WriteInstallManifestStep (5482518)
|
|
||||||
- add DownloadAndExtractStep with SHA256 verify (c1e3301)
|
|
||||||
- add Stop/StartServiceStep sc.exe wrappers (d87de15)
|
|
||||||
- replace sync ModeDetector with async InstallModeDetector (97fb215)
|
|
||||||
- add IReleaseClient + Gitea ReleaseClient (5603fd4)
|
|
||||||
- add ChecksumVerifier (SHA256 + checksums.txt parser) (d0c0e2c)
|
|
||||||
- add InstallManifest + json-backed store (921e626)
|
|
||||||
- add Gitea Actions release workflow (aea0909)
|
|
||||||
- add WPF installer/configurator project (78831b2)
|
|
||||||
- add config override fields to TaskEditorView (f8be2c1)
|
|
||||||
- complete Batch 2 — LiveText display, start feedback, modal theming, ListEditor config (699fe8a)
|
|
||||||
- replace LiveLines with formatted LiveText, add log reload and start feedback (0764bb3)
|
|
||||||
- add starting state feedback to task list (503fd69)
|
|
||||||
- add StreamLineFormatter for NDJSON stream parsing (365ecba)
|
|
||||||
- default to claude-sonnet-4-6 when no model configured (945a1ee)
|
|
||||||
- add RunNowRequestedEvent and GetAgentsAsync to WorkerClient (026df8d)
|
|
||||||
- add ContinueTask routing to QueueService (adc5a16)
|
|
||||||
- add ContinueTask, GetAgents, RefreshAgents hub methods and RunCreated broadcast (6cb8012)
|
|
||||||
- add AgentFileService for filesystem agent management (8825351)
|
|
||||||
- extend RunResult with structured output, session ID, and token metrics (54c4d3c)
|
|
||||||
- extend TaskRepository with model, system_prompt, agent_path columns (f57cdb7)
|
|
||||||
- add StreamAnalyzer for rich NDJSON stream parsing (8b342bc)
|
|
||||||
- add ClaudeArgsBuilder for dynamic CLI argument construction (dab461c)
|
|
||||||
- add GetConfigAsync and SetConfigAsync to ListRepository (5232d5f)
|
|
||||||
- add TaskRunRepository with CRUD and query methods (19a2104)
|
|
||||||
- add ListConfigEntity, TaskRunEntity, AgentInfo models and task config fields (02aaa9d)
|
|
||||||
- add list_config, task_runs tables and task config columns (36ae653)
|
|
||||||
- add global keyboard shortcuts (Ctrl+N, Ctrl+L, Ctrl+R, Ctrl+Shift+N) (ff5e56a)
|
|
||||||
- add inline add handlers, checkbox click, and task keyboard shortcuts (2dcfc7e)
|
|
||||||
- add auto-save LostFocus handlers and tag input KeyDown (a44c104)
|
|
||||||
- wire TaskDetail changes back to task list refresh (f51278e)
|
|
||||||
- make TaskDetailViewModel editable with auto-save and tag CRUD (28a0d9b)
|
|
||||||
- add inline task creation, toggle-done, and list name to TaskListViewModel (a4da2e2)
|
|
||||||
- add ToggleDone command and checkbox state to TaskItemViewModel (0796b3c)
|
|
||||||
- add colored dot brush to ListItemViewModel (3c52e9c)
|
|
||||||
- add CheckboxBorderConverter for task status circles (a548d41)
|
|
||||||
- add context menus for lists and tasks (3653dca)
|
|
||||||
- open editor on double-click for lists and tasks (db5a447)
|
|
||||||
- wire avalonia desktop ui to data and worker (48e4aab)
|
|
||||||
- add git worktree support and conventional commits (01235d9)
|
|
||||||
- add claude-cli runner, queue service, and hub api (e5038d7)
|
|
||||||
- add repositories, stale-task recovery, and test foundation (9f51ff0)
|
|
||||||
- add db schema init and signalr hub skeleton (f81ef02)
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
|
|
||||||
- prevent async void races and leak-on-exit (2b3fe02)
|
|
||||||
- address concurrency, cancellation, and resource issues (d3b85f2)
|
|
||||||
- wait for prior service registration to clear before create (fc9029d)
|
|
||||||
- publish framework-dependent single-file (1c764da)
|
|
||||||
- disable single-file compression to prevent WPF startup AV (cfec329)
|
|
||||||
- service hosting, dark theme, uninstall polish (f599f8d)
|
|
||||||
- set EnableWindowsTargeting so Linux Gitea runners can publish (9b928c6)
|
|
||||||
- UninstallRunner abort-on-stop-fail + path guard + partial-failure reporting (5d42438)
|
|
||||||
- null-defensive WelcomePage heading + guard unreachable modes (8d2f7e9)
|
|
||||||
- fall back to Config on detection timeout when install.json exists (5e432a4)
|
|
||||||
- wrap WriteInstallManifestStep I/O in try/catch like sibling steps (12e5327)
|
|
||||||
- harden DownloadAndExtractStep per review (ea32a74)
|
|
||||||
- check exit code (not stdout) for ERROR_SERVICE_ALREADY_RUNNING (5b4af29)
|
|
||||||
- propagate cancellation + defensive asset parsing in ReleaseClient (83d7058)
|
|
||||||
- fix live output visibility and editor dialog graying out (2a1f26d)
|
|
||||||
- address code review findings (7363e48)
|
|
||||||
- allow RunNow for any non-running task, not just queued (95c8cc8)
|
|
||||||
- update QueueServiceTests for new TaskRunner constructor signature (26c2445)
|
|
||||||
- replace deprecated Watermark with PlaceholderText (5f51fe9)
|
|
||||||
- register TagRepository in TaskDetailViewModel constructor (5b6c095)
|
|
||||||
- re-evaluate RunNow CanExecute when worker connection changes (473e0f7)
|
|
||||||
- make list and task rows fully hit-testable for clicks (981b8e4)
|
|
||||||
- context menu operates on right-clicked item and gates new-task on list selection (5d5a583)
|
|
||||||
- harden worker auto-reconnect lifecycle (fdf357b)
|
|
||||||
- cancel retry loop before disposing worker connection (36ef624)
|
|
||||||
- auto-reconnect worker connection with retry backoff (c6522cf)
|
|
||||||
|
|
||||||
### Refactoring
|
|
||||||
|
|
||||||
- replace SourceDirectory with Mode/Version fields in InstallContext (4fab048)
|
|
||||||
- remove source-build steps (replaced by DownloadAndExtractStep) (0989176)
|
|
||||||
- remove MessageParser (replaced by StreamAnalyzer) (c1c4c75)
|
|
||||||
- rewrite TaskRunner with config resolution, retry, and continue support (76473dd)
|
|
||||||
- simplify ClaudeProcess to accept pre-built args and use StreamAnalyzer (1cdaaf9)
|
|
||||||
- harden context menu event handling and simplify bindings (7838f08)
|
|
||||||
- harden double-click edit handlers (6727cc4)
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
|
|
||||||
- add download-mode implementation plan (c0bd465)
|
|
||||||
- finalize decisions — self-contained, auto-check, full uninstall (0498fba)
|
|
||||||
- pin release target to releases/ClaudeDo (43a10cf)
|
|
||||||
- add download-mode + Gitea Releases design spec (bd7d594)
|
|
||||||
- add implementation plan for UI fixes (a6fe91d)
|
|
||||||
- add design spec for post-integration UI fixes (fb3c96c)
|
|
||||||
- update CLAUDE.md with CLI modernization changes (03728c8)
|
|
||||||
- add UX redesign implementation plan (16 tasks) (9f61cd1)
|
|
||||||
- add UX redesign spec (Microsoft To Do style) (0e41c37)
|
|
||||||
|
|
||||||
@@ -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 task that spawns/has children passes through WaitingForChildren first, then surfaces for review once every child is terminal — this is the single parent model for both planning and improvement parents (planning/improvement *children* themselves go straight to Done, only the parent is reviewed). From review you can approve, reject-rerun (Queued, resumes the session with feedback), reject-park (Idle), or cancel. Approve is the single review+merge action: a childless task merges its own worktree then Done (conflicts keep it in WaitingForReview); a task with children drives the unit merge (parent worktree if any + each Done child in order, with conflict continue/abort). Tasks with no active worktree (sandbox run) approve straight to Done.
|
- Task status flow: Idle | Queued -> Running -> WaitingForReview -> Done | Failed | Cancelled. A standalone task's successful run lands in WaitingForReview (planning children go straight to Done); from review you can approve (Done), reject-rerun (Queued, resumes the session with feedback), reject-park (Idle), or cancel (Cancelled).
|
||||||
- Worktree state flow: Active -> Merged | Discarded | Kept
|
- 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)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
<Project Path="src/ClaudeDo.Installer/ClaudeDo.Installer.csproj" />
|
<Project Path="src/ClaudeDo.Installer/ClaudeDo.Installer.csproj" />
|
||||||
<Project Path="src/ClaudeDo.Releases/ClaudeDo.Releases.csproj" />
|
<Project Path="src/ClaudeDo.Releases/ClaudeDo.Releases.csproj" />
|
||||||
<Project Path="src/ClaudeDo.Localization/ClaudeDo.Localization.csproj" />
|
<Project Path="src/ClaudeDo.Localization/ClaudeDo.Localization.csproj" />
|
||||||
|
<Project Path="src/ClaudeDo.Logging/ClaudeDo.Logging.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
<Folder Name="/tests/">
|
<Folder Name="/tests/">
|
||||||
<Project Path="tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj" />
|
<Project Path="tests/ClaudeDo.Data.Tests/ClaudeDo.Data.Tests.csproj" />
|
||||||
|
|||||||
@@ -1,994 +0,0 @@
|
|||||||
# Approve = Merge → Done + Conflict Preview — Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Approving a `WaitingForReview` task merges its worktree into the target branch first and only marks the task `Done` on a clean merge; conflicts keep it in review and are surfaced. Add a non-destructive "merges cleanly / conflicts" indicator and a direct single-task Merge button.
|
|
||||||
|
|
||||||
**Architecture:** A new `GitService.PreviewMergeAsync` probes mergeability via `git merge-tree --write-tree` (no working-tree mutation). `TaskMergeService` gains `PreviewAsync` and `ApproveAndMergeAsync` (merge first, then delegate the `Done` flip to `ITaskStateService`). `WorkerHub` exposes `PreviewMerge` and a result-returning `ApproveReview(taskId, targetBranch)`. The UI loads merge targets whenever a worktree exists, shows the preview, and reacts to conflict results.
|
|
||||||
|
|
||||||
**Tech Stack:** .NET 8, Avalonia, EF Core/SQLite, SignalR, xUnit with real git (`GitRepoFixture`) and real SQLite (`DbFixture`).
|
|
||||||
|
|
||||||
**Conventions for the implementer:**
|
|
||||||
- Use the **sonnet** model.
|
|
||||||
- **Stage files explicitly by path** — never `git add -A` (parallel sessions leave unrelated WIP).
|
|
||||||
- Build with `-c Release` (a running Worker locks `Debug` output).
|
|
||||||
- Conventional Commit messages: `type(scope): description`.
|
|
||||||
- New UI strings use **plain English literals** to match the surrounding merge controls (no `loc:Tr`) — this avoids Localization.Tests parity churn.
|
|
||||||
- Ignore anything under `.claude/worktrees/` — those are stale worktrees, not the build tree.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File map
|
|
||||||
|
|
||||||
| File | Change |
|
|
||||||
|------|--------|
|
|
||||||
| `src/ClaudeDo.Data/Git/GitService.cs` | Add `MergePreview` record + `PreviewMergeAsync` + `CountChangedFilesAsync` |
|
|
||||||
| `src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs` | Inject `ITaskStateService`; add `MergePreviewResult` + `PreviewAsync` + `ApproveAndMergeAsync` |
|
|
||||||
| `src/ClaudeDo.Worker/Hub/WorkerHub.cs` | Add `MergePreviewDto` + `PreviewMerge`; change `ApproveReview` to `(taskId, targetBranch) → MergeResultDto` |
|
|
||||||
| `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs` | Change `ApproveReviewAsync`; add `PreviewMergeAsync`, `MergeTaskAsync` |
|
|
||||||
| `src/ClaudeDo.Ui/Services/WorkerClient.cs` | Implement the above; add UI `MergePreviewDto` record |
|
|
||||||
| `src/ClaudeDo.Ui/ViewModels/Islands/MergePreviewPresenter.cs` | New pure presenter (text + color flags) |
|
|
||||||
| `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` | Load targets for worktree tasks; preview props; approve conflict handling; `MergeCommand` |
|
|
||||||
| `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` | Update the list-level approve call to new signature |
|
|
||||||
| `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml` | Mergeability status line + Merge button |
|
|
||||||
| `tests/ClaudeDo.Worker.Tests/Runner/GitServicePreviewMergeTests.cs` | New — git-backed preview tests |
|
|
||||||
| `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs` | Update `BuildService`; add preview + approve-merge tests |
|
|
||||||
| `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs` | Update `FakeWorkerClient` |
|
|
||||||
| `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs` | Update fake |
|
|
||||||
| `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs` | Update the `ApproveReviewAsync` override |
|
|
||||||
| `tests/ClaudeDo.Ui.Tests/ViewModels/MergePreviewPresenterTests.cs` | New — presenter unit tests |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 1: GitService non-destructive merge probe
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/ClaudeDo.Data/Git/GitService.cs`
|
|
||||||
- Test: `tests/ClaudeDo.Worker.Tests/Runner/GitServicePreviewMergeTests.cs` (create)
|
|
||||||
|
|
||||||
Behaviour verified on git 2.50: `git merge-tree --write-tree --name-only <target> <source>` exits `0` when clean (stdout = a single tree-OID line) and `1` on conflict (stdout = tree-OID line, then conflicted file names, then a blank line, then informational messages). It writes only loose objects — the working tree, index, and refs are untouched.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing tests**
|
|
||||||
|
|
||||||
Create `tests/ClaudeDo.Worker.Tests/Runner/GitServicePreviewMergeTests.cs`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using ClaudeDo.Data.Git;
|
|
||||||
using ClaudeDo.Worker.Tests.Infrastructure;
|
|
||||||
|
|
||||||
namespace ClaudeDo.Worker.Tests.Runner;
|
|
||||||
|
|
||||||
public class GitServicePreviewMergeTests : IDisposable
|
|
||||||
{
|
|
||||||
private readonly List<GitRepoFixture> _repos = new();
|
|
||||||
private GitRepoFixture NewRepo() { var r = new GitRepoFixture(); _repos.Add(r); return r; }
|
|
||||||
public void Dispose() { foreach (var r in _repos) try { r.Dispose(); } catch { } }
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task PreviewMergeAsync_NonConflicting_ReportsCleanWithChangedCount()
|
|
||||||
{
|
|
||||||
if (!GitRepoFixture.IsGitAvailable()) return;
|
|
||||||
var repo = NewRepo();
|
|
||||||
var git = new GitService();
|
|
||||||
var baseBranch = await git.GetCurrentBranchAsync(repo.RepoDir);
|
|
||||||
|
|
||||||
GitRepoFixture.RunGit(repo.RepoDir, "checkout", "-b", "feature");
|
|
||||||
File.WriteAllText(Path.Combine(repo.RepoDir, "newfile.txt"), "x\n");
|
|
||||||
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
|
||||||
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "feat");
|
|
||||||
GitRepoFixture.RunGit(repo.RepoDir, "checkout", baseBranch);
|
|
||||||
|
|
||||||
var preview = await git.PreviewMergeAsync(repo.RepoDir, baseBranch, "feature", CancellationToken.None);
|
|
||||||
|
|
||||||
Assert.True(preview.Supported);
|
|
||||||
Assert.True(preview.Clean);
|
|
||||||
Assert.Empty(preview.ConflictFiles);
|
|
||||||
|
|
||||||
var count = await git.CountChangedFilesAsync(repo.RepoDir, baseBranch, "feature", CancellationToken.None);
|
|
||||||
Assert.Equal(1, count);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task PreviewMergeAsync_Conflicting_ReportsFilesAndDoesNotMutateTree()
|
|
||||||
{
|
|
||||||
if (!GitRepoFixture.IsGitAvailable()) return;
|
|
||||||
var repo = NewRepo();
|
|
||||||
var git = new GitService();
|
|
||||||
var baseBranch = await git.GetCurrentBranchAsync(repo.RepoDir);
|
|
||||||
|
|
||||||
GitRepoFixture.RunGit(repo.RepoDir, "checkout", "-b", "feature");
|
|
||||||
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# from feature\n");
|
|
||||||
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
|
||||||
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "feat readme");
|
|
||||||
GitRepoFixture.RunGit(repo.RepoDir, "checkout", baseBranch);
|
|
||||||
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# from base\n");
|
|
||||||
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
|
||||||
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "base readme");
|
|
||||||
|
|
||||||
var headBefore = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
|
|
||||||
|
|
||||||
var preview = await git.PreviewMergeAsync(repo.RepoDir, baseBranch, "feature", CancellationToken.None);
|
|
||||||
|
|
||||||
Assert.True(preview.Supported);
|
|
||||||
Assert.False(preview.Clean);
|
|
||||||
Assert.Contains("README.md", preview.ConflictFiles);
|
|
||||||
|
|
||||||
// Non-destructive: HEAD unchanged, no mid-merge state.
|
|
||||||
Assert.Equal(headBefore, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim());
|
|
||||||
Assert.False(await git.IsMidMergeAsync(repo.RepoDir));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run the tests, verify they fail to compile**
|
|
||||||
|
|
||||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~GitServicePreviewMergeTests`
|
|
||||||
Expected: build error — `PreviewMergeAsync`/`CountChangedFilesAsync` do not exist.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Implement the probe**
|
|
||||||
|
|
||||||
In `src/ClaudeDo.Data/Git/GitService.cs`, add this record just under `namespace ClaudeDo.Data.Git;`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public sealed record MergePreview(bool Supported, bool Clean, IReadOnlyList<string> ConflictFiles);
|
|
||||||
```
|
|
||||||
|
|
||||||
Add these methods inside the `GitService` class (e.g. after `ListConflictedFilesAsync`):
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
/// <summary>
|
|
||||||
/// Non-destructive mergeability probe via `git merge-tree --write-tree`. Writes only
|
|
||||||
/// loose objects — the working tree, index, and refs are left untouched.
|
|
||||||
/// </summary>
|
|
||||||
public async Task<MergePreview> PreviewMergeAsync(
|
|
||||||
string repoDir, string targetBranch, string sourceBranch, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var (exitCode, stdout, _) = await RunGitAsync(repoDir,
|
|
||||||
["merge-tree", "--write-tree", "--name-only", targetBranch, sourceBranch], ct);
|
|
||||||
|
|
||||||
if (exitCode == 0)
|
|
||||||
return new MergePreview(true, true, Array.Empty<string>());
|
|
||||||
|
|
||||||
if (exitCode == 1)
|
|
||||||
{
|
|
||||||
// stdout: <tree-oid>\n<file>\n...\n\n<informational messages>
|
|
||||||
var lines = stdout.Split('\n');
|
|
||||||
var files = new List<string>();
|
|
||||||
for (int i = 1; i < lines.Length; i++)
|
|
||||||
{
|
|
||||||
var line = lines[i].TrimEnd('\r');
|
|
||||||
if (string.IsNullOrWhiteSpace(line)) break;
|
|
||||||
files.Add(line.Trim());
|
|
||||||
}
|
|
||||||
return new MergePreview(true, false, files);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Any other exit (e.g. git too old: "unknown option --write-tree").
|
|
||||||
return new MergePreview(false, false, Array.Empty<string>());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Count of files that differ on <paramref name="sourceBranch"/> since its merge base with the target.</summary>
|
|
||||||
public async Task<int> CountChangedFilesAsync(
|
|
||||||
string repoDir, string targetBranch, string sourceBranch, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var (exitCode, stdout, _) = await RunGitAsync(repoDir,
|
|
||||||
["diff", "--name-only", $"{targetBranch}...{sourceBranch}"], ct);
|
|
||||||
if (exitCode != 0) return 0;
|
|
||||||
return stdout
|
|
||||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
|
||||||
.Count(s => s.Length > 0);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run the tests, verify they pass**
|
|
||||||
|
|
||||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~GitServicePreviewMergeTests`
|
|
||||||
Expected: PASS (2 tests).
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/ClaudeDo.Data/Git/GitService.cs tests/ClaudeDo.Worker.Tests/Runner/GitServicePreviewMergeTests.cs
|
|
||||||
git commit -m "feat(git): add non-destructive merge-tree conflict probe"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 2: TaskMergeService preview + approve-merge orchestration
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs`
|
|
||||||
- Test: `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs`
|
|
||||||
|
|
||||||
`ApproveAndMergeAsync` merges first (reusing `MergeAsync`, `removeWorktree:false`) and only then delegates the `Done` flip to `ITaskStateService.ApproveReviewAsync` (the sole owner of Status writes). Conflicts/blocks return without flipping status. No DI cycle: `TaskStateService` and `PlanningChainCoordinator` do not depend on `TaskMergeService`.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Update `BuildService` and add failing tests**
|
|
||||||
|
|
||||||
In `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs`, replace the `BuildService` helper so it also constructs a real `TaskStateService` (existing merge tests still pass — they only inspect the merge service's own broadcaster proxy):
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
private static (TaskMergeService svc, MergeRecordingClientProxy proxy) BuildService(DbFixture db)
|
|
||||||
{
|
|
||||||
var fakeHub = new MergeRecordingHubContext();
|
|
||||||
var broadcaster = new HubBroadcaster(fakeHub);
|
|
||||||
var state = TaskStateServiceBuilder.Build(db.CreateFactory()).State;
|
|
||||||
var svc = new TaskMergeService(
|
|
||||||
db.CreateFactory(),
|
|
||||||
new GitService(),
|
|
||||||
broadcaster,
|
|
||||||
state,
|
|
||||||
NullLogger<TaskMergeService>.Instance);
|
|
||||||
return (svc, fakeHub.Proxy);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Add these tests to the class:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
[Fact]
|
|
||||||
public async Task PreviewAsync_CleanWorktree_ReturnsClean()
|
|
||||||
{
|
|
||||||
if (!GitRepoFixture.IsGitAvailable()) return;
|
|
||||||
var repo = NewRepo();
|
|
||||||
var db = NewDb();
|
|
||||||
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
|
|
||||||
|
|
||||||
var wtMgr = BuildWorktreeManager(db);
|
|
||||||
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
|
|
||||||
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
|
|
||||||
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "x\n");
|
|
||||||
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
|
||||||
|
|
||||||
var (svc, _) = BuildService(db);
|
|
||||||
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
|
||||||
|
|
||||||
var preview = await svc.PreviewAsync(task.Id, target, CancellationToken.None);
|
|
||||||
|
|
||||||
Assert.Equal(TaskMergeService.PreviewClean, preview.Status);
|
|
||||||
Assert.True(preview.ChangedFileCount >= 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task PreviewAsync_Conflict_ReturnsConflictFiles()
|
|
||||||
{
|
|
||||||
if (!GitRepoFixture.IsGitAvailable()) return;
|
|
||||||
var repo = NewRepo();
|
|
||||||
var db = NewDb();
|
|
||||||
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
|
|
||||||
|
|
||||||
var wtMgr = BuildWorktreeManager(db);
|
|
||||||
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
|
|
||||||
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
|
|
||||||
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "README.md"), "# from worktree\n");
|
|
||||||
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
|
||||||
|
|
||||||
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# from main\n");
|
|
||||||
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
|
||||||
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main edit");
|
|
||||||
|
|
||||||
var (svc, _) = BuildService(db);
|
|
||||||
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
|
||||||
|
|
||||||
var preview = await svc.PreviewAsync(task.Id, target, CancellationToken.None);
|
|
||||||
|
|
||||||
Assert.Equal(TaskMergeService.PreviewConflict, preview.Status);
|
|
||||||
Assert.Contains("README.md", preview.ConflictFiles);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task PreviewAsync_NoActiveWorktree_ReturnsUnavailable()
|
|
||||||
{
|
|
||||||
var db = NewDb();
|
|
||||||
var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.WaitingForReview);
|
|
||||||
var (svc, _) = BuildService(db);
|
|
||||||
|
|
||||||
var preview = await svc.PreviewAsync(task.Id, "main", CancellationToken.None);
|
|
||||||
|
|
||||||
Assert.Equal(TaskMergeService.PreviewUnavailable, preview.Status);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ApproveAndMergeAsync_CleanWorktree_MergesAndMarksDone()
|
|
||||||
{
|
|
||||||
if (!GitRepoFixture.IsGitAvailable()) return;
|
|
||||||
var repo = NewRepo();
|
|
||||||
var db = NewDb();
|
|
||||||
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
|
|
||||||
|
|
||||||
var wtMgr = BuildWorktreeManager(db);
|
|
||||||
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
|
|
||||||
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
|
|
||||||
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "added.txt"), "new\n");
|
|
||||||
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
|
||||||
|
|
||||||
var (svc, _) = BuildService(db);
|
|
||||||
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
|
||||||
|
|
||||||
var result = await svc.ApproveAndMergeAsync(task.Id, target, CancellationToken.None);
|
|
||||||
|
|
||||||
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
|
|
||||||
using var ctx = db.CreateContext();
|
|
||||||
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
|
|
||||||
Assert.Equal(TaskStatus.Done, updated!.Status);
|
|
||||||
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
|
|
||||||
Assert.Equal(WorktreeState.Merged, wt!.State);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ApproveAndMergeAsync_Conflict_LeavesTaskWaitingForReview()
|
|
||||||
{
|
|
||||||
if (!GitRepoFixture.IsGitAvailable()) return;
|
|
||||||
var repo = NewRepo();
|
|
||||||
var db = NewDb();
|
|
||||||
var (list, task) = await SeedListAndTask(db, repo.RepoDir, TaskStatus.WaitingForReview);
|
|
||||||
|
|
||||||
var wtMgr = BuildWorktreeManager(db);
|
|
||||||
var wtCtx = await wtMgr.CreateAsync(task, list, CancellationToken.None);
|
|
||||||
_wtCleanups.Add((repo.RepoDir, wtCtx.WorktreePath));
|
|
||||||
File.WriteAllText(Path.Combine(wtCtx.WorktreePath, "README.md"), "# from worktree\n");
|
|
||||||
await wtMgr.CommitIfChangedAsync(wtCtx, task, list, CancellationToken.None);
|
|
||||||
|
|
||||||
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# from main\n");
|
|
||||||
GitRepoFixture.RunGit(repo.RepoDir, "add", "-A");
|
|
||||||
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-m", "main edit");
|
|
||||||
var headBefore = GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim();
|
|
||||||
|
|
||||||
var (svc, _) = BuildService(db);
|
|
||||||
var target = await new GitService().GetCurrentBranchAsync(repo.RepoDir);
|
|
||||||
|
|
||||||
var result = await svc.ApproveAndMergeAsync(task.Id, target, CancellationToken.None);
|
|
||||||
|
|
||||||
Assert.Equal(TaskMergeService.StatusConflict, result.Status);
|
|
||||||
Assert.Contains("README.md", result.ConflictFiles);
|
|
||||||
|
|
||||||
using var ctx = db.CreateContext();
|
|
||||||
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
|
|
||||||
Assert.Equal(TaskStatus.WaitingForReview, updated!.Status);
|
|
||||||
var wt = await new WorktreeRepository(ctx).GetByTaskIdAsync(task.Id);
|
|
||||||
Assert.Equal(WorktreeState.Active, wt!.State);
|
|
||||||
Assert.Equal(headBefore, GitRepoFixture.RunGit(repo.RepoDir, "rev-parse", "HEAD").Trim());
|
|
||||||
Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ApproveAndMergeAsync_NoWorktree_MarksDone()
|
|
||||||
{
|
|
||||||
var db = NewDb();
|
|
||||||
var (_, task) = await SeedListAndTask(db, workingDir: "/tmp", status: TaskStatus.WaitingForReview);
|
|
||||||
var (svc, _) = BuildService(db);
|
|
||||||
|
|
||||||
var result = await svc.ApproveAndMergeAsync(task.Id, "main", CancellationToken.None);
|
|
||||||
|
|
||||||
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
|
|
||||||
using var ctx = db.CreateContext();
|
|
||||||
var updated = await new TaskRepository(ctx).GetByIdAsync(task.Id);
|
|
||||||
Assert.Equal(TaskStatus.Done, updated!.Status);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run the tests, verify they fail**
|
|
||||||
|
|
||||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~TaskMergeServiceTests`
|
|
||||||
Expected: build error — `ITaskStateService` ctor arg, `PreviewAsync`, `ApproveAndMergeAsync`, `PreviewClean/PreviewConflict/PreviewUnavailable` do not exist.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Implement in TaskMergeService**
|
|
||||||
|
|
||||||
In `src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs`:
|
|
||||||
|
|
||||||
Add `using ClaudeDo.Worker.State;` to the usings.
|
|
||||||
|
|
||||||
Add the preview-result record beside `MergeTargets`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public sealed record MergePreviewResult(
|
|
||||||
string Status,
|
|
||||||
IReadOnlyList<string> ConflictFiles,
|
|
||||||
int ChangedFileCount);
|
|
||||||
```
|
|
||||||
|
|
||||||
Add the status constants beside the existing `StatusMerged` etc.:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public const string PreviewClean = "clean";
|
|
||||||
public const string PreviewConflict = "conflict";
|
|
||||||
public const string PreviewUnavailable = "unavailable";
|
|
||||||
```
|
|
||||||
|
|
||||||
Add the field and constructor param (inject `ITaskStateService`):
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
private readonly ITaskStateService _state;
|
|
||||||
|
|
||||||
public TaskMergeService(
|
|
||||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
|
||||||
GitService git,
|
|
||||||
HubBroadcaster broadcaster,
|
|
||||||
ITaskStateService state,
|
|
||||||
ILogger<TaskMergeService> logger)
|
|
||||||
{
|
|
||||||
_dbFactory = dbFactory;
|
|
||||||
_git = git;
|
|
||||||
_broadcaster = broadcaster;
|
|
||||||
_state = state;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Add the two methods (e.g. after `GetTargetsAsync`):
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public async Task<MergePreviewResult> PreviewAsync(string taskId, string targetBranch, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var (_, list, wt) = await LoadMergeContextAsync(taskId, ct);
|
|
||||||
|
|
||||||
if (wt is null || wt.State != WorktreeState.Active)
|
|
||||||
return new MergePreviewResult(PreviewUnavailable, Array.Empty<string>(), 0);
|
|
||||||
if (string.IsNullOrWhiteSpace(list.WorkingDir) || !await _git.IsGitRepoAsync(list.WorkingDir, ct))
|
|
||||||
return new MergePreviewResult(PreviewUnavailable, Array.Empty<string>(), 0);
|
|
||||||
|
|
||||||
var target = string.IsNullOrWhiteSpace(targetBranch)
|
|
||||||
? await _git.GetCurrentBranchAsync(list.WorkingDir, ct)
|
|
||||||
: targetBranch;
|
|
||||||
|
|
||||||
var preview = await _git.PreviewMergeAsync(list.WorkingDir, target, wt.BranchName, ct);
|
|
||||||
if (!preview.Supported)
|
|
||||||
return new MergePreviewResult(PreviewUnavailable, Array.Empty<string>(), 0);
|
|
||||||
if (!preview.Clean)
|
|
||||||
return new MergePreviewResult(PreviewConflict, preview.ConflictFiles, 0);
|
|
||||||
|
|
||||||
var count = await _git.CountChangedFilesAsync(list.WorkingDir, target, wt.BranchName, ct);
|
|
||||||
return new MergePreviewResult(PreviewClean, Array.Empty<string>(), count);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<MergeResult> ApproveAndMergeAsync(string taskId, string targetBranch, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var (task, list, wt) = await LoadMergeContextAsync(taskId, ct);
|
|
||||||
|
|
||||||
if (task.Status != TaskStatus.WaitingForReview)
|
|
||||||
return Blocked("task is not waiting for review");
|
|
||||||
|
|
||||||
// No worktree to merge (sandbox run, or an improvement parent whose children own
|
|
||||||
// the worktrees) — approve straight to Done.
|
|
||||||
if (wt is null || wt.State != WorktreeState.Active)
|
|
||||||
{
|
|
||||||
var done = await _state.ApproveReviewAsync(taskId, ct);
|
|
||||||
return done.Ok
|
|
||||||
? new MergeResult(StatusMerged, Array.Empty<string>(), null)
|
|
||||||
: Blocked(done.Reason ?? "approve failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
var target = string.IsNullOrWhiteSpace(targetBranch)
|
|
||||||
? await _git.GetCurrentBranchAsync(list.WorkingDir, ct)
|
|
||||||
: targetBranch;
|
|
||||||
|
|
||||||
var merge = await MergeAsync(taskId, target, removeWorktree: false, $"Merge {wt.BranchName}", ct);
|
|
||||||
if (merge.Status != StatusMerged)
|
|
||||||
return merge; // conflict or blocked — leave the task in WaitingForReview
|
|
||||||
|
|
||||||
var approve = await _state.ApproveReviewAsync(taskId, ct);
|
|
||||||
return approve.Ok ? merge : Blocked(approve.Reason ?? "approve failed");
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run the tests, verify they pass**
|
|
||||||
|
|
||||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~TaskMergeServiceTests`
|
|
||||||
Expected: PASS (all existing + 6 new).
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs
|
|
||||||
git commit -m "feat(worker): approve merges worktree before marking task done"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 3: WorkerHub — PreviewMerge + result-returning ApproveReview
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
|
|
||||||
|
|
||||||
This is SignalR wiring (no unit test); verify by building the Worker.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add the DTO**
|
|
||||||
|
|
||||||
Beside the existing `MergeResultDto`/`MergeTargetsDto` records (around line 56):
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public record MergePreviewDto(string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount);
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Add `PreviewMerge` and replace `ApproveReview`**
|
|
||||||
|
|
||||||
Add a `PreviewMerge` method beside `GetMergeTargets`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public Task<MergePreviewDto> PreviewMerge(string taskId, string targetBranch)
|
|
||||||
=> HubGuard(async () =>
|
|
||||||
{
|
|
||||||
var p = await _mergeService.PreviewAsync(taskId, targetBranch ?? "", CancellationToken.None);
|
|
||||||
return new MergePreviewDto(p.Status, p.ConflictFiles, p.ChangedFileCount);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace the existing `ApproveReview` method (currently lines ~383-387, delegating to `_state.ApproveReviewAsync`) with:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public Task<MergeResultDto> ApproveReview(string taskId, string targetBranch)
|
|
||||||
=> HubGuard(async () =>
|
|
||||||
{
|
|
||||||
var r = await _mergeService.ApproveAndMergeAsync(taskId, targetBranch ?? "", CancellationToken.None);
|
|
||||||
if (r.Status == TaskMergeService.StatusBlocked)
|
|
||||||
throw new HubException(r.ErrorMessage ?? "approve failed");
|
|
||||||
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
(Conflicts are returned, not thrown, so the UI can display the conflicting files; only hard blocks throw.)
|
|
||||||
|
|
||||||
- [ ] **Step 3: Build the Worker, verify green**
|
|
||||||
|
|
||||||
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`
|
|
||||||
Expected: Build succeeded. (DI resolves the new `ITaskStateService` dependency of `TaskMergeService` automatically — it is already registered.)
|
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/ClaudeDo.Worker/Hub/WorkerHub.cs
|
|
||||||
git commit -m "feat(worker): expose PreviewMerge hub method and merge-on-approve"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 4: UI client + interface + test fakes
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs`
|
|
||||||
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
|
|
||||||
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` (caller at line 648)
|
|
||||||
- Modify: `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs` (`FakeWorkerClient`)
|
|
||||||
- Modify: `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`
|
|
||||||
- Modify: `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs` (override)
|
|
||||||
|
|
||||||
Note: `DetailsIslandViewModel.ApproveReviewAsync` (line 1368) is updated in Task 5, not here — but the interface change forces it to compile, so Task 5 must follow before the Ui project builds. To keep this task self-contained and green on its own, update that call site here too (the conflict-handling logic lands in Task 5).
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add the UI DTO**
|
|
||||||
|
|
||||||
In `src/ClaudeDo.Ui/Services/WorkerClient.cs`, beside the existing `MergeResultDto`/`MergeTargetsDto` records (lines 521-522):
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public record MergePreviewDto(string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount);
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Update the interface**
|
|
||||||
|
|
||||||
In `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs`, replace `Task ApproveReviewAsync(string taskId);` (line 40) with:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch);
|
|
||||||
Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch);
|
|
||||||
Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage);
|
|
||||||
```
|
|
||||||
|
|
||||||
(`MergeTaskAsync` already exists on the concrete `WorkerClient` — this only adds it to the interface.)
|
|
||||||
|
|
||||||
- [ ] **Step 3: Update the concrete client**
|
|
||||||
|
|
||||||
In `src/ClaudeDo.Ui/Services/WorkerClient.cs`, replace the existing `ApproveReviewAsync` (line ~389) and add `PreviewMergeAsync`. Mirror the existing `GetMergeTargetsAsync` pattern (it uses the `TryInvokeAsync<T>` helper which returns `null` when disconnected):
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch)
|
|
||||||
=> TryInvokeAsync<MergeResultDto>("ApproveReview", taskId, targetBranch);
|
|
||||||
|
|
||||||
public Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch)
|
|
||||||
=> TryInvokeAsync<MergePreviewDto>("PreviewMerge", taskId, targetBranch);
|
|
||||||
```
|
|
||||||
|
|
||||||
Ensure the existing `public async Task<MergeResultDto> MergeTaskAsync(...)` signature matches the interface exactly (params: `string taskId, string targetBranch, bool removeWorktree, string commitMessage`). Leave its body as-is.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Update the two callers**
|
|
||||||
|
|
||||||
`src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` line 648 — the list-level quick approve has no merge-target selector, so it merges into the repo's current branch (empty string resolves server-side):
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
try { await _worker.ApproveReviewAsync(row.Id, ""); }
|
|
||||||
```
|
|
||||||
|
|
||||||
`src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` line 1368 — update to the new signature for now (full conflict handling is added in Task 5):
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
try { await _worker.ApproveReviewAsync(Task.Id, SelectedMergeTarget ?? ""); }
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Update the three test fakes**
|
|
||||||
|
|
||||||
`tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs` line 53 — replace and add:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public virtual Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) => Task.FromResult<MergeResultDto?>(null);
|
|
||||||
public virtual Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch) => Task.FromResult<MergePreviewDto?>(null);
|
|
||||||
public virtual Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));
|
|
||||||
```
|
|
||||||
|
|
||||||
`tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs` line 45 (`FakeWorkerClient`) — replace and add:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) => Task.FromResult<MergeResultDto?>(null);
|
|
||||||
public Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch) => Task.FromResult<MergePreviewDto?>(null);
|
|
||||||
public Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));
|
|
||||||
```
|
|
||||||
|
|
||||||
(Confirm whether `FakeWorkerClient` already implements `MergeTaskAsync`; if so, only change `ApproveReviewAsync` and add `PreviewMergeAsync`. Add `using` for the DTO namespace if needed — same namespace as `IWorkerClient`.)
|
|
||||||
|
|
||||||
`tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs` line 77 — update the override signature:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public override Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch) =>
|
|
||||||
/* keep whatever recording/behavior this override had, now returning Task<MergeResultDto?> */
|
|
||||||
Task.FromResult<MergeResultDto?>(null);
|
|
||||||
```
|
|
||||||
|
|
||||||
(Preserve any side effect the existing override performed — e.g. recording the call — just change the signature and return type.)
|
|
||||||
|
|
||||||
- [ ] **Step 6: Build UI + run both UI-touching test projects**
|
|
||||||
|
|
||||||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
|
||||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
|
|
||||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter FullyQualifiedName~TasksIslandViewModelPlanning`
|
|
||||||
Expected: all green.
|
|
||||||
|
|
||||||
- [ ] **Step 7: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs src/ClaudeDo.Ui/Services/WorkerClient.cs src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs
|
|
||||||
git commit -m "feat(ui): wire merge-aware approve and preview into the worker client"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 5: Mergeability presenter + DetailsIslandViewModel wiring
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `src/ClaudeDo.Ui/ViewModels/Islands/MergePreviewPresenter.cs`
|
|
||||||
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`
|
|
||||||
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/MergePreviewPresenterTests.cs` (create)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing presenter tests**
|
|
||||||
|
|
||||||
Create `tests/ClaudeDo.Ui.Tests/ViewModels/MergePreviewPresenterTests.cs`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using ClaudeDo.Ui.Services;
|
|
||||||
using ClaudeDo.Ui.ViewModels.Islands;
|
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.Tests.ViewModels;
|
|
||||||
|
|
||||||
public class MergePreviewPresenterTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void Clean_Plural()
|
|
||||||
{
|
|
||||||
var (text, clean, conflict) = MergePreviewPresenter.Describe(
|
|
||||||
new MergePreviewDto("clean", System.Array.Empty<string>(), 3));
|
|
||||||
Assert.Equal("Merges cleanly · 3 files", text);
|
|
||||||
Assert.True(clean);
|
|
||||||
Assert.False(conflict);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Clean_Singular()
|
|
||||||
{
|
|
||||||
var (text, _, _) = MergePreviewPresenter.Describe(
|
|
||||||
new MergePreviewDto("clean", System.Array.Empty<string>(), 1));
|
|
||||||
Assert.Equal("Merges cleanly · 1 file", text);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Conflict_ListsUpToThree()
|
|
||||||
{
|
|
||||||
var (text, clean, conflict) = MergePreviewPresenter.Describe(
|
|
||||||
new MergePreviewDto("conflict", new[] { "a.cs", "b.cs" }, 0));
|
|
||||||
Assert.Equal("Conflicts in a.cs, b.cs", text);
|
|
||||||
Assert.False(clean);
|
|
||||||
Assert.True(conflict);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Conflict_TruncatesWithMore()
|
|
||||||
{
|
|
||||||
var (text, _, _) = MergePreviewPresenter.Describe(
|
|
||||||
new MergePreviewDto("conflict", new[] { "a", "b", "c", "d", "e" }, 0));
|
|
||||||
Assert.Equal("Conflicts in a, b, c (+2 more)", text);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Unavailable_IsMuted()
|
|
||||||
{
|
|
||||||
var (text, clean, conflict) = MergePreviewPresenter.Describe(
|
|
||||||
new MergePreviewDto("unavailable", System.Array.Empty<string>(), 0));
|
|
||||||
Assert.Equal("Mergeability unknown", text);
|
|
||||||
Assert.False(clean);
|
|
||||||
Assert.False(conflict);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Null_IsEmpty()
|
|
||||||
{
|
|
||||||
var (text, clean, conflict) = MergePreviewPresenter.Describe(null);
|
|
||||||
Assert.Equal("", text);
|
|
||||||
Assert.False(clean);
|
|
||||||
Assert.False(conflict);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run, verify it fails to compile**
|
|
||||||
|
|
||||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter FullyQualifiedName~MergePreviewPresenterTests`
|
|
||||||
Expected: build error — `MergePreviewPresenter` does not exist.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Create the presenter**
|
|
||||||
|
|
||||||
Create `src/ClaudeDo.Ui/ViewModels/Islands/MergePreviewPresenter.cs`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using System.Linq;
|
|
||||||
using ClaudeDo.Ui.Services;
|
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.ViewModels.Islands;
|
|
||||||
|
|
||||||
/// Pure mapping from a merge-preview DTO to display text + color flags.
|
|
||||||
public static class MergePreviewPresenter
|
|
||||||
{
|
|
||||||
public static (string Text, bool IsClean, bool IsConflict) Describe(MergePreviewDto? dto)
|
|
||||||
{
|
|
||||||
if (dto is null) return ("", false, false);
|
|
||||||
|
|
||||||
switch (dto.Status)
|
|
||||||
{
|
|
||||||
case "clean":
|
|
||||||
var unit = dto.ChangedFileCount == 1 ? "file" : "files";
|
|
||||||
return ($"Merges cleanly · {dto.ChangedFileCount} {unit}", true, false);
|
|
||||||
|
|
||||||
case "conflict":
|
|
||||||
var names = string.Join(", ", dto.ConflictFiles.Take(3));
|
|
||||||
var more = dto.ConflictFiles.Count > 3 ? $" (+{dto.ConflictFiles.Count - 3} more)" : "";
|
|
||||||
return ($"Conflicts in {names}{more}", false, true);
|
|
||||||
|
|
||||||
default:
|
|
||||||
return ("Mergeability unknown", false, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run, verify the presenter tests pass**
|
|
||||||
|
|
||||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter FullyQualifiedName~MergePreviewPresenterTests`
|
|
||||||
Expected: PASS (6 tests).
|
|
||||||
|
|
||||||
- [ ] **Step 5: Wire the presenter into DetailsIslandViewModel**
|
|
||||||
|
|
||||||
In `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`:
|
|
||||||
|
|
||||||
(a) Add observable properties (near the other merge properties, ~line 334):
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
[ObservableProperty]
|
|
||||||
[NotifyPropertyChangedFor(nameof(ShowMergePreviewMuted))]
|
|
||||||
private string _mergePreviewText = "";
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
[NotifyPropertyChangedFor(nameof(ShowMergePreviewMuted))]
|
|
||||||
private bool _mergeIsClean;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
[NotifyPropertyChangedFor(nameof(ShowMergePreviewMuted))]
|
|
||||||
private bool _mergeIsConflict;
|
|
||||||
|
|
||||||
public bool ShowMergePreviewMuted =>
|
|
||||||
!MergeIsClean && !MergeIsConflict && !string.IsNullOrEmpty(MergePreviewText);
|
|
||||||
|
|
||||||
public bool ShowSingleMerge =>
|
|
||||||
WorktreePath != null && Task?.IsPlanningParent != true;
|
|
||||||
```
|
|
||||||
|
|
||||||
(b) Add the refresh method:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
private async System.Threading.Tasks.Task RefreshMergePreviewAsync()
|
|
||||||
{
|
|
||||||
if (Task is null || WorktreePath is null)
|
|
||||||
{
|
|
||||||
MergePreviewText = ""; MergeIsClean = false; MergeIsConflict = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Only probe Active worktrees; terminal states show their label instead.
|
|
||||||
if (WorktreeStateLabel is { } label && label != "Active")
|
|
||||||
{
|
|
||||||
MergePreviewText = label; MergeIsClean = false; MergeIsConflict = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var dto = await _worker.PreviewMergeAsync(Task.Id, SelectedMergeTarget ?? "");
|
|
||||||
var (text, clean, conflict) = MergePreviewPresenter.Describe(dto);
|
|
||||||
MergePreviewText = text; MergeIsClean = clean; MergeIsConflict = conflict;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
(c) Recompute when the merge target changes — add (or extend) the generated partial:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
partial void OnSelectedMergeTargetChanged(string? value)
|
|
||||||
{
|
|
||||||
_ = RefreshMergePreviewAsync();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
(d) Notify `ShowSingleMerge` when the worktree path changes. In the existing `OnWorktreePathChanged` (line ~1141) add:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
OnPropertyChanged(nameof(ShowSingleMerge));
|
|
||||||
```
|
|
||||||
|
|
||||||
(e) Load merge targets for standalone worktree tasks. In `BindAsync`, after the `if (entity.PlanningPhase != None) {...} else {...}` block (~line 814), add:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
if (entity.Worktree != null
|
|
||||||
&& entity.PlanningPhase == ClaudeDo.Data.Models.PlanningPhase.None
|
|
||||||
&& MergeTargetBranches.Count == 0)
|
|
||||||
{
|
|
||||||
var targets = await _worker.GetMergeTargetsAsync(row.Id);
|
|
||||||
if (targets != null)
|
|
||||||
{
|
|
||||||
MergeTargetBranches.Clear();
|
|
||||||
foreach (var b in targets.LocalBranches) MergeTargetBranches.Add(b);
|
|
||||||
SelectedMergeTarget = targets.DefaultBranch; // triggers OnSelectedMergeTargetChanged → preview
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await RefreshMergePreviewAsync();
|
|
||||||
```
|
|
||||||
|
|
||||||
(f) Replace the body of `ApproveReviewAsync` (line ~1362) to surface conflicts:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
[RelayCommand]
|
|
||||||
private async System.Threading.Tasks.Task ApproveReviewAsync()
|
|
||||||
{
|
|
||||||
if (Task is null || !_worker.IsConnected) return;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await _worker.ApproveReviewAsync(Task.Id, SelectedMergeTarget ?? "");
|
|
||||||
if (result?.Status == "conflict")
|
|
||||||
{
|
|
||||||
var (text, _, _) = MergePreviewPresenter.Describe(
|
|
||||||
new MergePreviewDto("conflict", result.ConflictFiles, 0));
|
|
||||||
MergePreviewText = text; MergeIsClean = false; MergeIsConflict = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch { /* stale review action; broadcast reconciles */ }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
(g) Add the single-task `MergeCommand` (place near `OpenDiffAsync`):
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
[RelayCommand]
|
|
||||||
private async System.Threading.Tasks.Task MergeAsync()
|
|
||||||
{
|
|
||||||
if (Task is null || WorktreePath is null || !_worker.IsConnected) return;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var result = await _worker.MergeTaskAsync(Task.Id, SelectedMergeTarget ?? "", false, "Merge task");
|
|
||||||
if (result.Status == "conflict")
|
|
||||||
{
|
|
||||||
var (text, _, _) = MergePreviewPresenter.Describe(
|
|
||||||
new MergePreviewDto("conflict", result.ConflictFiles, 0));
|
|
||||||
MergePreviewText = text; MergeIsClean = false; MergeIsConflict = true;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
await RefreshMergePreviewAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch { /* broadcast reconciles */ }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 6: Build UI + run the UI tests**
|
|
||||||
|
|
||||||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
|
||||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
|
|
||||||
Expected: green. (If `OnSelectedMergeTargetChanged` already exists, merge the new line into it instead of duplicating.)
|
|
||||||
|
|
||||||
- [ ] **Step 7: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/ClaudeDo.Ui/ViewModels/Islands/MergePreviewPresenter.cs src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/MergePreviewPresenterTests.cs
|
|
||||||
git commit -m "feat(ui): show mergeability and surface approve conflicts in the work console"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 6: WorkConsole — status line + Merge button
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml`
|
|
||||||
|
|
||||||
No unit test (XAML); verified by build + manual visual check in Task 7.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add the mergeability status line and the Merge button**
|
|
||||||
|
|
||||||
In the `MERGE & WORKTREE` `StackPanel` (starts line 196), insert the status line **between** the merge-target `StackPanel` (ends line 203) and the `<WrapPanel>` (line 204). Three single-line `TextBlock`s, one visible at a time by color:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<StackPanel Spacing="0">
|
|
||||||
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
|
|
||||||
Foreground="{DynamicResource MossBrush}"
|
|
||||||
IsVisible="{Binding MergeIsClean}" />
|
|
||||||
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
|
|
||||||
Foreground="{DynamicResource BloodBrush}"
|
|
||||||
IsVisible="{Binding MergeIsConflict}" />
|
|
||||||
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
|
|
||||||
Foreground="{DynamicResource TextMuteBrush}"
|
|
||||||
IsVisible="{Binding ShowMergePreviewMuted}" />
|
|
||||||
</StackPanel>
|
|
||||||
```
|
|
||||||
|
|
||||||
In the `<WrapPanel>` (line 204), add a **Merge** button immediately after the "Open Diff" button (line 206):
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<Button Classes="btn accent" Content="Merge" Margin="0,0,8,8"
|
|
||||||
Command="{Binding MergeCommand}"
|
|
||||||
IsVisible="{Binding ShowSingleMerge}" />
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Build UI, verify green**
|
|
||||||
|
|
||||||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
|
||||||
Expected: Build succeeded (XAML compiles; all bound members exist from Task 5).
|
|
||||||
|
|
||||||
- [ ] **Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml
|
|
||||||
git commit -m "feat(ui): add mergeability indicator and Merge button to work console"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 7: Full build, full test, manual verification
|
|
||||||
|
|
||||||
**Files:** none (verification only)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Build the whole app + worker**
|
|
||||||
|
|
||||||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
|
||||||
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`
|
|
||||||
Expected: both succeed.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run all touched test projects**
|
|
||||||
|
|
||||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release`
|
|
||||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
|
|
||||||
Expected: all green.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Manual verification (cannot be automated — no real Claude in tests)**
|
|
||||||
|
|
||||||
Start the Worker, then the App. Pick a list whose `WorkingDir` is a real git repo and use a task that already has an Active worktree (or create one).
|
|
||||||
|
|
||||||
Verify each acceptance criterion:
|
|
||||||
1. **Clean approve:** Open a `WaitingForReview` task whose worktree merges cleanly → the Session tab shows green "Merges cleanly · N files". Click **Approve** → the worktree merges into the target, the task becomes **Done**, and the worktree state becomes **Merged** (check the worktree overview).
|
|
||||||
2. **Conflicting approve:** Open a task whose worktree conflicts with the target → the Session tab shows red "Conflicts in …". Click **Approve** → the task stays **WaitingForReview** (NOT Done), the conflict line remains, and the target branch is unchanged.
|
|
||||||
3. **Done task preview:** Open a previously-Done task that was never merged (worktree still Active) → the merge/conflict status appears without any tree mutation; the **Merge** button merges it on demand.
|
|
||||||
|
|
||||||
Report the result of each check explicitly. If any visual issue appears (colors, layout, missing controls), note it for the user — do not claim the UI works without running it.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Self-review notes
|
|
||||||
|
|
||||||
- **Spec coverage:** Approve-merge (Task 2/3/5), conflict-keeps-review (Task 2 test + Task 5 surfacing), non-destructive preview (Task 1/2 + indicator in Task 5/6), real single-task Merge button (Task 5/6), standalone target-loading gap (Task 5e). All spec sections map to a task.
|
|
||||||
- **Type consistency:** `MergePreview` (Data) → `MergePreviewResult` (Worker service) → `MergePreviewDto` (hub + UI). Status strings `clean`/`conflict`/`unavailable` and merge statuses `merged`/`conflict`/`blocked` are used consistently across worker, client, presenter, and VM.
|
|
||||||
- **No new statuses, no DB migration, no localization keys** (literals match the surrounding controls).
|
|
||||||
- **External MCP unchanged:** `ExternalMcpService.ReviewTask` keeps calling `TaskStateService.ApproveReviewAsync` directly (its documented scope excludes merges); that method's signature is unchanged.
|
|
||||||
@@ -1,801 +0,0 @@
|
|||||||
# Refine Task Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. Subagents use the `sonnet` model and stage files explicitly by path (never `git add -A`).
|
|
||||||
|
|
||||||
**Goal:** Add a one-click "Refine Task" button to each Idle task card that spawns a headless Claude session which rewrites the task's description and adds subtasks (steps), then updates the task live in the UI.
|
|
||||||
|
|
||||||
**Architecture:** A new headless `RefineRunner` (modeled on `PrimeRunner`) runs `claude -p` read-only in the list's working dir, using the globally-registered `claudedo` MCP. Claude calls `update_task` (existing) and a new `add_subtask` tool. The task stays `Idle`; refine only mutates Title/Description/subtasks. UI shows a busy state via new `RefineStarted`/`RefineFinished` SignalR events; content updates arrive via the existing `TaskUpdated` events.
|
|
||||||
|
|
||||||
**Tech Stack:** .NET 8, ASP.NET Core + SignalR, EF Core (SQLite), Avalonia 12 (CommunityToolkit.Mvvm), ModelContextProtocol server tools, xUnit.
|
|
||||||
|
|
||||||
**Spec:** `docs/superpowers/specs/2026-06-04-refine-task-design.md`
|
|
||||||
|
|
||||||
**Build/test reminders:** Build individual csproj with `-c Release` (a running Worker locks Debug). `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`, `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`, `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release`. Keep `locales/en.json` and `locales/de.json` keys in parity.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File structure
|
|
||||||
|
|
||||||
**Create:**
|
|
||||||
- `src/ClaudeDo.Worker/Refine/RefineRunner.cs` — headless refine run orchestrator
|
|
||||||
- `src/ClaudeDo.Worker/Refine/RefinePrompt.cs` — prompt + CLI args + log path helper
|
|
||||||
- `src/ClaudeDo.Worker/Refine/Interfaces/IRefineRunner.cs` — interface + `RefineRunOutcome`
|
|
||||||
- `src/ClaudeDo.Worker/Refine/Interfaces/IRefineBroadcaster.cs` — `RefineStartedAsync`/`RefineFinishedAsync`
|
|
||||||
|
|
||||||
**Modify:**
|
|
||||||
- `src/ClaudeDo.Data/PromptFiles.cs` — add `Refine` to `PromptKind`, path, default
|
|
||||||
- `src/ClaudeDo.Worker/External/ExternalMcpService.cs` — add `add_subtask` tool
|
|
||||||
- `src/ClaudeDo.Worker/Hub/HubBroadcaster.cs` — implement `RefineStarted`/`RefineFinished` + `IRefineBroadcaster`
|
|
||||||
- `src/ClaudeDo.Worker/Hub/WorkerHub.cs` — add `RefineTask(string taskId)` method
|
|
||||||
- `src/ClaudeDo.Worker/Program.cs` — register `IRefineRunner`/`IRefineBroadcaster`
|
|
||||||
- `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs` — `RefineTaskAsync` + `RefineStartedEvent`/`RefineFinishedEvent`
|
|
||||||
- `src/ClaudeDo.Ui/Services/WorkerClient.cs` — implement call + subscribe events
|
|
||||||
- `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs` — `IsRefining` + `CanRefine`
|
|
||||||
- `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs` — `RefineTaskCommand` + event wiring
|
|
||||||
- `src/ClaudeDo.Ui/Design/IslandStyles.axaml` — `Icon.Refine` geometry
|
|
||||||
- `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml` — refine button
|
|
||||||
- `locales/en.json`, `locales/de.json` — tooltip key
|
|
||||||
- Test fakes implementing `IWorkerClient` in `tests/ClaudeDo.Ui.Tests` (and any other project that hand-rolls it)
|
|
||||||
|
|
||||||
**Test:**
|
|
||||||
- `tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs`
|
|
||||||
- `tests/ClaudeDo.Worker.Tests/Refine/RefinePromptTests.cs`
|
|
||||||
- `tests/ClaudeDo.Worker.Tests/Refine/RefineRunnerTests.cs`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 1: `add_subtask` MCP tool
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/ClaudeDo.Worker/External/ExternalMcpService.cs`
|
|
||||||
- Test: `tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs`
|
|
||||||
|
|
||||||
The `ExternalMcpService` already injects `IDbContextFactory<ClaudeDoDbContext> _dbFactory`, `TaskRepository _tasks`, and `HubBroadcaster _broadcaster`. Reuse them; new up a `SubtaskRepository` from a fresh context (matching the `SetMyDay`/`GetDailyPrepCandidates` pattern in the same file).
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing test**
|
|
||||||
|
|
||||||
Create `tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs`. Follow the existing External tool test setup in that test project (look at a sibling test, e.g. an `ExternalMcpService`/`UpdateTask` test, for the in-memory-real-SQLite fixture + broadcaster fake construction; reuse that exact fixture pattern).
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using ClaudeDo.Data.Models;
|
|
||||||
using ClaudeDo.Data.Repositories;
|
|
||||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
||||||
|
|
||||||
public class AddSubtaskToolTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public async Task AddSubtask_appends_row_with_next_order()
|
|
||||||
{
|
|
||||||
await using var f = new ExternalMcpServiceFixture(); // reuse the project's existing fixture helper
|
|
||||||
var list = await f.SeedListAsync();
|
|
||||||
var task = await f.SeedTaskAsync(list.Id, status: TaskStatus.Idle);
|
|
||||||
|
|
||||||
await f.Service.AddSubtask(task.Id, "First step", orderNum: null, CancellationToken.None);
|
|
||||||
await f.Service.AddSubtask(task.Id, "Second step", orderNum: null, CancellationToken.None);
|
|
||||||
|
|
||||||
await using var ctx = f.CreateContext();
|
|
||||||
var subs = await new SubtaskRepository(ctx).GetByTaskIdAsync(task.Id);
|
|
||||||
Assert.Equal(new[] { "First step", "Second step" }, subs.Select(s => s.Title));
|
|
||||||
Assert.Equal(new[] { 0, 1 }, subs.Select(s => s.OrderNum));
|
|
||||||
Assert.All(subs, s => Assert.False(s.Completed));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task AddSubtask_refuses_running_task()
|
|
||||||
{
|
|
||||||
await using var f = new ExternalMcpServiceFixture();
|
|
||||||
var list = await f.SeedListAsync();
|
|
||||||
var task = await f.SeedTaskAsync(list.Id, status: TaskStatus.Running);
|
|
||||||
|
|
||||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
|
||||||
() => f.Service.AddSubtask(task.Id, "x", null, CancellationToken.None));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> If the test project has no reusable `ExternalMcpServiceFixture`, mirror the construction already used by the nearest existing `ExternalMcpService` test (same ctor args, real SQLite via `IDbContextFactory`, a no-op/recording broadcaster). Do not invent a new pattern.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run the test to verify it fails** (compile error / method missing)
|
|
||||||
|
|
||||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter AddSubtaskToolTests`
|
|
||||||
Expected: FAIL — `AddSubtask` not defined.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Implement `add_subtask`**
|
|
||||||
|
|
||||||
Add to `ExternalMcpService` (near `UpdateTask`):
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
[McpServerTool, Description(
|
|
||||||
"Append a subtask (step) to a task. orderNum defaults to the end. " +
|
|
||||||
"Refuses if the task is currently Running. Subtasks are surfaced to the agent at run time and shown in the task's Steps list.")]
|
|
||||||
public async Task<TaskDto> AddSubtask(
|
|
||||||
string taskId,
|
|
||||||
string title,
|
|
||||||
int? orderNum,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(title))
|
|
||||||
throw new InvalidOperationException("title is required.");
|
|
||||||
|
|
||||||
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
|
|
||||||
var tasks = new TaskRepository(ctx);
|
|
||||||
var subtasks = new SubtaskRepository(ctx);
|
|
||||||
|
|
||||||
var task = await tasks.GetByIdAsync(taskId, cancellationToken)
|
|
||||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
|
||||||
if (task.Status == TaskStatus.Running)
|
|
||||||
throw new InvalidOperationException("Cannot add a subtask to a running task. Cancel it first.");
|
|
||||||
|
|
||||||
var existing = await subtasks.GetByTaskIdAsync(taskId, cancellationToken);
|
|
||||||
var order = orderNum ?? (existing.Count == 0 ? 0 : existing.Max(s => s.OrderNum) + 1);
|
|
||||||
|
|
||||||
await subtasks.AddAsync(new SubtaskEntity
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid().ToString(),
|
|
||||||
TaskId = taskId,
|
|
||||||
Title = title.Trim(),
|
|
||||||
Completed = false,
|
|
||||||
OrderNum = order,
|
|
||||||
CreatedAt = DateTime.UtcNow,
|
|
||||||
}, cancellationToken);
|
|
||||||
|
|
||||||
await _broadcaster.TaskUpdated(taskId);
|
|
||||||
return ToDto(task);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Add `using ClaudeDo.Data.Repositories;` if not present (it is). `SubtaskEntity` is in `ClaudeDo.Data.Models` (already imported).
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run the test to verify it passes**
|
|
||||||
|
|
||||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter AddSubtaskToolTests`
|
|
||||||
Expected: PASS (2 tests).
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/ClaudeDo.Worker/External/ExternalMcpService.cs tests/ClaudeDo.Worker.Tests/External/AddSubtaskToolTests.cs
|
|
||||||
git commit -m "feat(mcp): add add_subtask tool to claudedo MCP"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 2: Refine prompt (`PromptKind.Refine`)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/ClaudeDo.Data/PromptFiles.cs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add the enum value**
|
|
||||||
|
|
||||||
Change the enum line in `PromptFiles.cs`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport, ImprovementChild, Refine }
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Add the path mapping**
|
|
||||||
|
|
||||||
In `PathFor`, add before the `_ => throw`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
PromptKind.Refine => Path.Combine(Root, "refine.md"),
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Add the default mapping**
|
|
||||||
|
|
||||||
In `DefaultFor`, add:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
PromptKind.Refine => RefineDefault,
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Add the default prompt constant**
|
|
||||||
|
|
||||||
Add near the other `private const string ...Default` blocks:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
private const string RefineDefault = """
|
|
||||||
You are refining ONE ClaudeDo task so it is ready to run autonomously later.
|
|
||||||
You are NOT executing the task — only improving its specification.
|
|
||||||
|
|
||||||
The task you are refining:
|
|
||||||
- id: {taskId}
|
|
||||||
- title: {title}
|
|
||||||
- description: {description}
|
|
||||||
- current subtasks (steps):
|
|
||||||
{subtasks}
|
|
||||||
|
|
||||||
What to do:
|
|
||||||
1. If a repository is available, read the relevant code (read-only) to ground your
|
|
||||||
understanding. Do NOT edit, create, or delete any files. Do NOT run commands.
|
|
||||||
2. Rewrite the description so it is clear, specific, and self-contained: what to change,
|
|
||||||
where, and what "done" looks like. Keep scope tight — do not invent adjacent work.
|
|
||||||
3. Call mcp__claudedo__update_task to save the improved title (only if it genuinely
|
|
||||||
helps) and description.
|
|
||||||
4. If the work is clearer as discrete steps, add them as subtasks with
|
|
||||||
mcp__claudedo__add_subtask (one call per step, in order). Only add steps that are
|
|
||||||
not already present in the current subtasks above.
|
|
||||||
|
|
||||||
Use ONLY these tools: mcp__claudedo__get_task, mcp__claudedo__update_task,
|
|
||||||
mcp__claudedo__add_subtask, and read-only Read/Grep/Glob. When you have updated the
|
|
||||||
task, stop.
|
|
||||||
""";
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Build to verify it compiles**
|
|
||||||
|
|
||||||
Run: `dotnet build src/ClaudeDo.Data/ClaudeDo.Data.csproj -c Release`
|
|
||||||
Expected: Build succeeded.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/ClaudeDo.Data/PromptFiles.cs
|
|
||||||
git commit -m "feat(prompts): add Refine prompt kind and default"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 3: RefineRunner, interfaces, prompt/args helper
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `src/ClaudeDo.Worker/Refine/Interfaces/IRefineRunner.cs`
|
|
||||||
- Create: `src/ClaudeDo.Worker/Refine/Interfaces/IRefineBroadcaster.cs`
|
|
||||||
- Create: `src/ClaudeDo.Worker/Refine/RefinePrompt.cs`
|
|
||||||
- Create: `src/ClaudeDo.Worker/Refine/RefineRunner.cs`
|
|
||||||
- Test: `tests/ClaudeDo.Worker.Tests/Refine/RefinePromptTests.cs`
|
|
||||||
- Test: `tests/ClaudeDo.Worker.Tests/Refine/RefineRunnerTests.cs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Create `IRefineRunner.cs`**
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
namespace ClaudeDo.Worker.Refine;
|
|
||||||
|
|
||||||
public interface IRefineRunner
|
|
||||||
{
|
|
||||||
Task<RefineRunOutcome> RefineAsync(string taskId, CancellationToken ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed record RefineRunOutcome(bool Success, string Message);
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Create `IRefineBroadcaster.cs`**
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
namespace ClaudeDo.Worker.Refine;
|
|
||||||
|
|
||||||
public interface IRefineBroadcaster
|
|
||||||
{
|
|
||||||
Task RefineStartedAsync(string taskId);
|
|
||||||
Task RefineFinishedAsync(string taskId, bool success, string? error);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Create `RefinePrompt.cs`**
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using ClaudeDo.Data;
|
|
||||||
using ClaudeDo.Data.Models;
|
|
||||||
|
|
||||||
namespace ClaudeDo.Worker.Refine;
|
|
||||||
|
|
||||||
public static class RefinePrompt
|
|
||||||
{
|
|
||||||
public const string GetTaskTool = "mcp__claudedo__get_task";
|
|
||||||
public const string UpdateTaskTool = "mcp__claudedo__update_task";
|
|
||||||
public const string AddSubtaskTool = "mcp__claudedo__add_subtask";
|
|
||||||
|
|
||||||
public static string LogPath(string taskId) =>
|
|
||||||
System.IO.Path.Combine(Paths.AppDataRoot(), "logs", $"refine-{Short(taskId)}.log");
|
|
||||||
|
|
||||||
// canReadRepo=false drops the read-only filesystem tools (text-only fallback).
|
|
||||||
public static string BuildArgs(int maxTurns, bool canReadRepo)
|
|
||||||
{
|
|
||||||
var tools = canReadRepo
|
|
||||||
? $"{GetTaskTool} {UpdateTaskTool} {AddSubtaskTool} Read Grep Glob"
|
|
||||||
: $"{GetTaskTool} {UpdateTaskTool} {AddSubtaskTool}";
|
|
||||||
return "-p --output-format stream-json --verbose --permission-mode acceptEdits " +
|
|
||||||
$"--max-turns {maxTurns} --allowedTools {tools}";
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string BuildPrompt(TaskEntity task, IEnumerable<SubtaskEntity> subtasks)
|
|
||||||
{
|
|
||||||
var open = subtasks.Where(s => !s.Completed).Select(s => $"- {s.Title}").ToList();
|
|
||||||
var subText = open.Count == 0 ? "(none)" : string.Join("\n", open);
|
|
||||||
return PromptFiles.Render(PromptKind.Refine, new Dictionary<string, string>
|
|
||||||
{
|
|
||||||
["taskId"] = task.Id,
|
|
||||||
["title"] = task.Title,
|
|
||||||
["description"] = string.IsNullOrWhiteSpace(task.Description) ? "(empty)" : task.Description!,
|
|
||||||
["subtasks"] = subText,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string Short(string id) => id.Length >= 8 ? id[..8] : id;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Write `RefinePromptTests.cs`**
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using ClaudeDo.Data.Models;
|
|
||||||
using ClaudeDo.Worker.Refine;
|
|
||||||
|
|
||||||
public class RefinePromptTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void BuildArgs_includes_read_tools_when_repo_available()
|
|
||||||
{
|
|
||||||
var args = RefinePrompt.BuildArgs(20, canReadRepo: true);
|
|
||||||
Assert.Contains("--permission-mode acceptEdits", args);
|
|
||||||
Assert.Contains("mcp__claudedo__add_subtask", args);
|
|
||||||
Assert.Contains(" Read Grep Glob", args);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void BuildArgs_drops_read_tools_in_text_only_mode()
|
|
||||||
{
|
|
||||||
var args = RefinePrompt.BuildArgs(20, canReadRepo: false);
|
|
||||||
Assert.DoesNotContain("Glob", args);
|
|
||||||
Assert.Contains("mcp__claudedo__update_task", args);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void BuildPrompt_seeds_task_fields_and_open_subtasks()
|
|
||||||
{
|
|
||||||
var task = new TaskEntity { Id = "abc12345", ListId = "l", Title = "T", Description = "D",
|
|
||||||
Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow };
|
|
||||||
var subs = new[]
|
|
||||||
{
|
|
||||||
new SubtaskEntity { Id="1", TaskId="abc12345", Title="open one", Completed=false, OrderNum=0, CreatedAt=DateTime.UtcNow },
|
|
||||||
new SubtaskEntity { Id="2", TaskId="abc12345", Title="done one", Completed=true, OrderNum=1, CreatedAt=DateTime.UtcNow },
|
|
||||||
};
|
|
||||||
var prompt = RefinePrompt.BuildPrompt(task, subs);
|
|
||||||
Assert.Contains("abc12345", prompt);
|
|
||||||
Assert.Contains("open one", prompt);
|
|
||||||
Assert.DoesNotContain("done one", prompt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter RefinePromptTests`
|
|
||||||
Expected: PASS (3 tests).
|
|
||||||
|
|
||||||
- [ ] **Step 5: Create `RefineRunner.cs`**
|
|
||||||
|
|
||||||
`IClaudeProcess.RunAsync(arguments, prompt, workingDirectory, onStdoutLine, ct)` returns a result with `.IsSuccess` and `.ExitCode` (same as used by `PrimeRunner`). Resolve the working dir from the task's list; fall back to a sandbox dir + text-only when missing/invalid. Per-task single-flight via a guarded `HashSet<string>`.
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using ClaudeDo.Data;
|
|
||||||
using ClaudeDo.Data.Repositories;
|
|
||||||
using ClaudeDo.Worker.Runner;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
||||||
|
|
||||||
namespace ClaudeDo.Worker.Refine;
|
|
||||||
|
|
||||||
public sealed class RefineRunner : IRefineRunner
|
|
||||||
{
|
|
||||||
private static readonly TimeSpan RunTimeout = TimeSpan.FromMinutes(5);
|
|
||||||
private const int MaxTurns = 25;
|
|
||||||
|
|
||||||
private readonly IClaudeProcess _claude;
|
|
||||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
|
||||||
private readonly ILogger<RefineRunner> _logger;
|
|
||||||
private readonly IRefineBroadcaster _broadcaster;
|
|
||||||
|
|
||||||
private readonly object _lock = new();
|
|
||||||
private readonly HashSet<string> _inFlight = new();
|
|
||||||
|
|
||||||
public RefineRunner(
|
|
||||||
IClaudeProcess claude,
|
|
||||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
|
||||||
ILogger<RefineRunner> logger,
|
|
||||||
IRefineBroadcaster broadcaster)
|
|
||||||
{
|
|
||||||
_claude = claude;
|
|
||||||
_dbFactory = dbFactory;
|
|
||||||
_logger = logger;
|
|
||||||
_broadcaster = broadcaster;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<RefineRunOutcome> RefineAsync(string taskId, CancellationToken ct)
|
|
||||||
{
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
if (!_inFlight.Add(taskId))
|
|
||||||
return new RefineRunOutcome(false, "Already refining this task");
|
|
||||||
}
|
|
||||||
|
|
||||||
var success = false;
|
|
||||||
string? error = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
ClaudeDo.Data.Models.TaskEntity task;
|
|
||||||
List<ClaudeDo.Data.Models.SubtaskEntity> subs;
|
|
||||||
string? workingDir;
|
|
||||||
await using (var dbCtx = await _dbFactory.CreateDbContextAsync(ct))
|
|
||||||
{
|
|
||||||
var tasks = new TaskRepository(dbCtx);
|
|
||||||
task = await tasks.GetByIdAsync(taskId, ct)
|
|
||||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
|
||||||
if (task.Status != TaskStatus.Idle)
|
|
||||||
return new RefineRunOutcome(false, $"Task must be Idle to refine (is {task.Status}).");
|
|
||||||
subs = await new SubtaskRepository(dbCtx).GetByTaskIdAsync(taskId, ct);
|
|
||||||
var list = await new ListRepository(dbCtx).GetByIdAsync(task.ListId, ct);
|
|
||||||
workingDir = list?.WorkingDir;
|
|
||||||
}
|
|
||||||
|
|
||||||
var canReadRepo = !string.IsNullOrWhiteSpace(workingDir) && Directory.Exists(workingDir);
|
|
||||||
var cwd = canReadRepo ? workingDir! : Paths.AppDataRoot();
|
|
||||||
Directory.CreateDirectory(cwd);
|
|
||||||
|
|
||||||
var logPath = RefinePrompt.LogPath(taskId);
|
|
||||||
try { if (File.Exists(logPath)) File.Delete(logPath); } catch { }
|
|
||||||
await using var logWriter = new LogWriter(logPath);
|
|
||||||
|
|
||||||
await _broadcaster.RefineStartedAsync(taskId);
|
|
||||||
|
|
||||||
var prompt = RefinePrompt.BuildPrompt(task, subs);
|
|
||||||
var args = RefinePrompt.BuildArgs(MaxTurns, canReadRepo);
|
|
||||||
|
|
||||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
|
||||||
timeoutCts.CancelAfter(RunTimeout);
|
|
||||||
|
|
||||||
var result = await _claude.RunAsync(
|
|
||||||
arguments: args,
|
|
||||||
prompt: prompt,
|
|
||||||
workingDirectory: cwd,
|
|
||||||
onStdoutLine: async line => await logWriter.WriteLineAsync(line),
|
|
||||||
ct: timeoutCts.Token);
|
|
||||||
|
|
||||||
success = result.IsSuccess;
|
|
||||||
if (!success) error = $"exit code {result.ExitCode}";
|
|
||||||
return success
|
|
||||||
? new RefineRunOutcome(true, "Refine complete")
|
|
||||||
: new RefineRunOutcome(false, error!);
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
error = $"timed out after {RunTimeout.TotalMinutes:0} min";
|
|
||||||
return new RefineRunOutcome(false, error);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "Refine run failed for {TaskId}", taskId);
|
|
||||||
error = ex.Message;
|
|
||||||
return new RefineRunOutcome(false, ex.Message);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
await _broadcaster.RefineFinishedAsync(taskId, success, error);
|
|
||||||
lock (_lock) { _inFlight.Remove(taskId); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 6: Write `RefineRunnerTests.cs` (guards, with a fake IClaudeProcess)**
|
|
||||||
|
|
||||||
The test project already has a fake/stub for `IClaudeProcess` used by Prime tests — reuse it (recording invocation + returning a configurable success result). Do NOT spawn the real CLI.
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public class RefineRunnerTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public async Task Refuses_when_task_not_idle()
|
|
||||||
{
|
|
||||||
await using var f = new RefineRunnerFixture(); // mirror Prime test fixture wiring
|
|
||||||
var task = await f.SeedTaskAsync(status: TaskStatus.Queued);
|
|
||||||
var outcome = await f.Runner.RefineAsync(task.Id, CancellationToken.None);
|
|
||||||
Assert.False(outcome.Success);
|
|
||||||
Assert.Equal(0, f.Claude.RunCount); // never invoked the CLI
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Idle_task_invokes_claude_once_and_brackets_with_events()
|
|
||||||
{
|
|
||||||
await using var f = new RefineRunnerFixture();
|
|
||||||
var task = await f.SeedTaskAsync(status: TaskStatus.Idle);
|
|
||||||
var outcome = await f.Runner.RefineAsync(task.Id, CancellationToken.None);
|
|
||||||
Assert.True(outcome.Success);
|
|
||||||
Assert.Equal(1, f.Claude.RunCount);
|
|
||||||
Assert.Equal(1, f.Broadcaster.Started);
|
|
||||||
Assert.Equal(1, f.Broadcaster.Finished);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> Build the `RefineRunnerFixture`/fakes by copying the Prime test's `IClaudeProcess` stub + real-SQLite `IDbContextFactory` setup and a recording `IRefineBroadcaster`. If a Prime fixture exists, mirror it; otherwise construct inline.
|
|
||||||
|
|
||||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter RefineRunnerTests`
|
|
||||||
Expected: PASS (2 tests).
|
|
||||||
|
|
||||||
- [ ] **Step 7: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/ClaudeDo.Worker/Refine tests/ClaudeDo.Worker.Tests/Refine
|
|
||||||
git commit -m "feat(refine): add RefineRunner, prompt/args helper, and interfaces"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 4: Worker wiring — broadcaster, hub, DI
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/ClaudeDo.Worker/Hub/HubBroadcaster.cs`
|
|
||||||
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
|
|
||||||
- Modify: `src/ClaudeDo.Worker/Program.cs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Implement events on `HubBroadcaster`**
|
|
||||||
|
|
||||||
Add `IRefineBroadcaster` to the class's interface list (`public sealed class HubBroadcaster : ..., IRefineBroadcaster`) and add (mirroring the `Prep*` block):
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public Task RefineStarted(string taskId) => _hub.Clients.All.SendAsync("RefineStarted", taskId);
|
|
||||||
public Task RefineFinished(string taskId, bool success, string? error) =>
|
|
||||||
_hub.Clients.All.SendAsync("RefineFinished", taskId, success, error);
|
|
||||||
|
|
||||||
Task IRefineBroadcaster.RefineStartedAsync(string taskId) => RefineStarted(taskId);
|
|
||||||
Task IRefineBroadcaster.RefineFinishedAsync(string taskId, bool success, string? error) =>
|
|
||||||
RefineFinished(taskId, success, error);
|
|
||||||
```
|
|
||||||
|
|
||||||
Add `using ClaudeDo.Worker.Refine;`.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Add `RefineTask` to `WorkerHub`**
|
|
||||||
|
|
||||||
`WorkerHub` injects services via its constructor. Add a `private readonly IRefineRunner _refineRunner;` field, add the parameter to the constructor and assign it. Add the method (fire-and-forget; the runner brackets with its own events):
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public Task RefineTask(string taskId)
|
|
||||||
{
|
|
||||||
_ = _refineRunner.RefineAsync(taskId, CancellationToken.None);
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Add `using ClaudeDo.Worker.Refine;`.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Register DI in `Program.cs`**
|
|
||||||
|
|
||||||
Near the Prime registrations:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
builder.Services.AddSingleton<IRefineRunner, RefineRunner>();
|
|
||||||
builder.Services.AddSingleton<IRefineBroadcaster>(sp => sp.GetRequiredService<HubBroadcaster>());
|
|
||||||
```
|
|
||||||
|
|
||||||
Add `using ClaudeDo.Worker.Refine;` if needed. (`HubBroadcaster` is already registered as a singleton — confirm and reuse that registration; do not double-register it.)
|
|
||||||
|
|
||||||
- [ ] **Step 4: Build the worker**
|
|
||||||
|
|
||||||
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`
|
|
||||||
Expected: Build succeeded.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/ClaudeDo.Worker/Hub/HubBroadcaster.cs src/ClaudeDo.Worker/Hub/WorkerHub.cs src/ClaudeDo.Worker/Program.cs
|
|
||||||
git commit -m "feat(refine): wire RefineTask hub method, broadcaster events, and DI"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 5: UI worker client — call + events + fakes
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs`
|
|
||||||
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
|
|
||||||
- Modify: test fakes implementing `IWorkerClient`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Extend the interface**
|
|
||||||
|
|
||||||
In `IWorkerClient.cs` add (near `RunDailyPrepNowAsync` and the `Prep*` events):
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
Task RefineTaskAsync(string taskId);
|
|
||||||
|
|
||||||
event Action<string>? RefineStartedEvent;
|
|
||||||
event Action<string, bool, string?>? RefineFinishedEvent;
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Implement in `WorkerClient`**
|
|
||||||
|
|
||||||
Add the method (mirror `RunDailyPrepNowAsync`):
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public Task RefineTaskAsync(string taskId) => _hub.InvokeAsync("RefineTask", taskId);
|
|
||||||
```
|
|
||||||
|
|
||||||
Declare the events:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public event Action<string>? RefineStartedEvent;
|
|
||||||
public event Action<string, bool, string?>? RefineFinishedEvent;
|
|
||||||
```
|
|
||||||
|
|
||||||
Subscribe in the constructor (mirror the `Prep*` subscriptions block):
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
_hub.On<string>("RefineStarted", id =>
|
|
||||||
Dispatcher.UIThread.Post(() => RefineStartedEvent?.Invoke(id)));
|
|
||||||
_hub.On<string, bool, string?>("RefineFinished", (id, ok, err) =>
|
|
||||||
Dispatcher.UIThread.Post(() => RefineFinishedEvent?.Invoke(id, ok, err)));
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Update test fakes**
|
|
||||||
|
|
||||||
Find every hand-rolled `IWorkerClient` implementation (search the test projects) and add `RefineTaskAsync` (return `Task.CompletedTask`) plus the two events (`= delegate {}` or `add{}remove{}` no-ops as the fake convention dictates). Build each affected test project.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Build UI + test projects**
|
|
||||||
|
|
||||||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
|
||||||
Then build the UI test project(s). Expected: Build succeeded.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs src/ClaudeDo.Ui/Services/WorkerClient.cs <fake files>
|
|
||||||
git commit -m "feat(ui): add RefineTask client call and refine events"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 6: UI — icon, button, view model, command
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/ClaudeDo.Ui/Design/IslandStyles.axaml`
|
|
||||||
- Modify: `src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml`
|
|
||||||
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs`
|
|
||||||
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs`
|
|
||||||
- Modify: `locales/en.json`, `locales/de.json`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add the `Icon.Refine` geometry**
|
|
||||||
|
|
||||||
In `IslandStyles.axaml`, near the other `Icon.*` `StreamGeometry` resources, add the supplied SVG converted to path data (line-art, rendered stroked via `plan-icon`):
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<StreamGeometry x:Key="Icon.Refine">M3,5 L11,5 M3,9 L9,9 M3,13 L7,13 M19,1.8 L19.7,3.9 L21.7,4.6 L19.7,5.3 L19,7.4 L18.3,5.3 L16.3,4.6 L18.3,3.9 Z M18,10.5 L12.2,16.3 M16.6,9.1 L19.4,11.9 M12.2,16.3 L11,18.5 L13.2,17.5 Z</StreamGeometry>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Add `IsRefining`/`CanRefine` to `TaskRowViewModel`**
|
|
||||||
|
|
||||||
Add the observable property (with the other `[ObservableProperty]` fields):
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
[ObservableProperty] private bool _isRefining;
|
|
||||||
```
|
|
||||||
|
|
||||||
Add a computed gate (refine is only offered for Idle, non-parent tasks). Place near other `Can*` getters:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public bool CanRefine => Status == TaskStatus.Idle && PlanningPhase == PlanningPhase.None && !IsRefining;
|
|
||||||
```
|
|
||||||
|
|
||||||
If `Status`/`PlanningPhase`/`IsRefining` are `[ObservableProperty]`, raise `CanRefine` change notifications via partial `On<Prop>Changed` hooks:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
partial void OnStatusChanged(TaskStatus value) => OnPropertyChanged(nameof(CanRefine));
|
|
||||||
partial void OnPlanningPhaseChanged(PlanningPhase value) => OnPropertyChanged(nameof(CanRefine));
|
|
||||||
partial void OnIsRefiningChanged(bool value) => OnPropertyChanged(nameof(CanRefine));
|
|
||||||
```
|
|
||||||
|
|
||||||
> If `On...Changed` partials already exist for `Status`/`PlanningPhase`, add the `OnPropertyChanged(nameof(CanRefine))` line inside them instead of redeclaring.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Add `RefineTaskCommand` + event wiring to `TasksIslandViewModel`**
|
|
||||||
|
|
||||||
Add the command (mirror an existing per-row command like `ToggleStarCommand`, which takes a `TaskRowViewModel`):
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
[RelayCommand]
|
|
||||||
private async Task RefineTask(TaskRowViewModel row)
|
|
||||||
{
|
|
||||||
if (row is null || !row.CanRefine) return;
|
|
||||||
row.IsRefining = true;
|
|
||||||
try { await _worker.RefineTaskAsync(row.Id); }
|
|
||||||
catch { row.IsRefining = false; }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> Use the same injected worker-client field name this VM already uses (e.g. `_worker`/`_client`). Match it.
|
|
||||||
|
|
||||||
Subscribe to the refine events where the VM wires other worker events (where `OnWorkerTaskUpdated` is subscribed). Add handlers that flip the row flag:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
private void OnRefineStarted(string taskId)
|
|
||||||
{
|
|
||||||
var row = Items.FirstOrDefault(r => r.Id == taskId);
|
|
||||||
if (row is not null) row.IsRefining = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnRefineFinished(string taskId, bool ok, string? error)
|
|
||||||
{
|
|
||||||
var row = Items.FirstOrDefault(r => r.Id == taskId);
|
|
||||||
if (row is not null) row.IsRefining = false;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Wire them next to the existing subscriptions (and unsubscribe in the same place the VM unsubscribes others, if it does):
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
_worker.RefineStartedEvent += OnRefineStarted;
|
|
||||||
_worker.RefineFinishedEvent += OnRefineFinished;
|
|
||||||
```
|
|
||||||
|
|
||||||
(Content changes—new description/subtasks—arrive through the existing `TaskUpdated` → `OnWorkerTaskUpdated` path; no extra work needed.)
|
|
||||||
|
|
||||||
- [ ] **Step 4: Add the button to `TaskRowView.axaml`**
|
|
||||||
|
|
||||||
Mirror the star button (`Grid.Column="5"` area). Add a refine `icon-btn` (e.g. as a new column or beside the star) bound to the parent ItemsControl's command, passing the row as parameter. Use the `plan-icon` stroked `Path` inside a `Viewbox` (matching the Plan-day button), gate visibility on `CanRefine`, and disable/spin on `IsRefining`:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<Button Classes="icon-btn refine-btn"
|
|
||||||
IsVisible="{Binding CanRefine}"
|
|
||||||
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).RefineTaskCommand}"
|
|
||||||
CommandParameter="{Binding}"
|
|
||||||
ToolTip.Tip="{loc:Tr tasks.refineTip}">
|
|
||||||
<Viewbox Width="16" Height="16">
|
|
||||||
<Path Classes="plan-icon" Data="{StaticResource Icon.Refine}"/>
|
|
||||||
</Viewbox>
|
|
||||||
</Button>
|
|
||||||
```
|
|
||||||
|
|
||||||
> Match the column layout already in `TaskRowView.axaml`. If a new grid column is needed, widen `ColumnDefinitions` accordingly and place the refine button left of the star (`Grid.Column`). Keep the existing `vm:` / `loc:` xmlns aliases the file already declares.
|
|
||||||
|
|
||||||
Optionally show a spinning/dimmed state while `IsRefining` (e.g. a style `Selector="Button.refine-btn:disabled"` or bind opacity to `IsRefining`). Keep it simple; a disabled look is enough.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Add localization keys**
|
|
||||||
|
|
||||||
Add to both `locales/en.json` and `locales/de.json` under the `tasks` group (keys must stay in parity):
|
|
||||||
|
|
||||||
- en: `"tasks.refineTip": "Refine this task with Claude"`
|
|
||||||
- de: `"tasks.refineTip": "Aufgabe mit Claude verfeinern"`
|
|
||||||
|
|
||||||
> Match the file's actual key structure (flat `"tasks.x"` vs nested `tasks: { x }`)—look at an existing `tasks.*` tooltip key (e.g. the plan-day tip) and follow it exactly.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Build UI**
|
|
||||||
|
|
||||||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
|
||||||
Then run the Localization parity tests: `dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release`
|
|
||||||
Expected: Build succeeded; locale parity passes.
|
|
||||||
|
|
||||||
- [ ] **Step 7: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/ClaudeDo.Ui/Design/IslandStyles.axaml src/ClaudeDo.Ui/Views/Islands/TaskRowView.axaml src/ClaudeDo.Ui/ViewModels/Islands/TaskRowViewModel.cs src/ClaudeDo.Ui/ViewModels/Islands/TasksIslandViewModel.cs locales/en.json locales/de.json
|
|
||||||
git commit -m "feat(ui): add Refine button, icon, and command to task card"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 7: Full build + test sweep, manual smoke
|
|
||||||
|
|
||||||
- [ ] **Step 1: Build all main projects**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
|
|
||||||
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
|
|
||||||
```
|
|
||||||
Expected: Build succeeded for both.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run the worker + UI test suites**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
|
|
||||||
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
|
|
||||||
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
|
|
||||||
```
|
|
||||||
Expected: all green.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Manual smoke (visual + real CLI — flag to user)**
|
|
||||||
|
|
||||||
Cannot be automated (no real-Claude in tests). Verify by hand: start Worker + UI, on an Idle task click the refine icon → button shows busy → after the run the description improves and steps appear in the Steps card → task stays Idle. Confirm the refine icon is hidden for Queued/Running/Done tasks and for planning parents. **Report this as a visual-verification gap for the user to confirm.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notes on parallelism / execution
|
|
||||||
|
|
||||||
- Tasks 1–4 are backend and largely sequential (4 depends on 3). Tasks 1 and 2 are independent and could be done first in either order.
|
|
||||||
- Tasks 5–6 (UI) depend on Task 4's hub/event contract.
|
|
||||||
- Per project convention: subagents use `sonnet`, stage files by explicit path, and do NOT run git/build inside parallel agents — the orchestrator builds, tests, and commits after each task.
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
# Task Detail Redesign — Component Build Prompts
|
|
||||||
|
|
||||||
Three isolated build tasks (one per component). Each runs in its own worktree off
|
|
||||||
`main`, with the project CLAUDE.md auto-loaded. Full design context lives in
|
|
||||||
`docs/superpowers/specs/2026-06-04-task-detail-redesign-design.md` — every task
|
|
||||||
must read it first.
|
|
||||||
|
|
||||||
Shared rules (all three):
|
|
||||||
- Build a **standalone** `UserControl` + dedicated `ViewModel` that renders fully
|
|
||||||
in the Avalonia previewer via **design-time sample data** (parameterless ctor
|
|
||||||
populating realistic values). Do **not** bind to `DetailsIslandViewModel`.
|
|
||||||
- New files under `src/ClaudeDo.Ui/Views/Islands/Detail/` and
|
|
||||||
`src/ClaudeDo.Ui/ViewModels/Islands/Detail/`.
|
|
||||||
- Use **only** tokens from `Design/Tokens.axaml` and classes from
|
|
||||||
`Design/IslandStyles.axaml`. No inline hex, no magic numbers where a token
|
|
||||||
exists. `PathIcon` fills geometry — stroke-only art is invisible.
|
|
||||||
- Compiled bindings (`x:DataType`). MVVM via CommunityToolkit
|
|
||||||
(`[ObservableProperty]`, `[RelayCommand]`); VM inherits `ViewModelBase`.
|
|
||||||
- **Do NOT modify** `DetailsIslandView.axaml`, `DetailsIslandViewModel.cs`,
|
|
||||||
`AgentStripView`, `SessionTerminalView`, or `TaskRunner.cs`.
|
|
||||||
- Verify: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj -c Release` is green.
|
|
||||||
Stage files explicitly by path (never `git add -A`). Commit with a conventional
|
|
||||||
message.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## TASK 1 — TaskHeaderBar
|
|
||||||
|
|
||||||
(prompt text = task description; see below)
|
|
||||||
|
|
||||||
## TASK 2 — DescriptionStepsCard
|
|
||||||
|
|
||||||
## TASK 3 — WorkConsole
|
|
||||||
@@ -1,432 +0,0 @@
|
|||||||
# Git Merge/Review — Shared Foundation + Layer A Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Build the shared worker conflict contract (so parallel Layer B/C sessions branch from frozen interfaces) and rework the Git tab into a single Approve+merge cockpit.
|
|
||||||
|
|
||||||
**Architecture:** Phase 0 adds the conflict-resolution contract to `IWorkerClient`/`WorkerClient` (real `_hub.InvokeAsync` bodies — the worker hub methods are implemented later by Layer C; calls simply fail at runtime until then) plus client-side DTOs and test-fake updates, then commits + pushes so B and C branch from it. Phase A reworks `WorkConsole.axaml`'s Git tab and routes single-task merge/approve conflicts into a `RequestConflictResolution` seam (wired to Layer C's resolver by the integrator at merge time).
|
|
||||||
|
|
||||||
**Tech Stack:** .NET 8, Avalonia 12 (Fluent), CommunityToolkit.Mvvm, SignalR, xUnit. Build individual csproj with `-c Release` (`.slnx` needs .NET 9; a running Worker locks `Debug`).
|
|
||||||
|
|
||||||
**Reference spec:** `docs/superpowers/specs/2026-06-05-git-merge-review-rework-design.md`
|
|
||||||
|
|
||||||
**Note on the canonical diff renderer:** the unified diff model/control already exists — `DiffFileViewModel`/`DiffLineViewModel`/`UnifiedDiffParser` (in `src/ClaudeDo.Ui/ViewModels/Modals/`) rendered by `DiffLinesView` (`src/ClaudeDo.Ui/Views/Controls/DiffLinesView.axaml`). `DiffModalView` and `PlanningDiffView` already use it. So "consolidate diff renderers" for this scope is just verifying that (Task A.3); migrating `WorktreeModalView`'s bespoke diff onto `DiffLinesView` is Layer B's job.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
**Phase 0 (foundation — pushed before B/C branch):**
|
|
||||||
- Modify `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs` — 5 new method signatures.
|
|
||||||
- Modify `src/ClaudeDo.Ui/Services/WorkerClient.cs` — 5 `InvokeAsync` bodies + 3 new DTO records.
|
|
||||||
- Modify `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs` — 5 new `virtual` no-op methods.
|
|
||||||
- Modify `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs` — 5 new methods on `FakeWorkerClient`.
|
|
||||||
|
|
||||||
**Phase A (Layer A — this session, after foundation commit):**
|
|
||||||
- Modify `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` — `RequestConflictResolution` seam; route Approve/Merge conflicts into it.
|
|
||||||
- Modify `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml` — fuse REVIEW + MERGE sections into one cockpit block.
|
|
||||||
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs` (or a sibling test file in the same folder).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 0 — Shared Foundation
|
|
||||||
|
|
||||||
### Task 0.1: Add the conflict contract (interface + client + DTOs)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs`
|
|
||||||
- Modify: `src/ClaudeDo.Ui/Services/WorkerClient.cs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add the 5 method signatures to `IWorkerClient`**
|
|
||||||
|
|
||||||
In `src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs`, after the existing
|
|
||||||
`Task CancelReviewAsync(string taskId);` line (line 45), add:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// ── Conflict resolution (worker hub side implemented by Layer C) ──
|
|
||||||
Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch);
|
|
||||||
Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId);
|
|
||||||
Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent);
|
|
||||||
Task<MergeResultDto> ContinueMergeAsync(string taskId);
|
|
||||||
Task AbortMergeAsync(string taskId);
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Add the 3 DTO records to `WorkerClient.cs`**
|
|
||||||
|
|
||||||
In `src/ClaudeDo.Ui/Services/WorkerClient.cs`, immediately after line 534
|
|
||||||
(`public record MergeTargetsDto(...)`), add:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public record MergeConflictsDto(string TaskId, IReadOnlyList<ConflictFileDto> Files);
|
|
||||||
public record ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks);
|
|
||||||
public record ConflictHunkDto(string Ours, string Theirs, string? Base);
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Add the 5 client method bodies to `WorkerClient.cs`**
|
|
||||||
|
|
||||||
In `src/ClaudeDo.Ui/Services/WorkerClient.cs`, right after the `MergeTaskAsync`
|
|
||||||
method (ends at line 270), add:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch)
|
|
||||||
=> _hub.InvokeAsync<MergeResultDto>("StartConflictMerge", taskId, targetBranch);
|
|
||||||
|
|
||||||
public Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId)
|
|
||||||
=> _hub.InvokeAsync<MergeConflictsDto>("GetMergeConflicts", taskId);
|
|
||||||
|
|
||||||
public Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent)
|
|
||||||
=> _hub.InvokeAsync("WriteConflictResolution", taskId, path, resolvedContent);
|
|
||||||
|
|
||||||
public Task<MergeResultDto> ContinueMergeAsync(string taskId)
|
|
||||||
=> _hub.InvokeAsync<MergeResultDto>("ContinueMerge", taskId);
|
|
||||||
|
|
||||||
public Task AbortMergeAsync(string taskId)
|
|
||||||
=> _hub.InvokeAsync("AbortMerge", taskId);
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Build the UI project**
|
|
||||||
|
|
||||||
Run: `dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj -c Release`
|
|
||||||
Expected: build FAILS — the two test projects won't compile yet, but the UI project
|
|
||||||
itself should succeed. If the UI project reports "does not implement interface member"
|
|
||||||
it means a body is missing; fix before continuing. (Test projects are fixed in 0.2.)
|
|
||||||
|
|
||||||
### Task 0.2: Update the hand-rolled test fakes
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`
|
|
||||||
- Modify: `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add 5 virtual no-ops to `StubWorkerClient`**
|
|
||||||
|
|
||||||
In `tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs`, after the `MergeTaskAsync` override
|
|
||||||
(line 57), add:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public virtual Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch) => Task.FromResult(new MergeResultDto("conflict", System.Array.Empty<string>(), null));
|
|
||||||
public virtual Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId) => Task.FromResult(new MergeConflictsDto(taskId, System.Array.Empty<ConflictFileDto>()));
|
|
||||||
public virtual Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent) => Task.CompletedTask;
|
|
||||||
public virtual Task<MergeResultDto> ContinueMergeAsync(string taskId) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));
|
|
||||||
public virtual Task AbortMergeAsync(string taskId) => Task.CompletedTask;
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Add 5 methods to `FakeWorkerClient`**
|
|
||||||
|
|
||||||
In `tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs`, after the
|
|
||||||
`MergeTaskAsync` method (line 47), add:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch) => Task.FromResult(new MergeResultDto("conflict", System.Array.Empty<string>(), null));
|
|
||||||
public Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId) => Task.FromResult(new MergeConflictsDto(taskId, System.Array.Empty<ConflictFileDto>()));
|
|
||||||
public Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent) => Task.CompletedTask;
|
|
||||||
public Task<MergeResultDto> ContinueMergeAsync(string taskId) => Task.FromResult(new MergeResultDto("merged", System.Array.Empty<string>(), null));
|
|
||||||
public Task AbortMergeAsync(string taskId) => Task.CompletedTask;
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Build both test projects**
|
|
||||||
|
|
||||||
Run: `dotnet build tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release && dotnet build tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release`
|
|
||||||
Expected: both BUILD succeed.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run the UI test suite to confirm green baseline**
|
|
||||||
|
|
||||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
|
|
||||||
Expected: PASS (no behavior changed yet).
|
|
||||||
|
|
||||||
### Task 0.3: Commit and push the foundation
|
|
||||||
|
|
||||||
- [ ] **Step 1: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/ClaudeDo.Ui/Services/Interfaces/IWorkerClient.cs src/ClaudeDo.Ui/Services/WorkerClient.cs tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs
|
|
||||||
git commit -m "feat(ui): add conflict-resolution worker contract (foundation for merge rework)"
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Push so Layer B/C can branch from this commit**
|
|
||||||
|
|
||||||
Run: `git push`
|
|
||||||
Expected: pushed to `main`. (First push to git.kuns.dev may fail auth — retry once.)
|
|
||||||
**This commit is the branch point for the Layer B and Layer C kickoff prompts.**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase A — Layer A Review/Merge Cockpit
|
|
||||||
|
|
||||||
### Task A.1: Conflict-resolution seam + route Approve/Merge conflicts into it (TDD)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`
|
|
||||||
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandConflictSeamTests.cs` (new)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing test**
|
|
||||||
|
|
||||||
Create `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandConflictSeamTests.cs`. Mirror
|
|
||||||
the VM-construction harness used in
|
|
||||||
`tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandPlanningTests.cs` (same folder) —
|
|
||||||
construct `DetailsIslandViewModel` exactly as that file does, including its
|
|
||||||
`StubWorkerClient` subclass pattern. The test:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
[Fact]
|
|
||||||
public async Task ApproveReview_OnConflict_InvokesConflictResolutionSeam()
|
|
||||||
{
|
|
||||||
string? resolvedTaskId = null;
|
|
||||||
string? resolvedTarget = null;
|
|
||||||
|
|
||||||
// Construct the VM as in DetailsIslandPlanningTests, with a worker stub whose
|
|
||||||
// ApproveReviewAsync returns a conflict result:
|
|
||||||
// public override Task<MergeResultDto?> ApproveReviewAsync(string id, string target)
|
|
||||||
// => Task.FromResult<MergeResultDto?>(new MergeResultDto("conflict", new[]{"a.cs"}, null));
|
|
||||||
var vm = CreateVm(/* worker stub above */);
|
|
||||||
vm.RequestConflictResolution = (taskId, target) =>
|
|
||||||
{
|
|
||||||
resolvedTaskId = taskId; resolvedTarget = target;
|
|
||||||
return System.Threading.Tasks.Task.CompletedTask;
|
|
||||||
};
|
|
||||||
// assign a task in WaitingForReview + a SelectedMergeTarget = "main" via the same
|
|
||||||
// helpers DetailsIslandPlanningTests uses.
|
|
||||||
|
|
||||||
await vm.ApproveReviewCommand.ExecuteAsync(null);
|
|
||||||
|
|
||||||
Assert.Equal(/* the seeded task id */, resolvedTaskId);
|
|
||||||
Assert.Equal("main", resolvedTarget);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run the test to verify it fails**
|
|
||||||
|
|
||||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter ApproveReview_OnConflict_InvokesConflictResolutionSeam`
|
|
||||||
Expected: FAIL — `RequestConflictResolution` property does not exist (compile error).
|
|
||||||
|
|
||||||
- [ ] **Step 3: Add the seam property**
|
|
||||||
|
|
||||||
In `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs`, beside the other
|
|
||||||
view-wired delegates (`ShowDiffModal`, `ShowMergeModal` around line 387–390), add:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// Invoked when a single-task merge/approve hits a conflict. Wired by the
|
|
||||||
// integrator to Layer C's conflict resolver. Args: (taskId, targetBranch).
|
|
||||||
public Func<string, string, System.Threading.Tasks.Task>? RequestConflictResolution { get; set; }
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Route the Approve conflict branch into the seam**
|
|
||||||
|
|
||||||
In `ApproveReviewAsync` (around line 1453), replace the conflict branch body so it
|
|
||||||
prefers the seam, falling back to the current preview-text behavior:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
var result = await _worker.ApproveReviewAsync(Task.Id, SelectedMergeTarget ?? "");
|
|
||||||
if (result?.Status == "conflict")
|
|
||||||
{
|
|
||||||
if (RequestConflictResolution is not null)
|
|
||||||
{
|
|
||||||
await RequestConflictResolution(Task.Id, SelectedMergeTarget ?? "");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var (text, _, _) = MergePreviewPresenter.Describe(
|
|
||||||
new MergePreviewDto("conflict", result.ConflictFiles, 0));
|
|
||||||
MergePreviewText = text; MergeIsClean = false; MergeIsConflict = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Route the manual Merge conflict branch into the seam**
|
|
||||||
|
|
||||||
In `MergeAsync` (around line 1170), apply the same pattern to its conflict branch:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
var result = await _worker.MergeTaskAsync(Task.Id, SelectedMergeTarget ?? "", false, "Merge task");
|
|
||||||
if (result.Status == "conflict")
|
|
||||||
{
|
|
||||||
if (RequestConflictResolution is not null)
|
|
||||||
{
|
|
||||||
await RequestConflictResolution(Task.Id, SelectedMergeTarget ?? "");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var (text, _, _) = MergePreviewPresenter.Describe(
|
|
||||||
new MergePreviewDto("conflict", result.ConflictFiles, 0));
|
|
||||||
MergePreviewText = text; MergeIsClean = false; MergeIsConflict = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
await RefreshMergePreviewAsync();
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 6: Run the test to verify it passes**
|
|
||||||
|
|
||||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter ApproveReview_OnConflict_InvokesConflictResolutionSeam`
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
- [ ] **Step 7: Run the full UI suite (no regressions)**
|
|
||||||
|
|
||||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
- [ ] **Step 8: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandConflictSeamTests.cs
|
|
||||||
git commit -m "feat(ui): route single-task merge conflicts into a resolution seam"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Task A.2: Fuse the Git tab into one Approve+merge cockpit
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Replace the two Git-tab sections with one cockpit block**
|
|
||||||
|
|
||||||
In `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml`, replace the entire Git
|
|
||||||
`ScrollViewer` body (lines 255–313 — the `<!-- Git: ... -->` block containing the
|
|
||||||
separate `REVIEW` `StackPanel` and the `MERGE & WORKTREE` `StackPanel`) with a single
|
|
||||||
cockpit where Approve sits with the merge target/preview/actions. Keep the existing
|
|
||||||
control class names (`section-label`, `field-label`, `btn`, `btn accent`, `meta`) and
|
|
||||||
the existing bindings (`SelectedMergeTarget`, `MergeTargetBranches`, `MergePreviewText`,
|
|
||||||
`MergeIsClean`, `MergeIsConflict`, `ShowMergePreviewMuted`, `OpenDiffCommand`,
|
|
||||||
`ApproveReviewCommand`, `MergeCommand`, `ShowSingleMerge`, `OpenWorktreeCommand`,
|
|
||||||
`ReviewCombinedDiffCommand`, `MergeAllCommand`, `CanMergeAll`, `MergeAllDisabledReason`,
|
|
||||||
`MergeAllError`):
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<!-- Git: one Approve + merge cockpit -->
|
|
||||||
<ScrollViewer IsVisible="{Binding IsGitTab}" Padding="14,10">
|
|
||||||
<StackPanel Spacing="12" IsVisible="{Binding ShowMergeSection}">
|
|
||||||
<TextBlock Classes="section-label" Text="MERGE" />
|
|
||||||
|
|
||||||
<StackPanel Spacing="4">
|
|
||||||
<TextBlock Classes="field-label" Text="Target branch" />
|
|
||||||
<ComboBox ItemsSource="{Binding MergeTargetBranches}"
|
|
||||||
SelectedItem="{Binding SelectedMergeTarget, Mode=TwoWay}"
|
|
||||||
HorizontalAlignment="Stretch" />
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<StackPanel Spacing="0">
|
|
||||||
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
|
|
||||||
Foreground="{DynamicResource MossBrush}"
|
|
||||||
IsVisible="{Binding MergeIsClean}" />
|
|
||||||
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
|
|
||||||
Foreground="{DynamicResource BloodBrush}"
|
|
||||||
IsVisible="{Binding MergeIsConflict}" />
|
|
||||||
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
|
|
||||||
Foreground="{DynamicResource TextMuteBrush}"
|
|
||||||
IsVisible="{Binding ShowMergePreviewMuted}" />
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<!-- Primary action: Approve flows straight into the merge.
|
|
||||||
Approve is the review-gated path; the plain Merge button covers
|
|
||||||
already-reviewed / kept worktrees. -->
|
|
||||||
<WrapPanel Orientation="Horizontal">
|
|
||||||
<Button Classes="btn accent" Content="Approve & Merge" Margin="0,0,8,8"
|
|
||||||
Command="{Binding ApproveReviewCommand}"
|
|
||||||
IsVisible="{Binding IsWaitingForReview}" />
|
|
||||||
<Button Classes="btn accent" Content="Merge" Margin="0,0,8,8"
|
|
||||||
Command="{Binding MergeCommand}"
|
|
||||||
IsVisible="{Binding ShowSingleMerge}" />
|
|
||||||
<Button Classes="btn" Content="Open Diff" Margin="0,0,8,8"
|
|
||||||
Command="{Binding OpenDiffCommand}" />
|
|
||||||
<Button Classes="btn" Margin="0,0,8,8"
|
|
||||||
Command="{Binding OpenWorktreeCommand}">
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="5">
|
|
||||||
<TextBlock Text="Worktree" />
|
|
||||||
<PathIcon Data="{StaticResource Icon.ArrowOut}" Width="11" Height="11" />
|
|
||||||
</StackPanel>
|
|
||||||
</Button>
|
|
||||||
<Button Classes="btn" Content="Review Combined Diff" Margin="0,0,8,8"
|
|
||||||
Command="{Binding ReviewCombinedDiffCommand}" />
|
|
||||||
<Button Classes="btn accent" Content="Merge All Subtasks" Margin="0,0,0,8"
|
|
||||||
Command="{Binding MergeAllCommand}"
|
|
||||||
IsEnabled="{Binding CanMergeAll}"
|
|
||||||
ToolTip.Tip="{Binding MergeAllDisabledReason}" />
|
|
||||||
</WrapPanel>
|
|
||||||
|
|
||||||
<TextBlock Text="{Binding MergeAllError}"
|
|
||||||
Foreground="{DynamicResource BloodBrush}"
|
|
||||||
TextWrapping="Wrap"
|
|
||||||
IsVisible="{Binding MergeAllError,
|
|
||||||
Converter={x:Static ObjectConverters.IsNotNull}}" />
|
|
||||||
</StackPanel>
|
|
||||||
</ScrollViewer>
|
|
||||||
```
|
|
||||||
|
|
||||||
Note: the cockpit now shows whenever `ShowMergeSection` is true. `ShowMergeSection`
|
|
||||||
(DetailsIslandViewModel line 161) must be true while `IsWaitingForReview` so the
|
|
||||||
Approve button appears. Check its expression in Step 2.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Verify `ShowMergeSection` covers the review state**
|
|
||||||
|
|
||||||
Read `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` line 161. If
|
|
||||||
`ShowMergeSection` is false while `IsWaitingForReview` (e.g. it requires a non-review
|
|
||||||
state), widen it to also be true when `IsWaitingForReview && WorktreePath != null`, and
|
|
||||||
ensure `OnPropertyChanged(nameof(ShowMergeSection))` already fires on the relevant state
|
|
||||||
transitions (it is notified via `NotifySessionSections`). Make the minimal change needed
|
|
||||||
so the Approve button is visible in review state. If it already covers review, change
|
|
||||||
nothing.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Build the app project**
|
|
||||||
|
|
||||||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
|
||||||
Expected: BUILD succeeds (pulls in Ui + Data).
|
|
||||||
|
|
||||||
- [ ] **Step 4: Visual verification (manual — flag for the user)**
|
|
||||||
|
|
||||||
This is an AXAML layout change with no automated coverage. Launch the app, open a task
|
|
||||||
in `WaitingForReview`, open the Git tab, and confirm: the single MERGE block shows the
|
|
||||||
target combo, the colored preview line, an "Approve & Merge" button (review state), and
|
|
||||||
the diff/worktree/combined/merge-all actions. **Explicitly tell the user this needs a
|
|
||||||
visual pass — do not claim it works without running it.**
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs
|
|
||||||
git commit -m "feat(ui): fuse git tab into one approve+merge cockpit"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Task A.3: Verify diff-renderer consolidation
|
|
||||||
|
|
||||||
**Files:** none modified (verification only).
|
|
||||||
|
|
||||||
- [ ] **Step 1: Confirm DiffModal + Planning already use the canonical renderer**
|
|
||||||
|
|
||||||
Run: `rg -l "DiffLinesView" src/ClaudeDo.Ui/Views`
|
|
||||||
Expected: matches in `Modals/DiffModalView.axaml` and `Planning/PlanningDiffView.axaml`.
|
|
||||||
If `PlanningDiffView.axaml` does NOT use `DiffLinesView`, change its diff `ItemsControl`
|
|
||||||
to a `<controls:DiffLinesView Lines="{Binding SelectedFile.Lines}" />` (matching
|
|
||||||
`DiffModalView.axaml`'s usage) and rebuild the App project. If both already use it, this
|
|
||||||
task is a no-op — record that and move on. (`WorktreeModalView`'s bespoke diff is
|
|
||||||
intentionally left for Layer B.)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Self-Review
|
|
||||||
|
|
||||||
- **Spec coverage:** Foundation contract (spec §"Frozen worker conflict contract") →
|
|
||||||
Task 0.1. Test fakes (spec parallel-boundaries row) → Task 0.2. Branch point (spec
|
|
||||||
§"built & pushed this session") → Task 0.3. Layer A cockpit + Approve/merge flow
|
|
||||||
together (spec §"Layer A") → Task A.2. Single-task approve-on-conflict opens resolver
|
|
||||||
via seam (spec §"Layer A" + §"integration seams") → Task A.1. Diff consolidation
|
|
||||||
(spec §"One diff model") → Task A.3. Output-footer feedback unchanged → not touched
|
|
||||||
(correct). No spec requirement left unmapped for this session's scope.
|
|
||||||
- **Placeholder scan:** none — every code step has concrete code; the only "mirror the
|
|
||||||
existing harness" reference (Task A.1 Step 1) points at a real file with a working
|
|
||||||
pattern, not a TODO.
|
|
||||||
- **Type consistency:** `MergeConflictsDto`/`ConflictFileDto`/`ConflictHunkDto` and the
|
|
||||||
5 method names match between `IWorkerClient` (0.1 Step 1), `WorkerClient` (0.1 Steps
|
|
||||||
2–3), and both fakes (0.2). The seam `RequestConflictResolution` is
|
|
||||||
`Func<string,string,Task>?` everywhere (A.1 Steps 1, 3–5). DTO field names match the
|
|
||||||
spec.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Integration notes (for the integrator merging A + B + C)
|
|
||||||
|
|
||||||
- Wire `DetailsIslandViewModel.RequestConflictResolution` and Layer B's equivalent
|
|
||||||
callback to Layer C's `ConflictResolverViewModel` factory + `ShowConflictResolver`
|
|
||||||
dialog delegate.
|
|
||||||
- Layer C implements the worker hub methods `StartConflictMerge`, `GetMergeConflicts`,
|
|
||||||
`WriteConflictResolution`, `ContinueMerge`, `AbortMerge`; the client side from Task
|
|
||||||
0.1 already calls them by name.
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
# Git Merge/Review Rework — Parallel Kickoff Prompts (Layer B & Layer C)
|
|
||||||
|
|
||||||
These are self-contained prompts to paste into two fresh ClaudeDo sessions, each in its
|
|
||||||
own git worktree, run **in parallel** with the main session's Layer A work.
|
|
||||||
|
|
||||||
**Prerequisite — branch point:** Both sessions must branch from `main` **at or after**
|
|
||||||
the foundation commit `feat(ui): add conflict-resolution worker contract (foundation for
|
|
||||||
merge rework)` (Phase 0, Task 0.3 of
|
|
||||||
`docs/superpowers/plans/2026-06-05-git-merge-review-foundation-layerA.md`). That commit
|
|
||||||
adds the frozen `IWorkerClient` conflict contract both layers rely on. Do not start B/C
|
|
||||||
until that commit is pushed.
|
|
||||||
|
|
||||||
**Integration:** Neither session pushes to `main` or merges. Each leaves its branch/
|
|
||||||
worktree for the orchestrator (the main session) to review and merge.
|
|
||||||
|
|
||||||
Design reference for both: `docs/superpowers/specs/2026-06-05-git-merge-review-rework-design.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Layer B — Multi-worktree merge cockpit
|
|
||||||
|
|
||||||
```
|
|
||||||
We're reworking ClaudeDo's merge/review UX. Your job is Layer B: a multi-worktree merge
|
|
||||||
cockpit. The overall design is in docs/superpowers/specs/2026-06-05-git-merge-review-rework-design.md
|
|
||||||
(read the "Layer B" section and "Parallel boundaries" table first). A shared foundation
|
|
||||||
commit ("add conflict-resolution worker contract") is already on main — branch from it.
|
|
||||||
|
|
||||||
First, create an isolated worktree for this work (use the superpowers:using-git-worktrees
|
|
||||||
skill). Then write a plan (superpowers:writing-plans) for just Layer B and implement it
|
|
||||||
with superpowers:subagent-driven-development (sonnet subagents, TDD, commit per task).
|
|
||||||
|
|
||||||
Scope:
|
|
||||||
- Rework WorktreesOverviewModalView + WorktreesOverviewModalViewModel into a batch-merge
|
|
||||||
cockpit: list mergeable worktrees, multi-select N, pick ONE target branch, "Merge all".
|
|
||||||
- Skip-and-continue: loop the EXISTING IWorkerClient.MergeTaskAsync(taskId, target,
|
|
||||||
removeWorktree:false, msg) over the selected tasks. Clean ones merge; conflicting ones
|
|
||||||
(MergeTaskAsync returns Status=="conflict", auto-aborts leaving the tree clean) are
|
|
||||||
collected into a "needs resolution" list shown with live progress.
|
|
||||||
- Each conflict row gets a "Resolve" button that invokes a seam:
|
|
||||||
public Func<string, string, Task>? RequestConflictResolution { get; set; } // (taskId, targetBranch)
|
|
||||||
Define this callback property on the cockpit VM; leave it unwired (the orchestrator
|
|
||||||
wires it to Layer C's resolver at merge time). Do NOT reference any ConflictResolver
|
|
||||||
type.
|
|
||||||
- Migrate WorktreeModalView's bespoke inline diff onto the canonical DiffLinesView
|
|
||||||
control (src/ClaudeDo.Ui/Views/Controls/DiffLinesView.axaml) using DiffFileViewModel/
|
|
||||||
DiffLineViewModel/UnifiedDiffParser (src/ClaudeDo.Ui/ViewModels/Modals/). This removes
|
|
||||||
the last duplicate diff renderer.
|
|
||||||
|
|
||||||
Reuse these existing IWorkerClient methods (already implemented): MergeTaskAsync,
|
|
||||||
GetMergeTargetsAsync, GetWorktreesOverviewAsync, SetWorktreeStateAsync,
|
|
||||||
CleanupFinishedWorktreesAsync, ForceRemoveWorktreeAsync.
|
|
||||||
|
|
||||||
Do NOT touch (other layers own them): any worker-side files (WorkerHub, TaskMergeService,
|
|
||||||
GitService), IWorkerClient.cs / WorkerClient.cs, WorkConsole.axaml,
|
|
||||||
DetailsIslandViewModel.cs, or create the ConflictResolver UI.
|
|
||||||
|
|
||||||
Build with: dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release (a running
|
|
||||||
Worker locks Debug — use Release). Keep locales/en.json and de.json keys in parity if you
|
|
||||||
add any. If you change IWorkerClient (you shouldn't need to), update the hand-rolled fakes
|
|
||||||
in tests/ClaudeDo.Ui.Tests/StubWorkerClient.cs and
|
|
||||||
tests/ClaudeDo.Worker.Tests/UiVm/TasksIslandViewModelPlanningTests.cs. No tests that spawn
|
|
||||||
the real claude CLI.
|
|
||||||
|
|
||||||
Commit per task with Conventional Commits. Do NOT push to main and do NOT merge — leave
|
|
||||||
your worktree/branch for the orchestrator. Flag any AXAML layout for visual verification
|
|
||||||
rather than claiming it works.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Layer C — Inline conflict resolver
|
|
||||||
|
|
||||||
```
|
|
||||||
We're reworking ClaudeDo's merge/review UX. Your job is Layer C: an in-app, VSCode-style
|
|
||||||
inline conflict resolver, plus the worker plumbing it needs. The overall design is in
|
|
||||||
docs/superpowers/specs/2026-06-05-git-merge-review-rework-design.md (read the "Layer C",
|
|
||||||
"Frozen worker conflict contract", and "Parallel boundaries" sections first). A shared
|
|
||||||
foundation commit ("add conflict-resolution worker contract") is already on main — branch
|
|
||||||
from it. That commit already wired the CLIENT side (IWorkerClient + WorkerClient call
|
|
||||||
these hub methods by name); your job includes implementing the matching WORKER hub methods.
|
|
||||||
|
|
||||||
First, create an isolated worktree (superpowers:using-git-worktrees). Then write a plan
|
|
||||||
(superpowers:writing-plans) for Layer C and implement it with
|
|
||||||
superpowers:subagent-driven-development (sonnet subagents, TDD, commit per task).
|
|
||||||
|
|
||||||
Worker side — implement these 5 hub methods in WorkerHub (names/params/returns MUST match
|
|
||||||
the client calls already shipped in the foundation):
|
|
||||||
- StartConflictMerge(string taskId, string targetBranch) -> MergeResultDto
|
|
||||||
Calls TaskMergeService.MergeAsync with leaveConflictsInTree:true (the overload/flag
|
|
||||||
already exists — used today by PlanningMergeOrchestrator). Leaves .git/MERGE_HEAD in
|
|
||||||
the list's WorkingDir, returns Status="conflict" + conflict file list.
|
|
||||||
- GetMergeConflicts(string taskId) -> MergeConflictsDto
|
|
||||||
For each conflicted file (git diff --name-only --diff-filter=U), read ours/theirs/base
|
|
||||||
via `git show :2:<path>` / `:3:<path>` / `:1:<path>`. Add GitService helpers as needed.
|
|
||||||
- WriteConflictResolution(string taskId, string path, string resolvedContent) -> void
|
|
||||||
Write resolvedContent to the file in WorkingDir and `git add` it.
|
|
||||||
- ContinueMerge(string taskId) -> MergeResultDto
|
|
||||||
Wrap the EXISTING TaskMergeService.ContinueMergeAsync (git add -A → re-check
|
|
||||||
diff --diff-filter=U → git commit). Currently service-level only; expose it on the hub.
|
|
||||||
- AbortMerge(string taskId) -> void
|
|
||||||
Wrap the EXISTING TaskMergeService.AbortMergeAsync (git merge --abort).
|
|
||||||
|
|
||||||
Define worker-side DTO records that serialize identically to the client records already in
|
|
||||||
WorkerClient.cs:
|
|
||||||
MergeConflictsDto(string TaskId, IReadOnlyList<ConflictFileDto> Files)
|
|
||||||
ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks)
|
|
||||||
ConflictHunkDto(string Ours, string Theirs, string? Base)
|
|
||||||
(place beside the other hub DTOs in WorkerHub.cs). MergeResultDto already exists.
|
|
||||||
|
|
||||||
UI side — new files only:
|
|
||||||
- ConflictResolverViewModel + ConflictResolverView. On open: StartConflictMergeAsync then
|
|
||||||
GetMergeConflictsAsync(taskId). Per conflict hunk show ours vs theirs stacked with
|
|
||||||
buttons Accept Current / Accept Incoming / Accept Both / Edit manually, plus a free-text
|
|
||||||
box for the merged result of that hunk. Use the UI conflict model from the design
|
|
||||||
(ConflictFile { Path, Hunks[] }, ConflictHunk { Ours, Theirs, Base, Resolution }) —
|
|
||||||
shape it so a future 3-way pane needs no model change.
|
|
||||||
- When every file is resolved: WriteConflictResolutionAsync per file, then
|
|
||||||
ContinueMergeAsync(taskId) (Status "merged" closes; "conflict" means not fully resolved,
|
|
||||||
stay open). AbortMergeAsync(taskId) cancels.
|
|
||||||
- Expose a factory Func<string, ConflictResolverViewModel> and a
|
|
||||||
Func<ConflictResolverViewModel, Task> ShowConflictResolver dialog delegate for the
|
|
||||||
orchestrator to wire to Layer A/B's RequestConflictResolution(taskId, target) seams.
|
|
||||||
|
|
||||||
Do NOT touch (other layers own them): WorkerClient.cs, IWorkerClient.cs (already wired),
|
|
||||||
WorkConsole.axaml, DetailsIslandViewModel.cs, WorktreesOverviewModalView/VM. You WILL need
|
|
||||||
to add the 5 worker hub methods + GitService conflict reads.
|
|
||||||
|
|
||||||
Tests: add worker tests for the conflict reads / continue / abort using real SQLite + real
|
|
||||||
git (follow existing GitService/TaskMergeService test patterns). NEVER spawn the real
|
|
||||||
claude CLI. If you change IWorkerClient (you should NOT — client is frozen), update the
|
|
||||||
fakes in both test projects.
|
|
||||||
|
|
||||||
Build with: dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release and
|
|
||||||
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release (a running Worker locks
|
|
||||||
Debug). Keep locales/en.json and de.json in parity for any new UI strings.
|
|
||||||
|
|
||||||
Commit per task with Conventional Commits. Do NOT push to main and do NOT merge — leave
|
|
||||||
your worktree/branch for the orchestrator. Flag the resolver UI for visual verification.
|
|
||||||
```
|
|
||||||
@@ -1,920 +0,0 @@
|
|||||||
# Layer C — Inline Conflict Resolver Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Build the worker-side conflict plumbing (5 frozen hub methods + GitService reads) and a VSCode-style in-app inline conflict resolver UI for ClaudeDo's merge rework.
|
|
||||||
|
|
||||||
**Architecture:** The worker performs a real merge that leaves conflicts in the list's working tree (`leaveConflictsInTree:true`), exposes ours/theirs/base per conflicted file via `git show :2:/:3:/:1:`, accepts written resolutions, and finishes via the existing `ContinueMergeAsync`/`AbortMergeAsync`. The UI presents each conflicted file's hunk with Accept Current/Incoming/Both/Edit-manually controls plus a free-text merged box, then writes resolutions and continues.
|
|
||||||
|
|
||||||
**Tech Stack:** .NET 8, ASP.NET Core SignalR (WorkerHub), EF Core/SQLite, Avalonia MVVM (CommunityToolkit), xUnit + real git/SQLite fixtures.
|
|
||||||
|
|
||||||
**Frozen client contract (already shipped in foundation commit `2dfc455`, DO NOT edit):**
|
|
||||||
- `IWorkerClient` / `WorkerClient.cs` already call hub methods by name: `StartConflictMerge`, `GetMergeConflicts`, `WriteConflictResolution`, `ContinueMerge`, `AbortMerge`.
|
|
||||||
- Client DTOs already exist in `WorkerClient.cs`: `MergeConflictsDto(string TaskId, IReadOnlyList<ConflictFileDto> Files)`, `ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks)`, `ConflictHunkDto(string Ours, string Theirs, string? Base)`, plus existing `MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage)`.
|
|
||||||
- Worker-side DTOs must serialize identically (same record shape) and live in `WorkerHub.cs`.
|
|
||||||
|
|
||||||
**Do NOT touch:** `WorkerClient.cs`, `Interfaces/IWorkerClient.cs`, `WorkConsole.axaml`, `DetailsIslandViewModel.cs`, `WorktreesOverviewModalView/VM`, `WorktreeModalView`. Test fakes for `IWorkerClient` already implement the 5 methods as no-op stubs (`StubWorkerClient` is `virtual` in Ui.Tests) — subclass/override, never edit the interface.
|
|
||||||
|
|
||||||
**Build/test commands (.NET 8 — running Worker locks `Debug`, always `-c Release`):**
|
|
||||||
```bash
|
|
||||||
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
|
|
||||||
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
|
|
||||||
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
|
|
||||||
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
|
|
||||||
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
**Worker / Data (create + modify):**
|
|
||||||
- Modify `src/ClaudeDo.Data/Git/GitService.cs` — add `ShowStageAsync` (untrimmed blob read) + `AddPathAsync`; add `trimOutput` param to `RunGitAsync`.
|
|
||||||
- Modify `src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs` — add records `MergeConflicts`/`ConflictFileContent`; add `GetConflictsAsync` + `WriteResolutionAsync`.
|
|
||||||
- Modify `src/ClaudeDo.Worker/Hub/WorkerHub.cs` — add DTOs `MergeConflictsDto`/`ConflictFileDto`/`ConflictHunkDto` + 5 hub methods.
|
|
||||||
|
|
||||||
**UI (create new only):**
|
|
||||||
- Create `src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictModels.cs` — `ConflictFile`, `ConflictHunk`.
|
|
||||||
- Create `src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictResolverViewModel.cs`.
|
|
||||||
- Create `src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml` + `.axaml.cs`.
|
|
||||||
|
|
||||||
**Wiring (modify):**
|
|
||||||
- Modify `src/ClaudeDo.App/Program.cs` — register `ConflictResolverViewModel` + `Func<string, ConflictResolverViewModel>`.
|
|
||||||
- Modify `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs` — additive seam (`ConflictResolverFactory`, `ShowConflictResolver`, `RequestConflictResolutionAsync`).
|
|
||||||
- Modify `src/ClaudeDo.Ui/Views/MainWindow.axaml.cs` — wire `ShowConflictResolver` dialog delegate.
|
|
||||||
- Modify `src/ClaudeDo.Localization/locales/en.json` + `de.json` — `conflictResolver.*` keys (parity enforced by Localization.Tests).
|
|
||||||
|
|
||||||
**Tests (create + modify):**
|
|
||||||
- Modify `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs` — conflict-read / write-resolution / round-trip tests.
|
|
||||||
- Create `tests/ClaudeDo.Ui.Tests/ViewModels/ConflictResolverViewModelTests.cs`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 1: GitService conflict-blob reads
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/ClaudeDo.Data/Git/GitService.cs`
|
|
||||||
- Test: `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs` (GitService exercised here via real repo; add focused tests in Task 2 round-trip)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add `trimOutput` param to `RunGitAsync`** so blob reads keep exact bytes.
|
|
||||||
|
|
||||||
In `RunGitAsync` signature add `bool trimOutput = true`, and change the return to:
|
|
||||||
```csharp
|
|
||||||
return (proc.ExitCode, trimOutput ? stdout.TrimEnd() : stdout, stderr.TrimEnd());
|
|
||||||
```
|
|
||||||
(All existing callers keep the default `true`.)
|
|
||||||
|
|
||||||
- [ ] **Step 2: Add `ShowStageAsync` + `AddPathAsync`** (place after `ListConflictedFilesAsync`):
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
/// <summary>
|
|
||||||
/// Reads a conflicted file's blob at a merge stage: 1=base, 2=ours, 3=theirs.
|
|
||||||
/// Returns null when the stage doesn't exist (e.g. add/add conflict has no base).
|
|
||||||
/// Output is NOT trimmed so file content round-trips exactly.
|
|
||||||
/// </summary>
|
|
||||||
public async Task<string?> ShowStageAsync(string repoDir, int stage, string path, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var (exitCode, stdout, _) = await RunGitAsync(repoDir, ["show", $":{stage}:{path}"], ct, trimOutput: false);
|
|
||||||
return exitCode == 0 ? stdout : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task AddPathAsync(string repoDir, string path, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["add", "--", path], ct);
|
|
||||||
if (exitCode != 0)
|
|
||||||
throw new InvalidOperationException($"git add '{path}' failed (exit {exitCode}): {stderr}");
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Build the Data + Worker projects to verify compilation.**
|
|
||||||
|
|
||||||
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`
|
|
||||||
Expected: Build succeeded, 0 errors.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/ClaudeDo.Data/Git/GitService.cs
|
|
||||||
git commit -m "feat(git): add conflict-stage blob reads and single-path staging"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 2: TaskMergeService conflict reads + resolution writes
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs`
|
|
||||||
- Test: `tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing tests** (append inside `TaskMergeServiceTests`, before `#region Test doubles`). Reuse the existing helpers `SeedListAndTask`, `SeedWorktree`, `BuildService`, and the `GitRepoFixture` conflict setup pattern from `ContinueMergeAsync_AfterUserResolves...`.
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
[Fact]
|
|
||||||
public async Task GetConflictsAsync_AfterConflictMerge_ReturnsOursAndTheirs()
|
|
||||||
{
|
|
||||||
if (!GitRepoFixture.IsGitAvailable()) return;
|
|
||||||
|
|
||||||
var db = NewDb();
|
|
||||||
var repo = NewRepo();
|
|
||||||
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
|
||||||
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n");
|
|
||||||
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change");
|
|
||||||
|
|
||||||
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
|
||||||
_wtCleanups.Add((repo.RepoDir, wtPath));
|
|
||||||
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/c1", wtPath, repo.BaseCommit);
|
|
||||||
File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n");
|
|
||||||
GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch change");
|
|
||||||
|
|
||||||
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.WaitingForReview);
|
|
||||||
await SeedWorktree(db, task.Id, wtPath, "claudedo/c1", repo.BaseCommit);
|
|
||||||
|
|
||||||
var (svc, _) = BuildService(db);
|
|
||||||
var start = await svc.MergeAsync(task.Id, "main", false, "msg", leaveConflictsInTree: true, CancellationToken.None);
|
|
||||||
Assert.Equal(TaskMergeService.StatusConflict, start.Status);
|
|
||||||
|
|
||||||
var conflicts = await svc.GetConflictsAsync(task.Id, CancellationToken.None);
|
|
||||||
|
|
||||||
Assert.Equal(task.Id, conflicts.TaskId);
|
|
||||||
var file = Assert.Single(conflicts.Files);
|
|
||||||
Assert.Equal("README.md", file.Path);
|
|
||||||
Assert.Contains("main change", file.Ours); // ours = target (main) side after checkout
|
|
||||||
Assert.Contains("branch change", file.Theirs); // theirs = merged-in branch
|
|
||||||
Assert.NotNull(file.Base);
|
|
||||||
|
|
||||||
GitRepoFixture.RunGit(repo.RepoDir, "merge", "--abort");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task WriteResolutionAsync_ThenContinue_CompletesMerge()
|
|
||||||
{
|
|
||||||
if (!GitRepoFixture.IsGitAvailable()) return;
|
|
||||||
|
|
||||||
var db = NewDb();
|
|
||||||
var repo = NewRepo();
|
|
||||||
GitRepoFixture.RunGit(repo.RepoDir, "branch", "-m", "main");
|
|
||||||
File.WriteAllText(Path.Combine(repo.RepoDir, "README.md"), "# main change\n");
|
|
||||||
GitRepoFixture.RunGit(repo.RepoDir, "commit", "-am", "main change");
|
|
||||||
|
|
||||||
var wtPath = Path.Combine(Path.GetTempPath(), $"wt_{Guid.NewGuid():N}");
|
|
||||||
_wtCleanups.Add((repo.RepoDir, wtPath));
|
|
||||||
GitRepoFixture.RunGit(repo.RepoDir, "worktree", "add", "-b", "claudedo/c2", wtPath, repo.BaseCommit);
|
|
||||||
File.WriteAllText(Path.Combine(wtPath, "README.md"), "# branch change\n");
|
|
||||||
GitRepoFixture.RunGit(wtPath, "commit", "-am", "branch change");
|
|
||||||
|
|
||||||
var (_, task) = await SeedListAndTask(db, workingDir: repo.RepoDir, status: TaskStatus.WaitingForReview);
|
|
||||||
await SeedWorktree(db, task.Id, wtPath, "claudedo/c2", repo.BaseCommit);
|
|
||||||
|
|
||||||
var (svc, _) = BuildService(db);
|
|
||||||
await svc.MergeAsync(task.Id, "main", false, "msg", leaveConflictsInTree: true, CancellationToken.None);
|
|
||||||
|
|
||||||
await svc.WriteResolutionAsync(task.Id, "README.md", "# resolved by user\n", CancellationToken.None);
|
|
||||||
var result = await svc.ContinueMergeAsync(task.Id, CancellationToken.None);
|
|
||||||
|
|
||||||
Assert.Equal(TaskMergeService.StatusMerged, result.Status);
|
|
||||||
Assert.Equal("# resolved by user\n", File.ReadAllText(Path.Combine(repo.RepoDir, "README.md")));
|
|
||||||
Assert.False(await new GitService().IsMidMergeAsync(repo.RepoDir));
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run tests to verify they fail** (no such methods).
|
|
||||||
|
|
||||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "GetConflictsAsync_AfterConflictMerge_ReturnsOursAndTheirs|WriteResolutionAsync_ThenContinue_CompletesMerge"`
|
|
||||||
Expected: compile error / FAIL (methods don't exist).
|
|
||||||
|
|
||||||
- [ ] **Step 3: Add records + methods to `TaskMergeService.cs`.**
|
|
||||||
|
|
||||||
Add records beside `MergeResult` (top of file, after the existing record declarations):
|
|
||||||
```csharp
|
|
||||||
public sealed record MergeConflicts(
|
|
||||||
string TaskId,
|
|
||||||
IReadOnlyList<ConflictFileContent> Files);
|
|
||||||
|
|
||||||
public sealed record ConflictFileContent(
|
|
||||||
string Path,
|
|
||||||
string Ours,
|
|
||||||
string Theirs,
|
|
||||||
string? Base);
|
|
||||||
```
|
|
||||||
|
|
||||||
Add methods inside the class (after `AbortMergeAsync`):
|
|
||||||
```csharp
|
|
||||||
public async Task<MergeConflicts> GetConflictsAsync(string taskId, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var (_, list, _) = await LoadMergeContextAsync(taskId, ct);
|
|
||||||
if (string.IsNullOrWhiteSpace(list.WorkingDir))
|
|
||||||
throw new InvalidOperationException("list has no working directory");
|
|
||||||
|
|
||||||
var files = await _git.ListConflictedFilesAsync(list.WorkingDir, ct);
|
|
||||||
var result = new List<ConflictFileContent>(files.Count);
|
|
||||||
foreach (var path in files)
|
|
||||||
{
|
|
||||||
var ours = await _git.ShowStageAsync(list.WorkingDir, 2, path, ct) ?? "";
|
|
||||||
var theirs = await _git.ShowStageAsync(list.WorkingDir, 3, path, ct) ?? "";
|
|
||||||
var @base = await _git.ShowStageAsync(list.WorkingDir, 1, path, ct);
|
|
||||||
result.Add(new ConflictFileContent(path, ours, theirs, @base));
|
|
||||||
}
|
|
||||||
return new MergeConflicts(taskId, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task WriteResolutionAsync(string taskId, string path, string content, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var (_, list, _) = await LoadMergeContextAsync(taskId, ct);
|
|
||||||
if (string.IsNullOrWhiteSpace(list.WorkingDir))
|
|
||||||
throw new InvalidOperationException("list has no working directory");
|
|
||||||
|
|
||||||
var full = Path.Combine(list.WorkingDir, path.Replace('/', Path.DirectorySeparatorChar));
|
|
||||||
await File.WriteAllTextAsync(full, content, ct);
|
|
||||||
await _git.AddPathAsync(list.WorkingDir, path, ct);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
(Note: `Path` is `System.IO.Path` — the file already uses it via other helpers; the record property `Path` does not shadow it inside these methods because it's accessed as a static type, not an instance member.)
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run the tests to verify they pass.**
|
|
||||||
|
|
||||||
Run: `dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release --filter "GetConflictsAsync_AfterConflictMerge_ReturnsOursAndTheirs|WriteResolutionAsync_ThenContinue_CompletesMerge"`
|
|
||||||
Expected: PASS (2 tests). If git unavailable they no-op.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/ClaudeDo.Worker/Lifecycle/TaskMergeService.cs tests/ClaudeDo.Worker.Tests/Services/TaskMergeServiceTests.cs
|
|
||||||
git commit -m "feat(merge): read conflict stages and write user resolutions"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 3: WorkerHub conflict methods + DTOs
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/ClaudeDo.Worker/Hub/WorkerHub.cs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add DTOs** beside the existing merge DTOs (after `public record MergeTargetsDto(...)`):
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public record MergeConflictsDto(string TaskId, IReadOnlyList<ConflictFileDto> Files);
|
|
||||||
public record ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks);
|
|
||||||
public record ConflictHunkDto(string Ours, string Theirs, string? Base);
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Add the 5 hub methods** (after `PreviewMerge`). Names/params/returns MUST match the frozen client calls.
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public Task<MergeResultDto> StartConflictMerge(string taskId, string targetBranch)
|
|
||||||
=> HubGuard(async () =>
|
|
||||||
{
|
|
||||||
var r = await _mergeService.MergeAsync(
|
|
||||||
taskId, targetBranch ?? "", removeWorktree: false, "Merge task",
|
|
||||||
leaveConflictsInTree: true, CancellationToken.None);
|
|
||||||
if (r.Status == TaskMergeService.StatusBlocked)
|
|
||||||
throw new HubException(r.ErrorMessage ?? "merge blocked");
|
|
||||||
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
|
|
||||||
});
|
|
||||||
|
|
||||||
public Task<MergeConflictsDto> GetMergeConflicts(string taskId)
|
|
||||||
=> HubGuard(async () =>
|
|
||||||
{
|
|
||||||
var c = await _mergeService.GetConflictsAsync(taskId, CancellationToken.None);
|
|
||||||
return new MergeConflictsDto(
|
|
||||||
c.TaskId,
|
|
||||||
c.Files.Select(f => new ConflictFileDto(
|
|
||||||
f.Path,
|
|
||||||
new[] { new ConflictHunkDto(f.Ours, f.Theirs, f.Base) })).ToList());
|
|
||||||
});
|
|
||||||
|
|
||||||
public Task WriteConflictResolution(string taskId, string path, string resolvedContent)
|
|
||||||
=> HubGuard(() => _mergeService.WriteResolutionAsync(
|
|
||||||
taskId, path, resolvedContent ?? "", CancellationToken.None));
|
|
||||||
|
|
||||||
public Task<MergeResultDto> ContinueMerge(string taskId)
|
|
||||||
=> HubGuard(async () =>
|
|
||||||
{
|
|
||||||
var r = await _mergeService.ContinueMergeAsync(taskId, CancellationToken.None);
|
|
||||||
if (r.Status == TaskMergeService.StatusBlocked)
|
|
||||||
throw new HubException(r.ErrorMessage ?? "continue failed");
|
|
||||||
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
|
|
||||||
});
|
|
||||||
|
|
||||||
public Task AbortMerge(string taskId)
|
|
||||||
=> HubGuard(async () =>
|
|
||||||
{
|
|
||||||
var r = await _mergeService.AbortMergeAsync(taskId, CancellationToken.None);
|
|
||||||
if (r.Status == TaskMergeService.StatusBlocked)
|
|
||||||
throw new HubException(r.ErrorMessage ?? "abort failed");
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Build the Worker project to verify compilation.**
|
|
||||||
|
|
||||||
Run: `dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release`
|
|
||||||
Expected: Build succeeded, 0 errors.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/ClaudeDo.Worker/Hub/WorkerHub.cs
|
|
||||||
git commit -m "feat(hub): expose conflict-resolution merge methods"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 4: Conflict UI model
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictModels.cs`
|
|
||||||
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/ConflictResolverViewModelTests.cs` (model tests added here in Task 5; this task is build-verified)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Create the model file.** Shaped so a 3-way pane needs no model change (`Base` retained per hunk).
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
|
||||||
using CommunityToolkit.Mvvm.Input;
|
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.ViewModels.Conflicts;
|
|
||||||
|
|
||||||
public sealed partial class ConflictHunk : ObservableObject
|
|
||||||
{
|
|
||||||
public string Ours { get; }
|
|
||||||
public string Theirs { get; }
|
|
||||||
public string? Base { get; }
|
|
||||||
|
|
||||||
[ObservableProperty] private string? _resolution;
|
|
||||||
|
|
||||||
public bool IsResolved => Resolution is not null;
|
|
||||||
|
|
||||||
public ConflictHunk(string ours, string theirs, string? @base)
|
|
||||||
{
|
|
||||||
Ours = ours;
|
|
||||||
Theirs = theirs;
|
|
||||||
Base = @base;
|
|
||||||
}
|
|
||||||
|
|
||||||
partial void OnResolutionChanged(string? value) => OnPropertyChanged(nameof(IsResolved));
|
|
||||||
|
|
||||||
[RelayCommand] private void AcceptCurrent() => Resolution = Ours;
|
|
||||||
[RelayCommand] private void AcceptIncoming() => Resolution = Theirs;
|
|
||||||
[RelayCommand] private void AcceptBoth() => Resolution = Ours + Theirs;
|
|
||||||
[RelayCommand] private void EditManually() => Resolution ??= Ours;
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class ConflictFile
|
|
||||||
{
|
|
||||||
public string Path { get; }
|
|
||||||
public IReadOnlyList<ConflictHunk> Hunks { get; }
|
|
||||||
|
|
||||||
public ConflictFile(string path, IReadOnlyList<ConflictHunk> hunks)
|
|
||||||
{
|
|
||||||
Path = path;
|
|
||||||
Hunks = hunks;
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool AllHunksResolved => Hunks.Count > 0 && Hunks.All(h => h.IsResolved);
|
|
||||||
|
|
||||||
/// <summary>The merged file content: concatenation of each hunk's resolution
|
|
||||||
/// (single whole-file hunk today; concatenation keeps it correct for multi-hunk later).</summary>
|
|
||||||
public string ComposeResolvedContent() => string.Concat(Hunks.Select(h => h.Resolution));
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Build the Ui project to verify compilation.**
|
|
||||||
|
|
||||||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
|
||||||
Expected: Build succeeded.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictModels.cs
|
|
||||||
git commit -m "feat(ui): add inline conflict model (file/hunk with resolution)"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 5: ConflictResolverViewModel
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictResolverViewModel.cs`
|
|
||||||
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/ConflictResolverViewModelTests.cs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write failing tests.** Subclass the existing `StubWorkerClient` (its conflict methods are `virtual`).
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using ClaudeDo.Ui.Services;
|
|
||||||
using ClaudeDo.Ui.ViewModels.Conflicts;
|
|
||||||
using Xunit;
|
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.Tests.ViewModels;
|
|
||||||
|
|
||||||
public class ConflictResolverViewModelTests
|
|
||||||
{
|
|
||||||
private sealed class FakeWorker : StubWorkerClient
|
|
||||||
{
|
|
||||||
public string? WrittenPath;
|
|
||||||
public string? WrittenContent;
|
|
||||||
public bool Continued;
|
|
||||||
public bool Aborted;
|
|
||||||
public string ContinueStatus = "merged";
|
|
||||||
|
|
||||||
public override Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch)
|
|
||||||
=> Task.FromResult(new MergeResultDto("conflict", new[] { "README.md" }, null));
|
|
||||||
|
|
||||||
public override Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId)
|
|
||||||
=> Task.FromResult(new MergeConflictsDto(taskId, new[]
|
|
||||||
{
|
|
||||||
new ConflictFileDto("README.md", new[] { new ConflictHunkDto("ours\n", "theirs\n", "base\n") })
|
|
||||||
}));
|
|
||||||
|
|
||||||
public override Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent)
|
|
||||||
{
|
|
||||||
WrittenPath = path; WrittenContent = resolvedContent; return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override Task<MergeResultDto> ContinueMergeAsync(string taskId)
|
|
||||||
{
|
|
||||||
Continued = true;
|
|
||||||
return Task.FromResult(new MergeResultDto(ContinueStatus, System.Array.Empty<string>(), null));
|
|
||||||
}
|
|
||||||
|
|
||||||
public override Task AbortMergeAsync(string taskId) { Aborted = true; return Task.CompletedTask; }
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task OpenAsync_LoadsConflicts_AndBlocksContinueUntilResolved()
|
|
||||||
{
|
|
||||||
var vm = new ConflictResolverViewModel(new FakeWorker(), "task-1");
|
|
||||||
var hasConflicts = await vm.OpenAsync("main");
|
|
||||||
|
|
||||||
Assert.True(hasConflicts);
|
|
||||||
var file = Assert.Single(vm.Files);
|
|
||||||
Assert.Equal("README.md", file.Path);
|
|
||||||
Assert.False(vm.CanContinue); // nothing resolved yet
|
|
||||||
|
|
||||||
file.Hunks[0].AcceptIncomingCommand.Execute(null);
|
|
||||||
Assert.True(vm.CanContinue); // every hunk resolved
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Continue_WritesComposedResolution_AndClosesOnMerged()
|
|
||||||
{
|
|
||||||
var worker = new FakeWorker();
|
|
||||||
var vm = new ConflictResolverViewModel(worker, "task-1");
|
|
||||||
var closed = false;
|
|
||||||
vm.CloseRequested = () => closed = true;
|
|
||||||
|
|
||||||
await vm.OpenAsync("main");
|
|
||||||
vm.Files[0].Hunks[0].AcceptCurrentCommand.Execute(null); // resolution = "ours\n"
|
|
||||||
await vm.ContinueCommand.ExecuteAsync(null);
|
|
||||||
|
|
||||||
Assert.Equal("README.md", worker.WrittenPath);
|
|
||||||
Assert.Equal("ours\n", worker.WrittenContent);
|
|
||||||
Assert.True(worker.Continued);
|
|
||||||
Assert.True(closed);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Continue_StaysOpenAndReportsError_WhenStillConflicted()
|
|
||||||
{
|
|
||||||
var worker = new FakeWorker { ContinueStatus = "conflict" };
|
|
||||||
var vm = new ConflictResolverViewModel(worker, "task-1");
|
|
||||||
var closed = false;
|
|
||||||
vm.CloseRequested = () => closed = true;
|
|
||||||
|
|
||||||
await vm.OpenAsync("main");
|
|
||||||
vm.Files[0].Hunks[0].AcceptBothCommand.Execute(null);
|
|
||||||
await vm.ContinueCommand.ExecuteAsync(null);
|
|
||||||
|
|
||||||
Assert.False(closed);
|
|
||||||
Assert.NotNull(vm.Error);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task Abort_CallsWorkerAndCloses()
|
|
||||||
{
|
|
||||||
var worker = new FakeWorker();
|
|
||||||
var vm = new ConflictResolverViewModel(worker, "task-1");
|
|
||||||
var closed = false;
|
|
||||||
vm.CloseRequested = () => closed = true;
|
|
||||||
|
|
||||||
await vm.AbortCommand.ExecuteAsync(null);
|
|
||||||
|
|
||||||
Assert.True(worker.Aborted);
|
|
||||||
Assert.True(closed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run tests to verify they fail** (VM not defined).
|
|
||||||
|
|
||||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "ConflictResolverViewModelTests"`
|
|
||||||
Expected: compile error / FAIL.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Implement the ViewModel.**
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using System;
|
|
||||||
using System.Collections.ObjectModel;
|
|
||||||
using System.ComponentModel;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
|
||||||
using CommunityToolkit.Mvvm.Input;
|
|
||||||
using ClaudeDo.Ui.Services;
|
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.ViewModels.Conflicts;
|
|
||||||
|
|
||||||
public sealed partial class ConflictResolverViewModel : ObservableObject
|
|
||||||
{
|
|
||||||
private readonly IWorkerClient _worker;
|
|
||||||
private readonly string _taskId;
|
|
||||||
|
|
||||||
public ObservableCollection<ConflictFile> Files { get; } = new();
|
|
||||||
|
|
||||||
[ObservableProperty] private bool _isBusy;
|
|
||||||
[ObservableProperty] private string? _error;
|
|
||||||
[ObservableProperty] private bool _canContinue;
|
|
||||||
|
|
||||||
public string TaskId => _taskId;
|
|
||||||
public Action? CloseRequested { get; set; }
|
|
||||||
|
|
||||||
public ConflictResolverViewModel(IWorkerClient worker, string taskId)
|
|
||||||
{
|
|
||||||
_worker = worker;
|
|
||||||
_taskId = taskId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Starts the conflict merge and loads ours/theirs/base per file.
|
|
||||||
/// Returns true when there are conflicts to resolve (caller should show the dialog).</summary>
|
|
||||||
public async Task<bool> OpenAsync(string targetBranch)
|
|
||||||
{
|
|
||||||
IsBusy = true;
|
|
||||||
Error = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var start = await _worker.StartConflictMergeAsync(_taskId, targetBranch);
|
|
||||||
if (!string.Equals(start.Status, "conflict", StringComparison.Ordinal))
|
|
||||||
{
|
|
||||||
if (string.Equals(start.Status, "blocked", StringComparison.Ordinal))
|
|
||||||
Error = start.ErrorMessage;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var conflicts = await _worker.GetMergeConflictsAsync(_taskId);
|
|
||||||
Files.Clear();
|
|
||||||
foreach (var f in conflicts.Files)
|
|
||||||
{
|
|
||||||
var hunks = f.Hunks.Select(h =>
|
|
||||||
{
|
|
||||||
var hk = new ConflictHunk(h.Ours, h.Theirs, h.Base);
|
|
||||||
hk.PropertyChanged += OnHunkChanged;
|
|
||||||
return hk;
|
|
||||||
}).ToList();
|
|
||||||
Files.Add(new ConflictFile(f.Path, hunks));
|
|
||||||
}
|
|
||||||
RecomputeCanContinue();
|
|
||||||
return Files.Count > 0;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Error = ex.Message;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
finally { IsBusy = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnHunkChanged(object? sender, PropertyChangedEventArgs e)
|
|
||||||
{
|
|
||||||
if (e.PropertyName is nameof(ConflictHunk.IsResolved) or nameof(ConflictHunk.Resolution))
|
|
||||||
RecomputeCanContinue();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RecomputeCanContinue()
|
|
||||||
=> CanContinue = Files.Count > 0 && Files.All(f => f.AllHunksResolved);
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private async Task ContinueAsync()
|
|
||||||
{
|
|
||||||
if (!CanContinue) return;
|
|
||||||
IsBusy = true;
|
|
||||||
Error = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
foreach (var file in Files)
|
|
||||||
await _worker.WriteConflictResolutionAsync(_taskId, file.Path, file.ComposeResolvedContent());
|
|
||||||
|
|
||||||
var result = await _worker.ContinueMergeAsync(_taskId);
|
|
||||||
if (string.Equals(result.Status, "merged", StringComparison.Ordinal))
|
|
||||||
CloseRequested?.Invoke();
|
|
||||||
else
|
|
||||||
Error = result.ErrorMessage ?? "Conflicts not fully resolved — review and retry.";
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Error = ex.Message;
|
|
||||||
}
|
|
||||||
finally { IsBusy = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private async Task AbortAsync()
|
|
||||||
{
|
|
||||||
IsBusy = true;
|
|
||||||
try { await _worker.AbortMergeAsync(_taskId); }
|
|
||||||
catch (Exception ex) { Error = ex.Message; }
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
IsBusy = false;
|
|
||||||
CloseRequested?.Invoke();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run tests to verify they pass.**
|
|
||||||
|
|
||||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter "ConflictResolverViewModelTests"`
|
|
||||||
Expected: PASS (4 tests).
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/ClaudeDo.Ui/ViewModels/Conflicts/ConflictResolverViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/ConflictResolverViewModelTests.cs
|
|
||||||
git commit -m "feat(ui): add inline conflict resolver view-model"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 6: ConflictResolverView + localization
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml`
|
|
||||||
- Create: `src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml.cs`
|
|
||||||
- Modify: `src/ClaudeDo.Localization/locales/en.json`
|
|
||||||
- Modify: `src/ClaudeDo.Localization/locales/de.json`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add localization keys** to `en.json` as a new top-level section (sibling of `"planning"`):
|
|
||||||
|
|
||||||
```json
|
|
||||||
"conflictResolver": {
|
|
||||||
"windowTitle": "Resolve merge conflicts",
|
|
||||||
"modalTitle": "RESOLVE CONFLICTS",
|
|
||||||
"loading": "Loading conflicts…",
|
|
||||||
"current": "Current (ours)",
|
|
||||||
"incoming": "Incoming (theirs)",
|
|
||||||
"mergedResult": "Merged result",
|
|
||||||
"acceptCurrent": "Accept Current",
|
|
||||||
"acceptIncoming": "Accept Incoming",
|
|
||||||
"acceptBoth": "Accept Both",
|
|
||||||
"editManually": "Edit manually",
|
|
||||||
"continue": "Resolve & continue",
|
|
||||||
"abort": "Abort merge"
|
|
||||||
},
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Add the SAME keys to `de.json`** (German values, identical key set — parity enforced by Localization.Tests):
|
|
||||||
|
|
||||||
```json
|
|
||||||
"conflictResolver": {
|
|
||||||
"windowTitle": "Merge-Konflikte lösen",
|
|
||||||
"modalTitle": "KONFLIKTE LÖSEN",
|
|
||||||
"loading": "Konflikte werden geladen…",
|
|
||||||
"current": "Aktuell (unsere)",
|
|
||||||
"incoming": "Eingehend (ihre)",
|
|
||||||
"mergedResult": "Zusammengeführtes Ergebnis",
|
|
||||||
"acceptCurrent": "Aktuelle übernehmen",
|
|
||||||
"acceptIncoming": "Eingehende übernehmen",
|
|
||||||
"acceptBoth": "Beide übernehmen",
|
|
||||||
"editManually": "Manuell bearbeiten",
|
|
||||||
"continue": "Lösen & fortfahren",
|
|
||||||
"abort": "Merge abbrechen"
|
|
||||||
},
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Create the View** (`ConflictResolverView.axaml`). A `Window` using `ModalShell`, mirroring `ConflictResolutionView.axaml`. Two stacked read-only boxes (ours/theirs), a button row, and a two-way merged-result box per hunk.
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<Window xmlns="https://github.com/avaloniaui"
|
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
||||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Conflicts"
|
|
||||||
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
|
|
||||||
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
|
||||||
x:DataType="vm:ConflictResolverViewModel"
|
|
||||||
x:Class="ClaudeDo.Ui.Views.Conflicts.ConflictResolverView"
|
|
||||||
Title="{loc:Tr conflictResolver.windowTitle}"
|
|
||||||
Width="760" Height="640" MinWidth="560" MinHeight="420"
|
|
||||||
CanResize="True"
|
|
||||||
WindowDecorations="BorderOnly"
|
|
||||||
ExtendClientAreaToDecorationsHint="True"
|
|
||||||
ExtendClientAreaTitleBarHeightHint="-1"
|
|
||||||
WindowStartupLocation="CenterOwner"
|
|
||||||
Background="{DynamicResource SurfaceBrush}">
|
|
||||||
|
|
||||||
<Window.KeyBindings>
|
|
||||||
<KeyBinding Gesture="Escape" Command="{Binding AbortCommand}"/>
|
|
||||||
</Window.KeyBindings>
|
|
||||||
|
|
||||||
<ctl:ModalShell Title="{loc:Tr conflictResolver.modalTitle}" CloseCommand="{Binding AbortCommand}">
|
|
||||||
<ctl:ModalShell.Footer>
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8"
|
|
||||||
HorizontalAlignment="Right" VerticalAlignment="Center">
|
|
||||||
<Button Classes="btn" Content="{loc:Tr conflictResolver.continue}"
|
|
||||||
Command="{Binding ContinueCommand}" IsEnabled="{Binding CanContinue}"/>
|
|
||||||
<Button Classes="btn" Content="{loc:Tr conflictResolver.abort}" Command="{Binding AbortCommand}"/>
|
|
||||||
</StackPanel>
|
|
||||||
</ctl:ModalShell.Footer>
|
|
||||||
|
|
||||||
<Grid RowDefinitions="Auto,*" Margin="16,12">
|
|
||||||
<TextBlock Grid.Row="0" Classes="meta" Margin="0,0,0,8"
|
|
||||||
Text="{loc:Tr conflictResolver.loading}"
|
|
||||||
IsVisible="{Binding IsBusy}"/>
|
|
||||||
<TextBlock Grid.Row="0" Classes="meta" Foreground="{DynamicResource BloodBrush}"
|
|
||||||
Text="{Binding Error}" TextWrapping="Wrap"
|
|
||||||
IsVisible="{Binding Error, Converter={x:Static ObjectConverters.IsNotNull}}"/>
|
|
||||||
|
|
||||||
<ScrollViewer Grid.Row="1">
|
|
||||||
<ItemsControl ItemsSource="{Binding Files}">
|
|
||||||
<ItemsControl.ItemTemplate>
|
|
||||||
<DataTemplate x:DataType="vm:ConflictFile">
|
|
||||||
<StackPanel Spacing="8" Margin="0,0,0,16">
|
|
||||||
<TextBlock Classes="path-mono heading" Text="{Binding Path}"/>
|
|
||||||
<ItemsControl ItemsSource="{Binding Hunks}">
|
|
||||||
<ItemsControl.ItemTemplate>
|
|
||||||
<DataTemplate x:DataType="vm:ConflictHunk">
|
|
||||||
<Border BorderBrush="{DynamicResource BorderBrush}" BorderThickness="1"
|
|
||||||
CornerRadius="6" Padding="10" Margin="0,0,0,8">
|
|
||||||
<StackPanel Spacing="6">
|
|
||||||
<TextBlock Classes="meta" Text="{loc:Tr conflictResolver.current}"/>
|
|
||||||
<TextBox Text="{Binding Ours, Mode=OneWay}" IsReadOnly="True"
|
|
||||||
TextWrapping="NoWrap" AcceptsReturn="True" MaxHeight="120"
|
|
||||||
FontFamily="{DynamicResource MonoFont}"/>
|
|
||||||
<TextBlock Classes="meta" Text="{loc:Tr conflictResolver.incoming}"/>
|
|
||||||
<TextBox Text="{Binding Theirs, Mode=OneWay}" IsReadOnly="True"
|
|
||||||
TextWrapping="NoWrap" AcceptsReturn="True" MaxHeight="120"
|
|
||||||
FontFamily="{DynamicResource MonoFont}"/>
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
|
||||||
<Button Classes="btn" Content="{loc:Tr conflictResolver.acceptCurrent}"
|
|
||||||
Command="{Binding AcceptCurrentCommand}"/>
|
|
||||||
<Button Classes="btn" Content="{loc:Tr conflictResolver.acceptIncoming}"
|
|
||||||
Command="{Binding AcceptIncomingCommand}"/>
|
|
||||||
<Button Classes="btn" Content="{loc:Tr conflictResolver.acceptBoth}"
|
|
||||||
Command="{Binding AcceptBothCommand}"/>
|
|
||||||
<Button Classes="btn" Content="{loc:Tr conflictResolver.editManually}"
|
|
||||||
Command="{Binding EditManuallyCommand}"/>
|
|
||||||
</StackPanel>
|
|
||||||
<TextBlock Classes="meta" Text="{loc:Tr conflictResolver.mergedResult}"/>
|
|
||||||
<TextBox Text="{Binding Resolution, Mode=TwoWay}"
|
|
||||||
TextWrapping="NoWrap" AcceptsReturn="True" MinHeight="80" MaxHeight="200"
|
|
||||||
FontFamily="{DynamicResource MonoFont}"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
</DataTemplate>
|
|
||||||
</ItemsControl.ItemTemplate>
|
|
||||||
</ItemsControl>
|
|
||||||
</StackPanel>
|
|
||||||
</DataTemplate>
|
|
||||||
</ItemsControl.ItemTemplate>
|
|
||||||
</ItemsControl>
|
|
||||||
</ScrollViewer>
|
|
||||||
</Grid>
|
|
||||||
</ctl:ModalShell>
|
|
||||||
</Window>
|
|
||||||
```
|
|
||||||
**Note for the implementer:** if `MonoFont` / `path-mono` / `heading` / `meta` / `btn` resource keys or style classes don't resolve at build, drop the `FontFamily` attribute and unknown `Classes` (keep `btn`) — match whatever the existing `ConflictResolutionView.axaml` and app styles actually expose. Verify against `src/ClaudeDo.Ui/Views/Planning/ConflictResolutionView.axaml` and the app's style resources before finalizing.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Create the code-behind** (`ConflictResolverView.axaml.cs`):
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using Avalonia.Controls;
|
|
||||||
using ClaudeDo.Ui.ViewModels.Conflicts;
|
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.Views.Conflicts;
|
|
||||||
|
|
||||||
public partial class ConflictResolverView : Window
|
|
||||||
{
|
|
||||||
public ConflictResolverView()
|
|
||||||
{
|
|
||||||
InitializeComponent();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void OnDataContextChanged(System.EventArgs e)
|
|
||||||
{
|
|
||||||
base.OnDataContextChanged(e);
|
|
||||||
if (DataContext is ConflictResolverViewModel vm)
|
|
||||||
vm.CloseRequested = Close;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Build the App + run Localization tests.**
|
|
||||||
|
|
||||||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release && dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release`
|
|
||||||
Expected: Build succeeded; localization parity tests PASS.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml src/ClaudeDo.Ui/Views/Conflicts/ConflictResolverView.axaml.cs src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json
|
|
||||||
git commit -m "feat(ui): add inline conflict resolver view and localization"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 7: Wire factory + dialog seam for the integrator
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/ClaudeDo.App/Program.cs`
|
|
||||||
- Modify: `src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs`
|
|
||||||
- Modify: `src/ClaudeDo.Ui/Views/MainWindow.axaml.cs`
|
|
||||||
|
|
||||||
These are additive seams only. The integrator connects Layer A/B's `RequestConflictResolution(taskId, target)` callback to `IslandsShellViewModel.RequestConflictResolutionAsync`.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Register the factory in `Program.cs`** (in the ViewModels region, near the other `Func<>` factories). Only the `Func<>` factory is needed — the VM is never resolved directly:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
sc.AddSingleton<Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>>(sp =>
|
|
||||||
taskId => new ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel(
|
|
||||||
sp.GetRequiredService<WorkerClient>(), taskId));
|
|
||||||
```
|
|
||||||
Then, after `IslandsShellViewModel` is registered, set the factory on it once resolved. Replace the existing `sc.AddSingleton<IslandsShellViewModel>();` registration with a factory that injects the conflict-resolver factory:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
sc.AddSingleton<IslandsShellViewModel>(sp =>
|
|
||||||
{
|
|
||||||
var shell = ActivatorUtilities.CreateInstance<IslandsShellViewModel>(sp);
|
|
||||||
shell.ConflictResolverFactory =
|
|
||||||
sp.GetRequiredService<Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>>();
|
|
||||||
return shell;
|
|
||||||
});
|
|
||||||
```
|
|
||||||
(`ActivatorUtilities.CreateInstance` resolves the existing big constructor + its `Func<>` deps exactly as the default registration did.)
|
|
||||||
|
|
||||||
- [ ] **Step 2: Add the additive seam to `IslandsShellViewModel`** (near the other `Show*` delegate properties):
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// Layer C seam: composition root sets the factory; MainWindow sets the dialog opener.
|
|
||||||
// The integrator connects Layer A/B's RequestConflictResolution(taskId, target) to this method.
|
|
||||||
public Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>? ConflictResolverFactory { get; set; }
|
|
||||||
public Func<ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel, Task>? ShowConflictResolver { get; set; }
|
|
||||||
|
|
||||||
public async Task RequestConflictResolutionAsync(string taskId, string targetBranch)
|
|
||||||
{
|
|
||||||
if (ConflictResolverFactory is null || ShowConflictResolver is null) return;
|
|
||||||
var vm = ConflictResolverFactory(taskId);
|
|
||||||
var hasConflicts = await vm.OpenAsync(targetBranch);
|
|
||||||
if (hasConflicts)
|
|
||||||
await ShowConflictResolver(vm);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
(Add `using ClaudeDo.Ui.ViewModels.Conflicts;` or use fully-qualified names as above.)
|
|
||||||
|
|
||||||
- [ ] **Step 3: Wire the dialog opener in `MainWindow.axaml.cs`** inside `OnDataContextChanged`, alongside the other `vm.Show*` assignments:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
vm.ShowConflictResolver = async (resolverVm) =>
|
|
||||||
{
|
|
||||||
var dlg = new ClaudeDo.Ui.Views.Conflicts.ConflictResolverView { DataContext = resolverVm };
|
|
||||||
await dlg.ShowDialog(this);
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Build the App to verify compilation.**
|
|
||||||
|
|
||||||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
|
||||||
Expected: Build succeeded.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/ClaudeDo.App/Program.cs src/ClaudeDo.Ui/ViewModels/IslandsShellViewModel.cs src/ClaudeDo.Ui/Views/MainWindow.axaml.cs
|
|
||||||
git commit -m "feat(ui): expose conflict-resolver factory and dialog seam for integrator"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 8: Full verification
|
|
||||||
|
|
||||||
- [ ] **Step 1: Build both head projects.**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
```bash
|
|
||||||
dotnet build src/ClaudeDo.Worker/ClaudeDo.Worker.csproj -c Release
|
|
||||||
dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release
|
|
||||||
```
|
|
||||||
Expected: both Build succeeded, 0 errors/warnings.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run the full relevant test suites.**
|
|
||||||
|
|
||||||
Run:
|
|
||||||
```bash
|
|
||||||
dotnet test tests/ClaudeDo.Worker.Tests/ClaudeDo.Worker.Tests.csproj -c Release
|
|
||||||
dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release
|
|
||||||
dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release
|
|
||||||
```
|
|
||||||
Expected: all PASS.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Flag visual verification.** The resolver dialog cannot be opened end-to-end until the integrator wires Layer A/B's `RequestConflictResolution(taskId, target)` → `IslandsShellViewModel.RequestConflictResolutionAsync`. Report this as a visual-verification gap for the user/integrator: open a real conflicting merge, confirm hunks render, Accept buttons populate the merged box, Resolve & continue closes on success, Abort restores the tree.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Leave the branch for the orchestrator.** Do NOT push, do NOT merge to main.
|
|
||||||
@@ -1,837 +0,0 @@
|
|||||||
# Layer B — Multi-Worktree Merge Cockpit Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Turn the worktrees-overview modal into a batch-merge cockpit (multi-select N worktrees → one target branch → "Merge all" with skip-and-continue conflict collection), and migrate `WorktreeModalView`'s bespoke inline diff onto the canonical `DiffLinesView`.
|
|
||||||
|
|
||||||
**Architecture:** The cockpit VM keeps depending on the concrete `WorkerClient` (the overview/cleanup/state methods live only on `WorkerClient`, not `IWorkerClient`). The batch loop is extracted into a delegate-driven method `MergeSelectedAsync(Func<...> mergeFn)` so it is unit-testable with a fake merge function and a never-connected `WorkerClient`. Clean merges (`Status=="merged"`) update the row; conflicts (`Status=="conflict"`, which `MergeTaskAsync` already auto-aborts) are collected into a `ConflictRows` list whose rows expose a `Resolve` button wired to an inert `RequestConflictResolution(taskId, targetBranch)` seam. The diff migration replaces the right-pane `ItemsControl` in `WorktreeModalView` with `DiffLinesView`, feeding it `DiffLineViewModel`s produced by `UnifiedDiffParser`, and deletes the now-dead `WorktreeDiffLineViewModel`/`WorktreeDiffLineKind`.
|
|
||||||
|
|
||||||
**Tech Stack:** .NET 8, Avalonia 12, CommunityToolkit.Mvvm source generators, xUnit. Build UI with `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`; run `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`.
|
|
||||||
|
|
||||||
**Frozen contracts reused (do NOT modify):**
|
|
||||||
- `WorkerClient.MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage) -> Task<MergeResultDto>`
|
|
||||||
- `WorkerClient.GetMergeTargetsAsync(string taskId) -> Task<MergeTargetsDto?>` (`MergeTargetsDto(string DefaultBranch, IReadOnlyList<string> LocalBranches)`)
|
|
||||||
- `MergeResultDto(string Status, IReadOnlyList<string> ConflictFiles, string? ErrorMessage)` — `Status` is `"merged" | "conflict" | "blocked" | <other>`
|
|
||||||
- `WorkerClient.GetWorktreesOverviewAsync`, `CleanupFinishedWorktreesAsync`, `SetWorktreeStateAsync`, `ForceRemoveWorktreeAsync`
|
|
||||||
- `GitService.GetFileDiffAsync(worktreePath, baseCommit?, relativePath)` returns a `git diff` blob including the `diff --git` header (so `UnifiedDiffParser.Parse` handles it)
|
|
||||||
- `DiffLinesView` (`Lines` styled property, `IEnumerable?`), `DiffLineViewModel`, `DiffFileViewModel`, `UnifiedDiffParser.Parse` / `.Flatten`
|
|
||||||
|
|
||||||
**Do NOT touch:** any worker-side files (`WorkerHub`, `TaskMergeService`, `GitService`), `IWorkerClient.cs` / `WorkerClient.cs`, `WorkConsole.axaml`, `DetailsIslandViewModel.cs`, and do not create any `ConflictResolver` UI or reference any `ConflictResolver` type.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
- `src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs` — **modify.** Add `BatchMergeOutcome` enum; add `IsChecked`/`MergeOutcome` (+ derived) to the row VM; add `MergeTargets`, `SelectedTarget`, `SelectedCount`, `IsMerging`, `BatchProgress`, `ConflictRows`, the `RequestConflictResolution` seam, `MergeSelectedAsync`, `MergeAllCommand`, `ResolveConflictCommand`, `ToggleSelectAllCommand`, target loading, and per-row check subscription. Keep all existing context-menu commands/wiring intact.
|
|
||||||
- `src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml` — **modify.** Add a per-row checkbox + outcome badge, a target `ComboBox` + "Merge all" button + progress text in the toolbar, and a "Needs resolution" panel listing `ConflictRows` with `Resolve` buttons.
|
|
||||||
- `src/ClaudeDo.Ui/ViewModels/Modals/WorktreeModalViewModel.cs` — **modify.** Replace `SelectedFileDiffLines` element type with `DiffLineViewModel` produced via `UnifiedDiffParser`; delete `WorktreeDiffLineKind` and `WorktreeDiffLineViewModel`.
|
|
||||||
- `src/ClaudeDo.Ui/Views/Modals/WorktreeModalView.axaml` — **modify.** Replace the right-pane `ItemsControl` with `ctl:DiffLinesView`; drop the `DiffLineKindToBrushConverter` resource.
|
|
||||||
- `src/ClaudeDo.Localization/locales/en.json` + `de.json` — **modify.** Add new `modals.worktreesOverview.*` and `vm.worktreesOverview.*` keys (keep parity).
|
|
||||||
- `tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs` — **create.** Unit tests for `MergeSelectedAsync` skip-and-continue, conflict collection, progress, selection gating, and the resolve seam.
|
|
||||||
|
|
||||||
No `IWorkerClient` change → no test-fake updates needed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 1: Row-level batch state (outcome enum + row VM fields)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs`
|
|
||||||
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing test**
|
|
||||||
|
|
||||||
Create `tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using ClaudeDo.Data.Models;
|
|
||||||
using ClaudeDo.Ui.ViewModels.Modals;
|
|
||||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
||||||
using Xunit;
|
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.Tests.ViewModels;
|
|
||||||
|
|
||||||
public class WorktreesOverviewBatchMergeTests
|
|
||||||
{
|
|
||||||
private static WorktreeOverviewRowViewModel ActiveRow(string id) => new()
|
|
||||||
{
|
|
||||||
TaskId = id,
|
|
||||||
TaskTitle = $"Task {id}",
|
|
||||||
TaskStatus = TaskStatus.WaitingForReview,
|
|
||||||
State = WorktreeState.Active,
|
|
||||||
};
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Row_outcome_helpers_reflect_state()
|
|
||||||
{
|
|
||||||
var row = ActiveRow("a");
|
|
||||||
Assert.Equal(BatchMergeOutcome.None, row.MergeOutcome);
|
|
||||||
Assert.False(row.IsConflict);
|
|
||||||
|
|
||||||
row.MergeOutcome = BatchMergeOutcome.Conflict;
|
|
||||||
Assert.True(row.IsConflict);
|
|
||||||
|
|
||||||
row.MergeOutcome = BatchMergeOutcome.Merged;
|
|
||||||
Assert.False(row.IsConflict);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run test to verify it fails**
|
|
||||||
|
|
||||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter WorktreesOverviewBatchMergeTests`
|
|
||||||
Expected: FAIL — `BatchMergeOutcome` and `MergeOutcome`/`IsConflict` do not exist (compile error).
|
|
||||||
|
|
||||||
- [ ] **Step 3: Add the enum and row fields**
|
|
||||||
|
|
||||||
In `WorktreesOverviewModalViewModel.cs`, add the enum just above `WorktreeOverviewRowViewModel`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public enum BatchMergeOutcome { None, Merging, Merged, Conflict, Blocked, Failed }
|
|
||||||
```
|
|
||||||
|
|
||||||
Inside `WorktreeOverviewRowViewModel`, add after the existing `_isSelected` field:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
[ObservableProperty] private bool _isChecked;
|
|
||||||
[ObservableProperty]
|
|
||||||
[NotifyPropertyChangedFor(nameof(IsConflict))]
|
|
||||||
[NotifyPropertyChangedFor(nameof(HasOutcome))]
|
|
||||||
private BatchMergeOutcome _mergeOutcome;
|
|
||||||
|
|
||||||
public bool IsConflict => MergeOutcome == BatchMergeOutcome.Conflict;
|
|
||||||
public bool HasOutcome => MergeOutcome != BatchMergeOutcome.None;
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run test to verify it passes**
|
|
||||||
|
|
||||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter WorktreesOverviewBatchMergeTests`
|
|
||||||
Expected: PASS (1 test).
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs
|
|
||||||
git commit -m "feat(ui): add batch-merge row state to worktrees cockpit VM"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 2: Batch orchestration (`MergeSelectedAsync` skip-and-continue)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs`
|
|
||||||
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing tests**
|
|
||||||
|
|
||||||
Append to `WorktreesOverviewBatchMergeTests.cs`. The helper builds a VM with a never-connected `WorkerClient` (the loop never touches it) and seeds `Rows` directly:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
private static WorktreesOverviewModalViewModel NewVm() =>
|
|
||||||
new(new ClaudeDo.Ui.Services.WorkerClient("http://127.0.0.1:1/hub"), () => null!);
|
|
||||||
|
|
||||||
private static MergeResultDto Merged() => new("merged", System.Array.Empty<string>(), null);
|
|
||||||
private static MergeResultDto Conflict() => new("conflict", new[] { "f.cs" }, null);
|
|
||||||
private static MergeResultDto Blocked() => new("blocked", System.Array.Empty<string>(), "blocked");
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async System.Threading.Tasks.Task MergeSelected_only_processes_checked_active_rows()
|
|
||||||
{
|
|
||||||
var vm = NewVm();
|
|
||||||
var a = ActiveRow("a"); a.IsChecked = true;
|
|
||||||
var b = ActiveRow("b"); b.IsChecked = false; // unchecked -> skipped
|
|
||||||
var c = ActiveRow("c"); c.IsChecked = true; c.State = WorktreeState.Merged; // not active -> skipped
|
|
||||||
vm.Rows.Add(a); vm.Rows.Add(b); vm.Rows.Add(c);
|
|
||||||
vm.SelectedTarget = "main";
|
|
||||||
|
|
||||||
var seen = new System.Collections.Generic.List<string>();
|
|
||||||
await vm.MergeSelectedAsync((id, target, remove, msg) =>
|
|
||||||
{
|
|
||||||
seen.Add(id);
|
|
||||||
Assert.Equal("main", target);
|
|
||||||
Assert.False(remove); // removeWorktree must be false
|
|
||||||
return System.Threading.Tasks.Task.FromResult(Merged());
|
|
||||||
});
|
|
||||||
|
|
||||||
Assert.Equal(new[] { "a" }, seen);
|
|
||||||
Assert.Equal(BatchMergeOutcome.Merged, a.MergeOutcome);
|
|
||||||
Assert.False(a.IsChecked); // cleared after merge
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async System.Threading.Tasks.Task MergeSelected_continues_past_conflict_and_collects_it()
|
|
||||||
{
|
|
||||||
var vm = NewVm();
|
|
||||||
var a = ActiveRow("a"); a.IsChecked = true;
|
|
||||||
var b = ActiveRow("b"); b.IsChecked = true;
|
|
||||||
var c = ActiveRow("c"); c.IsChecked = true;
|
|
||||||
vm.Rows.Add(a); vm.Rows.Add(b); vm.Rows.Add(c);
|
|
||||||
vm.SelectedTarget = "main";
|
|
||||||
|
|
||||||
await vm.MergeSelectedAsync((id, target, remove, msg) =>
|
|
||||||
System.Threading.Tasks.Task.FromResult(id == "b" ? Conflict() : Merged()));
|
|
||||||
|
|
||||||
Assert.Equal(BatchMergeOutcome.Merged, a.MergeOutcome);
|
|
||||||
Assert.Equal(BatchMergeOutcome.Conflict, b.MergeOutcome);
|
|
||||||
Assert.Equal(BatchMergeOutcome.Merged, c.MergeOutcome); // continued past the conflict
|
|
||||||
Assert.Contains(b, vm.ConflictRows);
|
|
||||||
Assert.Single(vm.ConflictRows);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async System.Threading.Tasks.Task MergeSelected_maps_blocked_and_exception_to_failure_outcomes()
|
|
||||||
{
|
|
||||||
var vm = NewVm();
|
|
||||||
var a = ActiveRow("a"); a.IsChecked = true;
|
|
||||||
var b = ActiveRow("b"); b.IsChecked = true;
|
|
||||||
vm.Rows.Add(a); vm.Rows.Add(b);
|
|
||||||
vm.SelectedTarget = "main";
|
|
||||||
|
|
||||||
await vm.MergeSelectedAsync((id, target, remove, msg) => id == "a"
|
|
||||||
? System.Threading.Tasks.Task.FromResult(Blocked())
|
|
||||||
: throw new System.InvalidOperationException("boom"));
|
|
||||||
|
|
||||||
Assert.Equal(BatchMergeOutcome.Blocked, a.MergeOutcome);
|
|
||||||
Assert.Equal(BatchMergeOutcome.Failed, b.MergeOutcome);
|
|
||||||
Assert.Empty(vm.ConflictRows);
|
|
||||||
Assert.False(vm.IsMerging);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async System.Threading.Tasks.Task MergeSelected_noop_when_no_target()
|
|
||||||
{
|
|
||||||
var vm = NewVm();
|
|
||||||
var a = ActiveRow("a"); a.IsChecked = true;
|
|
||||||
vm.Rows.Add(a);
|
|
||||||
vm.SelectedTarget = null;
|
|
||||||
|
|
||||||
var called = false;
|
|
||||||
await vm.MergeSelectedAsync((id, t, r, m) => { called = true; return System.Threading.Tasks.Task.FromResult(Merged()); });
|
|
||||||
|
|
||||||
Assert.False(called);
|
|
||||||
Assert.Equal(BatchMergeOutcome.None, a.MergeOutcome);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run tests to verify they fail**
|
|
||||||
|
|
||||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter WorktreesOverviewBatchMergeTests`
|
|
||||||
Expected: FAIL — `MergeSelectedAsync`, `ConflictRows`, `IsMerging`, `SelectedTarget` do not exist (compile error).
|
|
||||||
|
|
||||||
- [ ] **Step 3: Implement the orchestration + cockpit fields**
|
|
||||||
|
|
||||||
In `WorktreesOverviewModalViewModel.cs`, add these `using`s if missing: `using ClaudeDo.Ui.Services;` (already present). Add fields/properties to `WorktreesOverviewModalViewModel` (after the existing `_selectedRow` field):
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(MergeAllCommand))] private string? _selectedTarget;
|
|
||||||
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(MergeAllCommand))] private int _selectedCount;
|
|
||||||
[ObservableProperty][NotifyCanExecuteChangedFor(nameof(MergeAllCommand))] private bool _isMerging;
|
|
||||||
[ObservableProperty] private string? _batchProgress;
|
|
||||||
|
|
||||||
public ObservableCollection<string> MergeTargets { get; } = new();
|
|
||||||
public ObservableCollection<WorktreeOverviewRowViewModel> ConflictRows { get; } = new();
|
|
||||||
|
|
||||||
/// Inert seam wired by the integrator to Layer C's resolver at merge time. (taskId, targetBranch)
|
|
||||||
public Func<string, string, Task>? RequestConflictResolution { get; set; }
|
|
||||||
```
|
|
||||||
|
|
||||||
Add a helper to enumerate rows regardless of grouped/flat mode, plus the orchestration method:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public IEnumerable<WorktreeOverviewRowViewModel> AllRows =>
|
|
||||||
IsGlobal ? Groups.SelectMany(g => g.Rows) : Rows;
|
|
||||||
|
|
||||||
public async Task MergeSelectedAsync(
|
|
||||||
Func<string, string, bool, string, Task<MergeResultDto>> mergeFn,
|
|
||||||
CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var target = SelectedTarget;
|
|
||||||
if (string.IsNullOrWhiteSpace(target)) return;
|
|
||||||
|
|
||||||
var selected = AllRows.Where(r => r.IsChecked && r.IsActive).ToList();
|
|
||||||
if (selected.Count == 0) return;
|
|
||||||
|
|
||||||
IsMerging = true;
|
|
||||||
ConflictRows.Clear();
|
|
||||||
var done = 0;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
foreach (var row in selected)
|
|
||||||
{
|
|
||||||
ct.ThrowIfCancellationRequested();
|
|
||||||
row.MergeOutcome = BatchMergeOutcome.Merging;
|
|
||||||
BatchProgress = Loc.T("vm.worktreesOverview.batchProgress", ++done, selected.Count);
|
|
||||||
|
|
||||||
MergeResultDto result;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
result = await mergeFn(row.TaskId, target!, false,
|
|
||||||
Loc.T("vm.merge.commitMessage", row.TaskTitle));
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
row.MergeOutcome = BatchMergeOutcome.Failed;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (result.Status)
|
|
||||||
{
|
|
||||||
case "merged":
|
|
||||||
row.MergeOutcome = BatchMergeOutcome.Merged;
|
|
||||||
row.State = WorktreeState.Merged;
|
|
||||||
row.IsChecked = false;
|
|
||||||
break;
|
|
||||||
case "conflict":
|
|
||||||
row.MergeOutcome = BatchMergeOutcome.Conflict;
|
|
||||||
ConflictRows.Add(row);
|
|
||||||
break;
|
|
||||||
case "blocked":
|
|
||||||
row.MergeOutcome = BatchMergeOutcome.Blocked;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
row.MergeOutcome = BatchMergeOutcome.Failed;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BatchProgress = Loc.T("vm.worktreesOverview.batchDone",
|
|
||||||
selected.Count(r => r.MergeOutcome == BatchMergeOutcome.Merged), ConflictRows.Count);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
IsMerging = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
> Note: `Loc.T` keys are added in Task 5; they resolve to the key name (harmless) until then, so tests pass now.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run tests to verify they pass**
|
|
||||||
|
|
||||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter WorktreesOverviewBatchMergeTests`
|
|
||||||
Expected: PASS (5 tests).
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs
|
|
||||||
git commit -m "feat(ui): add skip-and-continue batch merge orchestration"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 3: Selection tracking, target loading, commands + resolve seam
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs`
|
|
||||||
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing tests**
|
|
||||||
|
|
||||||
Append to `WorktreesOverviewBatchMergeTests.cs`:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
[Fact]
|
|
||||||
public void SelectedCount_tracks_checked_active_rows()
|
|
||||||
{
|
|
||||||
var vm = NewVm();
|
|
||||||
var a = ActiveRow("a");
|
|
||||||
var b = ActiveRow("b");
|
|
||||||
var merged = ActiveRow("c"); merged.State = WorktreeState.Merged;
|
|
||||||
vm.AddRowForTest(a); vm.AddRowForTest(b); vm.AddRowForTest(merged);
|
|
||||||
|
|
||||||
Assert.Equal(0, vm.SelectedCount);
|
|
||||||
a.IsChecked = true;
|
|
||||||
Assert.Equal(1, vm.SelectedCount);
|
|
||||||
b.IsChecked = true;
|
|
||||||
merged.IsChecked = true; // not active -> not counted
|
|
||||||
Assert.Equal(2, vm.SelectedCount);
|
|
||||||
a.IsChecked = false;
|
|
||||||
Assert.Equal(1, vm.SelectedCount);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ResolveConflict_invokes_seam_with_task_and_target()
|
|
||||||
{
|
|
||||||
var vm = NewVm();
|
|
||||||
vm.SelectedTarget = "release";
|
|
||||||
var row = ActiveRow("x"); row.MergeOutcome = BatchMergeOutcome.Conflict;
|
|
||||||
|
|
||||||
(string Task, string Target)? captured = null;
|
|
||||||
vm.RequestConflictResolution = (taskId, target) => { captured = (taskId, target); return System.Threading.Tasks.Task.CompletedTask; };
|
|
||||||
|
|
||||||
vm.ResolveConflictCommand.Execute(row);
|
|
||||||
|
|
||||||
Assert.Equal(("x", "release"), captured);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void MergeAll_canExecute_requires_target_selection_and_idle()
|
|
||||||
{
|
|
||||||
var vm = NewVm();
|
|
||||||
var a = ActiveRow("a");
|
|
||||||
vm.AddRowForTest(a);
|
|
||||||
|
|
||||||
Assert.False(vm.MergeAllCommand.CanExecute(null)); // no selection, no target
|
|
||||||
a.IsChecked = true;
|
|
||||||
Assert.False(vm.MergeAllCommand.CanExecute(null)); // still no target
|
|
||||||
vm.SelectedTarget = "main";
|
|
||||||
Assert.True(vm.MergeAllCommand.CanExecute(null));
|
|
||||||
vm.IsMerging = true;
|
|
||||||
Assert.False(vm.MergeAllCommand.CanExecute(null)); // busy
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run tests to verify they fail**
|
|
||||||
|
|
||||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter WorktreesOverviewBatchMergeTests`
|
|
||||||
Expected: FAIL — `AddRowForTest`, `ResolveConflictCommand`, `MergeAllCommand` do not exist (compile error).
|
|
||||||
|
|
||||||
- [ ] **Step 3: Implement subscription, commands, target loading**
|
|
||||||
|
|
||||||
In `WorktreesOverviewModalViewModel.cs`:
|
|
||||||
|
|
||||||
(a) Add a row-hook that recomputes `SelectedCount` when a row's `IsChecked` changes, and a test seam to add a hooked row. Add these methods to the class:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
private void HookRow(WorktreeOverviewRowViewModel row)
|
|
||||||
{
|
|
||||||
row.PropertyChanged += (_, e) =>
|
|
||||||
{
|
|
||||||
if (e.PropertyName is nameof(WorktreeOverviewRowViewModel.IsChecked)
|
|
||||||
or nameof(WorktreeOverviewRowViewModel.State))
|
|
||||||
RecomputeSelected();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RecomputeSelected() =>
|
|
||||||
SelectedCount = AllRows.Count(r => r.IsChecked && r.IsActive);
|
|
||||||
|
|
||||||
// Test seam: adds a row to the flat list with selection tracking wired up.
|
|
||||||
internal void AddRowForTest(WorktreeOverviewRowViewModel row)
|
|
||||||
{
|
|
||||||
HookRow(row);
|
|
||||||
Rows.Add(row);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
(b) In `LoadAsync`, call `HookRow(row)` everywhere a row is added. Replace the two add sites:
|
|
||||||
|
|
||||||
In the grouped branch, change `foreach (var row in grp) group.Rows.Add(row);` to:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
foreach (var row in grp) { HookRow(row); group.Rows.Add(row); }
|
|
||||||
```
|
|
||||||
|
|
||||||
In the flat branch, change `foreach (var row in ordered) Rows.Add(row);` to:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
foreach (var row in ordered) { HookRow(row); Rows.Add(row); }
|
|
||||||
```
|
|
||||||
|
|
||||||
Also, at the start of `LoadAsync` after `IsBusy = true;`, reset batch UI state and (re)load merge targets at the end of the `try`:
|
|
||||||
|
|
||||||
After `Rows.Clear(); Groups.Clear();` add:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
ConflictRows.Clear();
|
|
||||||
SelectedCount = 0;
|
|
||||||
BatchProgress = null;
|
|
||||||
```
|
|
||||||
|
|
||||||
At the very end of the `try` block (after the if/else that fills rows/groups) add:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
await LoadMergeTargetsAsync();
|
|
||||||
```
|
|
||||||
|
|
||||||
(c) Add target loading. The branch list is repo-level, so query it from the first active row:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
private async Task LoadMergeTargetsAsync()
|
|
||||||
{
|
|
||||||
var anchor = AllRows.FirstOrDefault(r => r.IsActive);
|
|
||||||
if (anchor is null) { MergeTargets.Clear(); SelectedTarget = null; return; }
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var targets = await _worker.GetMergeTargetsAsync(anchor.TaskId);
|
|
||||||
MergeTargets.Clear();
|
|
||||||
if (targets is null) { SelectedTarget = null; return; }
|
|
||||||
foreach (var b in targets.LocalBranches) MergeTargets.Add(b);
|
|
||||||
SelectedTarget = MergeTargets.Contains(targets.DefaultBranch)
|
|
||||||
? targets.DefaultBranch
|
|
||||||
: MergeTargets.FirstOrDefault();
|
|
||||||
}
|
|
||||||
catch { MergeTargets.Clear(); SelectedTarget = null; }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
(d) Add the commands:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
private bool CanMergeAll() => !IsMerging && SelectedCount > 0 && !string.IsNullOrWhiteSpace(SelectedTarget);
|
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(CanMergeAll))]
|
|
||||||
private Task MergeAll() => MergeSelectedAsync(_worker.MergeTaskAsync);
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void ResolveConflict(WorktreeOverviewRowViewModel? row)
|
|
||||||
{
|
|
||||||
if (row is null) return;
|
|
||||||
RequestConflictResolution?.Invoke(row.TaskId, SelectedTarget ?? "");
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void ToggleSelectAll()
|
|
||||||
{
|
|
||||||
var actives = AllRows.Where(r => r.IsActive).ToList();
|
|
||||||
var allChecked = actives.Count > 0 && actives.All(r => r.IsChecked);
|
|
||||||
foreach (var r in actives) r.IsChecked = !allChecked;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run tests to verify they pass**
|
|
||||||
|
|
||||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter WorktreesOverviewBatchMergeTests`
|
|
||||||
Expected: PASS (8 tests total in this file).
|
|
||||||
|
|
||||||
- [ ] **Step 5: Build the app project to confirm the VM compiles against generated commands**
|
|
||||||
|
|
||||||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
|
||||||
Expected: Build succeeded.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/ClaudeDo.Ui/ViewModels/Modals/WorktreesOverviewModalViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/WorktreesOverviewBatchMergeTests.cs
|
|
||||||
git commit -m "feat(ui): wire batch selection, target loading and resolve seam"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 4: Cockpit view — checkboxes, target picker, Merge all, conflicts panel
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml`
|
|
||||||
|
|
||||||
This task is AXAML only (no logic) → no new unit test; flag for visual verification.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add the batch toolbar controls**
|
|
||||||
|
|
||||||
In `WorktreesOverviewModalView.axaml`, replace the toolbar `StackPanel` (currently containing Refresh, Cleanup finished, StatusMessage) with one that adds select-all, the target picker, the Merge-all button and progress text. Replace the inner `<StackPanel Orientation="Horizontal" Spacing="8">...</StackPanel>` of the toolbar `Border` with:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
|
||||||
<Button Classes="btn" Content="{loc:Tr modals.worktreesOverview.refresh}" Command="{Binding RefreshCommand}" IsEnabled="{Binding !IsBusy}"/>
|
|
||||||
<Button Classes="btn" Content="{loc:Tr modals.worktreesOverview.cleanupFinished}" Command="{Binding CleanupFinishedCommand}" IsEnabled="{Binding !IsBusy}"/>
|
|
||||||
<Button Classes="btn" Content="{loc:Tr modals.worktreesOverview.selectAll}" Command="{Binding ToggleSelectAllCommand}"/>
|
|
||||||
<Border Width="1" Background="{DynamicResource LineBrush}" Margin="4,2"/>
|
|
||||||
<TextBlock Text="{loc:Tr modals.worktreesOverview.targetLabel}" VerticalAlignment="Center" Foreground="{DynamicResource TextDimBrush}"/>
|
|
||||||
<ComboBox MinWidth="160"
|
|
||||||
ItemsSource="{Binding MergeTargets}"
|
|
||||||
SelectedItem="{Binding SelectedTarget, Mode=TwoWay}"/>
|
|
||||||
<Button Classes="btn accent"
|
|
||||||
Content="{loc:Tr modals.worktreesOverview.mergeAll}"
|
|
||||||
Command="{Binding MergeAllCommand}"/>
|
|
||||||
<TextBlock Text="{Binding SelectedCount, StringFormat='{}{0} selected'}"
|
|
||||||
VerticalAlignment="Center" Foreground="{DynamicResource TextDimBrush}"/>
|
|
||||||
<TextBlock Text="{Binding BatchProgress}" VerticalAlignment="Center" Margin="8,0,0,0"
|
|
||||||
Foreground="{DynamicResource TextDimBrush}"/>
|
|
||||||
<TextBlock Text="{Binding StatusMessage}" VerticalAlignment="Center" Margin="8,0,0,0"
|
|
||||||
Foreground="{DynamicResource TextDimBrush}"/>
|
|
||||||
</StackPanel>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Add a checkbox + outcome badge to the row template**
|
|
||||||
|
|
||||||
In the `WorktreeRowTemplate` `DataTemplate`, change the row `Grid` to add a leading checkbox column and a trailing outcome column. Replace the `<Grid ColumnDefinitions="*,90,80,80">...</Grid>` (the whole grid, lines for Task/State/Diff/Age) with:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<Grid ColumnDefinitions="Auto,*,90,90,80,80">
|
|
||||||
<CheckBox Grid.Column="0" VerticalAlignment="Center" Margin="0,0,8,0"
|
|
||||||
IsChecked="{Binding IsChecked, Mode=TwoWay}"
|
|
||||||
IsEnabled="{Binding IsActive}"
|
|
||||||
IsVisible="{Binding IsActive}"/>
|
|
||||||
<StackPanel Grid.Column="1" Orientation="Vertical" Spacing="2">
|
|
||||||
<TextBlock Classes="title" Text="{Binding TaskTitle}"/>
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="4">
|
|
||||||
<TextBlock Classes="meta" Text="{Binding TaskStatus}"/>
|
|
||||||
<TextBlock Classes="meta" Text="•"
|
|
||||||
IsVisible="{Binding !PathExistsOnDisk}"/>
|
|
||||||
<TextBlock Classes="meta" Text="{loc:Tr modals.worktreesOverview.phantom}" Foreground="{DynamicResource StatusErrorBrush}"
|
|
||||||
IsVisible="{Binding !PathExistsOnDisk}"
|
|
||||||
ToolTip.Tip="{loc:Tr modals.worktreesOverview.phantomTooltip}"/>
|
|
||||||
</StackPanel>
|
|
||||||
</StackPanel>
|
|
||||||
<TextBlock Grid.Column="2" Classes="meta" VerticalAlignment="Center"
|
|
||||||
Text="{Binding MergeOutcome}"
|
|
||||||
IsVisible="{Binding HasOutcome}"/>
|
|
||||||
<Border Grid.Column="3" CornerRadius="3" Padding="6,2" VerticalAlignment="Center"
|
|
||||||
Background="{Binding State, Converter={StaticResource WorktreeStateColor}}">
|
|
||||||
<TextBlock Classes="meta" Text="{Binding State}" Foreground="{DynamicResource TextBrush}"
|
|
||||||
HorizontalAlignment="Center"/>
|
|
||||||
</Border>
|
|
||||||
<TextBlock Grid.Column="4" Classes="meta" Text="{Binding DiffStat}" VerticalAlignment="Center"/>
|
|
||||||
<TextBlock Grid.Column="5" Classes="meta" Text="{Binding AgeText}" VerticalAlignment="Center"/>
|
|
||||||
</Grid>
|
|
||||||
```
|
|
||||||
|
|
||||||
Then update the column-header `Grid` (the one with `ColumnDefinitions="*,90,80,80"` near the ScrollViewer top) to match the new column layout:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<Grid ColumnDefinitions="Auto,*,90,90,80,80" Margin="12,0,12,4">
|
|
||||||
<TextBlock Grid.Column="1" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnTask}"/>
|
|
||||||
<TextBlock Grid.Column="2" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnOutcome}"/>
|
|
||||||
<TextBlock Grid.Column="3" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnState}"/>
|
|
||||||
<TextBlock Grid.Column="4" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnDiff}"/>
|
|
||||||
<TextBlock Grid.Column="5" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnAge}"/>
|
|
||||||
</Grid>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Add the "Needs resolution" panel**
|
|
||||||
|
|
||||||
Inside the content `ScrollViewer`'s root `StackPanel`, at the very top (before the column-header `Grid`), add a conflicts panel that only shows when there are conflicts:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<Border IsVisible="{Binding ConflictRows.Count}"
|
|
||||||
Background="{DynamicResource ErrorTintBrush}"
|
|
||||||
BorderBrush="{DynamicResource StatusErrorBrush}"
|
|
||||||
BorderThickness="1" CornerRadius="6" Padding="12,8" Margin="0,0,0,12">
|
|
||||||
<StackPanel Spacing="6">
|
|
||||||
<TextBlock Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.needsResolution}"/>
|
|
||||||
<ItemsControl ItemsSource="{Binding ConflictRows}">
|
|
||||||
<ItemsControl.ItemTemplate>
|
|
||||||
<DataTemplate x:DataType="vm:WorktreeOverviewRowViewModel">
|
|
||||||
<Grid ColumnDefinitions="*,Auto" Margin="0,2">
|
|
||||||
<TextBlock Grid.Column="0" Classes="meta" VerticalAlignment="Center"
|
|
||||||
Text="{Binding TaskTitle}"/>
|
|
||||||
<Button Grid.Column="1" Classes="btn"
|
|
||||||
Content="{loc:Tr modals.worktreesOverview.resolve}"
|
|
||||||
Command="{Binding $parent[Window].((vm:WorktreesOverviewModalViewModel)DataContext).ResolveConflictCommand}"
|
|
||||||
CommandParameter="{Binding}"/>
|
|
||||||
</Grid>
|
|
||||||
</DataTemplate>
|
|
||||||
</ItemsControl.ItemTemplate>
|
|
||||||
</ItemsControl>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
```
|
|
||||||
|
|
||||||
> `IsVisible="{Binding ConflictRows.Count}"` uses Avalonia's int→bool coercion (0 = false). If the build flags this, change to a value converter already present, but int→bool is supported.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Build the app to verify the AXAML compiles**
|
|
||||||
|
|
||||||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
|
||||||
Expected: Build succeeded (compiled bindings resolve against the new VM members).
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/ClaudeDo.Ui/Views/Modals/WorktreesOverviewModalView.axaml
|
|
||||||
git commit -m "feat(ui): batch-merge cockpit view with checkboxes and conflicts panel"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 5: Localization keys (en + de parity)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/ClaudeDo.Localization/locales/en.json`
|
|
||||||
- Modify: `src/ClaudeDo.Localization/locales/de.json`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add the new keys to `en.json`**
|
|
||||||
|
|
||||||
Under `modals.worktreesOverview`, add:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"columnOutcome": "RESULT",
|
|
||||||
"selectAll": "Select all",
|
|
||||||
"targetLabel": "Target",
|
|
||||||
"mergeAll": "Merge all",
|
|
||||||
"needsResolution": "NEEDS RESOLUTION",
|
|
||||||
"resolve": "Resolve"
|
|
||||||
```
|
|
||||||
|
|
||||||
Under `vm.worktreesOverview`, add:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"batchProgress": "Merging {0}/{1}…",
|
|
||||||
"batchDone": "Merged {0}, {1} need resolution."
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Add the matching keys to `de.json`**
|
|
||||||
|
|
||||||
Under `modals.worktreesOverview`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"columnOutcome": "ERGEBNIS",
|
|
||||||
"selectAll": "Alle auswählen",
|
|
||||||
"targetLabel": "Ziel",
|
|
||||||
"mergeAll": "Alle mergen",
|
|
||||||
"needsResolution": "ZU LÖSEN",
|
|
||||||
"resolve": "Lösen"
|
|
||||||
```
|
|
||||||
|
|
||||||
Under `vm.worktreesOverview`:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"batchProgress": "Merge {0}/{1}…",
|
|
||||||
"batchDone": "{0} gemergt, {1} zu lösen."
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Run the localization parity test**
|
|
||||||
|
|
||||||
Run: `dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release`
|
|
||||||
Expected: PASS (en/de key parity holds).
|
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/ClaudeDo.Localization/locales/en.json src/ClaudeDo.Localization/locales/de.json
|
|
||||||
git commit -m "feat(i18n): add batch-merge cockpit strings (en/de)"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 6: Migrate `WorktreeModalView` diff onto `DiffLinesView`
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/ClaudeDo.Ui/ViewModels/Modals/WorktreeModalViewModel.cs`
|
|
||||||
- Modify: `src/ClaudeDo.Ui/Views/Modals/WorktreeModalView.axaml`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Switch the VM to the canonical diff model**
|
|
||||||
|
|
||||||
In `WorktreeModalViewModel.cs`:
|
|
||||||
|
|
||||||
(a) Delete the now-dead types at the top of the file:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public enum WorktreeDiffLineKind { Header, Hunk, Added, Removed, Context }
|
|
||||||
|
|
||||||
public sealed partial class WorktreeDiffLineViewModel : ViewModelBase
|
|
||||||
{
|
|
||||||
public required string Text { get; init; }
|
|
||||||
public required WorktreeDiffLineKind Kind { get; init; }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
(b) Change the collection declaration from:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public ObservableCollection<WorktreeDiffLineViewModel> SelectedFileDiffLines { get; } = new();
|
|
||||||
```
|
|
||||||
|
|
||||||
to:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public ObservableCollection<DiffLineViewModel> SelectedFileDiffLines { get; } = new();
|
|
||||||
```
|
|
||||||
|
|
||||||
(c) Replace the body of `LoadFileDiffAsync` (the `foreach (var line in diff.Split('\n'))` block) so it parses via `UnifiedDiffParser`. The method becomes:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
private async Task LoadFileDiffAsync(WorktreeNodeViewModel? node)
|
|
||||||
{
|
|
||||||
SelectedFileDiffLines.Clear();
|
|
||||||
|
|
||||||
if (node is null || node.IsDirectory || string.IsNullOrEmpty(node.RelativePath))
|
|
||||||
return;
|
|
||||||
|
|
||||||
string diff;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
diff = await _git.GetFileDiffAsync(WorktreePath, BaseCommit, node.RelativePath);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var line in UnifiedDiffParser.Flatten(UnifiedDiffParser.Parse(diff)))
|
|
||||||
SelectedFileDiffLines.Add(line);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
(`DiffLineViewModel`, `DiffFileViewModel`, and `UnifiedDiffParser` are all in the same `ClaudeDo.Ui.ViewModels.Modals` namespace, so no new `using` is required.)
|
|
||||||
|
|
||||||
- [ ] **Step 2: Build to confirm the VM compiles and nothing else referenced the deleted types**
|
|
||||||
|
|
||||||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
|
||||||
Expected: Build succeeded. (If a compile error names `WorktreeDiffLineViewModel`/`WorktreeDiffLineKind` outside this file or the view, that reference must be migrated too — there should be none besides `WorktreeModalView.axaml`, handled next.)
|
|
||||||
|
|
||||||
- [ ] **Step 3: Swap the view's inline diff for `DiffLinesView`**
|
|
||||||
|
|
||||||
In `WorktreeModalView.axaml`:
|
|
||||||
|
|
||||||
(a) Remove the now-unused converter resource. Delete:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<Window.Resources>
|
|
||||||
<converters:DiffLineKindToBrushConverter x:Key="DiffLineKindToBrush"/>
|
|
||||||
</Window.Resources>
|
|
||||||
```
|
|
||||||
|
|
||||||
(b) Replace the right-pane `ScrollViewer`'s `ItemsControl` (the `SelectableTextBlock` template bound to `SelectedFileDiffLines`) with the canonical control. Replace:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<ItemsControl ItemsSource="{Binding SelectedFileDiffLines}">
|
|
||||||
<ItemsControl.ItemTemplate>
|
|
||||||
<DataTemplate DataType="vm:WorktreeDiffLineViewModel">
|
|
||||||
<SelectableTextBlock Text="{Binding Text}"
|
|
||||||
FontFamily="{DynamicResource MonoFont}"
|
|
||||||
FontSize="{StaticResource FontSizeMono}"
|
|
||||||
Foreground="{Binding Kind, Converter={StaticResource DiffLineKindToBrush}}"
|
|
||||||
TextWrapping="NoWrap"/>
|
|
||||||
</DataTemplate>
|
|
||||||
</ItemsControl.ItemTemplate>
|
|
||||||
</ItemsControl>
|
|
||||||
```
|
|
||||||
|
|
||||||
with:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<ctl:DiffLinesView Lines="{Binding SelectedFileDiffLines}"/>
|
|
||||||
```
|
|
||||||
|
|
||||||
(The `xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"` namespace is already declared at the top of this file.)
|
|
||||||
|
|
||||||
- [ ] **Step 4: Build the app to verify the AXAML compiles**
|
|
||||||
|
|
||||||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
|
||||||
Expected: Build succeeded.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/ClaudeDo.Ui/ViewModels/Modals/WorktreeModalViewModel.cs src/ClaudeDo.Ui/Views/Modals/WorktreeModalView.axaml
|
|
||||||
git commit -m "refactor(ui): render worktree modal diff via canonical DiffLinesView"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Task 7: Full build + test sweep
|
|
||||||
|
|
||||||
**Files:** none (verification only).
|
|
||||||
|
|
||||||
- [ ] **Step 1: Build the whole app**
|
|
||||||
|
|
||||||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
|
||||||
Expected: Build succeeded, 0 errors.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run the UI + localization test projects**
|
|
||||||
|
|
||||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
|
|
||||||
Then: `dotnet test tests/ClaudeDo.Localization.Tests/ClaudeDo.Localization.Tests.csproj -c Release`
|
|
||||||
Expected: PASS (all green, including the 8 new batch-merge tests).
|
|
||||||
|
|
||||||
- [ ] **Step 3: Flag visual-verification gaps**
|
|
||||||
|
|
||||||
The cockpit toolbar/checkbox/conflicts-panel layout and the migrated `WorktreeModalView` diff rendering are AXAML changes that cannot be verified headlessly. Report to the user that these need a visual pass (run the app, open the worktrees overview, select several worktrees, pick a target, "Merge all", and open a worktree diff).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Self-Review Notes
|
|
||||||
|
|
||||||
- **Spec coverage:** batch-merge cockpit (Tasks 1–4), skip-and-continue + conflict collection (Task 2), single target picker (Tasks 3–4), Resolve → `RequestConflictResolution(taskId, targetBranch)` seam left unwired (Tasks 3–4), `WorktreeModalView` diff migration to `DiffLinesView` (Task 6), no worker files touched, no `IWorkerClient` change, locales in parity (Task 5). ✔
|
|
||||||
- **No ConflictResolver reference:** the seam is a bare `Func<string,string,Task>?`; no Layer C type is named. ✔
|
|
||||||
- **Type consistency:** `BatchMergeOutcome`, `MergeOutcome`, `IsConflict`, `HasOutcome`, `MergeSelectedAsync`, `ConflictRows`, `SelectedTarget`, `SelectedCount`, `IsMerging`, `BatchProgress`, `RequestConflictResolution`, `MergeAllCommand`, `ResolveConflictCommand`, `ToggleSelectAllCommand`, `AddRowForTest`, `AllRows` are used consistently across tasks. ✔
|
|
||||||
@@ -1,522 +0,0 @@
|
|||||||
# Terminal-style Review Controls Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Move review feedback into the Output (terminal) tab as a prompt-style input with `[Retry]`/`[Reset]` actions, and relocate Approve + all merge/worktree controls to a new **Git** tab.
|
|
||||||
|
|
||||||
**Architecture:** Pure UI-layer change in `ClaudeDo.Ui`. Add an `IsGitTab` computed flag to `DetailsIslandViewModel`, re-home existing XAML blocks across three tabs (Output · Git · Session) in `WorkConsole.axaml`, add a bottom-docked review footer to the Output tab, and intercept Enter in `WorkConsole.axaml.cs`. No worker-side or `IWorkerClient` changes; no ViewModel command renames.
|
|
||||||
|
|
||||||
**Tech Stack:** .NET 8, Avalonia 12 (Fluent), CommunityToolkit.Mvvm, xUnit (ClaudeDo.Ui.Tests).
|
|
||||||
|
|
||||||
**Reference spec:** `docs/superpowers/specs/2026-06-05-terminal-review-design.md`
|
|
||||||
|
|
||||||
**Build/test note (from CLAUDE.md):** A running Worker locks `Debug` output — build UI in `-c Release`:
|
|
||||||
`dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
|
||||||
`dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs` — add `IsGitTab`, wire notifications.
|
|
||||||
- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml` — add Git tab button; split tab bodies; add Output-tab review footer; update Session empty-state text.
|
|
||||||
- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml.cs` — Enter-to-Retry key handling.
|
|
||||||
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandTabsTests.cs` (create) — `IsGitTab` behavior.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: Add `IsGitTab` tab flag to the ViewModel
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs:139-147`
|
|
||||||
- Test: `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandTabsTests.cs` (create)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Write the failing test**
|
|
||||||
|
|
||||||
Create `tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandTabsTests.cs`. Mirror the
|
|
||||||
construction pattern from `DetailsIslandPrepModeTests.cs` (temp SQLite db,
|
|
||||||
`TestDbFactory`, `StubWorkerClient`, `NullServiceProvider`, `StubNotesApi`).
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using ClaudeDo.Data;
|
|
||||||
using ClaudeDo.Ui.Services;
|
|
||||||
using ClaudeDo.Ui.ViewModels.Islands;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.Tests.ViewModels;
|
|
||||||
|
|
||||||
public class DetailsIslandTabsTests : IDisposable
|
|
||||||
{
|
|
||||||
private readonly string _dbPath;
|
|
||||||
|
|
||||||
public DetailsIslandTabsTests()
|
|
||||||
{
|
|
||||||
_dbPath = Path.Combine(Path.GetTempPath(), $"claudedo_tabs_test_{Guid.NewGuid():N}.db");
|
|
||||||
using var ctx = NewContext();
|
|
||||||
ctx.Database.EnsureCreated();
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
try { File.Delete(_dbPath); } catch { }
|
|
||||||
try { File.Delete(_dbPath + "-wal"); } catch { }
|
|
||||||
try { File.Delete(_dbPath + "-shm"); } catch { }
|
|
||||||
}
|
|
||||||
|
|
||||||
private ClaudeDoDbContext NewContext()
|
|
||||||
{
|
|
||||||
var opts = new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
|
||||||
.UseSqlite($"Data Source={_dbPath}")
|
|
||||||
.Options;
|
|
||||||
return new ClaudeDoDbContext(opts);
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class TestDbFactory : IDbContextFactory<ClaudeDoDbContext>
|
|
||||||
{
|
|
||||||
private readonly Func<ClaudeDoDbContext> _create;
|
|
||||||
public TestDbFactory(Func<ClaudeDoDbContext> create) => _create = create;
|
|
||||||
public ClaudeDoDbContext CreateDbContext() => _create();
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class StubNotesApi : ClaudeDo.Ui.Services.Interfaces.INotesApi
|
|
||||||
{
|
|
||||||
public Task<List<DailyNoteDto>> ListAsync(DateOnly day) => Task.FromResult(new List<DailyNoteDto>());
|
|
||||||
public Task<DailyNoteDto?> AddAsync(DateOnly day, string text) => Task.FromResult<DailyNoteDto?>(null);
|
|
||||||
public Task UpdateAsync(string id, string text) => Task.CompletedTask;
|
|
||||||
public Task DeleteAsync(string id) => Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class NullServiceProvider : IServiceProvider
|
|
||||||
{
|
|
||||||
public object? GetService(Type serviceType) => null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// StubWorkerClient is abstract — use a concrete no-op subclass (same pattern as DetailsIslandPrepModeTests).
|
|
||||||
private sealed class DefaultStub : StubWorkerClient { }
|
|
||||||
|
|
||||||
private DetailsIslandViewModel NewVm()
|
|
||||||
{
|
|
||||||
var factory = new TestDbFactory(NewContext);
|
|
||||||
return new DetailsIslandViewModel(factory, new DefaultStub(), new NullServiceProvider(), new StubNotesApi());
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void SelectTab_git_sets_IsGitTab_and_clears_others()
|
|
||||||
{
|
|
||||||
var vm = NewVm();
|
|
||||||
|
|
||||||
vm.SelectTabCommand.Execute("git");
|
|
||||||
|
|
||||||
Assert.True(vm.IsGitTab);
|
|
||||||
Assert.False(vm.IsOutputTab);
|
|
||||||
Assert.False(vm.IsSessionTab);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Default_tab_is_output_not_git()
|
|
||||||
{
|
|
||||||
var vm = NewVm();
|
|
||||||
|
|
||||||
Assert.True(vm.IsOutputTab);
|
|
||||||
Assert.False(vm.IsGitTab);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run test to verify it fails**
|
|
||||||
|
|
||||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter DetailsIslandTabsTests`
|
|
||||||
Expected: FAIL — compile error, `DetailsIslandViewModel` has no `IsGitTab`.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Add `IsGitTab` to the ViewModel**
|
|
||||||
|
|
||||||
In `DetailsIslandViewModel.cs`, find the `SelectedTab` property notifications and the
|
|
||||||
tab getters (around lines 139-147). Add the `IsGitTab` notification and getter:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
[NotifyPropertyChangedFor(nameof(IsOutputTab))]
|
|
||||||
[NotifyPropertyChangedFor(nameof(IsSessionTab))]
|
|
||||||
[NotifyPropertyChangedFor(nameof(IsGitTab))]
|
|
||||||
```
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public bool IsOutputTab => SelectedTab == "output";
|
|
||||||
public bool IsGitTab => SelectedTab == "git";
|
|
||||||
public bool IsSessionTab => SelectedTab == "session";
|
|
||||||
```
|
|
||||||
|
|
||||||
(Leave `SelectTab` unchanged — it already accepts any string and defaults to `"output"`.)
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run test to verify it passes**
|
|
||||||
|
|
||||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release --filter DetailsIslandTabsTests`
|
|
||||||
Expected: PASS (2 tests).
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/ClaudeDo.Ui/ViewModels/Islands/DetailsIslandViewModel.cs tests/ClaudeDo.Ui.Tests/ViewModels/DetailsIslandTabsTests.cs
|
|
||||||
git commit -m "feat(ui): add IsGitTab flag to work console view model"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 2: Add the Git tab button and move the merge/worktree block onto it
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml:124-135` (tab strip)
|
|
||||||
- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml:164-273` (tab body)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add the Git tab button**
|
|
||||||
|
|
||||||
In the tab strip `StackPanel` (lines 124-135), insert a Git button between the Output
|
|
||||||
and Session buttons:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<StackPanel Orientation="Horizontal">
|
|
||||||
<Button Classes="tab-btn"
|
|
||||||
Classes.active="{Binding IsOutputTab}"
|
|
||||||
Content="Output"
|
|
||||||
Command="{Binding SelectTabCommand}"
|
|
||||||
CommandParameter="output" />
|
|
||||||
<Button Classes="tab-btn"
|
|
||||||
Classes.active="{Binding IsGitTab}"
|
|
||||||
Content="Git"
|
|
||||||
Command="{Binding SelectTabCommand}"
|
|
||||||
CommandParameter="git" />
|
|
||||||
<Button Classes="tab-btn"
|
|
||||||
Classes.active="{Binding IsSessionTab}"
|
|
||||||
Content="Session"
|
|
||||||
Command="{Binding SelectTabCommand}"
|
|
||||||
CommandParameter="session" />
|
|
||||||
</StackPanel>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Move the "Merge & worktree" block to a new Git-tab ScrollViewer**
|
|
||||||
|
|
||||||
In the tab body `Grid` (starts line 139), the body currently holds the Output
|
|
||||||
`ScrollViewer` (`IsVisible="{Binding IsOutputTab}"`, lines 142-162) and the Session
|
|
||||||
`ScrollViewer` (`IsVisible="{Binding IsSessionTab}"`, lines 165-273).
|
|
||||||
|
|
||||||
Cut the **entire "Merge & worktree management" `StackPanel`** — the block currently at
|
|
||||||
lines 195-241, beginning with the comment `<!-- Merge & worktree management -->` and the
|
|
||||||
`<StackPanel Spacing="10" IsVisible="{Binding ShowMergeSection}">` and ending at its
|
|
||||||
matching `</StackPanel>` after the `MergeAllError` `TextBlock` (line 241).
|
|
||||||
|
|
||||||
Add a new Git-tab `ScrollViewer` between the Output and Session `ScrollViewer`s, and
|
|
||||||
paste the cut block inside it:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<!-- Git: merge target, approve, diff, worktree -->
|
|
||||||
<ScrollViewer IsVisible="{Binding IsGitTab}" Padding="14,10">
|
|
||||||
<StackPanel Spacing="14">
|
|
||||||
|
|
||||||
<!-- Approve (review-gated) -->
|
|
||||||
<StackPanel Spacing="8" IsVisible="{Binding IsWaitingForReview}">
|
|
||||||
<TextBlock Classes="section-label" Text="REVIEW" />
|
|
||||||
<Button Classes="btn accent" Content="Approve"
|
|
||||||
Command="{Binding ApproveReviewCommand}" />
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<!-- Merge & worktree management (moved from Session tab) -->
|
|
||||||
<StackPanel Spacing="10" IsVisible="{Binding ShowMergeSection}">
|
|
||||||
<TextBlock Classes="section-label" Text="MERGE & WORKTREE" />
|
|
||||||
<StackPanel Spacing="4">
|
|
||||||
<TextBlock Classes="field-label" Text="Merge target" />
|
|
||||||
<ComboBox ItemsSource="{Binding MergeTargetBranches}"
|
|
||||||
SelectedItem="{Binding SelectedMergeTarget, Mode=TwoWay}"
|
|
||||||
HorizontalAlignment="Stretch" />
|
|
||||||
</StackPanel>
|
|
||||||
<StackPanel Spacing="0">
|
|
||||||
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
|
|
||||||
Foreground="{DynamicResource MossBrush}"
|
|
||||||
IsVisible="{Binding MergeIsClean}" />
|
|
||||||
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
|
|
||||||
Foreground="{DynamicResource BloodBrush}"
|
|
||||||
IsVisible="{Binding MergeIsConflict}" />
|
|
||||||
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
|
|
||||||
Foreground="{DynamicResource TextMuteBrush}"
|
|
||||||
IsVisible="{Binding ShowMergePreviewMuted}" />
|
|
||||||
</StackPanel>
|
|
||||||
<WrapPanel Orientation="Horizontal">
|
|
||||||
<Button Classes="btn" Content="Open Diff" Margin="0,0,8,8"
|
|
||||||
Command="{Binding OpenDiffCommand}" />
|
|
||||||
<Button Classes="btn accent" Content="Merge" Margin="0,0,8,8"
|
|
||||||
Command="{Binding MergeCommand}"
|
|
||||||
IsVisible="{Binding ShowSingleMerge}" />
|
|
||||||
<Button Classes="btn" Margin="0,0,8,8"
|
|
||||||
Command="{Binding OpenWorktreeCommand}">
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="5">
|
|
||||||
<TextBlock Text="Worktree" />
|
|
||||||
<PathIcon Data="{StaticResource Icon.ArrowOut}" Width="11" Height="11" />
|
|
||||||
</StackPanel>
|
|
||||||
</Button>
|
|
||||||
<Button Classes="btn" Content="Review Combined Diff" Margin="0,0,8,8"
|
|
||||||
Command="{Binding ReviewCombinedDiffCommand}" />
|
|
||||||
<Button Classes="btn accent" Content="Merge All Subtasks" Margin="0,0,0,8"
|
|
||||||
Command="{Binding MergeAllCommand}"
|
|
||||||
IsEnabled="{Binding CanMergeAll}"
|
|
||||||
ToolTip.Tip="{Binding MergeAllDisabledReason}" />
|
|
||||||
</WrapPanel>
|
|
||||||
<TextBlock Text="{Binding MergeAllError}"
|
|
||||||
Foreground="{DynamicResource BloodBrush}"
|
|
||||||
TextWrapping="Wrap"
|
|
||||||
IsVisible="{Binding MergeAllError,
|
|
||||||
Converter={x:Static ObjectConverters.IsNotNull}}" />
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
</StackPanel>
|
|
||||||
</ScrollViewer>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Remove the old review block from the Session tab**
|
|
||||||
|
|
||||||
In the Session `ScrollViewer` (`IsVisible="{Binding IsSessionTab}"`), delete the
|
|
||||||
**"Review controls" `StackPanel`** currently at lines 168-193 (the
|
|
||||||
`<!-- Review controls -->` comment, the `<StackPanel Spacing="8" IsVisible="{Binding IsWaitingForReview}">`,
|
|
||||||
the REVIEW label, Feedback label, the `ReviewFeedback` TextBox, and the four buttons).
|
|
||||||
After this and Step 2, the Session tab's `StackPanel` should contain only the Child
|
|
||||||
outcomes block (lines 244-263) and the empty-state `TextBlock` (lines 266-270).
|
|
||||||
|
|
||||||
- [ ] **Step 4: Build and verify it compiles**
|
|
||||||
|
|
||||||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
|
||||||
Expected: Build succeeded, 0 errors.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml
|
|
||||||
git commit -m "feat(ui): add Git tab and move merge/approve controls onto it"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 3: Add the prompt-style review footer to the Output tab
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml` (Output-tab area + the `Grid` body)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Restructure the Output tab body to dock a footer below the log**
|
|
||||||
|
|
||||||
The body `Grid` (line 139) overlays all three tab `ScrollViewer`s. Replace the Output
|
|
||||||
`ScrollViewer` (lines 142-162) with a `DockPanel` that keeps the log filling and docks
|
|
||||||
the review footer at the bottom. Keep `Name="LogScroll"` on the `ScrollViewer` (the
|
|
||||||
code-behind references it). Use this exact markup:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<!-- Output: log + review footer, both gated on IsOutputTab -->
|
|
||||||
<DockPanel IsVisible="{Binding IsOutputTab}" LastChildFill="True">
|
|
||||||
|
|
||||||
<!-- Review footer (terminal prompt) — only while awaiting review -->
|
|
||||||
<Border DockPanel.Dock="Bottom"
|
|
||||||
IsVisible="{Binding IsWaitingForReview}"
|
|
||||||
Background="{DynamicResource Surface2Brush}"
|
|
||||||
BorderBrush="{DynamicResource LineBrush}"
|
|
||||||
BorderThickness="0,1,0,0"
|
|
||||||
Padding="10,6">
|
|
||||||
<DockPanel LastChildFill="True">
|
|
||||||
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal" Spacing="8"
|
|
||||||
VerticalAlignment="Bottom" Margin="8,0,0,0">
|
|
||||||
<Button Classes="btn accent" Content="Retry"
|
|
||||||
Command="{Binding RejectReviewCommand}" />
|
|
||||||
<Button Classes="btn" Content="Reset"
|
|
||||||
Command="{Binding ParkReviewCommand}" />
|
|
||||||
</StackPanel>
|
|
||||||
<TextBlock DockPanel.Dock="Left" Text="❯"
|
|
||||||
FontFamily="{StaticResource MonoFont}"
|
|
||||||
Foreground="{DynamicResource TextMuteBrush}"
|
|
||||||
VerticalAlignment="Top" Margin="0,4,8,0" />
|
|
||||||
<TextBox Name="ReviewInput"
|
|
||||||
Text="{Binding ReviewFeedback, Mode=TwoWay}"
|
|
||||||
AcceptsReturn="True"
|
|
||||||
TextWrapping="Wrap"
|
|
||||||
MaxHeight="160"
|
|
||||||
PlaceholderText="Feedback for the next run…"
|
|
||||||
Background="Transparent"
|
|
||||||
BorderThickness="0"
|
|
||||||
Padding="0,2"
|
|
||||||
FontFamily="{StaticResource MonoFont}"
|
|
||||||
FontSize="{StaticResource FontSizeMono}" />
|
|
||||||
</DockPanel>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<ScrollViewer Name="LogScroll"
|
|
||||||
VerticalScrollBarVisibility="Visible"
|
|
||||||
AllowAutoHide="False"
|
|
||||||
Padding="12,8,12,4">
|
|
||||||
<ItemsControl ItemsSource="{Binding Log}">
|
|
||||||
<ItemsControl.ItemTemplate>
|
|
||||||
<DataTemplate DataType="vm:LogLineViewModel">
|
|
||||||
<Grid ColumnDefinitions="60,*" Margin="0,1">
|
|
||||||
<TextBlock Grid.Column="0"
|
|
||||||
Classes="log-ts"
|
|
||||||
Text="{Binding TimestampFormatted}" />
|
|
||||||
<SelectableTextBlock Grid.Column="1"
|
|
||||||
Text="{Binding Text}" Tag="{Binding ClassName}"
|
|
||||||
Foreground="{DynamicResource TextDimBrush}"
|
|
||||||
TextWrapping="Wrap" />
|
|
||||||
</Grid>
|
|
||||||
</DataTemplate>
|
|
||||||
</ItemsControl.ItemTemplate>
|
|
||||||
</ItemsControl>
|
|
||||||
</ScrollViewer>
|
|
||||||
|
|
||||||
</DockPanel>
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Build and verify it compiles**
|
|
||||||
|
|
||||||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
|
||||||
Expected: Build succeeded, 0 errors.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml
|
|
||||||
git commit -m "feat(ui): add terminal review footer with Retry/Reset to Output tab"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 4: Enter-to-Retry key handling
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml.cs`
|
|
||||||
|
|
||||||
- [ ] **Step 1: Add the KeyDown handler**
|
|
||||||
|
|
||||||
In `WorkConsole.axaml.cs`, add `using Avalonia.Input;` at the top. Add a handler that
|
|
||||||
runs `RejectReviewCommand` on Enter (without Shift) and lets Shift+Enter insert a
|
|
||||||
newline. Wire it from the `ReviewInput` TextBox. Full file:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
using System;
|
|
||||||
using System.Collections.Specialized;
|
|
||||||
using Avalonia.Controls;
|
|
||||||
using Avalonia.Input;
|
|
||||||
using ClaudeDo.Ui.ViewModels.Islands;
|
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.Views.Islands.Detail;
|
|
||||||
|
|
||||||
public partial class WorkConsole : UserControl
|
|
||||||
{
|
|
||||||
private INotifyCollectionChanged? _log;
|
|
||||||
|
|
||||||
public WorkConsole()
|
|
||||||
{
|
|
||||||
InitializeComponent();
|
|
||||||
DataContextChanged += OnDataContextChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnDataContextChanged(object? sender, EventArgs e)
|
|
||||||
{
|
|
||||||
if (_log is not null)
|
|
||||||
_log.CollectionChanged -= OnLogChanged;
|
|
||||||
|
|
||||||
_log = (DataContext as DetailsIslandViewModel)?.Log;
|
|
||||||
|
|
||||||
if (_log is not null)
|
|
||||||
_log.CollectionChanged += OnLogChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnLogChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
|
||||||
{
|
|
||||||
if (e.Action != NotifyCollectionChangedAction.Add) return;
|
|
||||||
EventHandler? handler = null;
|
|
||||||
handler = (_, _) =>
|
|
||||||
{
|
|
||||||
LogScroll.LayoutUpdated -= handler;
|
|
||||||
LogScroll.ScrollToEnd();
|
|
||||||
};
|
|
||||||
LogScroll.LayoutUpdated += handler;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnReviewInputKeyDown(object? sender, KeyEventArgs e)
|
|
||||||
{
|
|
||||||
if (e.Key != Key.Enter || e.KeyModifiers.HasFlag(KeyModifiers.Shift))
|
|
||||||
return;
|
|
||||||
|
|
||||||
if (DataContext is DetailsIslandViewModel vm &&
|
|
||||||
vm.RejectReviewCommand.CanExecute(null))
|
|
||||||
{
|
|
||||||
vm.RejectReviewCommand.Execute(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
e.Handled = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Wire the handler in XAML**
|
|
||||||
|
|
||||||
On the `ReviewInput` TextBox added in Task 3, add the event hookup attribute:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<TextBox Name="ReviewInput"
|
|
||||||
KeyDown="OnReviewInputKeyDown"
|
|
||||||
Text="{Binding ReviewFeedback, Mode=TwoWay}"
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Build and verify it compiles**
|
|
||||||
|
|
||||||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
|
||||||
Expected: Build succeeded, 0 errors.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml.cs
|
|
||||||
git commit -m "feat(ui): send Retry on Enter in the review prompt"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 5: Update the Session empty-state copy
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml` (empty-state `TextBlock`, was line 266-270)
|
|
||||||
|
|
||||||
- [ ] **Step 1: Reword the empty-state text**
|
|
||||||
|
|
||||||
The Session empty-state still says review/merge controls appear there. Replace its
|
|
||||||
`Text` so it reflects that those moved:
|
|
||||||
|
|
||||||
```xml
|
|
||||||
<TextBlock IsVisible="{Binding ShowSessionEmpty}"
|
|
||||||
Classes="meta"
|
|
||||||
Foreground="{DynamicResource TextMuteBrush}"
|
|
||||||
TextWrapping="Wrap"
|
|
||||||
Text="Nothing to manage yet — subtask outcomes appear here once the run finishes. Review in the Output tab, merge in the Git tab." />
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Build and verify it compiles**
|
|
||||||
|
|
||||||
Run: `dotnet build src/ClaudeDo.App/ClaudeDo.App.csproj -c Release`
|
|
||||||
Expected: Build succeeded, 0 errors.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/ClaudeDo.Ui/Views/Islands/Detail/WorkConsole.axaml
|
|
||||||
git commit -m "docs(ui): reword Session empty-state for relocated review/merge controls"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 6: Final verification
|
|
||||||
|
|
||||||
- [ ] **Step 1: Run the full UI test project**
|
|
||||||
|
|
||||||
Run: `dotnet test tests/ClaudeDo.Ui.Tests/ClaudeDo.Ui.Tests.csproj -c Release`
|
|
||||||
Expected: all tests PASS.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Manual visual verification (cannot be auto-verified — flag to user)**
|
|
||||||
|
|
||||||
Launch the app with a task in `WaitingForReview` and confirm:
|
|
||||||
- Output tab shows the prompt footer (`❯` + input + `[Retry]` `[Reset]`) only while awaiting review; it is hidden otherwise.
|
|
||||||
- Typing + **Enter** sends Retry (requeues with feedback); **Shift+Enter** inserts a newline; **Enter on empty input** does nothing.
|
|
||||||
- `[Reset]` parks the task to Idle.
|
|
||||||
- Git tab shows **Approve** + merge target + Open Diff / Merge / Worktree / Review Combined Diff / Merge All Subtasks.
|
|
||||||
- Session tab shows only subtask outcomes / the reworded empty state.
|
|
||||||
- Tab switching highlights the active tab correctly (Output ↔ Git ↔ Session).
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
# Plan — Unify the parent-task model
|
|
||||||
|
|
||||||
Spec: `docs/superpowers/specs/2026-06-09-unify-parent-task-model-design.md`
|
|
||||||
|
|
||||||
Subagents: `sonnet`. Stage files explicitly by path (never `git add -A`). TDD.
|
|
||||||
Build with `-c Release` per project. Commit per task (Conventional Commits).
|
|
||||||
|
|
||||||
## Task 1 — Single parent-advance path
|
|
||||||
|
|
||||||
- Rename `TaskStateService.TryAdvanceImprovementParentAsync` → `TryAdvanceParentAsync`.
|
|
||||||
- Make it advance **any** `WaitingForChildren` parent → `WaitingForReview` when all
|
|
||||||
children are terminal, and advance a parent with **zero** children straight to
|
|
||||||
`WaitingForReview`.
|
|
||||||
- In `OnChildTerminalAsync`: drop the `TryCompleteParentAsync` call; keep
|
|
||||||
`_chain.OnChildFinishedAsync`; call the renamed advance method for all parents.
|
|
||||||
- Tests: extend `WaitingForChildrenLifecycleTests` — (a) improvement parent still
|
|
||||||
advances; (b) a `WaitingForChildren` parent whose children are a *sequential chain*
|
|
||||||
advances only after the last one is terminal; (c) zero-children parent advances.
|
|
||||||
|
|
||||||
## Task 2 — Delete `TryCompleteParentAsync`
|
|
||||||
|
|
||||||
- Remove `TaskRepository.TryCompleteParentAsync` (`TaskRepository.cs:477-502`) and
|
|
||||||
any remaining references.
|
|
||||||
- Update `src/ClaudeDo.Data/CLAUDE.md` (drop it from the TaskRepository helper list).
|
|
||||||
- Build Data + Worker; fix references.
|
|
||||||
|
|
||||||
## Task 3 — Planning finalize enters `WaitingForChildren`
|
|
||||||
|
|
||||||
- `TaskStateService.FinalizePlanningAsync`: in the same `ExecuteUpdateAsync`, set
|
|
||||||
`Status = WaitingForChildren` alongside `PlanningPhase = Finalized` /
|
|
||||||
`PlanningFinalizedAt`.
|
|
||||||
- Verify `PlanningSessionManager.FinalizeAsync` ordering: finalize (→ WaitingForChildren)
|
|
||||||
**before** `SetupChainAsync` enqueues child[0]. Adjust only if ordering is wrong.
|
|
||||||
- Tests: finalizing a planning parent with N children leaves it `WaitingForChildren`;
|
|
||||||
after the chain completes it is `WaitingForReview` (not `Done`); a planning parent
|
|
||||||
with zero finalized children lands in `WaitingForReview`.
|
|
||||||
|
|
||||||
## Task 4 — Approve merges the whole unit
|
|
||||||
|
|
||||||
**Decision: full UX consolidation.** Approve becomes the single entry for reviewing
|
|
||||||
*and* merging any task; the separate planning-merge views are folded into the review
|
|
||||||
panel. The `PlanningMergeOrchestrator` (which already merges the unit + sets the
|
|
||||||
parent `Done` for both planning and improvement, with conflict continue/abort) is
|
|
||||||
reused as the engine; only its *entry/UI* moves.
|
|
||||||
|
|
||||||
Backend:
|
|
||||||
- `WorkerHub.ApproveReview`: for a parent that **has children**, drive
|
|
||||||
`PlanningMergeOrchestrator.StartAsync` (event-based: `PlanningMergeStarted` /
|
|
||||||
`PlanningSubtaskMerged` / `PlanningMergeConflict` / `PlanningMergeAborted` /
|
|
||||||
`PlanningCompleted`) instead of the one-shot `ApproveAndMergeAsync`. Childless tasks
|
|
||||||
keep `ApproveAndMergeAsync`. Conflict resolution still goes through
|
|
||||||
`ContinuePlanningMerge` / `AbortPlanningMerge`.
|
|
||||||
- Keep the orchestrator, `ContinuePlanningMerge`, `AbortPlanningMerge`,
|
|
||||||
`GetPlanningAggregate`, `BuildPlanningIntegrationBranch`. Remove the now-redundant
|
|
||||||
standalone `MergeAllPlanning` hub method (approve is the entry).
|
|
||||||
- (Optional cleanup) route the orchestrator's `FinalizeParentDoneAsync` through
|
|
||||||
`TaskStateService` so `Status` writes stay centralized; low priority.
|
|
||||||
|
|
||||||
UI (Avalonia, MVVM — visual-verification gaps, flag for user):
|
|
||||||
- The review panel (`DetailsIslandViewModel` / its view) is the single approve+merge
|
|
||||||
surface. For a child-bearing parent in `WaitingForReview`, approve shows the
|
|
||||||
unit-merge progress + per-subtask state, the aggregate/integration diff preview, and
|
|
||||||
conflict continue/abort — all inline in the review panel.
|
|
||||||
- Remove the separate planning-merge view(s)/commands and the standalone "Merge all"
|
|
||||||
button; re-wire their `PlanningMerge*` event handlers into the review panel VM.
|
|
||||||
- Sync `IWorkerClient` + hand-rolled test fakes in both UI/Worker test projects.
|
|
||||||
|
|
||||||
Tests: approving a parent with two `Done` children merges both then sets `Done`; a
|
|
||||||
conflicting second child surfaces the conflict and pauses (continue/abort) without
|
|
||||||
losing the parent's `WaitingForReview`/merge state.
|
|
||||||
|
|
||||||
## Task 5 — Cancellable `WaitingForChildren` parent
|
|
||||||
|
|
||||||
- Add `TaskStatus.WaitingForChildren` to the `CancelAsync` guard.
|
|
||||||
- Test: a parent in `WaitingForChildren` can be cancelled.
|
|
||||||
|
|
||||||
## Task 6 — Docs
|
|
||||||
|
|
||||||
- `src/ClaudeDo.Worker/CLAUDE.md`: add `WaitingForChildren` to the Status table +
|
|
||||||
transition diagram; document the unified parent flow and approve-merges-unit;
|
|
||||||
remove `MergeAllPlanning` from the Hub method list.
|
|
||||||
- `src/ClaudeDo.Data/CLAUDE.md`: add `WaitingForChildren` to the TaskEntity status list.
|
|
||||||
- Root `CLAUDE.md`: update the "Task status flow" convention line.
|
|
||||||
|
|
||||||
## Verify
|
|
||||||
|
|
||||||
- `dotnet test` for Worker.Tests + Data.Tests (`-c Release`).
|
|
||||||
- UI flows (planning finalize → review → approve-merge; improvement parent;
|
|
||||||
retired MergeAllPlanning button) are **visual-verification gaps** — flag for the
|
|
||||||
user to run the app; do not claim they work from tests alone.
|
|
||||||
@@ -1,173 +0,0 @@
|
|||||||
# Approve = Merge → Done, plus Conflict Preview — Design
|
|
||||||
|
|
||||||
**Date:** 2026-06-04
|
|
||||||
**Status:** Approved (autonomous — user on break, authorized to continue)
|
|
||||||
**Author:** brainstormed from issue "Make merge/diff real"
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
Approving a `WaitingForReview` task flips it straight to `Done`
|
|
||||||
(`TaskStateService.ApproveReviewAsync`) and **never merges** its worktree — the
|
|
||||||
worktree stays `Active`. The user approved three component tasks expecting them
|
|
||||||
to merge; none did. Separately, there is **no way to see whether a task's
|
|
||||||
worktree merges cleanly** before acting, and a standalone task has no direct
|
|
||||||
**Merge** button (single-task merge is only reachable from inside the Diff
|
|
||||||
modal).
|
|
||||||
|
|
||||||
What is already real (verified): `WorkerHub.MergeTask → TaskMergeService.MergeAsync`
|
|
||||||
performs a real `git merge --no-ff`, aborts on conflict, and marks the worktree
|
|
||||||
`Merged`. **Open Diff** opens a real in-app diff. **Merge All Subtasks**
|
|
||||||
(planning) is real. So the gaps are narrow.
|
|
||||||
|
|
||||||
## Scope decisions (autonomous)
|
|
||||||
|
|
||||||
- **Tab location:** keep the **single "Session" tab** that the recent commit
|
|
||||||
`ac9bae9` deliberately consolidated. All new controls go in its existing
|
|
||||||
`MERGE & WORKTREE` block (`WorkConsole.axaml:196`). Do **not** re-introduce a
|
|
||||||
separate "Actions" tab.
|
|
||||||
- **Approve target:** Approve merges into the UI-selected merge target
|
|
||||||
(`SelectedMergeTarget`); when blank, the worker resolves to the repo's current
|
|
||||||
branch.
|
|
||||||
- **On conflict:** task stays in `WaitingForReview` (no new status). The conflict
|
|
||||||
is surfaced inline. No automatic state change to a "blocked" status.
|
|
||||||
- **Worktree removal on approve:** do **not** remove — merge marks the worktree
|
|
||||||
`Merged` and existing auto-cleanup handles disposal (matches the single-task
|
|
||||||
merge default `removeWorktree:false`).
|
|
||||||
- **Applies to:** standalone leaf tasks with an active worktree. A
|
|
||||||
`WaitingForReview` task with **no** active worktree (e.g. ran in a sandbox, or
|
|
||||||
an improvement parent whose children own the worktrees) is just marked `Done`
|
|
||||||
— current behavior preserved. Planning parents keep "Merge All Subtasks".
|
|
||||||
|
|
||||||
## Acceptance (restated)
|
|
||||||
|
|
||||||
1. Approve a clean-merging task → worktree merged into target, worktree `Merged`,
|
|
||||||
task `Done`.
|
|
||||||
2. Approve a conflicting task → task **not** `Done`, conflict surfaced.
|
|
||||||
3. Opening a Done/WaitingForReview task shows clean/conflict status **without
|
|
||||||
mutating** the tree (use `git merge-tree`, not a real merge).
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
Three layers, each single-purpose; the only new cross-dependency is
|
|
||||||
`TaskMergeService → ITaskStateService` (one-way; verify no DI cycle).
|
|
||||||
|
|
||||||
### 1. GitService — non-destructive conflict probe (`ClaudeDo.Data`)
|
|
||||||
|
|
||||||
New method:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public sealed record MergePreview(bool Supported, bool Clean, IReadOnlyList<string> ConflictFiles);
|
|
||||||
|
|
||||||
public async Task<MergePreview> PreviewMergeAsync(
|
|
||||||
string repoDir, string targetBranch, string sourceBranch, CancellationToken ct = default)
|
|
||||||
```
|
|
||||||
|
|
||||||
- Runs `git merge-tree --write-tree --name-only <target> <source>` from `repoDir`.
|
|
||||||
`merge-tree` computes the merge base itself and writes only loose objects — it
|
|
||||||
does **not** touch the working tree, index, or refs.
|
|
||||||
- Exit code `0` → `Clean = true`, no conflict files.
|
|
||||||
- Exit code `1` → `Clean = false`; conflicted paths are the lines after the
|
|
||||||
first (tree-OID) line, up to the first blank line.
|
|
||||||
- Any other outcome (e.g. git too old → "unknown option") → `Supported = false`
|
|
||||||
(UI shows "mergeability unknown").
|
|
||||||
|
|
||||||
New helper for the "· N files" count (clean case):
|
|
||||||
`git diff --name-only <target>...<source>` (three-dot = changes on source since
|
|
||||||
the merge base); count non-empty lines. May reuse/extend existing diff helpers.
|
|
||||||
|
|
||||||
### 2. TaskMergeService — preview + approve orchestration (`ClaudeDo.Worker`)
|
|
||||||
|
|
||||||
Inject `ITaskStateService` (verify `PlanningChainCoordinator` has no back-edge to
|
|
||||||
`TaskMergeService`; if a cycle exists, fall back to orchestrating in the hub).
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public sealed record MergePreviewResult(string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount);
|
|
||||||
// Status: "clean" | "conflict" | "unavailable"
|
|
||||||
public Task<MergePreviewResult> PreviewAsync(string taskId, string targetBranch, CancellationToken ct);
|
|
||||||
|
|
||||||
public Task<MergeResult> ApproveAndMergeAsync(string taskId, string targetBranch, CancellationToken ct);
|
|
||||||
```
|
|
||||||
|
|
||||||
**PreviewAsync:** load context. If no active worktree → `"unavailable"`. Resolve
|
|
||||||
`targetBranch` (blank → current branch). Call `GitService.PreviewMergeAsync`; map
|
|
||||||
`Supported=false` → `"unavailable"`, else clean/conflict (+ ChangedFileCount on
|
|
||||||
clean).
|
|
||||||
|
|
||||||
**ApproveAndMergeAsync:** load context; require `task.Status == WaitingForReview`
|
|
||||||
(else `Blocked`). Resolve target (blank → current branch).
|
|
||||||
- **No active worktree** → `_state.ApproveReviewAsync(taskId)` → return
|
|
||||||
`MergeResult(StatusMerged, [], null)` ("approved, nothing to merge").
|
|
||||||
- **Active worktree** → `MergeAsync(taskId, target, removeWorktree:false,
|
|
||||||
"Merge {branch}", ct)`. On `StatusMerged` → `_state.ApproveReviewAsync(taskId)`
|
|
||||||
then return the merged result. On `StatusConflict`/`StatusBlocked` → return as-is;
|
|
||||||
**do not** flip status (task stays `WaitingForReview`).
|
|
||||||
|
|
||||||
`TaskStateService.ApproveReviewAsync` is unchanged (still the sole Status writer;
|
|
||||||
still runs `OnChildTerminalAsync`).
|
|
||||||
|
|
||||||
### 3. WorkerHub — signatures (`ClaudeDo.Worker`)
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public record MergePreviewDto(string Status, IReadOnlyList<string> ConflictFiles, int ChangedFileCount);
|
|
||||||
|
|
||||||
public Task<MergePreviewDto> PreviewMerge(string taskId, string targetBranch); // new
|
|
||||||
public Task<MergeResultDto> ApproveReview(string taskId, string targetBranch); // CHANGED: was void(taskId)
|
|
||||||
```
|
|
||||||
|
|
||||||
`ApproveReview` returns the orchestration result so the UI can react to conflicts.
|
|
||||||
`MergeTask` / `GetMergeTargets` unchanged.
|
|
||||||
|
|
||||||
### 4. UI (`ClaudeDo.Ui`)
|
|
||||||
|
|
||||||
`IWorkerClient` (+ `WorkerClient` + **both test-project fakes** — see memory:
|
|
||||||
changing `IWorkerClient` breaks hand-rolled fakes):
|
|
||||||
- Change `Task ApproveReviewAsync(string)` → `Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch)`.
|
|
||||||
- Add `Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch)`.
|
|
||||||
- Add `Task<MergeResultDto> MergeTaskAsync(...)` to the **interface** (already on
|
|
||||||
the concrete client) so the single-task Merge button can use `_worker`.
|
|
||||||
|
|
||||||
`DetailsIslandViewModel`:
|
|
||||||
- **Load merge targets whenever a worktree exists.** In `BindAsync`, when
|
|
||||||
`entity.Worktree != null` and the task is not a planning parent, call
|
|
||||||
`GetMergeTargetsAsync(taskId)` and set `SelectedMergeTarget = DefaultBranch`
|
|
||||||
(fixes the standalone-task gap where targets were never loaded).
|
|
||||||
- **Mergeability indicator** properties: `MergePreviewText` (string),
|
|
||||||
`MergeIsClean` / `MergeIsConflict` (bool, for color). Compute via
|
|
||||||
`PreviewMergeAsync` when the merge section is shown for an **Active** worktree;
|
|
||||||
recompute on `SelectedMergeTarget` change. If worktree state is
|
|
||||||
`Merged/Discarded/Kept`, show that label instead of probing. Text examples:
|
|
||||||
"Merges cleanly · 7 files" / "Conflicts in a.cs, b.cs" / "Mergeability unknown".
|
|
||||||
- **Approve** (`ApproveReviewAsync`): pass `SelectedMergeTarget ?? ""`; inspect
|
|
||||||
result — on `"conflict"` set the conflict indicator + a short notice
|
|
||||||
("Approve blocked — resolve conflicts first"); success path relies on the
|
|
||||||
existing `TaskUpdated` broadcast to refresh.
|
|
||||||
- **Single-task Merge** (`MergeCommand`): `MergeTaskAsync(taskId,
|
|
||||||
SelectedMergeTarget ?? "", removeWorktree:false, "Merge task")`; on `"conflict"`
|
|
||||||
show the conflict indicator. Shown for non-planning tasks with an active
|
|
||||||
worktree (planning parents keep "Merge All Subtasks").
|
|
||||||
|
|
||||||
`WorkConsole.axaml` (Session tab, `MERGE & WORKTREE` block):
|
|
||||||
- Add a status line above the button row bound to `MergePreviewText`, colored
|
|
||||||
green (`MossBrush`) when `MergeIsClean`, red (`BloodBrush`) when
|
|
||||||
`MergeIsConflict`, muted otherwise. Use existing tokens/classes only.
|
|
||||||
- Add a **Merge** button (`MergeCommand`) beside **Open Diff** for the
|
|
||||||
single-task path.
|
|
||||||
|
|
||||||
## Testing (git-backed, no real Claude)
|
|
||||||
|
|
||||||
In `ClaudeDo.Worker.Tests` (real temp git repos + real SQLite), and/or
|
|
||||||
`ClaudeDo.Data.Tests` for the pure git probe:
|
|
||||||
- `GitService.PreviewMergeAsync`: clean branches → `Clean=true`; a real
|
|
||||||
edit-conflict on the same lines → `Clean=false` with the expected file in
|
|
||||||
`ConflictFiles`.
|
|
||||||
- `ApproveAndMergeAsync`: clean worktree → returns `merged`, task is `Done`,
|
|
||||||
worktree state `Merged`. Conflicting worktree → returns `conflict`, task still
|
|
||||||
`WaitingForReview`, worktree still `Active`, target branch unmodified
|
|
||||||
(HEAD unchanged, no `MERGE_HEAD`).
|
|
||||||
- No-worktree `WaitingForReview` task → returns `merged`, task `Done`.
|
|
||||||
|
|
||||||
## Out of scope
|
|
||||||
|
|
||||||
External difftools, new task statuses, auto-removing worktrees on approve,
|
|
||||||
re-splitting the console into separate tabs, conflict resolution UI (the existing
|
|
||||||
`ContinueMerge`/`AbortMerge` paths remain as-is for mid-merge cases).
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
# ClaudeDo distribution website — design
|
|
||||||
|
|
||||||
**Date:** 2026-06-04
|
|
||||||
**Status:** Approved (design), ready for implementation planning
|
|
||||||
**Repo:** new standalone repo `claudedo-web` (not part of the ClaudeDo app solution)
|
|
||||||
**Domain:** `claudedo.kuns.dev` (Coolify on the user's VPS)
|
|
||||||
|
|
||||||
## Purpose
|
|
||||||
|
|
||||||
Give friends a public place to download ClaudeDo and learn what it does, without
|
|
||||||
sending them to the Gitea repo — so the source repo can be made more private. The
|
|
||||||
site also fronts the app's self-updater so the Gitea URL is never exposed in the
|
|
||||||
app or on the page.
|
|
||||||
|
|
||||||
## Goals / non-goals
|
|
||||||
|
|
||||||
**Goals**
|
|
||||||
- Public, no-auth landing page at `claudedo.kuns.dev` that matches the app's visual identity.
|
|
||||||
- A primary download (installer `.exe`) plus the portable `.zip` and checksums.
|
|
||||||
- A release proxy that (a) feeds the page the current version and (b) serves the
|
|
||||||
app's self-updater the same JSON shape Gitea returns, with download URLs rewritten
|
|
||||||
to route through `claudedo.kuns.dev` — hiding Gitea entirely.
|
|
||||||
|
|
||||||
**Non-goals**
|
|
||||||
- No docs site / getting-started page (the app ships an installer that handles setup).
|
|
||||||
- No changelog page (release notes already live on Gitea releases).
|
|
||||||
- No auth, accounts, analytics, or CMS.
|
|
||||||
- No CI/PR tooling for this repo beyond what Coolify needs to deploy.
|
|
||||||
|
|
||||||
## Access & distribution decisions
|
|
||||||
|
|
||||||
- **Access:** fully public. No password/login. Relies on the unadvertised URL.
|
|
||||||
- **Download source:** build-time fetch of the latest Gitea release for the displayed
|
|
||||||
version; actual download links route through the proxy and resolve the latest asset
|
|
||||||
at request time (so a stale page still downloads the current build).
|
|
||||||
- **Self-updater proxy:** in scope for v1 (not deferred).
|
|
||||||
|
|
||||||
## Tech stack
|
|
||||||
|
|
||||||
- **Nuxt 3** (Vue 3) — single framework, single repo, single Coolify deploy.
|
|
||||||
- **Nitro** server routes for the release proxy + asset streaming.
|
|
||||||
- **No DB, no auth, no secrets** (the `releases/ClaudeDo` repo is public).
|
|
||||||
- Fonts: **Inter Tight** (display/body) + **JetBrains Mono** (mono), self-hosted or via
|
|
||||||
Google Fonts. Design tokens ported from `docs/UI Rewrite/design_handoff_claudedo/Tokens.axaml`
|
|
||||||
and `styles.css` (moss/sage/peat palette, dark-first, 14px island radius, grain texture).
|
|
||||||
|
|
||||||
## Concept: "the page IS the app"
|
|
||||||
|
|
||||||
The landing page is a faithful, in-browser rendering of the ClaudeDo desktop — the
|
|
||||||
window chrome + the three islands — rather than a conventional marketing page. This
|
|
||||||
is the chosen direction (over a cinematic single-window scroll and a worklog feed).
|
|
||||||
|
|
||||||
### Layout — three islands on the app "desktop"
|
|
||||||
|
|
||||||
Desktop background = the app's layered moss gradients + 3px grain overlay. A centered
|
|
||||||
app `window` (titlebar + body) holds a 3-column island grid:
|
|
||||||
|
|
||||||
1. **Lists island (left)** — repurposed as page nav.
|
|
||||||
- Header "Lists" + a decorative search box (`Ctrl K` kbd chip).
|
|
||||||
- "Pages" group: Overview · Features (6) · How it works · Screenshots (3) · Download,
|
|
||||||
each with a colored swatch dot; active item gets the accent left-bar.
|
|
||||||
- Footer styled like the app's user footer: avatar, "For friends", `claudedo.kuns.dev`.
|
|
||||||
|
|
||||||
2. **Tasks island (middle)** — features rendered as **task cards**.
|
|
||||||
- Header: date eyebrow, big title (the hero line "Queue the work. Claude does it."),
|
|
||||||
a `running · review` badge, eye/gear icon buttons, and a subtitle.
|
|
||||||
- A decorative "Add a task…" row.
|
|
||||||
- Six **feature cards** (circle check — done cards filled; title; a status chip
|
|
||||||
`done`/`running`/`waiting for review`; a star). The features:
|
|
||||||
1. Isolated worktrees
|
|
||||||
2. The task queue
|
|
||||||
3. Review & merge
|
|
||||||
4. Live session log
|
|
||||||
5. Per-list & per-task config
|
|
||||||
6. Self-updating
|
|
||||||
- A "Ready" group with the final **"↓ Download ClaudeDo"** card.
|
|
||||||
|
|
||||||
3. **Detail island (right)** — faithful to the reworked Task-Detail island.
|
|
||||||
- **Source of truth for the detail visuals:** `docs/superpowers/specs/2026-06-04-task-detail-redesign-design.md`
|
|
||||||
(the app's in-progress rework). The website's detail pane must track that design.
|
|
||||||
- Three-zone stack:
|
|
||||||
- **Task header** — mono id (`#F01…`), title, trash/gear action icons.
|
|
||||||
- **DETAILS bar** — `DETAILS` eyebrow + `Edit` / copy / `⋯`, then a **markdown body**
|
|
||||||
(headings, paragraphs, inline `code`, ordered/unordered lists) describing the feature.
|
|
||||||
- **WorkConsole** docked at the bottom — traffic-light dots, `· N turns · +x −y`,
|
|
||||||
tabs **Output / Actions / Session**, and a `Created …` footer.
|
|
||||||
- Per-feature mapping:
|
|
||||||
- Feature panels: markdown writeup in DETAILS + a short relevant **Output** log.
|
|
||||||
- "Review & merge": opens on the **Actions** tab with `Merge target` + `Open Diff` /
|
|
||||||
`Approve & merge`.
|
|
||||||
- **Download**: DETAILS shows requirements (`.NET 8 Desktop Runtime`, `Claude CLI`,
|
|
||||||
`Git`); the **Actions** tab holds the install controls, with `Merge target`
|
|
||||||
repurposed as a **Build** selector and buttons `↓ Download installer` /
|
|
||||||
`Portable .zip` / `checksums.txt`.
|
|
||||||
|
|
||||||
4. **Statusbar (bottom)** — `● Online · claudedo.kuns.dev · private build`.
|
|
||||||
|
|
||||||
### Interaction
|
|
||||||
|
|
||||||
- Clicking a task card selects it (accent bar + card highlight) and swaps the active
|
|
||||||
detail panel; the WorkConsole tabs are clickable within a panel.
|
|
||||||
- **All panels are server-rendered and present in the DOM**, toggled by class — the
|
|
||||||
page is fully readable and downloadable **without JavaScript** (progressive
|
|
||||||
enhancement). Vue handles the selection state on the client.
|
|
||||||
|
|
||||||
### Responsive
|
|
||||||
|
|
||||||
- ≤ ~1100px: drop the Detail island; show Lists + Tasks.
|
|
||||||
- ≤ ~780px: single column — the Tasks list; tapping a feature pushes to a full-screen
|
|
||||||
Detail view (mirrors the app's narrow-window behavior) with a back affordance.
|
|
||||||
|
|
||||||
## Server: release proxy (Nitro)
|
|
||||||
|
|
||||||
The app's `ReleaseClient` (`src/ClaudeDo.Releases/ReleaseClient.cs`) calls
|
|
||||||
`{apiBase}/releases/latest` and reads `tag_name`, `name`, and
|
|
||||||
`assets[].browser_download_url`; `DownloadAsync` GETs an asset URL directly.
|
|
||||||
|
|
||||||
- **`GET /api/releases/latest`** — fetches `https://git.kuns.dev/api/v1/repos/releases/ClaudeDo/releases/latest`,
|
|
||||||
returns the **same JSON shape**, but every `assets[].browser_download_url` is rewritten
|
|
||||||
from the Gitea URL to `https://claudedo.kuns.dev/api/download/<encoded-asset-path>`.
|
|
||||||
Cached briefly (e.g. 5 min) server-side.
|
|
||||||
- **`GET /api/download/[...path]`** — reconstructs the Gitea asset URL from the path and
|
|
||||||
**streams** the binary back (no redirect to Gitea, so the URL stays hidden). Sets
|
|
||||||
appropriate `Content-Type`/`Content-Disposition`.
|
|
||||||
- **`server/utils/gitea.ts`** — shared base URL (`GITEA_API`, `REPO` from env), fetch
|
|
||||||
helper, and the URL-rewrite/asset-path round-trip.
|
|
||||||
- The page's download buttons point at the same `/api/download/...` routes (with a
|
|
||||||
stable "latest installer" path), so links never go stale between deploys.
|
|
||||||
|
|
||||||
### App-side coordinating change (separate, in the ClaudeDo repo)
|
|
||||||
|
|
||||||
Point `ReleaseClient`'s `apiBase` at `https://claudedo.kuns.dev/api` instead of the
|
|
||||||
Gitea default (one-line DI change where `ReleaseClient`/`UpdateCheckService` are
|
|
||||||
constructed). Tracked as a follow-up; not part of the `claudedo-web` repo. The proxy
|
|
||||||
path (`/api/releases/latest`) is chosen to match the existing
|
|
||||||
`{apiBase}/releases/latest` call so the parser is untouched.
|
|
||||||
|
|
||||||
## Content / assets
|
|
||||||
|
|
||||||
- **Screenshots (provided):** main 3-column view (hero), diff review modal, worktrees
|
|
||||||
panel. Stored in `public/screenshots/`. Placeholders sized for them until dropped in.
|
|
||||||
- Copy: hero "Queue the work. Claude does it."; six feature writeups as above (final
|
|
||||||
wording during implementation).
|
|
||||||
|
|
||||||
## Error handling
|
|
||||||
|
|
||||||
- **Build-time release fetch fails:** render the page with a last-known/placeholder
|
|
||||||
version label; download buttons still work because they resolve via the runtime
|
|
||||||
proxy route.
|
|
||||||
- **Proxy `/api/releases/latest` upstream failure:** return a 502/`null`-equivalent the
|
|
||||||
way Gitea would on miss; the app's `UpdateCheckService` already treats null/exception
|
|
||||||
as `CheckFailed` and degrades gracefully.
|
|
||||||
- **`/api/download` upstream failure:** surface a 502; the button shows an error state.
|
|
||||||
- No retries beyond a single upstream attempt for v1 (low traffic, friends-only).
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
- **Vitest** unit tests for `server/utils/gitea.ts`: URL rewrite (Gitea → proxy) and the
|
|
||||||
asset-path round-trip (proxy path → Gitea URL), and release-JSON shape preservation.
|
|
||||||
- A light component smoke test that the page renders the islands and the download
|
|
||||||
controls without JS errors.
|
|
||||||
- No real-network/Gitea calls in tests — mock the upstream fetch.
|
|
||||||
|
|
||||||
## Deployment (Coolify)
|
|
||||||
|
|
||||||
- **Dockerfile**: `node:20-alpine` build → `nuxt build` → run `.output/server/index.mjs`.
|
|
||||||
- Coolify app bound to `claudedo.kuns.dev` with TLS via its reverse proxy.
|
|
||||||
- Env: `GITEA_API` (default `https://git.kuns.dev/api/v1`), `REPO` (`releases/ClaudeDo`),
|
|
||||||
`PUBLIC_BASE_URL` (`https://claudedo.kuns.dev`) for URL rewriting.
|
|
||||||
- Deploy on push to `main`; re-deploy (or a periodic rebuild) refreshes the displayed
|
|
||||||
version. No PR/CI tooling beyond Coolify's build.
|
|
||||||
|
|
||||||
## Open risk
|
|
||||||
|
|
||||||
- The reworked Detail island in the app is still in flux. The website's detail pane
|
|
||||||
must be kept in sync with `2026-06-04-task-detail-redesign-design.md`; expect a
|
|
||||||
visual-polish pass once that rework lands.
|
|
||||||
|
|
||||||
## Repo layout
|
|
||||||
|
|
||||||
```
|
|
||||||
claudedo-web/
|
|
||||||
├── nuxt.config.ts
|
|
||||||
├── app.vue
|
|
||||||
├── pages/index.vue # the one landing page
|
|
||||||
├── components/
|
|
||||||
│ ├── AppWindow.vue # window chrome + statusbar
|
|
||||||
│ ├── ListsIsland.vue # page nav
|
|
||||||
│ ├── TasksIsland.vue # feature cards + download card
|
|
||||||
│ ├── DetailIsland.vue # three-zone detail (header / DETAILS md / WorkConsole)
|
|
||||||
│ ├── WorkConsole.vue # tabs: Output / Actions / Session
|
|
||||||
│ └── content/ # per-feature markdown/blurbs + download panel
|
|
||||||
├── server/
|
|
||||||
│ ├── api/releases/latest.get.ts
|
|
||||||
│ ├── api/download/[...path].get.ts
|
|
||||||
│ └── utils/gitea.ts
|
|
||||||
├── assets/css/tokens.css # palette + type ported from Tokens.axaml/styles.css
|
|
||||||
├── public/screenshots/ # 3 PNGs
|
|
||||||
└── Dockerfile
|
|
||||||
```
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
# Refine Task — Design
|
|
||||||
|
|
||||||
**Date:** 2026-06-04
|
|
||||||
**Status:** Approved (pending spec review)
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Add a one-click **Refine Task** action to a task card. Clicking it spawns a
|
|
||||||
headless Claude session that reads the task (and the repo), rewrites the task's
|
|
||||||
description to be clearer and runnable autonomously, and — where it helps —
|
|
||||||
breaks the work into subtasks. The user then reviews/hand-edits the result and
|
|
||||||
queues the task manually.
|
|
||||||
|
|
||||||
This is **not** an interactive terminal session. It is a fire-and-forget
|
|
||||||
headless run, structurally similar to the existing daily-prep ("Prime Claude")
|
|
||||||
flow (`PrimeRunner`), not the interactive planning flow.
|
|
||||||
|
|
||||||
## Non-goals / scope
|
|
||||||
|
|
||||||
- No new task status. The task stays `Idle` throughout; refine only mutates the
|
|
||||||
task's `Title`/`Description` and its subtasks.
|
|
||||||
- No worktree, no interactive terminal, no auto-queue.
|
|
||||||
- No per-task refine config (model, turns) — uses the worker's defaults.
|
|
||||||
- Refine does not edit repository files; repo access is read-only.
|
|
||||||
|
|
||||||
## User flow
|
|
||||||
|
|
||||||
1. User clicks the refine icon on an `Idle` task's card.
|
|
||||||
2. UI calls `WorkerHub.RefineTask(taskId)` → `RefineRunner`.
|
|
||||||
3. `RefineRunner` spawns `claude -p` headless in the list's working directory,
|
|
||||||
seeded with a fixed refine prompt + the task's title/description/current
|
|
||||||
subtasks + the task id.
|
|
||||||
4. Claude reads the repo (read-only), then calls:
|
|
||||||
- `mcp__claudedo__update_task` to improve title/description, and
|
|
||||||
- `mcp__claudedo__add_subtask` to add steps where useful.
|
|
||||||
Each MCP call broadcasts `TaskUpdated`, so the description and Steps card
|
|
||||||
update live in the UI.
|
|
||||||
5. Run finishes; the card's refine button returns to its idle state. User
|
|
||||||
reviews, optionally hand-edits the description/steps, then queues manually.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Worker — `RefineRunner`
|
|
||||||
|
|
||||||
- New `Worker/Refine/RefineRunner.cs` implementing `IRefineRunner`
|
|
||||||
(`Worker/Refine/Interfaces/IRefineRunner.cs`). Modeled on `PrimeRunner`.
|
|
||||||
- **Concurrency / single-flight:** an in-flight `HashSet<string>` of task ids
|
|
||||||
guarded by a lock (or `SemaphoreSlim`), so the *same* task cannot refine
|
|
||||||
twice concurrently, but different tasks may refine in parallel. A second
|
|
||||||
click on an already-refining task is a no-op.
|
|
||||||
- **Guards:** only runs when `task.Status == Idle`. Resolves the list's working
|
|
||||||
directory. If the list has **no valid working dir**, fall back to a sandbox
|
|
||||||
directory and run text-only (drop `Read`/`Grep`/`Glob` from the allowlist).
|
|
||||||
- **CLI invocation** (relies on the globally-registered `claudedo` MCP, like
|
|
||||||
daily-prep — no `--mcp-config`):
|
|
||||||
```
|
|
||||||
claude -p --output-format stream-json --verbose
|
|
||||||
--permission-mode acceptEdits
|
|
||||||
--max-turns <N>
|
|
||||||
--allowedTools mcp__claudedo__get_task,mcp__claudedo__update_task,mcp__claudedo__add_subtask,Read,Grep,Glob
|
|
||||||
```
|
|
||||||
`Edit`/`Write`/`Bash` are deliberately **not** whitelisted, so the run is
|
|
||||||
read-only on the repo even under `acceptEdits`. (Chosen over `plan` mode to
|
|
||||||
avoid the headless "exit plan mode to act" friction; the allowlist is the
|
|
||||||
real read-only gate.)
|
|
||||||
- **Logging:** stream stdout to a per-run log at
|
|
||||||
`logs/refine-<taskId[:8]>.log`, truncated at the start of each run.
|
|
||||||
|
|
||||||
### Prompt — `PromptKind.Refine`
|
|
||||||
|
|
||||||
- Add `Refine` to the `PromptKind` enum in `PromptFiles.cs`, file
|
|
||||||
`prompts/refine.md`, with a bundled default.
|
|
||||||
- Default prompt instructs: refine one ClaudeDo task so it is ready to run
|
|
||||||
autonomously; ground the description in the actual code (read-only); keep
|
|
||||||
scope tight (no scope creep into adjacent work); add steps as subtasks only
|
|
||||||
when they genuinely help; use only `get_task`, `update_task`, `add_subtask`
|
|
||||||
and the read-only tools; never edit files.
|
|
||||||
- Rendered via `PromptFiles.Render` with `{taskId}`, `{title}`,
|
|
||||||
`{description}`, and the current subtask list seeded into the prompt so the
|
|
||||||
agent knows which steps already exist.
|
|
||||||
|
|
||||||
### MCP tool — `add_subtask`
|
|
||||||
|
|
||||||
- New `[McpServerTool]` on `ExternalMcpService` (part of the global `claudedo`
|
|
||||||
MCP), signature `add_subtask(taskId, title, orderNum?)`.
|
|
||||||
- Creates a `SubtaskEntity` via `SubtaskRepository`; `orderNum` defaults to
|
|
||||||
append-at-end (max existing + 1). Refuses if the task is `Running`.
|
|
||||||
Broadcasts `TaskUpdated`.
|
|
||||||
- **Append semantics, not replace:** the current subtasks are already in the
|
|
||||||
prompt, so the agent only adds missing steps; re-running refine will not
|
|
||||||
silently wipe steps the user hand-edited.
|
|
||||||
- `update_task` already exists (title/description/commitType) and is reused
|
|
||||||
unchanged.
|
|
||||||
|
|
||||||
### UI — button, icon, feedback
|
|
||||||
|
|
||||||
- **Icon:** add the supplied SVG as an `Icon.Refine` `StreamGeometry` in
|
|
||||||
`IslandStyles.axaml`, rendered as a **stroked `Path`** (`plan-icon` style,
|
|
||||||
fill none) — it is line art, so per the PathIcon-fills-geometry gotcha it
|
|
||||||
must be stroked, not filled.
|
|
||||||
- **Button:** a new `icon-btn` in `TaskRowView.axaml` near the star button,
|
|
||||||
visible only when the task is `Idle`. Bound to a new `RefineTaskCommand` on
|
|
||||||
`TasksIslandViewModel`.
|
|
||||||
- **Feedback:** new broadcaster events `RefineStarted(taskId)` /
|
|
||||||
`RefineFinished(taskId, ok, error?)` drive an `IsRefining` flag on
|
|
||||||
`TaskRowViewModel`; the button shows a busy/disabled state while running. The
|
|
||||||
description and Steps card update live via the existing `TaskUpdated` events
|
|
||||||
fired by the MCP calls.
|
|
||||||
- Wire `RefineTask` through `IWorkerClient` / `WorkerClient`, the `WorkerHub`
|
|
||||||
method, and update the hand-rolled test fakes in both test projects.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
- `add_subtask`: creates the row, appends order correctly, refuses when
|
|
||||||
`Running`, broadcasts `TaskUpdated`.
|
|
||||||
- Refine prompt builder and CLI-args builder produce the expected prompt/flags
|
|
||||||
(including the text-only fallback when no working dir).
|
|
||||||
- `RefineRunner` guards: `Idle`-only, per-task single-flight no-op on a second
|
|
||||||
concurrent call.
|
|
||||||
- **No test spawns the real `claude` CLI** (project rule). The end-to-end run
|
|
||||||
is a manual smoke step.
|
|
||||||
|
|
||||||
## Open implementation calls (decided)
|
|
||||||
|
|
||||||
- **Permission mode:** `acceptEdits` + restricted allowlist for read-only
|
|
||||||
(rather than `plan` mode).
|
|
||||||
- **`add_subtask`:** append-only (rather than replace-all).
|
|
||||||
@@ -1,200 +0,0 @@
|
|||||||
# Task Detail Island Redesign — Design
|
|
||||||
|
|
||||||
**Date:** 2026-06-04
|
|
||||||
**Status:** Approved (design), pending implementation
|
|
||||||
**Author:** brainstormed with user via visual companion
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
The Detail island (`DetailsIslandView`, 413 lines) grew into one long scrolling
|
|
||||||
column as features piled on. The user has to scroll constantly. Specific pains
|
|
||||||
(confirmed by the user):
|
|
||||||
|
|
||||||
- **Everything is always stacked** — Steps, Description, Terminal, and several
|
|
||||||
conditional sections share one scroll column with no way to hide/fold.
|
|
||||||
- **Duplicated info** — `model` shows in the gear flyout *and* the agent strip;
|
|
||||||
the branch line shows in the agent strip *and* as the terminal label.
|
|
||||||
- **Agent strip is a heavy 5-row block** pinned near the bottom even when idle.
|
|
||||||
- **Steps + Description take a lot of room** before the action controls.
|
|
||||||
|
|
||||||
The terminal staying prominent is *fine* — not a pain point.
|
|
||||||
|
|
||||||
## Solution overview
|
|
||||||
|
|
||||||
Replace the linear body with a **fixed-region layout** built from **3 new
|
|
||||||
self-contained components**, plus a roadblock band. Top region (header + details
|
|
||||||
card) stays put; the work console is pinned to the lower third.
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────┐
|
|
||||||
│ TaskHeaderBar (separated title) │ #T42 · title · 🗑/💀 · ⚙
|
|
||||||
├─────────────────────────────────────┤
|
|
||||||
│ DescriptionStepsCard │ card; text ⇄ steps toggle icon
|
|
||||||
│ (Preview = what Claude gets) │ copy · preview/edit
|
|
||||||
├─────────────────────────────────────┤
|
|
||||||
│ Roadblock band (only when failed) │ ⚠ message · Continue · Reset&Retry
|
|
||||||
├─────────────────────────────────────┤
|
|
||||||
│ WorkConsole (pinned, terminal) │ ●●● · model·turns·diff
|
|
||||||
│ tabs: Output | Actions | Session │
|
|
||||||
└─────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## The 3 components
|
|
||||||
|
|
||||||
Each is a standalone `UserControl` + dedicated `ViewModel` with **design-time
|
|
||||||
sample data** so it renders fully in the Avalonia previewer in isolation. Built
|
|
||||||
in separate worktrees; **none touch `DetailsIslandView.axaml` or
|
|
||||||
`DetailsIslandViewModel.cs`** (that is the wiring session). All visuals use
|
|
||||||
**only** the existing design tokens (`Design/Tokens.axaml`) and style classes
|
|
||||||
(`Design/IslandStyles.axaml`) — no hardcoded colors/sizes.
|
|
||||||
|
|
||||||
New folder: `src/ClaudeDo.Ui/Views/Islands/Detail/`
|
|
||||||
New VMs: `src/ClaudeDo.Ui/ViewModels/Islands/Detail/`
|
|
||||||
|
|
||||||
### 1. TaskHeaderBar
|
|
||||||
|
|
||||||
- **Layout:** one row — `#T42` id badge (mono `meta`, copyable) · editable title
|
|
||||||
`TextBox` (transparent, `FontSizeTaskTitle`, wraps) · **trash/skull button** ·
|
|
||||||
⚙ gear button with the agent-settings flyout.
|
|
||||||
- **Trash → Skull:** when **not** running show `Icon.Trash` (delete task,
|
|
||||||
`BloodBrush`); when **running** show a **skull** glyph (kill session). One
|
|
||||||
button, swaps icon + command on running state. Skull is a *new* filled
|
|
||||||
geometry to add to `IslandStyles.axaml` resources (`Icon.Skull`).
|
|
||||||
- **No done circle. No star** (the star lives on the task card/row already).
|
|
||||||
- **Gear flyout:** keep the existing agent-settings content verbatim — Model
|
|
||||||
combo + `InheritedBadge` + reset; Max Turns `NumericUpDown` + badge + reset;
|
|
||||||
System Prompt `TextBox` + "prepended" hint; Agent File combo + badge + reset.
|
|
||||||
Disabled while running (`IsAgentSectionEnabled`).
|
|
||||||
- **Existing bindings reused:** `TaskIdBadge`, `EditableTitle`, `DeleteTaskCommand`,
|
|
||||||
`StopCommand`, `IsRunning`, `IsAgentSectionEnabled`, all the agent-settings
|
|
||||||
members (`TaskModelOptions`/`TaskModelSelection`/`ModelBadge`/
|
|
||||||
`ResetTaskModelCommand`/`TaskMaxTurns`/`TurnsBadge`/`ResetTaskTurnsCommand`/
|
|
||||||
`TaskSystemPrompt`/`EffectiveSystemPromptHint`/`TaskAgentOptions`/
|
|
||||||
`TaskSelectedAgent`/`AgentBadge`/`ResetTaskAgentCommand`).
|
|
||||||
|
|
||||||
### 2. DescriptionStepsCard
|
|
||||||
|
|
||||||
A `Border.island`-style card. The single explicitly-requested "separate
|
|
||||||
component." Top-right **toggle icon** switches the card between **Description**
|
|
||||||
and **Steps** views; the icon shows the *other* mode (in Description view → steps
|
|
||||||
icon `Icon.MoreHorizontal`/list glyph; in Steps view → text glyph).
|
|
||||||
|
|
||||||
- **Header row:** small `section-label` ("DETAILS" / "STEPS") · spacer · **Copy**
|
|
||||||
icon button (`Icon.Copy`) · **Preview/Edit** toggle button (Description view
|
|
||||||
only) · **toggle icon** (top-right).
|
|
||||||
- **Description view:**
|
|
||||||
- *Preview mode* = renders **what Claude gets** via `MarkdownView`: the
|
|
||||||
canonical composed text (Title + Description + open steps — see below).
|
|
||||||
- *Edit mode* = raw description `TextBox` (mono, `Surface2Brush`, multiline).
|
|
||||||
- **Steps view:** add-step input (Enter to add) + list of step rows (check
|
|
||||||
circle `Ellipse.task-check` + inline-editable title, `subtask-row` style).
|
|
||||||
- **Copy** copies the **formatted** version (Title + Description + open steps),
|
|
||||||
nothing else, to the clipboard.
|
|
||||||
- **Existing bindings reused (when wired):** `EditableDescription`,
|
|
||||||
`IsEditingDescription`/`ToggleEditDescriptionCommand`, `Subtasks`,
|
|
||||||
`NewSubtaskTitle`/`AddSubtaskCommand`, `ToggleSubtaskDoneCommand`,
|
|
||||||
`CommitSubtaskEditCommand`.
|
|
||||||
- **New members (defined on the component VM now, lifted into
|
|
||||||
`DetailsIslandViewModel` at wiring):** `IsStepsView` + `ToggleCardViewCommand`;
|
|
||||||
`ComposedPreview` (string, the canonical format); `CopyFormattedCommand`.
|
|
||||||
|
|
||||||
### 3. WorkConsole
|
|
||||||
|
|
||||||
Terminal-styled card (`Border.terminal`) pinned to the lower third.
|
|
||||||
|
|
||||||
- **Title bar:** three cosmetic traffic-light dots (`Ellipse.dot-red`,
|
|
||||||
`dot-yellow`, `dot-green`) on the left; centered/!right small **info header**:
|
|
||||||
`model · {turns} turns · +adds −dels` (mono `meta`; `diff-add`/`diff-del`
|
|
||||||
classes for the numbers). **No branch line.** LIVE/DONE/FAILED chip
|
|
||||||
(`live-chip`) on the right.
|
|
||||||
- **Tab strip:** `Output` | `Actions` | `Session`.
|
|
||||||
- **Output** — the live log. Reuse `SessionTerminalView` (`Entries`, `Label`,
|
|
||||||
`IsRunning`, `IsDone`, `IsFailed`) for the body, *or* the same
|
|
||||||
timestamp+`SelectableTextBlock` row template.
|
|
||||||
- **Actions** — worktree management: merge-target `ComboBox`, **Open Diff**,
|
|
||||||
**Worktree**, **Merge** (+ planning **Merge All Subtasks** when planning
|
|
||||||
parent). Bindings: `MergeTargetBranches`/`SelectedMergeTarget`,
|
|
||||||
`OpenDiffCommand`, `OpenWorktreeCommand`, `MergeAllCommand`/`CanMergeAll`/
|
|
||||||
`MergeAllDisabledReason`/`MergeAllError`, `ReviewCombinedDiffCommand`.
|
|
||||||
- **Session** — review + outcomes: feedback `TextBox` + Approve/Reject/Park/
|
|
||||||
Cancel (`ReviewFeedback`, `ApproveReviewCommand`, `RejectReviewCommand`,
|
|
||||||
`ParkReviewCommand`, `CancelReviewCommand`, shown when `IsWaitingForReview`)
|
|
||||||
and the child-outcomes list (`ChildOutcomes`, `HasChildOutcomes`).
|
|
||||||
- **Roadblock band** (above the tabs, inside or just above the card): visible on
|
|
||||||
`IsFailed`/`IsCancelled`; shows a warning (`Icon.Warning`, `BloodBrush`) and
|
|
||||||
**Continue** (`ContinueCommand`, `ShowContinue`) + **Reset & Retry**
|
|
||||||
(`ResetAndRetryCommand`, `ShowResetAndRetry`).
|
|
||||||
- **Info-header bindings:** `Model`, `Turns`, `DiffAdditions`, `DiffDeletions`,
|
|
||||||
`IsRunning`/`IsDone`/`IsFailed`.
|
|
||||||
|
|
||||||
## Combined Description + Steps behavior
|
|
||||||
|
|
||||||
Steps are part of the description. When the task runs, the **effective prompt =
|
|
||||||
Title + Description + only the OPEN steps**. Resolved steps are dropped.
|
|
||||||
|
|
||||||
**Canonical composed format** (shared by the Worker prompt, the card's Preview,
|
|
||||||
and Copy):
|
|
||||||
|
|
||||||
```
|
|
||||||
<Title>
|
|
||||||
|
|
||||||
<Description>
|
|
||||||
|
|
||||||
## Sub-Tasks
|
|
||||||
- [ ] <open step 1>
|
|
||||||
- [ ] <open step 2>
|
|
||||||
```
|
|
||||||
|
|
||||||
- Omit the `## Sub-Tasks` section entirely when no open steps remain.
|
|
||||||
- Omit the description paragraph when description is empty.
|
|
||||||
|
|
||||||
**Worker change (wiring session, by Claude):** `TaskRunner.cs:104-113` currently
|
|
||||||
appends *all* subtasks with `[x]`/`[ ]`. Change to append **only incomplete**
|
|
||||||
subtasks as `- [ ]` lines (drop completed). Factor the format into a shared
|
|
||||||
`TaskPromptComposer` in `ClaudeDo.Data` (referenced by both Worker and UI) so the
|
|
||||||
card's Preview and the real prompt never diverge.
|
|
||||||
|
|
||||||
## Color / token guidelines (mandatory)
|
|
||||||
|
|
||||||
- Backgrounds: `IslandBackgroundBrush`, `Surface2Brush`, `Surface3Brush`,
|
|
||||||
`DeepBrush`, `VoidBrush` (terminal). Borders: `LineBrush`/`LineBrightBrush`,
|
|
||||||
`HairlineOverlayBrush`. Text: `TextBrush`/`TextDimBrush`/`TextMuteBrush`/
|
|
||||||
`TextFaintBrush`. Accent: `AccentBrush`/`AccentDimBrush`. Status: blood/peat/
|
|
||||||
moss/sage + the `*TintBrush` pairs.
|
|
||||||
- Radii: `IslandCornerRadius` (14), `ButtonCornerRadius` (6), `InputCornerRadius`
|
|
||||||
(8). Spacing: `SpaceXs..Space2Xl`. Fonts: `SansFont`, `MonoFont`; sizes
|
|
||||||
`FontSizeMono`/`FontSizeBody`/`FontSizeTaskTitle`.
|
|
||||||
- Reuse style classes: `island`, `island-header`, `chip`, `btn`/`btn accent`/
|
|
||||||
`primary`/`danger`, `icon-btn`, `flat`, `terminal`, `dot-red/yellow/green`,
|
|
||||||
`live-chip`, `task-check`, `subtask-row`, `section-label`, `field-label`,
|
|
||||||
`meta`, `diff-add`/`diff-del`, `diff-meter-*`.
|
|
||||||
- **No inline hex, no magic numbers** where a token exists. `PathIcon` fills
|
|
||||||
geometry — line-art must be filled or stroked via `Path`.
|
|
||||||
|
|
||||||
## Build / isolation strategy
|
|
||||||
|
|
||||||
1. Three ClaudeDo tasks (list "Claude do", repo `C:\Private\ClaudeDo`), one per
|
|
||||||
component, run sequentially in their own worktrees.
|
|
||||||
2. Each delivers: `Detail/<Name>.axaml` + `.axaml.cs` + `Detail/<Name>ViewModel.cs`
|
|
||||||
with design-time sample data; the `ClaudeDo.Ui` project **builds green**
|
|
||||||
(`dotnet build src/ClaudeDo.Ui/ClaudeDo.Ui.csproj -c Release`).
|
|
||||||
3. Components are visual-only against sample data. Real `DetailsIslandViewModel`
|
|
||||||
binding + the Worker steps→prompt change happen in the **wiring session**
|
|
||||||
(this Claude session, done while the build tasks run).
|
|
||||||
|
|
||||||
## Wiring plan (this session)
|
|
||||||
|
|
||||||
- Implement `TaskPromptComposer` + the `TaskRunner` open-steps change + a unit
|
|
||||||
test in `ClaudeDo.Worker.Tests`/`Data.Tests`.
|
|
||||||
- After the 3 components land: host them in `DetailsIslandView` (header top,
|
|
||||||
card below, roadblock band, work console pinned bottom), lift the new card VM
|
|
||||||
members into `DetailsIslandViewModel`, repoint `x:DataType`, delete the
|
|
||||||
superseded inline sections + `AgentStripView` usage. Update locale parity and
|
|
||||||
the test fakes.
|
|
||||||
|
|
||||||
## Monitoring loop (this session)
|
|
||||||
|
|
||||||
While the build tasks run: poll each via `get_task` / `get_task_log` /
|
|
||||||
`get_task_diff`, summarize progress and anything a session got stuck on, and if a
|
|
||||||
session is blocked on something missing, add a small follow-up task to the
|
|
||||||
"Claude do" list.
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
# Git Tab / Merge & Review Rework — Design
|
|
||||||
|
|
||||||
Date: 2026-06-05
|
|
||||||
Status: Approved
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Make handling merges and reviews as simple as possible in the Terminal component's
|
|
||||||
Git tab, and rework the diff viewers and worktree modals along the way. The work is
|
|
||||||
split into three layers built across separate sessions, with a shared foundation that
|
|
||||||
is built and pushed first so the parallel sessions branch from frozen contracts.
|
|
||||||
|
|
||||||
The user mostly trusts task output but wants the diff one click away for important
|
|
||||||
work, and wants to land several independently-queued worktrees without per-task
|
|
||||||
hopping or hand-resolving conflicts in an external editor.
|
|
||||||
|
|
||||||
## Layers
|
|
||||||
|
|
||||||
- **Layer A — Review/merge cockpit** (this session). Single-task review + merge UX in
|
|
||||||
the Git tab; consolidate the four diff renderers into one `DiffView`.
|
|
||||||
- **Layer B — Multi-worktree merge cockpit** (parallel session). Batch-merge N
|
|
||||||
worktrees into one target, skip-and-continue, conflicts collected for resolution.
|
|
||||||
- **Layer C — Inline conflict resolver** (parallel session). VSCode-style inline hunk
|
|
||||||
resolver plus the worker-side conflict plumbing it needs.
|
|
||||||
|
|
||||||
They stack: A defines the single-task flow, B reuses it for many tasks, both funnel
|
|
||||||
conflicts into C.
|
|
||||||
|
|
||||||
## Shared foundation (built & pushed this session, before B/C branch)
|
|
||||||
|
|
||||||
Everything B and C depend on lands first on `main`. B and C branch from that commit.
|
|
||||||
|
|
||||||
### 1. One diff model + one `DiffView` control
|
|
||||||
|
|
||||||
Today there are four diff renderers and two parallel diff models:
|
|
||||||
|
|
||||||
- `DiffLinesView.axaml` (used by `DiffModalView`)
|
|
||||||
- the inline diff `ItemsControl` in `WorktreeModalView.axaml`
|
|
||||||
- `PlanningDiffView.axaml`
|
|
||||||
- their backing models: `DiffFileViewModel`/`DiffLineViewModel` (+ `UnifiedDiffParser`)
|
|
||||||
vs `WorktreeNodeViewModel`/`WorktreeDiffLineViewModel`
|
|
||||||
|
|
||||||
Collapse into a single canonical diff model + parser + a `DiffView` UserControl. All
|
|
||||||
diff rendering across the app goes through `DiffView`.
|
|
||||||
|
|
||||||
- Model: `DiffFileViewModel { Path, AddCount, DelCount, Lines }`,
|
|
||||||
`DiffLineViewModel { OldNo, NewNo, Kind (Add|Del|Ctx|File|Hunk), Text }`.
|
|
||||||
- Parser: one static `UnifiedDiffParser.Parse(rawUnifiedDiff)` returning the model.
|
|
||||||
- `DiffView` exposes a `Files` styled property (file list + selected-file lines), or a
|
|
||||||
simpler `Lines` property for single-file use — Layer A decides the exact surface
|
|
||||||
while building it, but the type names above are frozen so B and C can bind to them.
|
|
||||||
|
|
||||||
### 2. Frozen worker conflict contract
|
|
||||||
|
|
||||||
Added to `IWorkerClient` (and `WorkerClient` with stub bodies that throw
|
|
||||||
`NotSupportedException`) plus new DTOs, so A and B compile against the interface while
|
|
||||||
C provides the real worker-side implementation.
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
// IWorkerClient additions (signatures frozen this session)
|
|
||||||
Task<MergeResultDto> StartConflictMergeAsync(string taskId, string targetBranch);
|
|
||||||
Task<MergeConflictsDto> GetMergeConflictsAsync(string taskId);
|
|
||||||
Task WriteConflictResolutionAsync(string taskId, string path, string resolvedContent);
|
|
||||||
Task<MergeResultDto> ContinueMergeAsync(string taskId);
|
|
||||||
Task AbortMergeAsync(string taskId);
|
|
||||||
```
|
|
||||||
|
|
||||||
- `StartConflictMergeAsync` performs the merge with `leaveConflictsInTree: true` (the
|
|
||||||
worker already supports this flag — used today by the planning orchestrator) and
|
|
||||||
returns `MergeResultDto` with `Status="conflict"` and the conflict file list, leaving
|
|
||||||
`.git/MERGE_HEAD` in place in the list's `WorkingDir`.
|
|
||||||
- `GetMergeConflictsAsync` returns each conflicted file with ours/theirs/base content,
|
|
||||||
read via `git show :2:<path>` (ours), `:3:<path>` (theirs), `:1:<path>` (base).
|
|
||||||
- `WriteConflictResolutionAsync` writes resolved content to the file in `WorkingDir`
|
|
||||||
and `git add`s it.
|
|
||||||
- `ContinueMergeAsync` wraps the existing `TaskMergeService.ContinueMergeAsync`
|
|
||||||
(`git add -A` → re-check `git diff --name-only --diff-filter=U` → `git commit`).
|
|
||||||
- `AbortMergeAsync` wraps the existing `TaskMergeService.AbortMergeAsync`
|
|
||||||
(`git merge --abort`).
|
|
||||||
|
|
||||||
New DTOs (defined in the worker hub DTO file, mirrored client-side):
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public record MergeConflictsDto(string TaskId, IReadOnlyList<ConflictFileDto> Files);
|
|
||||||
public record ConflictFileDto(string Path, IReadOnlyList<ConflictHunkDto> Hunks);
|
|
||||||
public record ConflictHunkDto(string Ours, string Theirs, string? Base);
|
|
||||||
```
|
|
||||||
|
|
||||||
Existing DTOs reused unchanged: `MergeResultDto(Status, ConflictFiles, ErrorMessage)`,
|
|
||||||
`MergePreviewDto`, `MergeTargetsDto`.
|
|
||||||
|
|
||||||
### 3. Conflict data model (UI)
|
|
||||||
|
|
||||||
`ConflictFile { Path, Hunks[] }`, `ConflictHunk { Ours, Theirs, Base, Resolution }`.
|
|
||||||
Shaped so a future 3-way merge pane needs no model change (Layer C is the inline
|
|
||||||
resolver now; the model leaves room for 3-way later).
|
|
||||||
|
|
||||||
### 4. Integration seams (delegates, wired by the integrator at merge)
|
|
||||||
|
|
||||||
A's and B's cockpits hold a `RequestConflictResolution(string taskId)` callback (an
|
|
||||||
`Action<string>` or `Func<string, Task>`). They never reference Layer C's resolver
|
|
||||||
types. The integrator connects these callbacks to C's `ConflictResolverViewModel`
|
|
||||||
factory when merging the three branches together.
|
|
||||||
|
|
||||||
## Parallel boundaries (verified disjoint)
|
|
||||||
|
|
||||||
| Area | A (this session) | B (parallel) | C (parallel) |
|
|
||||||
|---|---|---|---|
|
|
||||||
| `DiffView` + diff model/parser | builds | reuses | reuses |
|
|
||||||
| `WorkConsole.axaml` / `DetailsIslandViewModel` | owns | — | — |
|
|
||||||
| `DiffModalView` + `PlanningDiffView` | migrates to `DiffView` | — | — |
|
|
||||||
| `WorktreesOverviewModalView/VM` + `WorktreeModalView` | — | owns | — |
|
|
||||||
| `WorkerHub` / `TaskMergeService` / `GitService` | — | — | owns |
|
|
||||||
| New `ConflictResolverView/VM` + conflict UI model | — | — | owns |
|
|
||||||
| `IWorkerClient` / `WorkerClient` | adds frozen stubs + DTOs | reuses `MergeTaskAsync` | fills stub bodies |
|
|
||||||
| Test fakes (`IWorkerClient`) in both test projects | adds new no-op methods | — | makes them functional if needed |
|
|
||||||
|
|
||||||
The only file C and A both touch is `WorkerClient.cs` (C replaces the stub bodies A
|
|
||||||
wrote). Contained; reconciled at integration. Everything else is disjoint.
|
|
||||||
|
|
||||||
## Layer A — review/merge cockpit (this session)
|
|
||||||
|
|
||||||
- The Git tab becomes the single Approve + merge surface. `Approve` and the merge
|
|
||||||
target / preview / diff flow together as one block (no separate REVIEW vs
|
|
||||||
MERGE & WORKTREE sections).
|
|
||||||
- `Continue` (reject → requeue with feedback) and `Reset` (reject → idle) **stay** in
|
|
||||||
the Output tab footer — unchanged.
|
|
||||||
- The diff is shown via the unified `DiffView` opened as a modal from the cockpit. No
|
|
||||||
inline diff recap in the tab (the island is too small).
|
|
||||||
- On a single-task **Approve that conflicts**: instead of today's auto-abort, call
|
|
||||||
`StartConflictMergeAsync` and fire `RequestConflictResolution(taskId)`. This leaves
|
|
||||||
the main checkout mid-merge until the user resolves or aborts (behavior change,
|
|
||||||
intended). The callback is inert until Layer C is merged; the integrator wires it.
|
|
||||||
- Migrate `DiffModalView` and `PlanningDiffView` onto the new `DiffView`.
|
|
||||||
|
|
||||||
### Behavior change accepted
|
|
||||||
|
|
||||||
Today `MergeTask`/`ApproveReview` use `leaveConflictsInTree: false` (auto-abort on
|
|
||||||
conflict). Under this design, an Approve that conflicts leaves the merge in progress
|
|
||||||
and opens the resolver. The mid-merge guard (`IsMidMergeAsync`) still prevents a second
|
|
||||||
concurrent merge.
|
|
||||||
|
|
||||||
## Layer B — multi-worktree merge cockpit (parallel)
|
|
||||||
|
|
||||||
- Rework `WorktreesOverviewModalView`/`WorktreesOverviewModalViewModel` into a
|
|
||||||
batch-merge cockpit: list mergeable worktrees, select N, choose one target branch
|
|
||||||
(single target — 99% of the time everything goes to the same branch), "Merge all".
|
|
||||||
- **Skip-and-continue**: client-side loop calling the existing
|
|
||||||
`MergeTaskAsync(taskId, target, removeWorktree, msg)` per selected task. Clean merges
|
|
||||||
apply; conflicting ones are collected (existing `MergeTaskAsync` auto-aborts on
|
|
||||||
conflict, leaving the tree clean) into a "needs resolution" list with live progress.
|
|
||||||
- Each conflict row exposes a **Resolve** action → `RequestConflictResolution(taskId)`
|
|
||||||
(wired to Layer C at integration).
|
|
||||||
- Per-task diff via the shared `DiffView`; migrate `WorktreeModalView`'s inline diff
|
|
||||||
onto it.
|
|
||||||
- B touches no worker files — keeps it parallel-safe.
|
|
||||||
|
|
||||||
## Layer C — inline conflict resolver (parallel)
|
|
||||||
|
|
||||||
### Worker side
|
|
||||||
|
|
||||||
Implement the five frozen contract methods:
|
|
||||||
|
|
||||||
- Add hub methods `StartConflictMerge`, `GetMergeConflicts`, `WriteConflictResolution`,
|
|
||||||
`ContinueMerge`, `AbortMerge` in `WorkerHub`.
|
|
||||||
- `StartConflictMerge` calls the existing `TaskMergeService.MergeAsync` overload with
|
|
||||||
`leaveConflictsInTree: true`.
|
|
||||||
- `ContinueMerge` / `AbortMerge` wrap the existing `TaskMergeService.ContinueMergeAsync`
|
|
||||||
/ `AbortMergeAsync` (currently service-level only, not hub-exposed).
|
|
||||||
- `GetMergeConflicts` reads ours/theirs/base per conflicted file via
|
|
||||||
`git show :2:/:3:/:1:`; add the `GitService` helpers needed.
|
|
||||||
- `WriteConflictResolution` writes the resolved content to `WorkingDir` and stages it.
|
|
||||||
- Fill the `WorkerClient` stub bodies (real SignalR `InvokeAsync` calls).
|
|
||||||
- Update the hand-rolled `IWorkerClient` fakes in both test projects.
|
|
||||||
|
|
||||||
### UI
|
|
||||||
|
|
||||||
- New `ConflictResolverView` + `ConflictResolverViewModel`. Per conflict hunk, show
|
|
||||||
ours vs theirs stacked, with buttons **Accept Current / Accept Incoming / Accept Both
|
|
||||||
/ Edit manually** plus a free-text box for the merged result of that hunk.
|
|
||||||
- When every file's hunks are resolved → `ContinueMergeAsync(taskId)` → `MergeResultDto`
|
|
||||||
(`merged` closes the resolver; `conflict` means not fully resolved, stay open).
|
|
||||||
- `AbortMergeAsync(taskId)` cancels and aborts the merge.
|
|
||||||
- Expose a factory (`Func<string, ConflictResolverViewModel>`) the integrator wires to
|
|
||||||
A's and B's `RequestConflictResolution` callbacks.
|
|
||||||
|
|
||||||
## Build / test
|
|
||||||
|
|
||||||
`.slnx` needs .NET 9; on .NET 8 build individual csproj with `-c Release` (a running
|
|
||||||
Worker locks `Debug`). Run the relevant test projects. No tests that spawn the real
|
|
||||||
`claude` CLI. Keep `en.json`/`de.json` localization keys in parity.
|
|
||||||
|
|
||||||
## Out of scope
|
|
||||||
|
|
||||||
- Full 3-way synchronized merge editor (model leaves room; not built now).
|
|
||||||
- Per-task differing merge targets in the batch (single target only).
|
|
||||||
- Any CI/PR tooling (direct push-to-main workflow).
|
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
# Terminal-style review controls
|
|
||||||
|
|
||||||
**Date:** 2026-06-05
|
|
||||||
**Status:** Approved (design)
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
Review feedback today is a multi-line `TextBox` plus four buttons (Approve / Reject /
|
|
||||||
Park / Cancel) tucked into the WorkConsole **Session** tab
|
|
||||||
(`WorkConsole.axaml:169-193`). It feels disconnected from the live terminal. Entering
|
|
||||||
feedback should feel like typing into the terminal, with action buttons docked at the
|
|
||||||
bottom — and merge/approve actions should live in an obvious, dedicated place.
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
- Type review feedback directly in the **Output (terminal)** tab, prompt-style.
|
|
||||||
- Bottom-docked action strip on the terminal: `[Retry]` `[Reset]`.
|
|
||||||
- Move all git/merge/worktree actions (including **Approve**) into a new **Git** tab so
|
|
||||||
it is obvious where each action lives.
|
|
||||||
|
|
||||||
## Tab structure
|
|
||||||
|
|
||||||
Three tabs in WorkConsole: **Output** · **Git** · **Session**.
|
|
||||||
|
|
||||||
| Tab | Contents |
|
|
||||||
| --- | --- |
|
|
||||||
| **Output** | Live `Log` (unchanged) + new review footer (below), footer gated on `IsWaitingForReview`. |
|
|
||||||
| **Git** | The current "Merge & worktree" block — merge-target dropdown, mergeability indicator, **Approve**, Open Diff, Merge, Worktree, Review Combined Diff, Merge All Subtasks. Visibility gated on `ShowMergeSection` / `IsWaitingForReview` as today. |
|
|
||||||
| **Session** | Child outcomes + empty-state only. |
|
|
||||||
|
|
||||||
### ViewModel changes (`DetailsIslandViewModel`)
|
|
||||||
|
|
||||||
- Add `public bool IsGitTab => SelectedTab == "git";`
|
|
||||||
- Add `[NotifyPropertyChangedFor(nameof(IsGitTab))]` alongside the existing
|
|
||||||
`IsOutputTab` / `IsSessionTab` notifications on `SelectedTab` (`:139-144`).
|
|
||||||
- `SelectTab` already accepts a string parameter — no change beyond the new `"git"`
|
|
||||||
value wired from XAML.
|
|
||||||
- No command renames (avoids breaking hand-rolled test fakes).
|
|
||||||
|
|
||||||
## Terminal footer (Output tab)
|
|
||||||
|
|
||||||
A `Border` docked `Bottom` inside the Output tab body, visible only when
|
|
||||||
`IsWaitingForReview`:
|
|
||||||
|
|
||||||
- Background `Surface2Brush`, top border `LineBrush` (`BorderThickness="0,1,0,0"`).
|
|
||||||
- A `❯` prompt-prefix `TextBlock` (mono, `TextMuteBrush`) + a borderless mono `TextBox`:
|
|
||||||
- Bound `Text="{Binding ReviewFeedback, Mode=TwoWay}"`.
|
|
||||||
- `AcceptsReturn="True"`, `TextWrapping="Wrap"`, transparent background, no border.
|
|
||||||
- Starts ~1 line tall; grows with content up to `MaxHeight≈160`, then scrolls.
|
|
||||||
- `PlaceholderText` e.g. "Feedback for the next run…".
|
|
||||||
- Right-aligned button strip:
|
|
||||||
- `[Retry]` — `Classes="btn accent"` → `RejectReviewCommand`.
|
|
||||||
- `[Reset]` — `Classes="btn"` → `ParkReviewCommand`.
|
|
||||||
|
|
||||||
`[Accept]` is **not** in the footer; approval happens on the Git tab via
|
|
||||||
`ApproveReviewCommand`. The old `Cancel` review button is dropped from this UI; cancel
|
|
||||||
remains reachable through the task's existing cancel control (`CancelReviewCommand`
|
|
||||||
stays on the ViewModel, just not surfaced here).
|
|
||||||
|
|
||||||
### Enter handling (`WorkConsole.axaml.cs`)
|
|
||||||
|
|
||||||
- Handle `KeyDown` on the input `TextBox`:
|
|
||||||
- **Enter** without Shift → execute `RejectReviewCommand` (if it can execute) and set
|
|
||||||
`e.Handled = true`.
|
|
||||||
- **Shift+Enter** → fall through to default behavior (inserts newline).
|
|
||||||
- `RejectReviewAsync` already returns early on whitespace-only feedback
|
|
||||||
(`DetailsIslandViewModel.cs:1464`), so pressing Enter with an empty prompt is a no-op
|
|
||||||
with no extra guard needed.
|
|
||||||
|
|
||||||
## Command mapping
|
|
||||||
|
|
||||||
| Button | Location | Command | Effect |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| `[Retry]` | Output footer | `RejectReviewCommand` | Reject-to-queue with feedback; resumes the session (Queued). |
|
|
||||||
| `[Reset]` | Output footer | `ParkReviewCommand` | Park back to Idle. |
|
|
||||||
| `[Approve]` | Git tab | `ApproveReviewCommand` | Merge `SelectedMergeTarget` → Done (conflict keeps it in review). |
|
|
||||||
|
|
||||||
## Copy / empty state
|
|
||||||
|
|
||||||
- Update the Session empty-state text (`WorkConsole.axaml:270`) — it currently says
|
|
||||||
"review and merge controls appear here once the run finishes", which is no longer
|
|
||||||
accurate. Reword to reflect that only outcomes live on Session.
|
|
||||||
- Button labels remain literal strings (`Retry`, `Reset`, `Approve`), matching the
|
|
||||||
existing review buttons (no new localization keys).
|
|
||||||
|
|
||||||
## Out of scope
|
|
||||||
|
|
||||||
- No changes to worker-side review/merge logic or `IWorkerClient` signatures.
|
|
||||||
- No merge-target selector duplicated into the terminal footer (Approve uses the Git
|
|
||||||
tab dropdown / default target).
|
|
||||||
- No command renames on the ViewModel.
|
|
||||||
|
|
||||||
## Testing / verification
|
|
||||||
|
|
||||||
- Build `ClaudeDo.App` and `ClaudeDo.Worker` in `-c Release`.
|
|
||||||
- Manual visual verification (must be flagged — cannot be auto-verified):
|
|
||||||
- Footer appears only in `WaitingForReview`, on the Output tab.
|
|
||||||
- Enter sends Retry; Shift+Enter inserts a newline; empty Enter does nothing.
|
|
||||||
- Git tab shows Approve + merge/worktree controls; Session shows only outcomes.
|
|
||||||
@@ -1,148 +0,0 @@
|
|||||||
# Unify the parent-task model (planning · improvement · normal)
|
|
||||||
|
|
||||||
**Date:** 2026-06-09
|
|
||||||
**Status:** Approved-pending-implementation
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
ClaudeDo has three ways a task produces and waits on work, grown as separate
|
|
||||||
mechanisms that represent the *same shape* — "a task runs, may emit children,
|
|
||||||
and once it + its children are terminal it surfaces for review":
|
|
||||||
|
|
||||||
| | children authored | scheduling | parent flow today | merge of children |
|
|
||||||
|---|---|---|---|---|
|
|
||||||
| **Normal** | none | — | `Running → WaitingForReview → Done` | own worktree on approve |
|
|
||||||
| **Improvement** | autonomously *during* run (`suggest_improvement`) | parallel (no blockers) | `Running → WaitingForChildren → WaitingForReview → Done` | separate `MergeAllPlanning` |
|
|
||||||
| **Planning** | interactively *before* run (planning session) | sequential chain (`BlockedByTaskId`) | `Idle →(Active→Finalized)→ Done` (skips review) | separate `MergeAllPlanning` |
|
|
||||||
|
|
||||||
The incidental divergence we want to remove:
|
|
||||||
|
|
||||||
1. **Two "parent is waiting on children" representations** — improvement uses
|
|
||||||
`Status=WaitingForChildren`; planning uses `PlanningPhase=Finalized` with the
|
|
||||||
parent's `Status` jumping `Idle → Done`, never passing through the waiting/review
|
|
||||||
states at all.
|
|
||||||
2. **Two parent-advance methods** doing the same job —
|
|
||||||
`TaskRepository.TryCompleteParentAsync` (planning → `Done`, no review) vs
|
|
||||||
`TaskStateService.TryAdvanceImprovementParentAsync` (improvement → `WaitingForReview`).
|
|
||||||
3. **A separate merge action** — `MergeAllPlanning` / `PlanningMergeOrchestrator`
|
|
||||||
merges children, decoupled from the parent's `approve`. Approving a parent and
|
|
||||||
merging its unit are two clicks.
|
|
||||||
|
|
||||||
What is **genuinely unique and kept**: `PlanningPhase.Active` — the interactive,
|
|
||||||
human-in-the-loop authoring gate where children are drafted and cannot run until
|
|
||||||
finalize. Improvement has no equivalent. The two *authoring* entry points
|
|
||||||
(`PlanningMcpService.CreateChildTask` vs `TaskRunMcpService.SuggestImprovement`)
|
|
||||||
also stay distinct — they already share `CreateChildAsync`; unifying the authoring
|
|
||||||
UX is explicitly out of scope.
|
|
||||||
|
|
||||||
## Decisions (locked)
|
|
||||||
|
|
||||||
- **All parents get review.** A planning parent now surfaces in `WaitingForReview`
|
|
||||||
after its children finish, instead of auto-completing to `Done`.
|
|
||||||
- **Approve merges the whole unit — full UX consolidation.** Approve is the single
|
|
||||||
entry for reviewing *and* merging any task. For a parent with children it drives the
|
|
||||||
existing `PlanningMergeOrchestrator` (unit merge + parent→`Done` + conflict
|
|
||||||
continue/abort, all already implemented); the standalone "Merge All" button is
|
|
||||||
removed and the orchestrator's conflict dialog + combined-diff preview are reused
|
|
||||||
in-place. Childless tasks keep `ApproveAndMergeAsync`.
|
|
||||||
- **Scope = state model + code paths.** Internal refactor; authoring UX and child
|
|
||||||
base-commit resolution are unchanged.
|
|
||||||
|
|
||||||
## Target model
|
|
||||||
|
|
||||||
**One parent-with-children lifecycle, used by every parent regardless of how its
|
|
||||||
children were authored:**
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─ (no children) ──────────────┐
|
|
||||||
Idle → Queued → Running ──┤ ├→ WaitingForReview → Done
|
|
||||||
└─ (has/spawns children) ─┐ │ (approve =
|
|
||||||
│ │ merge unit)
|
|
||||||
WaitingForChildren ─┘ │
|
|
||||||
│ │
|
|
||||||
(all children terminal) ───────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
Planning parent (never runs as an agent — it runs an interactive session):
|
|
||||||
|
|
||||||
```
|
|
||||||
Idle (PlanningPhase None)
|
|
||||||
→[StartPlanning] Idle (PlanningPhase Active) ← authoring gate (KEPT)
|
|
||||||
→[FinalizePlanning] WaitingForChildren (Finalized) ← children chain runs
|
|
||||||
→[all children terminal] WaitingForReview
|
|
||||||
→[approve] merge unit → Done
|
|
||||||
```
|
|
||||||
|
|
||||||
Children (planning **and** improvement) keep going straight to `Done` with no
|
|
||||||
individual review; they accumulate on their branches and merge as a unit when the
|
|
||||||
parent is approved.
|
|
||||||
|
|
||||||
### State machine after the change
|
|
||||||
|
|
||||||
- `WaitingForChildren` is the **single** "parent waiting on children" state, used by
|
|
||||||
both planning and improvement parents.
|
|
||||||
- `WaitingForReview` is reached by every parent before `Done`.
|
|
||||||
- `PlanningPhase`: `None | Active | Finalized` — unchanged; `Active` remains the
|
|
||||||
authoring gate, `Finalized` marks "was a planning parent" and is set together with
|
|
||||||
`Status=WaitingForChildren`.
|
|
||||||
|
|
||||||
## Code changes
|
|
||||||
|
|
||||||
1. **Single parent-advance path.** Rename
|
|
||||||
`TaskStateService.TryAdvanceImprovementParentAsync` →
|
|
||||||
`TryAdvanceParentAsync`; it already only checks `Status==WaitingForChildren` +
|
|
||||||
"all children terminal" → `WaitingForReview` (with the failed/cancelled
|
|
||||||
annotation on `Result`). It becomes the only path for both systems.
|
|
||||||
- Handle **zero children**: a finalized planning parent with no children must go
|
|
||||||
straight to `WaitingForReview` (today `TryComplete`/`TryAdvance` both `return`
|
|
||||||
on `Count == 0`).
|
|
||||||
|
|
||||||
2. **Delete `TaskRepository.TryCompleteParentAsync`** (`TaskRepository.cs:477`) and
|
|
||||||
its invocation in `TaskStateService.OnChildTerminalAsync`. Planning parents now
|
|
||||||
advance via `TryAdvanceParentAsync` to `WaitingForReview` instead of `Done`.
|
|
||||||
- Keep `_chain.OnChildFinishedAsync` (inter-child unblock — planning-only effect).
|
|
||||||
|
|
||||||
3. **`FinalizePlanningAsync`** (`TaskStateService.cs:289`) sets the parent
|
|
||||||
`Status = WaitingForChildren` in the same update that sets
|
|
||||||
`PlanningPhase = Finalized`. This happens before `SetupChainAsync` enqueues
|
|
||||||
child[0], so the parent is in `WaitingForChildren` before any child can finish.
|
|
||||||
|
|
||||||
4. **Approve merges the unit.** `WorkerHub.ApproveReview` (and the MCP
|
|
||||||
`ReviewTask` approve path): when the approved task has children, run
|
|
||||||
`PlanningMergeOrchestrator` (parent worktree if `Active` + each `Done` child in
|
|
||||||
order), then transition the parent to `Done`. On a child merge conflict, the
|
|
||||||
parent stays in `WaitingForReview` (mirrors current single-task approve-conflict
|
|
||||||
behavior). Retire the `MergeAllPlanning` Hub method + UI button.
|
|
||||||
|
|
||||||
5. **Allow cancelling a `WaitingForChildren` parent.** Add `WaitingForChildren` to
|
|
||||||
the `CancelAsync` guard so a parent waiting on children can be cancelled (today it
|
|
||||||
cannot — minor gap).
|
|
||||||
|
|
||||||
6. **Docs.** Fix the `WaitingForChildren`-missing drift in
|
|
||||||
`src/ClaudeDo.Data/CLAUDE.md` and `src/ClaudeDo.Worker/CLAUDE.md`, and update the
|
|
||||||
transition diagram + the root `CLAUDE.md` status-flow line to the unified model.
|
|
||||||
|
|
||||||
## Out of scope (unchanged)
|
|
||||||
|
|
||||||
- Authoring UX: planning session vs `suggest_improvement` stay as two distinct
|
|
||||||
entry points (both already call `CreateChildAsync`).
|
|
||||||
- `WorktreeManager.ResolveBaseCommitAsync` base-commit divergence (planning children
|
|
||||||
branch from list HEAD; improvement children from parent head) — left as-is.
|
|
||||||
- Sequential-vs-parallel scheduling — already shared infrastructure
|
|
||||||
(`BlockedByTaskId`); planning chains, improvement doesn't. No change.
|
|
||||||
|
|
||||||
## Risks / edge cases
|
|
||||||
|
|
||||||
- **Ordering on finalize** — parent must be `WaitingForChildren` before the first
|
|
||||||
child can reach terminal. Guaranteed by setting it inside `FinalizePlanningAsync`,
|
|
||||||
which runs before `SetupChainAsync`.
|
|
||||||
- **Zero-children planning parent** — must advance to `WaitingForReview`, not stick
|
|
||||||
in `WaitingForChildren`. Explicit branch in `TryAdvanceParentAsync` /
|
|
||||||
`FinalizePlanningAsync`.
|
|
||||||
- **Failed/cancelled children** — parent still advances to `WaitingForReview` with
|
|
||||||
the existing `⚠ Children: N failed, M cancelled` annotation; no wedge.
|
|
||||||
- **Approve-merge conflict** — keep parent in `WaitingForReview`; surface the
|
|
||||||
conflicting child like the current merge-conflict path.
|
|
||||||
- **Existing rows** — planning parents currently sitting at `Idle`+`Finalized` with
|
|
||||||
live children: behavior change is forward-only (new finalizes use the new flow);
|
|
||||||
no migration needed since `Status`/`PlanningPhase` columns already exist.
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 44 KiB |
@@ -24,10 +24,12 @@
|
|||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
|
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
|
||||||
|
<PackageReference Include="Serilog.Extensions.Logging" Version="8.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\ClaudeDo.Ui\ClaudeDo.Ui.csproj" />
|
<ProjectReference Include="..\ClaudeDo.Ui\ClaudeDo.Ui.csproj" />
|
||||||
|
<ProjectReference Include="..\ClaudeDo.Logging\ClaudeDo.Logging.csproj" />
|
||||||
<ProjectReference Include="..\ClaudeDo.Localization\ClaudeDo.Localization.csproj" />
|
<ProjectReference Include="..\ClaudeDo.Localization\ClaudeDo.Localization.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<Import Project="..\ClaudeDo.Localization\Locales.targets" />
|
<Import Project="..\ClaudeDo.Localization\Locales.targets" />
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ using ClaudeDo.Ui.ViewModels.Modals;
|
|||||||
using ClaudeDo.Ui.ViewModels.Modals.Settings;
|
using ClaudeDo.Ui.ViewModels.Modals.Settings;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Serilog;
|
||||||
using System;
|
using System;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
@@ -77,6 +79,12 @@ sealed class Program
|
|||||||
|
|
||||||
var sc = new ServiceCollection();
|
var sc = new ServiceCollection();
|
||||||
|
|
||||||
|
var logRoot = Path.Combine(Path.GetDirectoryName(dbPath)!, "logs");
|
||||||
|
var serilogLogger = ClaudeDo.Logging.LoggingSetup
|
||||||
|
.Configure(new LoggerConfiguration(), "app", logRoot)
|
||||||
|
.CreateLogger();
|
||||||
|
sc.AddLogging(b => b.AddSerilog(serilogLogger, dispose: true));
|
||||||
|
|
||||||
// Infrastructure
|
// Infrastructure
|
||||||
sc.AddSingleton(settings);
|
sc.AddSingleton(settings);
|
||||||
var localesDir = Path.Combine(AppContext.BaseDirectory, "locales");
|
var localesDir = Path.Combine(AppContext.BaseDirectory, "locales");
|
||||||
@@ -98,7 +106,9 @@ sealed class Program
|
|||||||
|
|
||||||
// Services
|
// Services
|
||||||
sc.AddSingleton<GitService>();
|
sc.AddSingleton<GitService>();
|
||||||
sc.AddSingleton(sp => new WorkerClient(sp.GetRequiredService<AppSettings>().SignalRUrl));
|
sc.AddSingleton(sp => new WorkerClient(
|
||||||
|
sp.GetRequiredService<AppSettings>().SignalRUrl,
|
||||||
|
sp.GetRequiredService<ILogger<WorkerClient>>()));
|
||||||
sc.AddSingleton<IWorkerClient>(sp => sp.GetRequiredService<WorkerClient>());
|
sc.AddSingleton<IWorkerClient>(sp => sp.GetRequiredService<WorkerClient>());
|
||||||
|
|
||||||
// Release check + installer update
|
// Release check + installer update
|
||||||
@@ -132,9 +142,6 @@ 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 =>
|
||||||
@@ -152,13 +159,7 @@ sealed class Program
|
|||||||
sp.GetRequiredService<WorkerClient>(),
|
sp.GetRequiredService<WorkerClient>(),
|
||||||
sp,
|
sp,
|
||||||
sp.GetRequiredService<INotesApi>()));
|
sp.GetRequiredService<INotesApi>()));
|
||||||
sc.AddSingleton<IslandsShellViewModel>(sp =>
|
sc.AddSingleton<IslandsShellViewModel>();
|
||||||
{
|
|
||||||
var shell = ActivatorUtilities.CreateInstance<IslandsShellViewModel>(sp);
|
|
||||||
shell.ConflictResolverFactory =
|
|
||||||
sp.GetRequiredService<Func<string, ClaudeDo.Ui.ViewModels.Conflicts.ConflictResolverViewModel>>();
|
|
||||||
return shell;
|
|
||||||
});
|
|
||||||
|
|
||||||
return sc.BuildServiceProvider();
|
return sc.BuildServiceProvider();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Shared data layer: models, repositories, SQLite infrastructure, and git operatio
|
|||||||
|
|
||||||
## Models
|
## Models
|
||||||
|
|
||||||
- **TaskEntity** — Id, ListId, Title, Description, Status (`Idle|Queued|Running|WaitingForChildren|WaitingForReview|Done|Failed|Cancelled`), PlanningPhase (`None|Active|Finalized` — parent-only), BlockedByTaskId (nullable FK to predecessor in a chain), ScheduledFor, Result, ReviewFeedback (nullable; reviewer's rejection comment, consumed and cleared by the runner on the next re-run), LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath / MaxTurns (nullable overrides), IsStarred, IsMyDay, Notes, ParentTaskId, PlanningSessionId, PlanningSessionToken, PlanningFinalizedAt, CreatedBy. Legacy values `Manual`/`Planning`/`Planned`/`Draft`/`Waiting` were retired; existing rows backfill automatically via the `RetireLegacyTaskStatus` migration.
|
- **TaskEntity** — Id, ListId, Title, Description, Status (`Idle|Queued|Running|WaitingForReview|Done|Failed|Cancelled`), PlanningPhase (`None|Active|Finalized` — parent-only), BlockedByTaskId (nullable FK to predecessor in a chain), ScheduledFor, Result, ReviewFeedback (nullable; reviewer's rejection comment, consumed and cleared by the runner on the next re-run), LogPath, timestamps, CommitType, Model / SystemPrompt / AgentPath / MaxTurns (nullable overrides), IsStarred, IsMyDay, Notes, ParentTaskId, PlanningSessionId, PlanningSessionToken, PlanningFinalizedAt, CreatedBy. Legacy values `Manual`/`Planning`/`Planned`/`Draft`/`Waiting` were retired; existing rows backfill automatically via the `RetireLegacyTaskStatus` migration.
|
||||||
- **ListEntity** — Id, Name, WorkingDir, DefaultCommitType, CreatedAt
|
- **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`, `UpdateChildAsync`), `UpdateAgentSettingsAsync` (model / system-prompt / agent-path overrides). Status-mutation primitives `MarkRunningAsync` / `MarkDoneAsync` / `MarkFailedAsync` / `FlipAllRunningToFailedAsync` are `internal` and called only by `TaskStateService` in the worker. `CreateChildAsync` produces children with `Status=Idle, PlanningPhase=None`; once their parent's `PlanningPhase` becomes `Finalized`, the chain coordinator queues them.
|
- **TaskRepository** — CRUD, planning helpers (`CreateChildAsync`, `SetPlanningStartedAsync`, `DiscardPlanningAsync`, `TryCompleteParentAsync`, `UpdateChildAsync`), `UpdateAgentSettingsAsync` (model / system-prompt / agent-path overrides). Status-mutation primitives `MarkRunningAsync` / `MarkDoneAsync` / `MarkFailedAsync` / `FlipAllRunningToFailedAsync` are `internal` and called only by `TaskStateService` in the worker. `CreateChildAsync` produces children with `Status=Idle, PlanningPhase=None`; once their parent's `PlanningPhase` becomes `Finalized`, the chain coordinator queues them.
|
||||||
- **ListRepository** — CRUD, `GetConfigAsync` / `SetConfigAsync` (upsert) / `DeleteConfigAsync` for `list_config`
|
- **ListRepository** — CRUD, `GetConfigAsync` / `SetConfigAsync` (upsert) / `DeleteConfigAsync` for `list_config`
|
||||||
- **WorktreeRepository** — CRUD, `UpdateHeadAsync`, `SetStateAsync`
|
- **WorktreeRepository** — CRUD, `UpdateHeadAsync`, `SetStateAsync`
|
||||||
- **TaskRunRepository**, **SubtaskRepository**, **AppSettingsRepository**
|
- **TaskRunRepository**, **SubtaskRepository**, **AppSettingsRepository**
|
||||||
@@ -35,7 +35,7 @@ All repositories use EF Core LINQ queries via `ClaudeDoDbContext`. The atomic `Q
|
|||||||
|
|
||||||
## Git
|
## Git
|
||||||
|
|
||||||
- **GitService** — async wrapper around git CLI (ProcessStartInfo, no shell). Operations: worktree add/remove, add all, commit (stdin for message), merge ff-only, rev-parse, diff-stat, has-changes, is-git-repo, `PreviewMergeAsync` (non-destructive mergeability check via `git merge-tree --write-tree`), `CountChangedFilesAsync`
|
- **GitService** — async wrapper around git CLI (ProcessStartInfo, no shell). Operations: worktree add/remove, add all, commit (stdin for message), merge ff-only, rev-parse, diff-stat, has-changes, is-git-repo
|
||||||
|
|
||||||
## Schema
|
## Schema
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
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;
|
||||||
@@ -11,35 +9,8 @@ namespace ClaudeDo.Data;
|
|||||||
|
|
||||||
public class ClaudeDoDbContext : DbContext
|
public class ClaudeDoDbContext : DbContext
|
||||||
{
|
{
|
||||||
// Runs PRAGMA foreign_keys=ON on every EF-managed connection open so FK
|
|
||||||
// enforcement is active for all IDbContextFactory-created contexts, not
|
|
||||||
// just the single context used in MigrateAndConfigure.
|
|
||||||
private sealed class SqliteForeignKeyInterceptor : DbConnectionInterceptor
|
|
||||||
{
|
|
||||||
internal static readonly SqliteForeignKeyInterceptor Instance = new();
|
|
||||||
|
|
||||||
public override void ConnectionOpened(DbConnection connection, ConnectionEndEventData eventData)
|
|
||||||
=> Apply(connection);
|
|
||||||
|
|
||||||
public override Task ConnectionOpenedAsync(DbConnection connection, ConnectionEndEventData eventData, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
Apply(connection);
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void Apply(DbConnection connection)
|
|
||||||
{
|
|
||||||
using var cmd = connection.CreateCommand();
|
|
||||||
cmd.CommandText = "PRAGMA foreign_keys=ON;";
|
|
||||||
cmd.ExecuteNonQuery();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public ClaudeDoDbContext(DbContextOptions<ClaudeDoDbContext> options) : base(options) { }
|
public ClaudeDoDbContext(DbContextOptions<ClaudeDoDbContext> options) : base(options) { }
|
||||||
|
|
||||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
|
||||||
=> optionsBuilder.AddInterceptors(SqliteForeignKeyInterceptor.Instance);
|
|
||||||
|
|
||||||
public DbSet<TaskEntity> Tasks => Set<TaskEntity>();
|
public DbSet<TaskEntity> Tasks => Set<TaskEntity>();
|
||||||
public DbSet<ListEntity> Lists => Set<ListEntity>();
|
public DbSet<ListEntity> Lists => Set<ListEntity>();
|
||||||
public DbSet<ListConfigEntity> ListConfigs => Set<ListConfigEntity>();
|
public DbSet<ListConfigEntity> ListConfigs => Set<ListConfigEntity>();
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ 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.WaitingForReview;
|
t.Status == TaskStatus.Done &&
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,15 +3,8 @@ using System.Text;
|
|||||||
|
|
||||||
namespace ClaudeDo.Data.Git;
|
namespace ClaudeDo.Data.Git;
|
||||||
|
|
||||||
public sealed record MergePreview(bool Supported, bool Clean, IReadOnlyList<string> ConflictFiles);
|
|
||||||
|
|
||||||
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);
|
||||||
@@ -28,30 +21,10 @@ 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)
|
||||||
{
|
{
|
||||||
await WorktreeAddGate.WaitAsync(ct);
|
var (exitCode, _, stderr) = await RunGitAsync(repoDir,
|
||||||
try
|
["worktree", "add", "-b", branchName, worktreePath, baseCommit], ct);
|
||||||
{
|
if (exitCode != 0)
|
||||||
const int maxAttempts = 3;
|
throw new InvalidOperationException($"git worktree add failed (exit {exitCode}): {stderr}");
|
||||||
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)
|
||||||
@@ -124,20 +97,6 @@ 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,
|
||||||
@@ -277,67 +236,6 @@ 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>
|
|
||||||
/// Non-destructive mergeability probe via `git merge-tree --write-tree`. Writes only
|
|
||||||
/// loose objects — the working tree, index, and refs are left untouched.
|
|
||||||
/// </summary>
|
|
||||||
public async Task<MergePreview> PreviewMergeAsync(
|
|
||||||
string repoDir, string targetBranch, string sourceBranch, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var (exitCode, stdout, _) = await RunGitAsync(repoDir,
|
|
||||||
["merge-tree", "--write-tree", "--name-only", targetBranch, sourceBranch], ct);
|
|
||||||
|
|
||||||
if (exitCode == 0)
|
|
||||||
return new MergePreview(true, true, Array.Empty<string>());
|
|
||||||
|
|
||||||
if (exitCode == 1)
|
|
||||||
{
|
|
||||||
// stdout: <tree-oid>\n<file>\n...\n\n<informational messages>
|
|
||||||
var lines = stdout.Split('\n');
|
|
||||||
var files = new List<string>();
|
|
||||||
for (int i = 1; i < lines.Length; i++)
|
|
||||||
{
|
|
||||||
var line = lines[i].TrimEnd('\r');
|
|
||||||
if (string.IsNullOrWhiteSpace(line)) break;
|
|
||||||
files.Add(line.Trim());
|
|
||||||
}
|
|
||||||
return new MergePreview(true, false, files);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Any other exit (e.g. git too old: "unknown option --write-tree").
|
|
||||||
return new MergePreview(false, false, Array.Empty<string>());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Count of files that differ on <paramref name="sourceBranch"/> since its merge base with the target.</summary>
|
|
||||||
public async Task<int> CountChangedFilesAsync(
|
|
||||||
string repoDir, string targetBranch, string sourceBranch, CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var (exitCode, stdout, _) = await RunGitAsync(repoDir,
|
|
||||||
["diff", "--name-only", $"{targetBranch}...{sourceBranch}"], ct);
|
|
||||||
if (exitCode != 0) return 0;
|
|
||||||
return stdout
|
|
||||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
|
||||||
.Count(s => s.Length > 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task MergeFfOnlyAsync(string repoDir, string branchName, CancellationToken ct = default)
|
public async Task MergeFfOnlyAsync(string repoDir, string branchName, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["merge", "--ff-only", branchName], ct);
|
var (exitCode, _, stderr) = await RunGitAsync(repoDir, ["merge", "--ff-only", branchName], ct);
|
||||||
@@ -346,7 +244,7 @@ public sealed class GitService
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<(int ExitCode, string Stdout, string Stderr)> RunGitAsync(
|
private static async Task<(int ExitCode, string Stdout, string Stderr)> RunGitAsync(
|
||||||
string workDir, IEnumerable<string> args, CancellationToken ct, string? stdinData = null, bool trimOutput = true)
|
string workDir, IEnumerable<string> args, CancellationToken ct, string? stdinData = null)
|
||||||
{
|
{
|
||||||
var psi = new ProcessStartInfo
|
var psi = new ProcessStartInfo
|
||||||
{
|
{
|
||||||
@@ -395,6 +293,6 @@ public sealed class GitService
|
|||||||
|
|
||||||
ct.ThrowIfCancellationRequested();
|
ct.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
return (proc.ExitCode, trimOutput ? stdout.TrimEnd() : stdout, stderr.TrimEnd());
|
return (proc.ExitCode, stdout.TrimEnd(), stderr.TrimEnd());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,696 +0,0 @@
|
|||||||
// <auto-generated />
|
|
||||||
using System;
|
|
||||||
using ClaudeDo.Data;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace ClaudeDo.Data.Migrations
|
|
||||||
{
|
|
||||||
[DbContext(typeof(ClaudeDoDbContext))]
|
|
||||||
[Migration("20260609000000_UniqueListName")]
|
|
||||||
partial class UniqueListName
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
|
||||||
{
|
|
||||||
#pragma warning disable 612, 618
|
|
||||||
modelBuilder.HasAnnotation("ProductVersion", "8.0.11");
|
|
||||||
|
|
||||||
modelBuilder.Entity("ClaudeDo.Data.Models.AppSettingsEntity", b =>
|
|
||||||
{
|
|
||||||
b.Property<int>("Id")
|
|
||||||
.HasColumnType("INTEGER")
|
|
||||||
.HasColumnName("id");
|
|
||||||
|
|
||||||
b.Property<string>("CentralWorktreeRoot")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("central_worktree_root");
|
|
||||||
|
|
||||||
b.Property<int>("DailyPrepMaxTasks")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("INTEGER")
|
|
||||||
.HasDefaultValue(5)
|
|
||||||
.HasColumnName("daily_prep_max_tasks");
|
|
||||||
|
|
||||||
b.Property<string>("DefaultClaudeInstructions")
|
|
||||||
.IsRequired()
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasDefaultValue("")
|
|
||||||
.HasColumnName("default_claude_instructions");
|
|
||||||
|
|
||||||
b.Property<int>("DefaultMaxTurns")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("INTEGER")
|
|
||||||
.HasDefaultValue(30)
|
|
||||||
.HasColumnName("default_max_turns");
|
|
||||||
|
|
||||||
b.Property<string>("DefaultModel")
|
|
||||||
.IsRequired()
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasDefaultValue("sonnet")
|
|
||||||
.HasColumnName("default_model");
|
|
||||||
|
|
||||||
b.Property<string>("DefaultPermissionMode")
|
|
||||||
.IsRequired()
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasDefaultValue("bypassPermissions")
|
|
||||||
.HasColumnName("default_permission_mode");
|
|
||||||
|
|
||||||
b.Property<int>("MaxParallelExecutions")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("INTEGER")
|
|
||||||
.HasDefaultValue(1)
|
|
||||||
.HasColumnName("max_parallel_executions");
|
|
||||||
|
|
||||||
b.Property<string>("RepoImportFolders")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("repo_import_folders");
|
|
||||||
|
|
||||||
b.Property<string>("ReportExcludedPaths")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("report_excluded_paths");
|
|
||||||
|
|
||||||
b.Property<int>("StandupWeekday")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("INTEGER")
|
|
||||||
.HasDefaultValue(3)
|
|
||||||
.HasColumnName("standup_weekday");
|
|
||||||
|
|
||||||
b.Property<int>("WorktreeAutoCleanupDays")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("INTEGER")
|
|
||||||
.HasDefaultValue(7)
|
|
||||||
.HasColumnName("worktree_auto_cleanup_days");
|
|
||||||
|
|
||||||
b.Property<bool>("WorktreeAutoCleanupEnabled")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("INTEGER")
|
|
||||||
.HasDefaultValue(false)
|
|
||||||
.HasColumnName("worktree_auto_cleanup_enabled");
|
|
||||||
|
|
||||||
b.Property<string>("WorktreeStrategy")
|
|
||||||
.IsRequired()
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasDefaultValue("sibling")
|
|
||||||
.HasColumnName("worktree_strategy");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.ToTable("app_settings", (string)null);
|
|
||||||
|
|
||||||
b.HasData(
|
|
||||||
new
|
|
||||||
{
|
|
||||||
Id = 1,
|
|
||||||
DailyPrepMaxTasks = 5,
|
|
||||||
DefaultClaudeInstructions = "",
|
|
||||||
DefaultMaxTurns = 100,
|
|
||||||
DefaultModel = "sonnet",
|
|
||||||
DefaultPermissionMode = "auto",
|
|
||||||
MaxParallelExecutions = 1,
|
|
||||||
StandupWeekday = 3,
|
|
||||||
WorktreeAutoCleanupDays = 7,
|
|
||||||
WorktreeAutoCleanupEnabled = false,
|
|
||||||
WorktreeStrategy = "sibling"
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("ClaudeDo.Data.Models.DailyNoteEntity", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("Id")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("id");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("created_at");
|
|
||||||
|
|
||||||
b.Property<DateOnly>("Date")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("note_date");
|
|
||||||
|
|
||||||
b.Property<int>("SortOrder")
|
|
||||||
.HasColumnType("INTEGER")
|
|
||||||
.HasColumnName("sort_order");
|
|
||||||
|
|
||||||
b.Property<string>("Text")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("text");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("Date");
|
|
||||||
|
|
||||||
b.ToTable("daily_notes", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("ListId")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("list_id");
|
|
||||||
|
|
||||||
b.Property<string>("AgentPath")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("agent_path");
|
|
||||||
|
|
||||||
b.Property<int?>("MaxTurns")
|
|
||||||
.HasColumnType("INTEGER")
|
|
||||||
.HasColumnName("max_turns");
|
|
||||||
|
|
||||||
b.Property<string>("Model")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("model");
|
|
||||||
|
|
||||||
b.Property<string>("SystemPrompt")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("system_prompt");
|
|
||||||
|
|
||||||
b.HasKey("ListId");
|
|
||||||
|
|
||||||
b.ToTable("list_config", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("Id")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("id");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("created_at");
|
|
||||||
|
|
||||||
b.Property<string>("DefaultCommitType")
|
|
||||||
.IsRequired()
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasDefaultValue("chore")
|
|
||||||
.HasColumnName("default_commit_type");
|
|
||||||
|
|
||||||
b.Property<string>("Name")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("name");
|
|
||||||
|
|
||||||
b.Property<int>("SortOrder")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("INTEGER")
|
|
||||||
.HasDefaultValue(0)
|
|
||||||
.HasColumnName("sort_order");
|
|
||||||
|
|
||||||
b.Property<string>("WorkingDir")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("working_dir");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("SortOrder")
|
|
||||||
.HasDatabaseName("idx_lists_sort");
|
|
||||||
|
|
||||||
b.ToTable("lists", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("ClaudeDo.Data.Models.PrimeScheduleEntity", b =>
|
|
||||||
{
|
|
||||||
b.Property<Guid>("Id")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("id");
|
|
||||||
|
|
||||||
b.Property<DateTimeOffset>("CreatedAt")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("created_at");
|
|
||||||
|
|
||||||
b.Property<int>("Days")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("INTEGER")
|
|
||||||
.HasDefaultValue(31)
|
|
||||||
.HasColumnName("days_of_week");
|
|
||||||
|
|
||||||
b.Property<bool>("Enabled")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("INTEGER")
|
|
||||||
.HasDefaultValue(true)
|
|
||||||
.HasColumnName("enabled");
|
|
||||||
|
|
||||||
b.Property<DateTimeOffset?>("LastRunAt")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("last_run_at");
|
|
||||||
|
|
||||||
b.Property<string>("PromptOverride")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("prompt_override");
|
|
||||||
|
|
||||||
b.Property<TimeSpan>("TimeOfDay")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("time_of_day");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.ToTable("prime_schedules", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("Id")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("id");
|
|
||||||
|
|
||||||
b.Property<bool>("Completed")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("INTEGER")
|
|
||||||
.HasDefaultValue(false)
|
|
||||||
.HasColumnName("completed");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("created_at");
|
|
||||||
|
|
||||||
b.Property<int>("OrderNum")
|
|
||||||
.HasColumnType("INTEGER")
|
|
||||||
.HasColumnName("order_num");
|
|
||||||
|
|
||||||
b.Property<string>("TaskId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("task_id");
|
|
||||||
|
|
||||||
b.Property<string>("Title")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("title");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("TaskId")
|
|
||||||
.HasDatabaseName("idx_subtasks_task_id");
|
|
||||||
|
|
||||||
b.ToTable("subtasks", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("Id")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("id");
|
|
||||||
|
|
||||||
b.Property<string>("AgentPath")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("agent_path");
|
|
||||||
|
|
||||||
b.Property<string>("BlockedByTaskId")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("blocked_by_task_id");
|
|
||||||
|
|
||||||
b.Property<string>("CommitType")
|
|
||||||
.IsRequired()
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasDefaultValue("chore")
|
|
||||||
.HasColumnName("commit_type");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("created_at");
|
|
||||||
|
|
||||||
b.Property<string>("CreatedBy")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("created_by");
|
|
||||||
|
|
||||||
b.Property<string>("Description")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("description");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("FinishedAt")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("finished_at");
|
|
||||||
|
|
||||||
b.Property<bool>("IsMyDay")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("INTEGER")
|
|
||||||
.HasDefaultValue(false)
|
|
||||||
.HasColumnName("is_my_day");
|
|
||||||
|
|
||||||
b.Property<bool>("IsStarred")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("INTEGER")
|
|
||||||
.HasDefaultValue(false)
|
|
||||||
.HasColumnName("is_starred");
|
|
||||||
|
|
||||||
b.Property<string>("ListId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("list_id");
|
|
||||||
|
|
||||||
b.Property<string>("LogPath")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("log_path");
|
|
||||||
|
|
||||||
b.Property<int?>("MaxTurns")
|
|
||||||
.HasColumnType("INTEGER")
|
|
||||||
.HasColumnName("max_turns");
|
|
||||||
|
|
||||||
b.Property<string>("Model")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("model");
|
|
||||||
|
|
||||||
b.Property<string>("Notes")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("notes");
|
|
||||||
|
|
||||||
b.Property<string>("ParentTaskId")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("parent_task_id");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("PlanningFinalizedAt")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("planning_finalized_at");
|
|
||||||
|
|
||||||
b.Property<string>("PlanningPhase")
|
|
||||||
.IsRequired()
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasDefaultValue("none")
|
|
||||||
.HasColumnName("planning_phase");
|
|
||||||
|
|
||||||
b.Property<string>("PlanningSessionId")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("planning_session_id");
|
|
||||||
|
|
||||||
b.Property<string>("PlanningSessionToken")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("planning_session_token");
|
|
||||||
|
|
||||||
b.Property<string>("Result")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("result");
|
|
||||||
|
|
||||||
b.Property<string>("ReviewFeedback")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("review_feedback");
|
|
||||||
|
|
||||||
b.Property<int>("RoadblockCount")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("INTEGER")
|
|
||||||
.HasDefaultValue(0)
|
|
||||||
.HasColumnName("roadblock_count");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("ScheduledFor")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("scheduled_for");
|
|
||||||
|
|
||||||
b.Property<int>("SortOrder")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("INTEGER")
|
|
||||||
.HasDefaultValue(0)
|
|
||||||
.HasColumnName("sort_order");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("StartedAt")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("started_at");
|
|
||||||
|
|
||||||
b.Property<string>("Status")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("status");
|
|
||||||
|
|
||||||
b.Property<string>("SystemPrompt")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("system_prompt");
|
|
||||||
|
|
||||||
b.Property<string>("Title")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("title");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("BlockedByTaskId")
|
|
||||||
.HasDatabaseName("idx_tasks_blocked_by");
|
|
||||||
|
|
||||||
b.HasIndex("ListId")
|
|
||||||
.HasDatabaseName("idx_tasks_list_id");
|
|
||||||
|
|
||||||
b.HasIndex("ParentTaskId")
|
|
||||||
.HasDatabaseName("idx_tasks_parent_task_id");
|
|
||||||
|
|
||||||
b.HasIndex("Status")
|
|
||||||
.HasDatabaseName("idx_tasks_status");
|
|
||||||
|
|
||||||
b.HasIndex("ListId", "SortOrder")
|
|
||||||
.HasDatabaseName("idx_tasks_list_sort");
|
|
||||||
|
|
||||||
b.ToTable("tasks", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("Id")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("id");
|
|
||||||
|
|
||||||
b.Property<string>("ErrorMarkdown")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("error_markdown");
|
|
||||||
|
|
||||||
b.Property<int?>("ExitCode")
|
|
||||||
.HasColumnType("INTEGER")
|
|
||||||
.HasColumnName("exit_code");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("FinishedAt")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("finished_at");
|
|
||||||
|
|
||||||
b.Property<bool>("IsRetry")
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("INTEGER")
|
|
||||||
.HasDefaultValue(false)
|
|
||||||
.HasColumnName("is_retry");
|
|
||||||
|
|
||||||
b.Property<string>("LogPath")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("log_path");
|
|
||||||
|
|
||||||
b.Property<string>("Prompt")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("prompt");
|
|
||||||
|
|
||||||
b.Property<string>("ResultMarkdown")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("result_markdown");
|
|
||||||
|
|
||||||
b.Property<int>("RunNumber")
|
|
||||||
.HasColumnType("INTEGER")
|
|
||||||
.HasColumnName("run_number");
|
|
||||||
|
|
||||||
b.Property<string>("SessionId")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("session_id");
|
|
||||||
|
|
||||||
b.Property<DateTime?>("StartedAt")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("started_at");
|
|
||||||
|
|
||||||
b.Property<string>("StructuredOutputJson")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("structured_output");
|
|
||||||
|
|
||||||
b.Property<string>("TaskId")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("task_id");
|
|
||||||
|
|
||||||
b.Property<int?>("TokensIn")
|
|
||||||
.HasColumnType("INTEGER")
|
|
||||||
.HasColumnName("tokens_in");
|
|
||||||
|
|
||||||
b.Property<int?>("TokensOut")
|
|
||||||
.HasColumnType("INTEGER")
|
|
||||||
.HasColumnName("tokens_out");
|
|
||||||
|
|
||||||
b.Property<int?>("TurnCount")
|
|
||||||
.HasColumnType("INTEGER")
|
|
||||||
.HasColumnName("turn_count");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("TaskId")
|
|
||||||
.HasDatabaseName("idx_task_runs_task_id");
|
|
||||||
|
|
||||||
b.ToTable("task_runs", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("ClaudeDo.Data.Models.WeekReportEntity", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("Id")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("id");
|
|
||||||
|
|
||||||
b.Property<DateOnly>("EndDate")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("end_date");
|
|
||||||
|
|
||||||
b.Property<DateTime>("GeneratedAt")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("generated_at");
|
|
||||||
|
|
||||||
b.Property<string>("Markdown")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("markdown");
|
|
||||||
|
|
||||||
b.Property<DateOnly>("StartDate")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("start_date");
|
|
||||||
|
|
||||||
b.HasKey("Id");
|
|
||||||
|
|
||||||
b.HasIndex("StartDate", "EndDate")
|
|
||||||
.IsUnique();
|
|
||||||
|
|
||||||
b.ToTable("week_reports", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
|
|
||||||
{
|
|
||||||
b.Property<string>("TaskId")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("task_id");
|
|
||||||
|
|
||||||
b.Property<string>("BaseCommit")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("base_commit");
|
|
||||||
|
|
||||||
b.Property<string>("BranchName")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("branch_name");
|
|
||||||
|
|
||||||
b.Property<DateTime>("CreatedAt")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("created_at");
|
|
||||||
|
|
||||||
b.Property<string>("DiffStat")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("diff_stat");
|
|
||||||
|
|
||||||
b.Property<string>("HeadCommit")
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("head_commit");
|
|
||||||
|
|
||||||
b.Property<string>("Path")
|
|
||||||
.IsRequired()
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasColumnName("path");
|
|
||||||
|
|
||||||
b.Property<string>("State")
|
|
||||||
.IsRequired()
|
|
||||||
.ValueGeneratedOnAdd()
|
|
||||||
.HasColumnType("TEXT")
|
|
||||||
.HasDefaultValue("active")
|
|
||||||
.HasColumnName("state");
|
|
||||||
|
|
||||||
b.HasKey("TaskId");
|
|
||||||
|
|
||||||
b.ToTable("worktrees", (string)null);
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListConfigEntity", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
|
||||||
.WithOne("Config")
|
|
||||||
.HasForeignKey("ClaudeDo.Data.Models.ListConfigEntity", "ListId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("List");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("ClaudeDo.Data.Models.SubtaskEntity", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
|
||||||
.WithMany("Subtasks")
|
|
||||||
.HasForeignKey("TaskId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("Task");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", null)
|
|
||||||
.WithMany()
|
|
||||||
.HasForeignKey("BlockedByTaskId")
|
|
||||||
.OnDelete(DeleteBehavior.SetNull);
|
|
||||||
|
|
||||||
b.HasOne("ClaudeDo.Data.Models.ListEntity", "List")
|
|
||||||
.WithMany("Tasks")
|
|
||||||
.HasForeignKey("ListId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Parent")
|
|
||||||
.WithMany("Children")
|
|
||||||
.HasForeignKey("ParentTaskId")
|
|
||||||
.OnDelete(DeleteBehavior.Restrict);
|
|
||||||
|
|
||||||
b.Navigation("List");
|
|
||||||
|
|
||||||
b.Navigation("Parent");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskRunEntity", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
|
||||||
.WithMany("Runs")
|
|
||||||
.HasForeignKey("TaskId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("Task");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("ClaudeDo.Data.Models.WorktreeEntity", b =>
|
|
||||||
{
|
|
||||||
b.HasOne("ClaudeDo.Data.Models.TaskEntity", "Task")
|
|
||||||
.WithOne("Worktree")
|
|
||||||
.HasForeignKey("ClaudeDo.Data.Models.WorktreeEntity", "TaskId")
|
|
||||||
.OnDelete(DeleteBehavior.Cascade)
|
|
||||||
.IsRequired();
|
|
||||||
|
|
||||||
b.Navigation("Task");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("ClaudeDo.Data.Models.ListEntity", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("Config");
|
|
||||||
|
|
||||||
b.Navigation("Tasks");
|
|
||||||
});
|
|
||||||
|
|
||||||
modelBuilder.Entity("ClaudeDo.Data.Models.TaskEntity", b =>
|
|
||||||
{
|
|
||||||
b.Navigation("Children");
|
|
||||||
|
|
||||||
b.Navigation("Runs");
|
|
||||||
|
|
||||||
b.Navigation("Subtasks");
|
|
||||||
|
|
||||||
b.Navigation("Worktree");
|
|
||||||
});
|
|
||||||
#pragma warning restore 612, 618
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace ClaudeDo.Data.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public partial class UniqueListName : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
// Remove duplicate list rows that have no tasks — keep the oldest rowid.
|
|
||||||
// This handles the startup-race case where both App and Worker seeded
|
|
||||||
// the same default list names concurrently.
|
|
||||||
migrationBuilder.Sql("""
|
|
||||||
DELETE FROM lists
|
|
||||||
WHERE (SELECT COUNT(*) FROM tasks WHERE list_id = lists.id) = 0
|
|
||||||
AND rowid NOT IN (
|
|
||||||
SELECT MIN(l2.rowid) FROM lists l2 WHERE l2.name = lists.name
|
|
||||||
)
|
|
||||||
""");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,7 +2,7 @@ using System.Text;
|
|||||||
|
|
||||||
namespace ClaudeDo.Data;
|
namespace ClaudeDo.Data;
|
||||||
|
|
||||||
public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport, ImprovementChild, Refine }
|
public enum PromptKind { System, Planning, PlanningInitial, Retry, DailyPrep, WeeklyReport, ImprovementChild }
|
||||||
|
|
||||||
public static class PromptFiles
|
public static class PromptFiles
|
||||||
{
|
{
|
||||||
@@ -17,7 +17,6 @@ public static class PromptFiles
|
|||||||
PromptKind.DailyPrep => Path.Combine(Root, "daily-prep.md"),
|
PromptKind.DailyPrep => Path.Combine(Root, "daily-prep.md"),
|
||||||
PromptKind.WeeklyReport => Path.Combine(Root, "weekly-report.md"),
|
PromptKind.WeeklyReport => Path.Combine(Root, "weekly-report.md"),
|
||||||
PromptKind.ImprovementChild => Path.Combine(Root, "improvement-child.md"),
|
PromptKind.ImprovementChild => Path.Combine(Root, "improvement-child.md"),
|
||||||
PromptKind.Refine => Path.Combine(Root, "refine.md"),
|
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(kind))
|
_ => throw new ArgumentOutOfRangeException(nameof(kind))
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -62,7 +61,6 @@ public static class PromptFiles
|
|||||||
PromptKind.DailyPrep => DailyPrepDefault,
|
PromptKind.DailyPrep => DailyPrepDefault,
|
||||||
PromptKind.WeeklyReport => WeeklyReportDefault,
|
PromptKind.WeeklyReport => WeeklyReportDefault,
|
||||||
PromptKind.ImprovementChild => ImprovementChildDefault,
|
PromptKind.ImprovementChild => ImprovementChildDefault,
|
||||||
PromptKind.Refine => RefineDefault,
|
|
||||||
_ => ""
|
_ => ""
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -183,33 +181,6 @@ public static class PromptFiles
|
|||||||
If there are no candidates, do nothing.
|
If there are no candidates, do nothing.
|
||||||
""";
|
""";
|
||||||
|
|
||||||
private const string RefineDefault = """
|
|
||||||
You are refining ONE ClaudeDo task so it is ready to run autonomously later.
|
|
||||||
You are NOT executing the task — only improving its specification.
|
|
||||||
|
|
||||||
The task you are refining:
|
|
||||||
- id: {taskId}
|
|
||||||
- title: {title}
|
|
||||||
- description: {description}
|
|
||||||
- current subtasks (steps):
|
|
||||||
{subtasks}
|
|
||||||
|
|
||||||
What to do:
|
|
||||||
1. If a repository is available, read the relevant code (read-only) to ground your
|
|
||||||
understanding. Do NOT edit, create, or delete any files. Do NOT run commands.
|
|
||||||
2. Rewrite the description so it is clear, specific, and self-contained: what to change,
|
|
||||||
where, and what "done" looks like. Keep scope tight — do not invent adjacent work.
|
|
||||||
3. Call mcp__claudedo__update_task to save the improved title (only if it genuinely
|
|
||||||
helps) and description.
|
|
||||||
4. If the work is clearer as discrete steps, add them as subtasks with
|
|
||||||
mcp__claudedo__add_subtask (one call per step, in order). Only add steps that are
|
|
||||||
not already present in the current subtasks above.
|
|
||||||
|
|
||||||
Use ONLY these tools: mcp__claudedo__get_task, mcp__claudedo__update_task,
|
|
||||||
mcp__claudedo__add_subtask, and read-only Read/Grep/Glob. When you have updated the
|
|
||||||
task, stop.
|
|
||||||
""";
|
|
||||||
|
|
||||||
private const string WeeklyReportDefault = """
|
private const string WeeklyReportDefault = """
|
||||||
You are generating a concise weekly standup report for a software developer,
|
You are generating a concise weekly standup report for a software developer,
|
||||||
covering {start} to {end}.
|
covering {start} to {end}.
|
||||||
|
|||||||
@@ -18,18 +18,8 @@ public sealed class AppSettingsRepository
|
|||||||
|
|
||||||
row = new AppSettingsEntity { Id = AppSettingsEntity.SingletonId };
|
row = new AppSettingsEntity { Id = AppSettingsEntity.SingletonId };
|
||||||
_context.AppSettings.Add(row);
|
_context.AppSettings.Add(row);
|
||||||
try
|
await _context.SaveChangesAsync(ct);
|
||||||
{
|
_context.Entry(row).State = EntityState.Detached;
|
||||||
await _context.SaveChangesAsync(ct);
|
|
||||||
_context.Entry(row).State = EntityState.Detached;
|
|
||||||
}
|
|
||||||
catch (DbUpdateException)
|
|
||||||
{
|
|
||||||
// Concurrent process already inserted the singleton — discard our attempt and re-read.
|
|
||||||
_context.Entry(row).State = EntityState.Detached;
|
|
||||||
row = await _context.AppSettings.AsNoTracking()
|
|
||||||
.FirstAsync(s => s.Id == AppSettingsEntity.SingletonId, ct);
|
|
||||||
}
|
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -474,5 +474,32 @@ public sealed class TaskRepository
|
|||||||
return chainIds.Count;
|
return chainIds.Count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task TryCompleteParentAsync(
|
||||||
|
string parentId,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var parent = await _context.Tasks.AsNoTracking().FirstOrDefaultAsync(t => t.Id == parentId, ct);
|
||||||
|
if (parent is null || parent.PlanningPhase != PlanningPhase.Finalized) return;
|
||||||
|
|
||||||
|
var children = await _context.Tasks
|
||||||
|
.Where(t => t.ParentTaskId == parentId)
|
||||||
|
.Select(t => t.Status)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
if (children.Count == 0) return;
|
||||||
|
|
||||||
|
bool allTerminal = children.All(s => s == TaskStatus.Done || s == TaskStatus.Failed);
|
||||||
|
if (!allTerminal) return;
|
||||||
|
|
||||||
|
bool anyFailed = children.Any(s => s == TaskStatus.Failed);
|
||||||
|
var finalStatus = anyFailed ? TaskStatus.Failed : TaskStatus.Done;
|
||||||
|
var finishedAt = DateTime.UtcNow;
|
||||||
|
await _context.Tasks
|
||||||
|
.Where(t => t.Id == parentId)
|
||||||
|
.ExecuteUpdateAsync(s => s
|
||||||
|
.SetProperty(t => t.Status, finalStatus)
|
||||||
|
.SetProperty(t => t.FinishedAt, finishedAt), ct);
|
||||||
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using ClaudeDo.Data.Models;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace ClaudeDo.Data.Seeding;
|
namespace ClaudeDo.Data.Seeding;
|
||||||
@@ -8,18 +9,17 @@ 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)
|
foreach (var name in Defaults.Where(n => !existing.Contains(n)))
|
||||||
{
|
{
|
||||||
var id = Guid.NewGuid().ToString();
|
ctx.Lists.Add(new ListEntity
|
||||||
// Atomic conditional insert: the SELECT ... WHERE NOT EXISTS is a single
|
{
|
||||||
// SQLite statement and cannot race — only one writer holds the lock.
|
Id = Guid.NewGuid().ToString(),
|
||||||
await ctx.Database.ExecuteSqlAsync(
|
Name = name,
|
||||||
$"""
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
using System.Text;
|
|
||||||
|
|
||||||
namespace ClaudeDo.Data;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Single source of truth for the text handed to Claude as a task prompt:
|
|
||||||
/// title + description + the OPEN sub-tasks. Resolved sub-tasks are dropped.
|
|
||||||
/// Shared by the Worker (real prompt) and the UI (the card's "what Claude gets" preview).
|
|
||||||
/// </summary>
|
|
||||||
public static class TaskPromptComposer
|
|
||||||
{
|
|
||||||
public static string Compose(string title, string? description, IEnumerable<(string Title, bool Completed)> subtasks)
|
|
||||||
{
|
|
||||||
var sb = new StringBuilder((title ?? "").Trim());
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(description))
|
|
||||||
sb.Append("\n\n").Append(description.Trim());
|
|
||||||
|
|
||||||
var open = subtasks?.Where(s => !s.Completed).ToList() ?? new List<(string, bool)>();
|
|
||||||
if (open.Count > 0)
|
|
||||||
{
|
|
||||||
sb.Append("\n\n## Sub-Tasks\n");
|
|
||||||
foreach (var s in open)
|
|
||||||
sb.Append("- [ ] ").Append(s.Title).Append('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
return sb.ToString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 374 B |
@@ -53,7 +53,6 @@
|
|||||||
"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",
|
||||||
@@ -105,8 +104,6 @@
|
|||||||
"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",
|
||||||
@@ -114,8 +111,7 @@
|
|||||||
"reviewTitle": "Review",
|
"reviewTitle": "Review",
|
||||||
"feedbackLabel": "FEEDBACK FÜR DEN AGENTEN",
|
"feedbackLabel": "FEEDBACK FÜR DEN AGENTEN",
|
||||||
"feedbackPlaceholder": "Was soll der Agent korrigieren?",
|
"feedbackPlaceholder": "Was soll der Agent korrigieren?",
|
||||||
"rerun": "Erneut ausführen",
|
"rerun": "Erneut ausführen"
|
||||||
"refineTip": "Aufgabe mit Claude verfeinern"
|
|
||||||
},
|
},
|
||||||
"lists": {
|
"lists": {
|
||||||
"heading": "Listen",
|
"heading": "Listen",
|
||||||
@@ -133,7 +129,6 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
@@ -153,7 +148,6 @@
|
|||||||
"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",
|
||||||
@@ -189,9 +183,7 @@
|
|||||||
"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": {
|
||||||
@@ -238,10 +230,7 @@
|
|||||||
"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"
|
||||||
@@ -253,12 +242,6 @@
|
|||||||
"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",
|
||||||
@@ -374,20 +357,6 @@
|
|||||||
"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",
|
||||||
@@ -430,7 +399,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.", "batchProgress": "Merge {0}/{1}…", "batchDone": "{0} gemergt, {1} zu lösen." },
|
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "Liste", "cleanupFailed": "Aufräumen fehlgeschlagen.", "removed": "{0} Worktree(s) entfernt.", "discardFailed": "Worktree konnte nicht verworfen werden.", "keepFailed": "Worktree konnte nicht behalten werden.", "cannotForceRunning": "Eine laufende Aufgabe kann nicht zwangsweise entfernt werden.", "forceRemoveFailed": "Zwangsentfernung fehlgeschlagen." },
|
||||||
"listSettings": { "untitled": "Unbenannt" },
|
"listSettings": { "untitled": "Unbenannt" },
|
||||||
"lists": { "localSuffix": "{0} / lokal", "smartMyDay": "Mein Tag", "smartImportant": "Wichtig", "smartPlanned": "Geplant", "virtualQueue": "Warteschlange", "virtualRunning": "Läuft", "virtualReview": "Prüfung", "newList": "Neue Liste" }
|
"lists": { "localSuffix": "{0} / lokal", "smartMyDay": "Mein Tag", "smartImportant": "Wichtig", "smartPlanned": "Geplant", "virtualQueue": "Warteschlange", "virtualRunning": "Läuft", "virtualReview": "Prüfung", "newList": "Neue Liste" }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,6 @@
|
|||||||
"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",
|
||||||
@@ -105,8 +104,6 @@
|
|||||||
"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",
|
||||||
@@ -114,8 +111,7 @@
|
|||||||
"reviewTitle": "Review",
|
"reviewTitle": "Review",
|
||||||
"feedbackLabel": "FEEDBACK FOR THE AGENT",
|
"feedbackLabel": "FEEDBACK FOR THE AGENT",
|
||||||
"feedbackPlaceholder": "What should the agent fix?",
|
"feedbackPlaceholder": "What should the agent fix?",
|
||||||
"rerun": "Re-run",
|
"rerun": "Re-run"
|
||||||
"refineTip": "Refine this task with Claude"
|
|
||||||
},
|
},
|
||||||
"lists": {
|
"lists": {
|
||||||
"heading": "Lists",
|
"heading": "Lists",
|
||||||
@@ -133,7 +129,6 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
@@ -153,7 +148,6 @@
|
|||||||
"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",
|
||||||
@@ -189,9 +183,7 @@
|
|||||||
"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": {
|
||||||
@@ -238,10 +230,7 @@
|
|||||||
"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"
|
||||||
@@ -253,12 +242,6 @@
|
|||||||
"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",
|
||||||
@@ -374,20 +357,6 @@
|
|||||||
"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",
|
||||||
@@ -430,7 +399,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.", "batchProgress": "Merging {0}/{1}…", "batchDone": "Merged {0}, {1} need resolution." },
|
"worktreesOverview": { "titleAll": "Worktrees", "titleList": "Worktrees — {0}", "listFallback": "list", "cleanupFailed": "Cleanup failed.", "removed": "Removed {0} worktree(s).", "discardFailed": "Failed to discard worktree.", "keepFailed": "Failed to keep worktree.", "cannotForceRunning": "Cannot force-remove a running task.", "forceRemoveFailed": "Force remove failed." },
|
||||||
"listSettings": { "untitled": "Untitled" },
|
"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" }
|
||||||
}
|
}
|
||||||
|
|||||||
14
src/ClaudeDo.Logging/BuildConfig.cs
Normal file
14
src/ClaudeDo.Logging/BuildConfig.cs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
using System.Diagnostics;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Logging;
|
||||||
|
|
||||||
|
/// <summary>Runtime build-configuration detection — the replacement for #if DEBUG.
|
||||||
|
/// Debug builds compile with the JIT optimizer disabled; Release builds enable it.</summary>
|
||||||
|
public static class BuildConfig
|
||||||
|
{
|
||||||
|
public static bool IsDebug { get; } =
|
||||||
|
Assembly.GetEntryAssembly()
|
||||||
|
?.GetCustomAttribute<DebuggableAttribute>()
|
||||||
|
?.IsJITOptimizerDisabled ?? false;
|
||||||
|
}
|
||||||
19
src/ClaudeDo.Logging/ClaudeDo.Logging.csproj
Normal file
19
src/ClaudeDo.Logging/ClaudeDo.Logging.csproj
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Serilog" Version="4.1.0" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="ClaudeDo.Worker.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
15
src/ClaudeDo.Logging/DefaultTaskIdEnricher.cs
Normal file
15
src/ClaudeDo.Logging/DefaultTaskIdEnricher.cs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
using Serilog.Core;
|
||||||
|
using Serilog.Events;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Logging;
|
||||||
|
|
||||||
|
/// <summary>Ensures every log event carries a TaskId property (defaulting to "-")
|
||||||
|
/// so the output template's [{TaskId}] column never renders the raw token.</summary>
|
||||||
|
public sealed class DefaultTaskIdEnricher : ILogEventEnricher
|
||||||
|
{
|
||||||
|
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
|
||||||
|
{
|
||||||
|
if (!logEvent.Properties.ContainsKey("TaskId"))
|
||||||
|
logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("TaskId", "-"));
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/ClaudeDo.Logging/LoggingSetup.cs
Normal file
44
src/ClaudeDo.Logging/LoggingSetup.cs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
using Serilog;
|
||||||
|
using Serilog.Events;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Logging;
|
||||||
|
|
||||||
|
public static class LoggingSetup
|
||||||
|
{
|
||||||
|
private const string OutputTemplate =
|
||||||
|
"[{Timestamp:HH:mm:ss.fff} {Level:u3}] {Process}/{SourceContext} [{TaskId}] {Message:lj}{NewLine}{Exception}";
|
||||||
|
|
||||||
|
public static LoggerConfiguration Configure(LoggerConfiguration cfg, string processTag, string logRoot)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(logRoot);
|
||||||
|
var logFile = Path.Combine(logRoot, "claudedo-.log");
|
||||||
|
|
||||||
|
cfg.Enrich.FromLogContext()
|
||||||
|
.Enrich.WithProperty("Process", processTag)
|
||||||
|
.Enrich.With(new DefaultTaskIdEnricher());
|
||||||
|
|
||||||
|
if (BuildConfig.IsDebug)
|
||||||
|
{
|
||||||
|
cfg.MinimumLevel.Debug()
|
||||||
|
.WriteTo.Console(outputTemplate: OutputTemplate)
|
||||||
|
.WriteTo.File(
|
||||||
|
logFile,
|
||||||
|
rollingInterval: RollingInterval.Day,
|
||||||
|
retainedFileCountLimit: 2,
|
||||||
|
shared: true,
|
||||||
|
outputTemplate: OutputTemplate);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
cfg.MinimumLevel.Warning()
|
||||||
|
.WriteTo.File(
|
||||||
|
logFile,
|
||||||
|
rollingInterval: RollingInterval.Day,
|
||||||
|
retainedFileCountLimit: 2,
|
||||||
|
shared: true,
|
||||||
|
outputTemplate: OutputTemplate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,12 +38,12 @@ All views use compiled bindings (`x:DataType`).
|
|||||||
- **StatusBarViewModel** — connection state and active tasks
|
- **StatusBarViewModel** — connection state and active tasks
|
||||||
- **WeeklyReportModalViewModel** — drives the weekly report modal
|
- **WeeklyReportModalViewModel** — drives the weekly report modal
|
||||||
- **NotesEditorViewModel** — manages daily note bullet CRUD for the selected day
|
- **NotesEditorViewModel** — manages daily note bullet CRUD for the selected day
|
||||||
- **DetailsIslandViewModel** gains `IsNotesMode`, `ShowNotes()`, and hosts `NotesEditorViewModel`. Also gains daily-prep mode: `IsPrepMode`, `PrepLog` (`ObservableCollection<LogLineViewModel>`), `ShowPrep()`, `PlanDayCommand` (calls `RunDailyPrepNowAsync`), `ShowPrepEmptyState`, and computed `IsTaskDetailVisible` (= `!IsNotesMode && !IsPrepMode`). Subscribes to `PrepStartedEvent`, `PrepLineEvent`, `PrepFinishedEvent`; streams lines into `PrepLog` via `StreamLineFormatter` (same path as task logs). On open, if log is empty and no run is in progress, loads persisted last run via `GetLastPrepLogAsync`. The WorkConsole Session tab gains a mergeability indicator (`MergePreviewPresenter`) and a single-task Merge button; indicator is populated via `PreviewMergeAsync` and displayed for tasks in WaitingForReview.
|
- **DetailsIslandViewModel** gains `IsNotesMode`, `ShowNotes()`, and hosts `NotesEditorViewModel`. Also gains daily-prep mode: `IsPrepMode`, `PrepLog` (`ObservableCollection<LogLineViewModel>`), `ShowPrep()`, `PlanDayCommand` (calls `RunDailyPrepNowAsync`), `ShowPrepEmptyState`, and computed `IsTaskDetailVisible` (= `!IsNotesMode && !IsPrepMode`). Subscribes to `PrepStartedEvent`, `PrepLineEvent`, `PrepFinishedEvent`; streams lines into `PrepLog` via `StreamLineFormatter` (same path as task logs). On open, if log is empty and no run is in progress, loads persisted last run via `GetLastPrepLogAsync`.
|
||||||
- **TasksIslandViewModel** gains a pinned "Notes" pseudo-row (`ShowNotesRow`, `OpenNotesCommand`, `NotesRequested` event) that switches the Details island to notes mode. Also gains `IsMyDayList` (true when selected list is `smart:my-day`), `ShowPrepLogCommand` (raises `PrepRequested` event → shell calls `Details.ShowPrep()`), and `ClearDayCommand` (calls `ClearMyDayAsync`).
|
- **TasksIslandViewModel** gains a pinned "Notes" pseudo-row (`ShowNotesRow`, `OpenNotesCommand`, `NotesRequested` event) that switches the Details island to notes mode. Also gains `IsMyDayList` (true when selected list is `smart:my-day`), `ShowPrepLogCommand` (raises `PrepRequested` event → shell calls `Details.ShowPrep()`), and `ClearDayCommand` (calls `ClearMyDayAsync`).
|
||||||
|
|
||||||
## Services
|
## Services
|
||||||
|
|
||||||
- **WorkerClient** / **IWorkerClient** — SignalR client connecting to `http://127.0.0.1:47821/hub`. Auto-reconnect with exponential backoff. Methods: StartAsync, RunNowAsync, CancelTaskAsync, WakeQueueAsync, ContinueTaskAsync, ResetTaskAsync, GetAgentsAsync, UpdateAppSettingsAsync, UpdateListAsync, UpdateListConfigAsync, GetListConfigAsync, UpdateTaskAgentSettingsAsync, `ApproveReviewAsync(taskId, targetBranch) -> MergeResultDto`, `PreviewMergeAsync(taskId, targetBranch) -> MergePreviewDto`, `MergeTaskAsync`, `GetWeekReportAsync`, `GenerateWeekReportAsync`, `GetDailyNotesAsync`, `AddDailyNoteAsync`, `UpdateDailyNoteAsync`, `DeleteDailyNoteAsync`, `RunDailyPrepNowAsync`, `ClearMyDayAsync`, `GetLastPrepLogAsync`. Events: TaskStarted, TaskFinished, TaskMessage, TaskUpdated, WorktreeUpdated, ListUpdated, `PrepStartedEvent`, `PrepLineEvent`, `PrepFinishedEvent`
|
- **WorkerClient** / **IWorkerClient** — SignalR client connecting to `http://127.0.0.1:47821/hub`. Auto-reconnect with exponential backoff. Methods: StartAsync, RunNowAsync, CancelTaskAsync, WakeQueueAsync, ContinueTaskAsync, ResetTaskAsync, GetAgentsAsync, UpdateAppSettingsAsync, UpdateListAsync, UpdateListConfigAsync, GetListConfigAsync, UpdateTaskAgentSettingsAsync, `GetWeekReportAsync`, `GenerateWeekReportAsync`, `GetDailyNotesAsync`, `AddDailyNoteAsync`, `UpdateDailyNoteAsync`, `DeleteDailyNoteAsync`, `RunDailyPrepNowAsync`, `ClearMyDayAsync`, `GetLastPrepLogAsync`. Events: TaskStarted, TaskFinished, TaskMessage, TaskUpdated, WorktreeUpdated, ListUpdated, `PrepStartedEvent`, `PrepLineEvent`, `PrepFinishedEvent`
|
||||||
- **INotesApi** / **WorkerNotesApi** — thin wrapper over the daily-note WorkerClient methods (mirrors `IPrimeScheduleApi`). UI DTO: `DailyNoteDto(Id, Date, Text, SortOrder)`.
|
- **INotesApi** / **WorkerNotesApi** — thin wrapper over the daily-note WorkerClient methods (mirrors `IPrimeScheduleApi`). UI DTO: `DailyNoteDto(Id, Date, Text, SortOrder)`.
|
||||||
|
|
||||||
## Converters
|
## Converters
|
||||||
|
|||||||
@@ -11,7 +11,9 @@
|
|||||||
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
|
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.1" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" />
|
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="8.0.11" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.2" />
|
||||||
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
|
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
|
||||||
|
<PackageReference Include="Serilog" Version="4.1.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
30
src/ClaudeDo.Ui/Converters/DiffLineKindToBrushConverter.cs
Normal file
30
src/ClaudeDo.Ui/Converters/DiffLineKindToBrushConverter.cs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Avalonia.Data.Converters;
|
||||||
|
using Avalonia.Media;
|
||||||
|
using ClaudeDo.Ui.ViewModels.Modals;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Ui.Converters;
|
||||||
|
|
||||||
|
public sealed class DiffLineKindToBrushConverter : IValueConverter
|
||||||
|
{
|
||||||
|
private static readonly ISolidColorBrush Added = new SolidColorBrush(Color.Parse("#66BB6A"));
|
||||||
|
private static readonly ISolidColorBrush Removed = new SolidColorBrush(Color.Parse("#EF5350"));
|
||||||
|
private static readonly ISolidColorBrush Hunk = new SolidColorBrush(Color.Parse("#42A5F5"));
|
||||||
|
private static readonly ISolidColorBrush Header = new SolidColorBrush(Color.Parse("#9E9E9E"));
|
||||||
|
private static readonly ISolidColorBrush Default = new SolidColorBrush(Color.Parse("#CFD8DC"));
|
||||||
|
|
||||||
|
public object Convert(object? value, Type targetType, object? parameter, CultureInfo culture) =>
|
||||||
|
value is WorktreeDiffLineKind kind
|
||||||
|
? kind switch
|
||||||
|
{
|
||||||
|
WorktreeDiffLineKind.Added => Added,
|
||||||
|
WorktreeDiffLineKind.Removed => Removed,
|
||||||
|
WorktreeDiffLineKind.Hunk => Hunk,
|
||||||
|
WorktreeDiffLineKind.Header => Header,
|
||||||
|
_ => Default,
|
||||||
|
}
|
||||||
|
: Default;
|
||||||
|
|
||||||
|
public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
|
||||||
|
=> throw new NotSupportedException();
|
||||||
|
}
|
||||||
@@ -76,9 +76,6 @@
|
|||||||
<!-- 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 + two sparkles) -->
|
|
||||||
<StreamGeometry x:Key="Icon.Refine">M3,6 L13,6 M3,11 L11,11 M3,16 L9,16 M18.5,3 L19.28,5.22 L21.5,6 L19.28,6.78 L18.5,9 L17.72,6.78 L15.5,6 L17.72,5.22 Z M19.5,14.9 L19.85,16.15 L21.1,16.5 L19.85,16.85 L19.5,18.1 L19.15,16.85 L17.9,16.5 L19.15,16.15 Z</StreamGeometry>
|
|
||||||
|
|
||||||
<!-- Icon.X -->
|
<!-- Icon.X -->
|
||||||
<StreamGeometry x:Key="Icon.X">M6 6l12 12M18 6L6 18</StreamGeometry>
|
<StreamGeometry x:Key="Icon.X">M6 6l12 12M18 6L6 18</StreamGeometry>
|
||||||
|
|
||||||
@@ -88,9 +85,6 @@
|
|||||||
<!-- Icon.ArrowOut — filled arrow for "open external" button -->
|
<!-- Icon.ArrowOut — filled arrow for "open external" button -->
|
||||||
<StreamGeometry x:Key="Icon.ArrowOut">M13 4 H20 V11 H18 V7.4 L11.4 14 L10 12.6 L16.6 6 H13 Z M4 6 H10 V8 H6 V18 H16 V14 H18 V20 H4 Z</StreamGeometry>
|
<StreamGeometry x:Key="Icon.ArrowOut">M13 4 H20 V11 H18 V7.4 L11.4 14 L10 12.6 L16.6 6 H13 Z M4 6 H10 V8 H6 V18 H16 V14 H18 V20 H4 Z</StreamGeometry>
|
||||||
|
|
||||||
<!-- Icon.Text — three filled horizontal bars (paragraph / description icon) -->
|
|
||||||
<StreamGeometry x:Key="Icon.Text">M4 6 H20 V8 H4 Z M4 11 H20 V13 H4 Z M4 16 H14 V18 H4 Z</StreamGeometry>
|
|
||||||
|
|
||||||
<!-- Icon.Warning — filled triangle with exclamation (roadblock badge) -->
|
<!-- Icon.Warning — filled triangle with exclamation (roadblock badge) -->
|
||||||
<StreamGeometry x:Key="Icon.Warning">F0 M12 3 L22 20 H2 Z M11 9 H13 V14 H11 Z M11 16 H13 V18 H11 Z</StreamGeometry>
|
<StreamGeometry x:Key="Icon.Warning">F0 M12 3 L22 20 H2 Z M11 9 H13 V14 H11 Z M11 16 H13 V18 H11 Z</StreamGeometry>
|
||||||
|
|
||||||
@@ -100,9 +94,6 @@
|
|||||||
<!-- Icon.Settings (gear) -->
|
<!-- Icon.Settings (gear) -->
|
||||||
<StreamGeometry x:Key="Icon.Settings">M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65a.5.5 0 0 0 .12-.64l-2-3.46a.5.5 0 0 0-.61-.22l-2.49 1a7.03 7.03 0 0 0-1.69-.98l-.38-2.65a.5.5 0 0 0-.5-.42h-4a.5.5 0 0 0-.5.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1a.5.5 0 0 0-.61.22l-2 3.46a.5.5 0 0 0 .12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65a.5.5 0 0 0-.12.64l2 3.46a.5.5 0 0 0 .61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65a.5.5 0 0 0 .5.42h4a.5.5 0 0 0 .5-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1a.5.5 0 0 0 .61-.22l2-3.46a.5.5 0 0 0-.12-.64l-2.11-1.65z</StreamGeometry>
|
<StreamGeometry x:Key="Icon.Settings">M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8z M19.43 12.98c.04-.32.07-.64.07-.98s-.03-.66-.07-.98l2.11-1.65a.5.5 0 0 0 .12-.64l-2-3.46a.5.5 0 0 0-.61-.22l-2.49 1a7.03 7.03 0 0 0-1.69-.98l-.38-2.65a.5.5 0 0 0-.5-.42h-4a.5.5 0 0 0-.5.42l-.38 2.65c-.61.25-1.17.59-1.69.98l-2.49-1a.5.5 0 0 0-.61.22l-2 3.46a.5.5 0 0 0 .12.64l2.11 1.65c-.04.32-.07.65-.07.98s.03.66.07.98l-2.11 1.65a.5.5 0 0 0-.12.64l2 3.46a.5.5 0 0 0 .61.22l2.49-1c.52.4 1.08.73 1.69.98l.38 2.65a.5.5 0 0 0 .5.42h4a.5.5 0 0 0 .5-.42l.38-2.65c.61-.25 1.17-.59 1.69-.98l2.49 1a.5.5 0 0 0 .61-.22l2-3.46a.5.5 0 0 0-.12-.64l-2.11-1.65z</StreamGeometry>
|
||||||
|
|
||||||
<!-- Icon.Skull — filled silhouette: rounded cranium + eye holes (EvenOdd) + jaw -->
|
|
||||||
<StreamGeometry x:Key="Icon.Skull">F0 M12 2 C7 2 4 5.5 4 10 C4 13.5 6 16 8 17.5 L8 19 C8 20 8.9 21 10 21 L10 18.5 L14 18.5 L14 21 C15.1 21 16 20 16 19 L16 17.5 C18 16 20 13.5 20 10 C20 5.5 17 2 12 2 Z M8.5 8 L8.5 12 L11 12 L11 8 Z M13 8 L13 12 L15.5 12 L15.5 8 Z</StreamGeometry>
|
|
||||||
|
|
||||||
<!-- Badge brushes -->
|
<!-- Badge brushes -->
|
||||||
<SolidColorBrush x:Key="DraftBadgeBrush" Color="{StaticResource TextMuteColor}"/>
|
<SolidColorBrush x:Key="DraftBadgeBrush" Color="{StaticResource TextMuteColor}"/>
|
||||||
<SolidColorBrush x:Key="PlanningBadgeBrush" Color="{StaticResource PeatColor}"/>
|
<SolidColorBrush x:Key="PlanningBadgeBrush" Color="{StaticResource PeatColor}"/>
|
||||||
@@ -574,13 +565,6 @@
|
|||||||
<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 -->
|
||||||
|
|||||||
@@ -37,19 +37,10 @@ public interface IWorkerClient : INotifyPropertyChanged
|
|||||||
Task<ListConfigDto?> GetListConfigAsync(string listId);
|
Task<ListConfigDto?> GetListConfigAsync(string listId);
|
||||||
Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto);
|
Task UpdateTaskAgentSettingsAsync(UpdateTaskAgentSettingsDto dto);
|
||||||
Task SetTaskStatusAsync(string taskId, TaskStatus status);
|
Task SetTaskStatusAsync(string taskId, TaskStatus status);
|
||||||
Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch);
|
Task ApproveReviewAsync(string taskId);
|
||||||
Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch);
|
|
||||||
Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage);
|
|
||||||
Task RejectReviewToQueueAsync(string taskId, string feedback);
|
Task 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);
|
||||||
@@ -59,16 +50,13 @@ 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);
|
||||||
Task<string?> GetWeekReportAsync(DateOnly start, DateOnly end);
|
Task<string?> GetWeekReportAsync(DateOnly start, DateOnly end);
|
||||||
Task<string> GenerateWeekReportAsync(DateOnly start, DateOnly end);
|
Task<string> GenerateWeekReportAsync(DateOnly start, DateOnly end);
|
||||||
Task<bool> RunDailyPrepNowAsync();
|
Task<bool> RunDailyPrepNowAsync();
|
||||||
Task RefineTaskAsync(string taskId);
|
|
||||||
|
|
||||||
event Action<string>? RefineStartedEvent;
|
|
||||||
event Action<string, bool, string?>? RefineFinishedEvent;
|
|
||||||
Task ClearMyDayAsync();
|
Task ClearMyDayAsync();
|
||||||
Task<AppSettingsDto?> GetAppSettingsAsync();
|
Task<AppSettingsDto?> GetAppSettingsAsync();
|
||||||
Task<List<DailyNoteDto>> GetDailyNotesAsync(DateOnly day);
|
Task<List<DailyNoteDto>> GetDailyNotesAsync(DateOnly day);
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ using CommunityToolkit.Mvvm.ComponentModel;
|
|||||||
using Microsoft.AspNetCore.SignalR;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
using Microsoft.AspNetCore.SignalR.Client;
|
using Microsoft.AspNetCore.SignalR.Client;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Serilog.Context;
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.Services;
|
namespace ClaudeDo.Ui.Services;
|
||||||
|
|
||||||
@@ -30,6 +32,7 @@ sealed class IndefiniteRetryPolicy : IRetryPolicy
|
|||||||
public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerClient
|
public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerClient
|
||||||
{
|
{
|
||||||
private readonly HubConnection _hub;
|
private readonly HubConnection _hub;
|
||||||
|
private readonly ILogger<WorkerClient> _logger;
|
||||||
private CancellationTokenSource? _startCts;
|
private CancellationTokenSource? _startCts;
|
||||||
private Task _retryLoopTask = Task.CompletedTask;
|
private Task _retryLoopTask = Task.CompletedTask;
|
||||||
private readonly object _startLock = new();
|
private readonly object _startLock = new();
|
||||||
@@ -55,9 +58,6 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
public event Action<string>? PrepLineEvent;
|
public event Action<string>? PrepLineEvent;
|
||||||
public event Action<bool>? PrepFinishedEvent;
|
public event Action<bool>? PrepFinishedEvent;
|
||||||
|
|
||||||
public event Action<string>? RefineStartedEvent;
|
|
||||||
public event Action<string, bool, string?>? RefineFinishedEvent;
|
|
||||||
|
|
||||||
public event Action<string, string>? PlanningMergeStartedEvent;
|
public event Action<string, string>? PlanningMergeStartedEvent;
|
||||||
public event Action<string, string>? PlanningSubtaskMergedEvent;
|
public event Action<string, string>? PlanningSubtaskMergedEvent;
|
||||||
public event Action<string, string, IReadOnlyList<string>>? PlanningMergeConflictEvent;
|
public event Action<string, string, IReadOnlyList<string>>? PlanningMergeConflictEvent;
|
||||||
@@ -66,10 +66,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
|
|
||||||
public event Action<PrimeFiredEvent>? PrimeFired;
|
public event Action<PrimeFiredEvent>? PrimeFired;
|
||||||
|
|
||||||
public string? LastApproveTarget { get; private set; }
|
public string? LastMergeAllTarget { get; private set; }
|
||||||
|
|
||||||
public WorkerClient(string signalRUrl)
|
public WorkerClient(string signalRUrl, ILogger<WorkerClient> logger)
|
||||||
{
|
{
|
||||||
|
_logger = logger;
|
||||||
_hub = new HubConnectionBuilder()
|
_hub = new HubConnectionBuilder()
|
||||||
.WithUrl(signalRUrl)
|
.WithUrl(signalRUrl)
|
||||||
.WithAutomaticReconnect(new IndefiniteRetryPolicy())
|
.WithAutomaticReconnect(new IndefiniteRetryPolicy())
|
||||||
@@ -182,11 +183,6 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
_hub.On("PrepStarted", () => Dispatcher.UIThread.Post(() => PrepStartedEvent?.Invoke()));
|
_hub.On("PrepStarted", () => Dispatcher.UIThread.Post(() => PrepStartedEvent?.Invoke()));
|
||||||
_hub.On<string>("PrepLine", line => Dispatcher.UIThread.Post(() => PrepLineEvent?.Invoke(line)));
|
_hub.On<string>("PrepLine", line => Dispatcher.UIThread.Post(() => PrepLineEvent?.Invoke(line)));
|
||||||
_hub.On<bool>("PrepFinished", ok => Dispatcher.UIThread.Post(() => PrepFinishedEvent?.Invoke(ok)));
|
_hub.On<bool>("PrepFinished", ok => Dispatcher.UIThread.Post(() => PrepFinishedEvent?.Invoke(ok)));
|
||||||
|
|
||||||
_hub.On<string>("RefineStarted", id =>
|
|
||||||
Dispatcher.UIThread.Post(() => RefineStartedEvent?.Invoke(id)));
|
|
||||||
_hub.On<string, bool, string?>("RefineFinished", (id, ok, err) =>
|
|
||||||
Dispatcher.UIThread.Post(() => RefineFinishedEvent?.Invoke(id, ok, err)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task StartAsync()
|
public Task StartAsync()
|
||||||
@@ -248,20 +244,24 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
catch { return default; }
|
catch { return default; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task RunNowAsync(string taskId)
|
/// <summary>Invoke a task-targeted hub method under a TaskId log scope, emitting a debug trace line.</summary>
|
||||||
|
private async Task InvokeForTaskAsync(string taskId, string method, params object?[] args)
|
||||||
{
|
{
|
||||||
await _hub.InvokeAsync("RunNow", taskId);
|
using (LogContext.PushProperty("TaskId", taskId))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("UI invoking {Method} for task {TaskId}", method, taskId);
|
||||||
|
await _hub.InvokeCoreAsync(method, args);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ContinueTaskAsync(string taskId, string followUpPrompt)
|
public Task RunNowAsync(string taskId)
|
||||||
{
|
=> InvokeForTaskAsync(taskId, "RunNow", taskId);
|
||||||
await _hub.InvokeAsync("ContinueTask", taskId, followUpPrompt);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task ResetTaskAsync(string taskId)
|
public Task ContinueTaskAsync(string taskId, string followUpPrompt)
|
||||||
{
|
=> InvokeForTaskAsync(taskId, "ContinueTask", taskId, followUpPrompt);
|
||||||
await _hub.InvokeAsync("ResetTask", taskId);
|
|
||||||
}
|
public Task ResetTaskAsync(string taskId)
|
||||||
|
=> InvokeForTaskAsync(taskId, "ResetTask", taskId);
|
||||||
|
|
||||||
public async Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage)
|
public async Task<MergeResultDto> MergeTaskAsync(string taskId, string targetBranch, bool removeWorktree, string commitMessage)
|
||||||
{
|
{
|
||||||
@@ -269,28 +269,11 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
"MergeTask", taskId, targetBranch, removeWorktree, commitMessage);
|
"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);
|
||||||
|
|
||||||
public async Task CancelTaskAsync(string taskId)
|
public Task CancelTaskAsync(string taskId)
|
||||||
{
|
=> InvokeForTaskAsync(taskId, "CancelTask", taskId);
|
||||||
await _hub.InvokeAsync("CancelTask", taskId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task WakeQueueAsync()
|
public async Task WakeQueueAsync()
|
||||||
{
|
{
|
||||||
@@ -368,8 +351,6 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
public Task<bool> RunDailyPrepNowAsync()
|
public Task<bool> RunDailyPrepNowAsync()
|
||||||
=> _hub.InvokeAsync<bool>("RunDailyPrepNow");
|
=> _hub.InvokeAsync<bool>("RunDailyPrepNow");
|
||||||
|
|
||||||
public Task RefineTaskAsync(string taskId) => _hub.InvokeAsync("RefineTask", taskId);
|
|
||||||
|
|
||||||
public Task ClearMyDayAsync()
|
public Task ClearMyDayAsync()
|
||||||
=> _hub.InvokeAsync("ClearMyDay");
|
=> _hub.InvokeAsync("ClearMyDay");
|
||||||
|
|
||||||
@@ -411,29 +392,17 @@ public partial class WorkerClient : ObservableObject, IAsyncDisposable, IWorkerC
|
|||||||
await _hub.InvokeAsync("SetTaskStatus", taskId, status.ToString());
|
await _hub.InvokeAsync("SetTaskStatus", taskId, status.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<MergeResultDto?> ApproveReviewAsync(string taskId, string targetBranch)
|
public Task ApproveReviewAsync(string taskId)
|
||||||
{
|
=> InvokeForTaskAsync(taskId, "ApproveReview", taskId);
|
||||||
LastApproveTarget = targetBranch;
|
|
||||||
return TryInvokeAsync<MergeResultDto>("ApproveReview", taskId, targetBranch);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<MergePreviewDto?> PreviewMergeAsync(string taskId, string targetBranch)
|
public Task RejectReviewToQueueAsync(string taskId, string feedback)
|
||||||
=> TryInvokeAsync<MergePreviewDto>("PreviewMerge", taskId, targetBranch);
|
=> InvokeForTaskAsync(taskId, "RejectReviewToQueue", taskId, feedback);
|
||||||
|
|
||||||
public async Task RejectReviewToQueueAsync(string taskId, string feedback)
|
public Task RejectReviewToIdleAsync(string taskId)
|
||||||
{
|
=> InvokeForTaskAsync(taskId, "RejectReviewToIdle", taskId);
|
||||||
await _hub.InvokeAsync("RejectReviewToQueue", taskId, feedback);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task RejectReviewToIdleAsync(string taskId)
|
public Task CancelReviewAsync(string taskId)
|
||||||
{
|
=> InvokeForTaskAsync(taskId, "CancelReview", taskId);
|
||||||
await _hub.InvokeAsync("RejectReviewToIdle", taskId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task CancelReviewAsync(string taskId)
|
|
||||||
{
|
|
||||||
await _hub.InvokeAsync("CancelReview", taskId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync(string? listId = null)
|
public Task<WorktreeCleanupDto?> CleanupFinishedWorktreesAsync(string? listId = null)
|
||||||
=> TryInvokeAsync<WorktreeCleanupDto>("CleanupFinishedWorktrees", listId);
|
=> TryInvokeAsync<WorktreeCleanupDto>("CleanupFinishedWorktrees", listId);
|
||||||
@@ -489,6 +458,12 @@ 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);
|
||||||
@@ -542,11 +517,7 @@ public sealed record AppSettingsDto(
|
|||||||
public sealed record WorktreeCleanupDto(int Removed);
|
public sealed record WorktreeCleanupDto(int Removed);
|
||||||
public sealed record WorktreeResetDto(int Removed, int TasksAffected, bool Blocked, int RunningTasks);
|
public sealed record WorktreeResetDto(int Removed, int TasksAffected, bool Blocked, int RunningTasks);
|
||||||
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 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);
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
|
||||||
using CommunityToolkit.Mvvm.Input;
|
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.ViewModels.Conflicts;
|
|
||||||
|
|
||||||
public sealed partial class ConflictHunk : ObservableObject
|
|
||||||
{
|
|
||||||
public string Ours { get; }
|
|
||||||
public string Theirs { get; }
|
|
||||||
public string? Base { get; }
|
|
||||||
|
|
||||||
[ObservableProperty] private string? _resolution;
|
|
||||||
|
|
||||||
public bool IsResolved => Resolution is not null;
|
|
||||||
|
|
||||||
public ConflictHunk(string ours, string theirs, string? @base)
|
|
||||||
{
|
|
||||||
Ours = ours;
|
|
||||||
Theirs = theirs;
|
|
||||||
Base = @base;
|
|
||||||
}
|
|
||||||
|
|
||||||
partial void OnResolutionChanged(string? value) => OnPropertyChanged(nameof(IsResolved));
|
|
||||||
|
|
||||||
[RelayCommand] private void AcceptCurrent() => Resolution = Ours;
|
|
||||||
[RelayCommand] private void AcceptIncoming() => Resolution = Theirs;
|
|
||||||
[RelayCommand] private void AcceptBoth() => Resolution = Ours + Theirs;
|
|
||||||
[RelayCommand] private void EditManually() => Resolution ??= Ours;
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class ConflictFile
|
|
||||||
{
|
|
||||||
public string Path { get; }
|
|
||||||
public IReadOnlyList<ConflictHunk> Hunks { get; }
|
|
||||||
|
|
||||||
public ConflictFile(string path, IReadOnlyList<ConflictHunk> hunks)
|
|
||||||
{
|
|
||||||
Path = path;
|
|
||||||
Hunks = hunks;
|
|
||||||
}
|
|
||||||
|
|
||||||
public bool AllHunksResolved => Hunks.Count > 0 && Hunks.All(h => h.IsResolved);
|
|
||||||
|
|
||||||
/// <summary>Merged file content: concatenation of each hunk's resolution
|
|
||||||
/// (single whole-file hunk today; concatenation stays correct for multi-hunk later).</summary>
|
|
||||||
public string ComposeResolvedContent() => string.Concat(Hunks.Select(h => h.Resolution));
|
|
||||||
}
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.ObjectModel;
|
|
||||||
using System.ComponentModel;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
|
||||||
using CommunityToolkit.Mvvm.Input;
|
|
||||||
using ClaudeDo.Ui.Services;
|
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.ViewModels.Conflicts;
|
|
||||||
|
|
||||||
public sealed partial class ConflictResolverViewModel : ObservableObject
|
|
||||||
{
|
|
||||||
private readonly IWorkerClient _worker;
|
|
||||||
private readonly string _taskId;
|
|
||||||
|
|
||||||
public ObservableCollection<ConflictFile> Files { get; } = new();
|
|
||||||
|
|
||||||
[ObservableProperty] private bool _isBusy;
|
|
||||||
[ObservableProperty] private string? _error;
|
|
||||||
[ObservableProperty] private bool _canContinue;
|
|
||||||
|
|
||||||
public string TaskId => _taskId;
|
|
||||||
public Action? CloseRequested { get; set; }
|
|
||||||
|
|
||||||
public ConflictResolverViewModel(IWorkerClient worker, string taskId)
|
|
||||||
{
|
|
||||||
_worker = worker;
|
|
||||||
_taskId = taskId;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Starts the conflict merge and loads ours/theirs/base per file.
|
|
||||||
/// Returns true when there are conflicts to resolve (caller should show the dialog).</summary>
|
|
||||||
public async Task<bool> OpenAsync(string targetBranch)
|
|
||||||
{
|
|
||||||
IsBusy = true;
|
|
||||||
Error = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var start = await _worker.StartConflictMergeAsync(_taskId, targetBranch);
|
|
||||||
if (!string.Equals(start.Status, "conflict", StringComparison.Ordinal))
|
|
||||||
{
|
|
||||||
if (string.Equals(start.Status, "blocked", StringComparison.Ordinal))
|
|
||||||
Error = start.ErrorMessage;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var conflicts = await _worker.GetMergeConflictsAsync(_taskId);
|
|
||||||
Files.Clear();
|
|
||||||
foreach (var f in conflicts.Files)
|
|
||||||
{
|
|
||||||
var hunks = f.Hunks.Select(h =>
|
|
||||||
{
|
|
||||||
var hk = new ConflictHunk(h.Ours, h.Theirs, h.Base);
|
|
||||||
hk.PropertyChanged += OnHunkChanged;
|
|
||||||
return hk;
|
|
||||||
}).ToList();
|
|
||||||
Files.Add(new ConflictFile(f.Path, hunks));
|
|
||||||
}
|
|
||||||
RecomputeCanContinue();
|
|
||||||
return Files.Count > 0;
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Error = ex.Message;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
finally { IsBusy = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnHunkChanged(object? sender, PropertyChangedEventArgs e)
|
|
||||||
{
|
|
||||||
if (e.PropertyName is nameof(ConflictHunk.IsResolved) or nameof(ConflictHunk.Resolution))
|
|
||||||
RecomputeCanContinue();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RecomputeCanContinue()
|
|
||||||
=> CanContinue = Files.Count > 0 && Files.All(f => f.AllHunksResolved);
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private async Task ContinueAsync()
|
|
||||||
{
|
|
||||||
if (!CanContinue) return;
|
|
||||||
IsBusy = true;
|
|
||||||
Error = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
foreach (var file in Files)
|
|
||||||
await _worker.WriteConflictResolutionAsync(_taskId, file.Path, file.ComposeResolvedContent());
|
|
||||||
|
|
||||||
var result = await _worker.ContinueMergeAsync(_taskId);
|
|
||||||
if (string.Equals(result.Status, "merged", StringComparison.Ordinal))
|
|
||||||
CloseRequested?.Invoke();
|
|
||||||
else
|
|
||||||
Error = result.ErrorMessage ?? "Conflicts not fully resolved — review and retry.";
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Error = ex.Message;
|
|
||||||
}
|
|
||||||
finally { IsBusy = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private async Task AbortAsync()
|
|
||||||
{
|
|
||||||
IsBusy = true;
|
|
||||||
try { await _worker.AbortMergeAsync(_taskId); }
|
|
||||||
catch (Exception ex) { Error = ex.Message; }
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
IsBusy = false;
|
|
||||||
CloseRequested?.Invoke();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -46,21 +46,13 @@ public sealed class LogLineViewModel
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
public sealed partial class DetailsIslandViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
private readonly 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;
|
||||||
@@ -82,12 +74,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
private TaskRowViewModel? _task;
|
private TaskRowViewModel? _task;
|
||||||
|
|
||||||
// Editable fields
|
// Editable fields
|
||||||
[ObservableProperty]
|
[ObservableProperty] private string _editableTitle = "";
|
||||||
[NotifyPropertyChangedFor(nameof(ComposedPreview))]
|
[ObservableProperty] private string _editableDescription = "";
|
||||||
private string _editableTitle = "";
|
|
||||||
[ObservableProperty]
|
|
||||||
[NotifyPropertyChangedFor(nameof(ComposedPreview))]
|
|
||||||
private string _editableDescription = "";
|
|
||||||
[ObservableProperty] private bool _isEditingDescription;
|
[ObservableProperty] private bool _isEditingDescription;
|
||||||
[ObservableProperty] private bool _isDescriptionExpanded = true;
|
[ObservableProperty] private bool _isDescriptionExpanded = true;
|
||||||
|
|
||||||
@@ -112,129 +100,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void ToggleDescriptionExpanded() => IsDescriptionExpanded = !IsDescriptionExpanded;
|
private void ToggleDescriptionExpanded() => IsDescriptionExpanded = !IsDescriptionExpanded;
|
||||||
|
|
||||||
// ── Description / Steps card (redesign) ─────────────────────────────
|
|
||||||
// Description is always the card body; steps live in an expandable summary
|
|
||||||
// strip below it so step presence is visible without switching views.
|
|
||||||
[ObservableProperty] private bool _isStepsExpanded;
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void ToggleStepsExpanded() => IsStepsExpanded = !IsStepsExpanded;
|
|
||||||
|
|
||||||
public int TotalStepCount => Subtasks.Count;
|
|
||||||
public int OpenStepCount => Subtasks.Count(s => !s.Done);
|
|
||||||
public string StepsSummary =>
|
|
||||||
TotalStepCount == 0 ? "no steps yet"
|
|
||||||
: OpenStepCount == 0 ? $"all done · {TotalStepCount} total"
|
|
||||||
: $"{OpenStepCount} open · {TotalStepCount} total";
|
|
||||||
|
|
||||||
private void NotifyStepsChanged()
|
|
||||||
{
|
|
||||||
OnPropertyChanged(nameof(TotalStepCount));
|
|
||||||
OnPropertyChanged(nameof(OpenStepCount));
|
|
||||||
OnPropertyChanged(nameof(StepsSummary));
|
|
||||||
OnPropertyChanged(nameof(ComposedPreview));
|
|
||||||
}
|
|
||||||
|
|
||||||
// The exact text handed to Claude: title + description + open steps only.
|
|
||||||
public string ComposedPreview =>
|
|
||||||
ClaudeDo.Data.TaskPromptComposer.Compose(
|
|
||||||
EditableTitle, EditableDescription, Subtasks.Select(s => (s.Title, s.Done)));
|
|
||||||
|
|
||||||
// ── Work console (redesign) ────────────────────────────────────────
|
|
||||||
// Two tabs: Output (live log) and Session (review + merge/worktree +
|
|
||||||
// outcomes, each section gated on the relevant state).
|
|
||||||
[ObservableProperty]
|
|
||||||
[NotifyPropertyChangedFor(nameof(IsOutputTab))]
|
|
||||||
[NotifyPropertyChangedFor(nameof(IsGitTab))]
|
|
||||||
[NotifyPropertyChangedFor(nameof(IsSessionTab))]
|
|
||||||
private string _selectedTab = "output";
|
|
||||||
|
|
||||||
public bool IsOutputTab => SelectedTab == "output";
|
|
||||||
public bool IsGitTab => SelectedTab == "git";
|
|
||||||
public bool IsSessionTab => SelectedTab == "session";
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void SelectTab(string? tab) => SelectedTab = tab ?? "output";
|
|
||||||
|
|
||||||
// Merge/worktree controls only matter once there's a worktree to manage
|
|
||||||
// (standalone task), or a planning parent / improvement parent with children.
|
|
||||||
public bool ShowMergeSection =>
|
|
||||||
WorktreePath != null || Task?.IsPlanningParent == true || HasChildOutcomes;
|
|
||||||
|
|
||||||
private void NotifySessionSections()
|
|
||||||
{
|
|
||||||
OnPropertyChanged(nameof(HasChildOutcomes));
|
|
||||||
OnPropertyChanged(nameof(ShowMergeSection));
|
|
||||||
NotifyAttention();
|
|
||||||
|
|
||||||
// The Session tab is only visible when it has outcomes; if it just
|
|
||||||
// emptied while selected, fall back to Output so the body isn't blank.
|
|
||||||
if (!HasChildOutcomes && SelectedTab == "session")
|
|
||||||
SelectedTab = "output";
|
|
||||||
}
|
|
||||||
|
|
||||||
public string TurnsText => $"{Turns}/{EffectiveMaxTurns}";
|
|
||||||
public string DiffAddText => $"+{DiffAdditions}";
|
|
||||||
public string DiffDelText => $"-{DiffDeletions}";
|
|
||||||
|
|
||||||
// Resolved turn budget: per-task override → list default → global default.
|
|
||||||
public int EffectiveMaxTurns =>
|
|
||||||
TaskMaxTurns is decimal t ? (int)t : (_listMaxTurns ?? _globalMaxTurns);
|
|
||||||
|
|
||||||
public bool ShowRoadblock => IsFailed || IsCancelled;
|
|
||||||
public string RoadblockMessage =>
|
|
||||||
IsFailed ? "The session ended with an error." :
|
|
||||||
IsCancelled ? "The session was cancelled." : "";
|
|
||||||
|
|
||||||
// The session's outcome summary — the task's Result minus any roadblock
|
|
||||||
// section (those get their own card), falling back to the run's
|
|
||||||
// ErrorMarkdown for hard failures. Shown once a run has finished.
|
|
||||||
[ObservableProperty]
|
|
||||||
[NotifyPropertyChangedFor(nameof(ShowSessionOutcome))]
|
|
||||||
private string? _sessionOutcome;
|
|
||||||
|
|
||||||
public bool ShowSessionOutcome =>
|
|
||||||
!string.IsNullOrWhiteSpace(SessionOutcome)
|
|
||||||
&& (IsWaitingForReview || IsDone || IsFailed || IsCancelled);
|
|
||||||
|
|
||||||
// The roadblocks the agent emitted (CLAUDEDO_BLOCKED), parsed out of the
|
|
||||||
// run result so they can surface as a distinct colored card.
|
|
||||||
[ObservableProperty]
|
|
||||||
[NotifyPropertyChangedFor(nameof(ShowRoadblockCard))]
|
|
||||||
private string? _roadblocks;
|
|
||||||
|
|
||||||
public bool ShowRoadblockCard =>
|
|
||||||
!string.IsNullOrWhiteSpace(Roadblocks)
|
|
||||||
&& (IsWaitingForReview || IsDone || IsFailed || IsCancelled);
|
|
||||||
|
|
||||||
// Worker writes roadblocks into the result under this header
|
|
||||||
// (TaskRunner.ComposeReviewResult). Split it back out for display.
|
|
||||||
private const string RoadblockMarker = "Roadblocks reported during the run:";
|
|
||||||
|
|
||||||
private void ApplyOutcome(string? result, string? errorFallback)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(result))
|
|
||||||
{
|
|
||||||
SessionOutcome = errorFallback;
|
|
||||||
Roadblocks = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var idx = result.IndexOf(RoadblockMarker, StringComparison.Ordinal);
|
|
||||||
if (idx < 0)
|
|
||||||
{
|
|
||||||
SessionOutcome = result;
|
|
||||||
Roadblocks = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var summary = result[..idx].TrimEnd().TrimEnd('⚠').TrimEnd();
|
|
||||||
SessionOutcome = string.IsNullOrWhiteSpace(summary) ? null : summary;
|
|
||||||
Roadblocks = result[(idx + RoadblockMarker.Length)..].Trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
public string SessionLabel => "claude-session";
|
|
||||||
|
|
||||||
// Short task-id badge, e.g. "#T1A"
|
// Short task-id badge, e.g. "#T1A"
|
||||||
public string TaskIdBadge => Task != null ? $"#T{Task.Id[..Math.Min(3, Task.Id.Length)].ToUpperInvariant()}" : "";
|
public string TaskIdBadge => Task != null ? $"#T{Task.Id[..Math.Min(3, Task.Id.Length)].ToUpperInvariant()}" : "";
|
||||||
|
|
||||||
@@ -277,11 +142,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
OnPropertyChanged(nameof(ShowContinue));
|
OnPropertyChanged(nameof(ShowContinue));
|
||||||
OnPropertyChanged(nameof(ShowResetAndRetry));
|
OnPropertyChanged(nameof(ShowResetAndRetry));
|
||||||
OnPropertyChanged(nameof(IsAgentSectionEnabled));
|
OnPropertyChanged(nameof(IsAgentSectionEnabled));
|
||||||
OnPropertyChanged(nameof(ShowRoadblock));
|
|
||||||
OnPropertyChanged(nameof(RoadblockMessage));
|
|
||||||
OnPropertyChanged(nameof(ShowSessionOutcome));
|
|
||||||
OnPropertyChanged(nameof(ShowRoadblockCard));
|
|
||||||
NotifySessionSections();
|
|
||||||
}
|
}
|
||||||
[ObservableProperty] private string? _model;
|
[ObservableProperty] private string? _model;
|
||||||
|
|
||||||
@@ -304,13 +164,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
private string? _listAgentName;
|
private string? _listAgentName;
|
||||||
|
|
||||||
partial void OnTaskModelSelectionChanged(string? value) { RecomputeModelBadge(); QueueAgentSave(); }
|
partial void OnTaskModelSelectionChanged(string? value) { RecomputeModelBadge(); QueueAgentSave(); }
|
||||||
partial void OnTaskMaxTurnsChanged(decimal? value)
|
partial void OnTaskMaxTurnsChanged(decimal? value) { RecomputeTurnsBadge(); QueueAgentSave(); }
|
||||||
{
|
|
||||||
RecomputeTurnsBadge();
|
|
||||||
OnPropertyChanged(nameof(EffectiveMaxTurns));
|
|
||||||
OnPropertyChanged(nameof(TurnsText));
|
|
||||||
QueueAgentSave();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RecomputeModelBadge()
|
private void RecomputeModelBadge()
|
||||||
{
|
{
|
||||||
@@ -355,11 +209,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
|
|
||||||
[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;
|
||||||
@@ -371,9 +221,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
public string ElapsedFormatted => ""; // placeholder — no start-time stored yet
|
public string ElapsedFormatted => ""; // placeholder — no start-time stored yet
|
||||||
|
|
||||||
partial void OnTokensChanged(int value) => OnPropertyChanged(nameof(TokensFormatted));
|
partial void OnTokensChanged(int value) => OnPropertyChanged(nameof(TokensFormatted));
|
||||||
partial void OnTurnsChanged(int value) => OnPropertyChanged(nameof(TurnsText));
|
partial void OnDiffAdditionsChanged(int value) => OnPropertyChanged(nameof(DiffMeterRatio));
|
||||||
partial void OnDiffAdditionsChanged(int value) { OnPropertyChanged(nameof(DiffMeterRatio)); OnPropertyChanged(nameof(DiffAddText)); }
|
partial void OnDiffDeletionsChanged(int value) => OnPropertyChanged(nameof(DiffMeterRatio));
|
||||||
partial void OnDiffDeletionsChanged(int value) { OnPropertyChanged(nameof(DiffMeterRatio)); OnPropertyChanged(nameof(DiffDelText)); }
|
|
||||||
|
|
||||||
// 0.0–1.0 additions share for the diff meter
|
// 0.0–1.0 additions share for the diff meter
|
||||||
public double DiffMeterRatio
|
public double DiffMeterRatio
|
||||||
@@ -393,47 +242,16 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
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]
|
[ObservableProperty]
|
||||||
[NotifyPropertyChangedFor(nameof(ShowMergePreviewMuted))]
|
[NotifyCanExecuteChangedFor(nameof(MergeAllCommand))]
|
||||||
private string _mergePreviewText = "";
|
private bool _canMergeAll;
|
||||||
|
[ObservableProperty] private string? _mergeAllDisabledReason;
|
||||||
[ObservableProperty]
|
[ObservableProperty] private string? _mergeAllError;
|
||||||
[NotifyPropertyChangedFor(nameof(ShowMergePreviewMuted))]
|
|
||||||
private bool _mergeIsClean;
|
|
||||||
|
|
||||||
[ObservableProperty]
|
|
||||||
[NotifyPropertyChangedFor(nameof(ShowMergePreviewMuted))]
|
|
||||||
private bool _mergeIsConflict;
|
|
||||||
|
|
||||||
public bool ShowMergePreviewMuted =>
|
|
||||||
!MergeIsClean && !MergeIsConflict && !string.IsNullOrEmpty(MergePreviewText);
|
|
||||||
|
|
||||||
// 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();
|
||||||
@@ -466,10 +284,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
// 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",
|
||||||
@@ -507,21 +321,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
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;
|
||||||
@@ -529,15 +328,13 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
_services = services;
|
_services = services;
|
||||||
_notesApi = notesApi;
|
_notesApi = notesApi;
|
||||||
Notes = new NotesEditorViewModel(_notesApi);
|
Notes = new NotesEditorViewModel(_notesApi);
|
||||||
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;
|
||||||
@@ -546,7 +343,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
_worker.PrepFinishedEvent += OnPrepFinished;
|
_worker.PrepFinishedEvent += OnPrepFinished;
|
||||||
|
|
||||||
// Re-evaluate CanExecute when worker connection flips.
|
// Re-evaluate CanExecute when worker connection flips.
|
||||||
_workerPropertyChangedHandler = (_, e) =>
|
_worker.PropertyChanged += (_, e) =>
|
||||||
{
|
{
|
||||||
if (e.PropertyName == nameof(WorkerClient.IsConnected))
|
if (e.PropertyName == nameof(WorkerClient.IsConnected))
|
||||||
{
|
{
|
||||||
@@ -556,17 +353,14 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
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.
|
||||||
_workerTaskStartedHandler = (slot, taskId, startedAt) =>
|
_worker.TaskStartedEvent += (slot, taskId, startedAt) =>
|
||||||
{
|
{
|
||||||
if (Task?.Id == taskId) AgentState = "running";
|
if (Task?.Id == taskId) AgentState = "running";
|
||||||
_ = RefreshChildOutcomeAsync(taskId);
|
_ = RefreshChildOutcomeAsync(taskId);
|
||||||
};
|
};
|
||||||
_worker.TaskStartedEvent += _workerTaskStartedHandler;
|
_worker.TaskFinishedEvent += (slot, taskId, status, finishedAt) =>
|
||||||
|
|
||||||
_workerTaskFinishedHandler = (slot, taskId, status, finishedAt) =>
|
|
||||||
{
|
{
|
||||||
if (Task?.Id != taskId) return;
|
if (Task?.Id != taskId) return;
|
||||||
FlushClaudeBuffer();
|
FlushClaudeBuffer();
|
||||||
@@ -579,54 +373,37 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
// 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;
|
|
||||||
|
|
||||||
_workerWorktreeUpdatedHandler = taskId =>
|
_worker.WorktreeUpdatedEvent += 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;
|
|
||||||
|
|
||||||
_workerTaskUpdatedHandler = taskId =>
|
_worker.TaskUpdatedEvent += 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();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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;
|
||||||
@@ -799,8 +576,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
RecomputeModelBadge();
|
RecomputeModelBadge();
|
||||||
RecomputeTurnsBadge();
|
RecomputeTurnsBadge();
|
||||||
RecomputeAgentBadge();
|
RecomputeAgentBadge();
|
||||||
OnPropertyChanged(nameof(EffectiveMaxTurns));
|
|
||||||
OnPropertyChanged(nameof(TurnsText));
|
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -855,8 +630,9 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
OnPropertyChanged(nameof(HasChildOutcomes));
|
OnPropertyChanged(nameof(HasChildOutcomes));
|
||||||
MergeTargetBranches.Clear();
|
MergeTargetBranches.Clear();
|
||||||
SelectedMergeTarget = null;
|
SelectedMergeTarget = null;
|
||||||
SessionOutcome = null;
|
CanMergeAll = false;
|
||||||
Roadblocks = null;
|
MergeAllDisabledReason = null;
|
||||||
|
MergeAllError = null;
|
||||||
_claudeBuf.Clear();
|
_claudeBuf.Clear();
|
||||||
|
|
||||||
if (row == null)
|
if (row == null)
|
||||||
@@ -866,8 +642,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
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;
|
||||||
@@ -904,7 +678,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
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;
|
||||||
@@ -914,10 +687,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
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);
|
||||||
@@ -931,7 +702,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
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;
|
||||||
@@ -945,26 +715,13 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
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);
|
|
||||||
// Surface every parent's children — planning or improvement — in the
|
|
||||||
// Session tab with their live status + roadblock count. This is what
|
|
||||||
// makes the Session tab appear for planning parents and lets a child's
|
|
||||||
// roadblock register on the parent.
|
|
||||||
await LoadChildOutcomesAsync(row.Id, ct);
|
|
||||||
|
|
||||||
if (entity.Worktree != null
|
|
||||||
&& entity.PlanningPhase == ClaudeDo.Data.Models.PlanningPhase.None
|
|
||||||
&& MergeTargetBranches.Count == 0)
|
|
||||||
{
|
{
|
||||||
var targets = await _worker.GetMergeTargetsAsync(row.Id);
|
await LoadPlanningChildrenAsync(row.Id, ct);
|
||||||
if (targets != null)
|
}
|
||||||
{
|
else
|
||||||
MergeTargetBranches.Clear();
|
{
|
||||||
foreach (var b in targets.LocalBranches) MergeTargetBranches.Add(b);
|
await LoadChildOutcomesAsync(row.Id, ct);
|
||||||
SelectedMergeTarget = targets.DefaultBranch; // triggers OnSelectedMergeTargetChanged → preview
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
await RefreshMergePreviewAsync();
|
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) { }
|
catch (OperationCanceledException) { }
|
||||||
}
|
}
|
||||||
@@ -1013,6 +770,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RecomputeCanMergeAll();
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) { }
|
catch (OperationCanceledException) { }
|
||||||
catch { /* best-effort */ }
|
catch { /* best-effort */ }
|
||||||
@@ -1109,6 +867,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RecomputeCanMergeAll();
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException) { }
|
catch (OperationCanceledException) { }
|
||||||
catch { /* best-effort */ }
|
catch { /* best-effort */ }
|
||||||
@@ -1133,6 +892,7 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
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 */ }
|
||||||
}
|
}
|
||||||
@@ -1154,12 +914,54 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
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()
|
||||||
{
|
{
|
||||||
@@ -1171,6 +973,20 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
|
|
||||||
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
|
||||||
@@ -1179,14 +995,11 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
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);
|
||||||
@@ -1199,78 +1012,24 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
catch { /* best-effort refresh */ }
|
catch { /* best-effort refresh */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
private async System.Threading.Tasks.Task RefreshMergePreviewAsync()
|
|
||||||
{
|
|
||||||
if (Task is null || WorktreePath is null)
|
|
||||||
{
|
|
||||||
MergePreviewText = ""; MergeIsClean = false; MergeIsConflict = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Only probe Active worktrees; terminal states show their label instead.
|
|
||||||
if (WorktreeStateLabel is { } label && label != "Active")
|
|
||||||
{
|
|
||||||
MergePreviewText = label; MergeIsClean = false; MergeIsConflict = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var capturedTaskId = Task.Id;
|
|
||||||
var capturedTarget = SelectedMergeTarget;
|
|
||||||
var dto = await _worker.PreviewMergeAsync(capturedTaskId, capturedTarget ?? "");
|
|
||||||
// Discard a probe that resolved after the user switched task or target.
|
|
||||||
if (Task?.Id != capturedTaskId || SelectedMergeTarget != capturedTarget) return;
|
|
||||||
var (text, clean, conflict) = MergePreviewPresenter.Describe(dto);
|
|
||||||
MergePreviewText = text; MergeIsClean = clean; MergeIsConflict = conflict;
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(CanOpenDiff))]
|
[RelayCommand(CanExecute = nameof(CanOpenDiff))]
|
||||||
private async System.Threading.Tasks.Task OpenDiffAsync()
|
private async System.Threading.Tasks.Task OpenDiffAsync()
|
||||||
{
|
{
|
||||||
if (ShowDiffModal == null) return;
|
if (WorktreePath == null || ShowDiffModal == null) return;
|
||||||
var git = _services.GetRequiredService<ClaudeDo.Data.Git.GitService>();
|
var diffVm = new DiffModalViewModel(_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)
|
|
||||||
{
|
{
|
||||||
diffVm = new DiffModalViewModel(git)
|
WorktreePath = WorktreePath,
|
||||||
{
|
BaseRef = WorktreeBaseCommit,
|
||||||
WorktreePath = WorktreePath!,
|
TaskId = Task?.Id,
|
||||||
BaseRef = WorktreeBaseCommit,
|
TaskTitle = Task?.Title ?? "",
|
||||||
TaskId = Task?.Id,
|
ShowMergeModal = ShowMergeModal,
|
||||||
TaskTitle = Task?.Title ?? "",
|
ResolveMergeVm = () => _services.GetRequiredService<MergeModalViewModel>(),
|
||||||
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 CanDiffMergedRange =>
|
private bool CanOpenDiff() => WorktreePath != null;
|
||||||
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()
|
||||||
@@ -1289,23 +1048,12 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
|
|
||||||
private bool CanOpenWorktree() => WorktreePath != null;
|
private bool CanOpenWorktree() => WorktreePath != null;
|
||||||
|
|
||||||
partial void OnSelectedMergeTargetChanged(string? value)
|
|
||||||
{
|
|
||||||
_ = RefreshMergePreviewAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
partial void OnWorktreePathChanged(string? value)
|
partial void OnWorktreePathChanged(string? value)
|
||||||
{
|
{
|
||||||
OpenDiffCommand.NotifyCanExecuteChanged();
|
OpenDiffCommand.NotifyCanExecuteChanged();
|
||||||
OpenWorktreeCommand.NotifyCanExecuteChanged();
|
OpenWorktreeCommand.NotifyCanExecuteChanged();
|
||||||
NotifySessionSections();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnWorktreeHeadCommitChanged(string? value) =>
|
|
||||||
OpenDiffCommand.NotifyCanExecuteChanged();
|
|
||||||
|
|
||||||
partial void OnTaskChanged(TaskRowViewModel? value) => NotifySessionSections();
|
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private void CloseDetails() => CloseDetail?.Invoke();
|
private void CloseDetails() => CloseDetail?.Invoke();
|
||||||
|
|
||||||
@@ -1344,7 +1092,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
{
|
{
|
||||||
if (row is null) return;
|
if (row is null) return;
|
||||||
row.Done = !row.Done;
|
row.Done = !row.Done;
|
||||||
NotifyStepsChanged();
|
|
||||||
await using var ctx = _dbFactory.CreateDbContext();
|
await using var ctx = _dbFactory.CreateDbContext();
|
||||||
var repo = new SubtaskRepository(ctx);
|
var repo = new SubtaskRepository(ctx);
|
||||||
var subs = await repo.GetByTaskIdAsync(Task?.Id ?? "");
|
var subs = await repo.GetByTaskIdAsync(Task?.Id ?? "");
|
||||||
@@ -1410,7 +1157,6 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
await repo.UpdateAsync(entity);
|
await repo.UpdateAsync(entity);
|
||||||
}
|
}
|
||||||
row.Title = title;
|
row.Title = title;
|
||||||
OnPropertyChanged(nameof(ComposedPreview));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -1440,10 +1186,8 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async System.Threading.Tasks.Task StopAsync()
|
private async System.Threading.Tasks.Task StopAsync()
|
||||||
{
|
{
|
||||||
if (Task == null || !IsRunning) return;
|
if (Task == null) return;
|
||||||
if (!_worker.IsConnected) return;
|
await _worker.CancelTaskAsync(Task.Id);
|
||||||
try { await _worker.CancelTaskAsync(Task.Id); }
|
|
||||||
catch { /* offline */ }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(CanEnqueue))]
|
[RelayCommand(CanExecute = nameof(CanEnqueue))]
|
||||||
@@ -1523,32 +1267,11 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
private async System.Threading.Tasks.Task ApproveReviewAsync()
|
private async System.Threading.Tasks.Task ApproveReviewAsync()
|
||||||
{
|
{
|
||||||
if (Task is null || !_worker.IsConnected) return;
|
if (Task is null || !_worker.IsConnected) return;
|
||||||
try
|
// The hub rejects (HubException) if the task is no longer WaitingForReview
|
||||||
{
|
// — e.g. after "Merge all" folded the parent. Swallow it; the TaskUpdated
|
||||||
var hasChildren = Subtasks.Count > 0 || ChildOutcomes.Count > 0;
|
// broadcast reconciles the UI. An unhandled command exception would crash.
|
||||||
var result = await _worker.ApproveReviewAsync(Task.Id, SelectedMergeTarget ?? "");
|
try { await _worker.ApproveReviewAsync(Task.Id); }
|
||||||
if (!hasChildren && result?.Status == "conflict")
|
catch { /* stale review action; broadcast reconciles */ }
|
||||||
{
|
|
||||||
if (RequestConflictResolution is not null)
|
|
||||||
{
|
|
||||||
await RequestConflictResolution(Task.Id, SelectedMergeTarget ?? "");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var (text, _, _) = MergePreviewPresenter.Describe(
|
|
||||||
new MergePreviewDto("conflict", result.ConflictFiles, 0));
|
|
||||||
MergePreviewText = text; MergeIsClean = false; MergeIsConflict = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// hasChildren: conflicts arrive via PlanningMergeConflictEvent → conflict dialog
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
// A real failure (e.g. a child still needs attention, so the unit can't
|
|
||||||
// be approved yet) must not vanish — tell the user why nothing happened.
|
|
||||||
if (ShowErrorAsync != null)
|
|
||||||
await ShowErrorAsync(ex.Message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
@@ -1563,14 +1286,10 @@ public sealed partial class DetailsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
private async System.Threading.Tasks.Task ResetReviewAsync()
|
private async System.Threading.Tasks.Task ParkReviewAsync()
|
||||||
{
|
{
|
||||||
if (Task is null || !_worker.IsConnected || ConfirmAsync is null) return;
|
if (Task is null || !_worker.IsConnected) return;
|
||||||
var branchName = $"claudedo/{Task.Id.Replace("-", "")}";
|
try { await _worker.RejectReviewToIdleAsync(Task.Id); }
|
||||||
var ok = await ConfirmAsync(
|
|
||||||
$"Reset working tree?\nThis discards branch {branchName} (and all changes) and returns the task to Idle.");
|
|
||||||
if (!ok) return;
|
|
||||||
try { await _worker.ResetTaskAsync(Task.Id); }
|
|
||||||
catch { /* stale review action; broadcast reconciles */ }
|
catch { /* stale review action; broadcast reconciles */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ namespace ClaudeDo.Ui.ViewModels.Islands;
|
|||||||
|
|
||||||
public enum ListKind { Smart, Virtual, User }
|
public enum ListKind { Smart, Virtual, User }
|
||||||
|
|
||||||
public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
public sealed partial class ListsIslandViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
private readonly IServiceProvider? _services;
|
private readonly IServiceProvider? _services;
|
||||||
@@ -141,8 +141,6 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
public string MachineNameLocal => Loc.T("vm.lists.localSuffix", MachineName);
|
public string 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;
|
||||||
@@ -165,13 +163,7 @@ public sealed partial class ListsIslandViewModel : ViewModelBase, IDisposable
|
|||||||
_worker.ConnectionRestoredEvent += () => _ = RefreshCountsAsync();
|
_worker.ConnectionRestoredEvent += () => _ = RefreshCountsAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
_langChangedHandler = (_, _) => RefreshLocalizedLabels();
|
Loc.LanguageChanged += (_, _) => 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
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
using System.Linq;
|
|
||||||
using ClaudeDo.Ui.Services;
|
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.ViewModels.Islands;
|
|
||||||
|
|
||||||
/// Pure mapping from a merge-preview DTO to display text + color flags.
|
|
||||||
public static class MergePreviewPresenter
|
|
||||||
{
|
|
||||||
public static (string Text, bool IsClean, bool IsConflict) Describe(MergePreviewDto? dto)
|
|
||||||
{
|
|
||||||
if (dto is null) return ("", false, false);
|
|
||||||
|
|
||||||
switch (dto.Status)
|
|
||||||
{
|
|
||||||
case "clean":
|
|
||||||
var unit = dto.ChangedFileCount == 1 ? "file" : "files";
|
|
||||||
return ($"Merges cleanly · {dto.ChangedFileCount} {unit}", true, false);
|
|
||||||
|
|
||||||
case "conflict":
|
|
||||||
var names = string.Join(", ", dto.ConflictFiles.Take(3));
|
|
||||||
var more = dto.ConflictFiles.Count > 3 ? $" (+{dto.ConflictFiles.Count - 3} more)" : "";
|
|
||||||
return ($"Conflicts in {names}{more}", false, true);
|
|
||||||
|
|
||||||
default:
|
|
||||||
return ("Mergeability unknown", false, false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -32,9 +32,6 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
[ObservableProperty] private bool _showListChip = true;
|
[ObservableProperty] private bool _showListChip = true;
|
||||||
[ObservableProperty] private bool _parentFinalized;
|
[ObservableProperty] private bool _parentFinalized;
|
||||||
[ObservableProperty] private int _roadblockCount;
|
[ObservableProperty] private int _roadblockCount;
|
||||||
[ObservableProperty] private bool _isRefining;
|
|
||||||
|
|
||||||
public bool CanRefine => Status == TaskStatus.Idle && PlanningPhase == PlanningPhase.None && !IsRefining;
|
|
||||||
|
|
||||||
public DateTime CreatedAt { get; init; }
|
public DateTime CreatedAt { get; init; }
|
||||||
public string CreatedAtFormatted => CreatedAt == default ? "—" : Loc.T("vm.taskRow.createdPrefix", CreatedAt.ToString("MMM d"));
|
public string CreatedAtFormatted => CreatedAt == default ? "—" : Loc.T("vm.taskRow.createdPrefix", CreatedAt.ToString("MMM d"));
|
||||||
@@ -128,7 +125,6 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
||||||
OnPropertyChanged(nameof(CanRemoveFromQueue));
|
OnPropertyChanged(nameof(CanRemoveFromQueue));
|
||||||
OnPropertyChanged(nameof(CanSendToQueue));
|
OnPropertyChanged(nameof(CanSendToQueue));
|
||||||
OnPropertyChanged(nameof(CanRefine));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnParentTaskIdChanged(string? value)
|
partial void OnParentTaskIdChanged(string? value)
|
||||||
@@ -159,11 +155,8 @@ public sealed partial class TaskRowViewModel : ViewModelBase
|
|||||||
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
OnPropertyChanged(nameof(CanOpenPlanningSession));
|
||||||
OnPropertyChanged(nameof(CanResumeOrDiscardPlanning));
|
OnPropertyChanged(nameof(CanResumeOrDiscardPlanning));
|
||||||
OnPropertyChanged(nameof(CanQueuePlan));
|
OnPropertyChanged(nameof(CanQueuePlan));
|
||||||
OnPropertyChanged(nameof(CanRefine));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
partial void OnIsRefiningChanged(bool value) => OnPropertyChanged(nameof(CanRefine));
|
|
||||||
|
|
||||||
partial void OnHasQueuedSubtasksChanged(bool value)
|
partial void OnHasQueuedSubtasksChanged(bool value)
|
||||||
{
|
{
|
||||||
OnPropertyChanged(nameof(CanRemoveFromQueue));
|
OnPropertyChanged(nameof(CanRemoveFromQueue));
|
||||||
|
|||||||
@@ -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, IDisposable
|
public sealed partial class TasksIslandViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
private readonly IWorkerClient? _worker;
|
private readonly IWorkerClient? _worker;
|
||||||
@@ -71,8 +71,6 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
|
|
||||||
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;
|
||||||
@@ -84,16 +82,8 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
_worker.WorktreeUpdatedEvent += OnWorkerTaskUpdated;
|
_worker.WorktreeUpdatedEvent += OnWorkerTaskUpdated;
|
||||||
_worker.ListUpdatedEvent += OnWorkerListUpdated;
|
_worker.ListUpdatedEvent += OnWorkerListUpdated;
|
||||||
_worker.ConnectionRestoredEvent += () => LoadForList(_currentList);
|
_worker.ConnectionRestoredEvent += () => LoadForList(_currentList);
|
||||||
_worker.RefineStartedEvent += OnRefineStarted;
|
|
||||||
_worker.RefineFinishedEvent += OnRefineFinished;
|
|
||||||
}
|
}
|
||||||
_langChangedHandler = (_, _) => RefreshLocalizedText();
|
Loc.LanguageChanged += (_, _) => RefreshLocalizedText();
|
||||||
Loc.LanguageChanged += _langChangedHandler;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
Loc.LanguageChanged -= _langChangedHandler;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RefreshLocalizedText()
|
private void RefreshLocalizedText()
|
||||||
@@ -186,7 +176,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
ListKind.Smart when list.Id == "smart:my-day" => t.IsMyDay,
|
ListKind.Smart when list.Id == "smart: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.WaitingForReview,
|
ListKind.Virtual when list.Id == "virtual:review" => t.Status == TaskStatus.Done && t.Worktree?.State == WorktreeState.Active && t.ParentTaskId == null,
|
||||||
ListKind.User => $"user:{t.ListId}" == list.Id,
|
ListKind.User => $"user:{t.ListId}" == list.Id,
|
||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
@@ -655,7 +645,7 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
private async Task ApproveReviewAsync(TaskRowViewModel? row)
|
private async Task ApproveReviewAsync(TaskRowViewModel? row)
|
||||||
{
|
{
|
||||||
if (row is null || !row.IsWaitingForReview || _worker is null) return;
|
if (row is null || !row.IsWaitingForReview || _worker is null) return;
|
||||||
try { await _worker.ApproveReviewAsync(row.Id, ""); }
|
try { await _worker.ApproveReviewAsync(row.Id); }
|
||||||
catch { /* offline; broadcast reconciles on return */ }
|
catch { /* offline; broadcast reconciles on return */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -840,27 +830,6 @@ public sealed partial class TasksIslandViewModel : ViewModelBase, IDisposable
|
|||||||
Regroup();
|
Regroup();
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private async Task RefineTask(TaskRowViewModel row)
|
|
||||||
{
|
|
||||||
if (row is null || !row.CanRefine) return;
|
|
||||||
row.IsRefining = true;
|
|
||||||
try { await _worker!.RefineTaskAsync(row.Id); }
|
|
||||||
catch { row.IsRefining = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnRefineStarted(string taskId)
|
|
||||||
{
|
|
||||||
var row = Items.FirstOrDefault(r => r.Id == taskId);
|
|
||||||
if (row is not null) row.IsRefining = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnRefineFinished(string taskId, bool ok, string? error)
|
|
||||||
{
|
|
||||||
var row = Items.FirstOrDefault(r => r.Id == taskId);
|
|
||||||
if (row is not null) row.IsRefining = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
partial void OnSelectedTaskChanged(TaskRowViewModel? value)
|
partial void OnSelectedTaskChanged(TaskRowViewModel? value)
|
||||||
{
|
{
|
||||||
foreach (var i in Items) i.IsSelected = ReferenceEquals(i, value);
|
foreach (var i in Items) i.IsSelected = ReferenceEquals(i, value);
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
|
|
||||||
namespace ClaudeDo.Ui.ViewModels;
|
namespace ClaudeDo.Ui.ViewModels;
|
||||||
|
|
||||||
public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
public sealed partial class IslandsShellViewModel : ViewModelBase
|
||||||
{
|
{
|
||||||
public ListsIslandViewModel? Lists { get; }
|
public ListsIslandViewModel? Lists { get; }
|
||||||
public TasksIslandViewModel? Tasks { get; }
|
public TasksIslandViewModel? Tasks { get; }
|
||||||
@@ -44,20 +44,6 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
|||||||
// Set by MainWindow to open the conflict resolution dialog.
|
// 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; }
|
||||||
|
|
||||||
@@ -154,23 +140,21 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
|||||||
if (ShowConflictDialog == null || _dbFactory == null) return;
|
if (ShowConflictDialog == null || _dbFactory == null) return;
|
||||||
|
|
||||||
string subtaskTitle = subtaskId;
|
string subtaskTitle = subtaskId;
|
||||||
// The conflict lives in the list's working dir (the repo being merged into),
|
string worktreePath = System.Environment.CurrentDirectory;
|
||||||
// not the subtask worktree. VS Code must open this folder to show the merge UI.
|
string targetBranch = Worker?.LastMergeAllTarget ?? "main";
|
||||||
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.List)
|
.Include(t => t.Worktree)
|
||||||
.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.List?.WorkingDir is { } dir && !string.IsNullOrWhiteSpace(dir))
|
if (entity.Worktree?.Path is { } p)
|
||||||
repoDirectory = dir;
|
worktreePath = p;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch { /* Non-fatal: fall back to subtaskId and cwd */ }
|
catch { /* Non-fatal: fall back to subtaskId and cwd */ }
|
||||||
@@ -181,7 +165,7 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
|||||||
subtaskTitle,
|
subtaskTitle,
|
||||||
targetBranch,
|
targetBranch,
|
||||||
conflictedFiles,
|
conflictedFiles,
|
||||||
repoDirectory);
|
worktreePath);
|
||||||
|
|
||||||
await ShowConflictDialog(vm);
|
await ShowConflictDialog(vm);
|
||||||
}
|
}
|
||||||
@@ -229,7 +213,6 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
|||||||
_ = 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))
|
||||||
@@ -270,16 +253,6 @@ public sealed partial class IslandsShellViewModel : ViewModelBase, IDisposable
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
|
||||||
{
|
|
||||||
_clearTimer.Stop();
|
|
||||||
_clearTimer.Dispose();
|
|
||||||
_connectTimer.Stop();
|
|
||||||
_connectTimer.Dispose();
|
|
||||||
_primeStatusTimer.Stop();
|
|
||||||
_primeStatusTimer.Dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RefreshBannerFromStatus()
|
private void RefreshBannerFromStatus()
|
||||||
{
|
{
|
||||||
switch (_updateCheck.LastCheckStatus)
|
switch (_updateCheck.LastCheckStatus)
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ 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; }
|
||||||
@@ -34,27 +32,10 @@ public sealed class DiffLineViewModel
|
|||||||
|
|
||||||
public sealed class DiffFileViewModel
|
public sealed class DiffFileViewModel
|
||||||
{
|
{
|
||||||
public required string Path { get; set; }
|
public required string Path { get; init; }
|
||||||
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
|
||||||
@@ -63,11 +44,6 @@ 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; }
|
||||||
@@ -101,8 +77,6 @@ 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)
|
||||||
@@ -113,11 +87,9 @@ public sealed partial class DiffModalViewModel : ViewModelBase
|
|||||||
string raw;
|
string raw;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
raw = FromCommitRange && BaseRef is not null && HeadCommit is not null
|
raw = BaseRef is not null
|
||||||
? await _git.GetCommitRangeDiffAsync(WorktreePath, BaseRef, HeadCommit, ct)
|
? await _git.GetBranchDiffAsync(WorktreePath, BaseRef, ct)
|
||||||
: BaseRef is not null
|
: await _git.GetDiffAsync(WorktreePath, ct);
|
||||||
? await _git.GetBranchDiffAsync(WorktreePath, BaseRef, ct)
|
|
||||||
: await _git.GetDiffAsync(WorktreePath, ct);
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -28,10 +28,6 @@ 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;
|
||||||
@@ -84,7 +80,6 @@ public sealed partial class MergeModalViewModel : ViewModelBase
|
|||||||
switch (result.Status)
|
switch (result.Status)
|
||||||
{
|
{
|
||||||
case "merged":
|
case "merged":
|
||||||
Merged = true;
|
|
||||||
SuccessMessage = result.ErrorMessage is not null
|
SuccessMessage = result.ErrorMessage is not null
|
||||||
? $"Merged with warning: {result.ErrorMessage}"
|
? $"Merged with warning: {result.ErrorMessage}"
|
||||||
: Loc.T("vm.merge.merged");
|
: Loc.T("vm.merge.merged");
|
||||||
|
|||||||
@@ -27,36 +27,6 @@ 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 @@"
|
||||||
@@ -64,15 +34,13 @@ public static class UnifiedDiffParser
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skip remaining diff metadata lines
|
// Skip 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("old mode", StringComparison.Ordinal) ||
|
line.StartsWith("new file", StringComparison.Ordinal) ||
|
||||||
line.StartsWith("new mode", StringComparison.Ordinal) ||
|
line.StartsWith("deleted file", StringComparison.Ordinal) ||
|
||||||
line.StartsWith("similarity index", StringComparison.Ordinal) ||
|
line.StartsWith("Binary ", StringComparison.Ordinal))
|
||||||
line.StartsWith("copy from", StringComparison.Ordinal) ||
|
|
||||||
line.StartsWith("copy to", StringComparison.Ordinal))
|
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
if (line.StartsWith('+'))
|
if (line.StartsWith('+'))
|
||||||
|
|||||||
@@ -5,6 +5,14 @@ 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; }
|
||||||
@@ -20,7 +28,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<DiffLineViewModel> SelectedFileDiffLines { get; } = new();
|
public ObservableCollection<WorktreeDiffLineViewModel> SelectedFileDiffLines { get; } = new();
|
||||||
|
|
||||||
[ObservableProperty] private string _worktreePath = "";
|
[ObservableProperty] private string _worktreePath = "";
|
||||||
[ObservableProperty] private string? _baseCommit;
|
[ObservableProperty] private string? _baseCommit;
|
||||||
@@ -56,8 +64,19 @@ public sealed partial class WorktreeModalViewModel : ViewModelBase
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (var line in UnifiedDiffParser.Flatten(UnifiedDiffParser.Parse(diff)))
|
foreach (var line in diff.Split('\n'))
|
||||||
SelectedFileDiffLines.Add(line);
|
{
|
||||||
|
var kind = line switch
|
||||||
|
{
|
||||||
|
_ when line.StartsWith("+++") || line.StartsWith("---") => WorktreeDiffLineKind.Header,
|
||||||
|
_ when line.StartsWith("@@") => WorktreeDiffLineKind.Hunk,
|
||||||
|
_ when line.StartsWith('+') => WorktreeDiffLineKind.Added,
|
||||||
|
_ when line.StartsWith('-') => WorktreeDiffLineKind.Removed,
|
||||||
|
_ when line.StartsWith("diff ") || line.StartsWith("index ") || line.StartsWith("\\ ") => WorktreeDiffLineKind.Header,
|
||||||
|
_ => WorktreeDiffLineKind.Context,
|
||||||
|
};
|
||||||
|
SelectedFileDiffLines.Add(new WorktreeDiffLineViewModel { Text = line, Kind = kind });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[RelayCommand]
|
[RelayCommand]
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ 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 = "";
|
||||||
@@ -29,14 +27,6 @@ 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;
|
||||||
@@ -69,18 +59,9 @@ 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; }
|
||||||
@@ -125,24 +106,20 @@ 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) { HookRow(row); group.Rows.Add(row); }
|
foreach (var row in grp) group.Rows.Add(row);
|
||||||
Groups.Add(group);
|
Groups.Add(group);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
foreach (var row in ordered) { HookRow(row); Rows.Add(row); }
|
foreach (var row in ordered) Rows.Add(row);
|
||||||
}
|
}
|
||||||
await LoadMergeTargetsAsync();
|
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
@@ -278,125 +255,4 @@ public sealed partial class WorktreesOverviewModalViewModel : ViewModelBase
|
|||||||
Path = d.Path, BranchName = d.BranchName, BaseCommit = d.BaseCommit, State = d.State,
|
Path = d.Path, BranchName = d.BranchName, BaseCommit = d.BaseCommit, State = d.State,
|
||||||
DiffStat = d.DiffStat, CreatedAt = d.CreatedAt, PathExistsOnDisk = d.PathExistsOnDisk,
|
DiffStat = d.DiffStat, CreatedAt = d.CreatedAt, PathExistsOnDisk = d.PathExistsOnDisk,
|
||||||
};
|
};
|
||||||
|
|
||||||
public IEnumerable<WorktreeOverviewRowViewModel> AllRows =>
|
|
||||||
IsGlobal ? Groups.SelectMany(g => g.Rows) : Rows;
|
|
||||||
|
|
||||||
private void HookRow(WorktreeOverviewRowViewModel row)
|
|
||||||
{
|
|
||||||
row.PropertyChanged += (_, e) =>
|
|
||||||
{
|
|
||||||
if (e.PropertyName is nameof(WorktreeOverviewRowViewModel.IsChecked)
|
|
||||||
or nameof(WorktreeOverviewRowViewModel.State))
|
|
||||||
RecomputeSelected();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private void RecomputeSelected() =>
|
|
||||||
SelectedCount = AllRows.Count(r => r.IsChecked && r.IsActive);
|
|
||||||
|
|
||||||
// Test seam: adds a row to the flat list with selection tracking wired up.
|
|
||||||
internal void AddRowForTest(WorktreeOverviewRowViewModel row)
|
|
||||||
{
|
|
||||||
HookRow(row);
|
|
||||||
Rows.Add(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task LoadMergeTargetsAsync()
|
|
||||||
{
|
|
||||||
var anchor = AllRows.FirstOrDefault(r => r.IsActive);
|
|
||||||
if (anchor is null) { MergeTargets.Clear(); SelectedTarget = null; return; }
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var targets = await _worker.GetMergeTargetsAsync(anchor.TaskId);
|
|
||||||
MergeTargets.Clear();
|
|
||||||
if (targets is null) { SelectedTarget = null; return; }
|
|
||||||
foreach (var b in targets.LocalBranches) MergeTargets.Add(b);
|
|
||||||
SelectedTarget = MergeTargets.Contains(targets.DefaultBranch)
|
|
||||||
? targets.DefaultBranch
|
|
||||||
: MergeTargets.FirstOrDefault();
|
|
||||||
}
|
|
||||||
catch { MergeTargets.Clear(); SelectedTarget = null; }
|
|
||||||
}
|
|
||||||
|
|
||||||
private bool CanMergeAll() => !IsMerging && SelectedCount > 0 && !string.IsNullOrWhiteSpace(SelectedTarget);
|
|
||||||
|
|
||||||
[RelayCommand(CanExecute = nameof(CanMergeAll))]
|
|
||||||
private Task MergeAll() => MergeSelectedAsync(_worker.MergeTaskAsync);
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void ResolveConflict(WorktreeOverviewRowViewModel? row)
|
|
||||||
{
|
|
||||||
if (row is null) return;
|
|
||||||
RequestConflictResolution?.Invoke(row.TaskId, SelectedTarget ?? "");
|
|
||||||
}
|
|
||||||
|
|
||||||
[RelayCommand]
|
|
||||||
private void ToggleSelectAll()
|
|
||||||
{
|
|
||||||
var actives = AllRows.Where(r => r.IsActive).ToList();
|
|
||||||
var allChecked = actives.Count > 0 && actives.All(r => r.IsChecked);
|
|
||||||
foreach (var r in actives) r.IsChecked = !allChecked;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task MergeSelectedAsync(
|
|
||||||
Func<string, string, bool, string, Task<MergeResultDto>> mergeFn,
|
|
||||||
CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
var target = SelectedTarget;
|
|
||||||
if (string.IsNullOrWhiteSpace(target)) return;
|
|
||||||
|
|
||||||
var selected = AllRows.Where(r => r.IsChecked && r.IsActive).ToList();
|
|
||||||
if (selected.Count == 0) return;
|
|
||||||
|
|
||||||
IsMerging = true;
|
|
||||||
ConflictRows.Clear();
|
|
||||||
var done = 0;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
foreach (var row in selected)
|
|
||||||
{
|
|
||||||
ct.ThrowIfCancellationRequested();
|
|
||||||
row.MergeOutcome = BatchMergeOutcome.Merging;
|
|
||||||
BatchProgress = Loc.T("vm.worktreesOverview.batchProgress", ++done, selected.Count);
|
|
||||||
|
|
||||||
MergeResultDto result;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
result = await mergeFn(row.TaskId, target!, false,
|
|
||||||
Loc.T("vm.merge.commitMessage", row.TaskTitle));
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
row.MergeOutcome = BatchMergeOutcome.Failed;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (result.Status)
|
|
||||||
{
|
|
||||||
case "merged":
|
|
||||||
row.MergeOutcome = BatchMergeOutcome.Merged;
|
|
||||||
row.State = WorktreeState.Merged;
|
|
||||||
row.IsChecked = false;
|
|
||||||
break;
|
|
||||||
case "conflict":
|
|
||||||
row.MergeOutcome = BatchMergeOutcome.Conflict;
|
|
||||||
ConflictRows.Add(row);
|
|
||||||
break;
|
|
||||||
case "blocked":
|
|
||||||
row.MergeOutcome = BatchMergeOutcome.Blocked;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
row.MergeOutcome = BatchMergeOutcome.Failed;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
BatchProgress = Loc.T("vm.worktreesOverview.batchDone",
|
|
||||||
selected.Count(r => r.MergeOutcome == BatchMergeOutcome.Merged), ConflictRows.Count);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
IsMerging = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,7 @@ public sealed partial class ConflictResolutionViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
private readonly IWorkerClient _worker;
|
private readonly IWorkerClient _worker;
|
||||||
private readonly string _planningTaskId;
|
private readonly string _planningTaskId;
|
||||||
// The repository directory that is currently mid-merge (the list's working dir),
|
private readonly string _worktreePath;
|
||||||
// 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; }
|
||||||
@@ -32,11 +29,11 @@ public sealed partial class ConflictResolutionViewModel : ObservableObject
|
|||||||
string subtaskTitle,
|
string subtaskTitle,
|
||||||
string targetBranch,
|
string targetBranch,
|
||||||
IReadOnlyList<string> conflictedFiles,
|
IReadOnlyList<string> conflictedFiles,
|
||||||
string repoDirectory)
|
string worktreePath)
|
||||||
{
|
{
|
||||||
_worker = worker;
|
_worker = worker;
|
||||||
_planningTaskId = planningTaskId;
|
_planningTaskId = planningTaskId;
|
||||||
_repoDirectory = repoDirectory;
|
_worktreePath = worktreePath;
|
||||||
SubtaskTitle = subtaskTitle;
|
SubtaskTitle = subtaskTitle;
|
||||||
TargetBranch = targetBranch;
|
TargetBranch = targetBranch;
|
||||||
ConflictedFiles = conflictedFiles;
|
ConflictedFiles = conflictedFiles;
|
||||||
@@ -47,13 +44,12 @@ public sealed partial class ConflictResolutionViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Open the folder that is mid-merge so VS Code shows the Source Control
|
var args = string.Join(" ", ConflictedFiles.Select(f => $"\"{f}\""));
|
||||||
// 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 = $"\"{_repoDirectory}\"",
|
Arguments = args,
|
||||||
|
WorkingDirectory = _worktreePath,
|
||||||
UseShellExecute = true,
|
UseShellExecute = true,
|
||||||
});
|
});
|
||||||
VsCodeError = null;
|
VsCodeError = null;
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
<Window xmlns="https://github.com/avaloniaui"
|
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
||||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Conflicts"
|
|
||||||
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
|
|
||||||
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
|
||||||
x:DataType="vm:ConflictResolverViewModel"
|
|
||||||
x:Class="ClaudeDo.Ui.Views.Conflicts.ConflictResolverView"
|
|
||||||
Title="{loc:Tr conflictResolver.windowTitle}"
|
|
||||||
Width="760" Height="640" MinWidth="560" MinHeight="420"
|
|
||||||
CanResize="True"
|
|
||||||
WindowDecorations="BorderOnly"
|
|
||||||
ExtendClientAreaToDecorationsHint="True"
|
|
||||||
ExtendClientAreaTitleBarHeightHint="-1"
|
|
||||||
WindowStartupLocation="CenterOwner"
|
|
||||||
Background="{DynamicResource SurfaceBrush}">
|
|
||||||
|
|
||||||
<Window.KeyBindings>
|
|
||||||
<KeyBinding Gesture="Escape" Command="{Binding AbortCommand}"/>
|
|
||||||
</Window.KeyBindings>
|
|
||||||
|
|
||||||
<ctl:ModalShell Title="{loc:Tr conflictResolver.modalTitle}" CloseCommand="{Binding AbortCommand}">
|
|
||||||
<ctl:ModalShell.Footer>
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8"
|
|
||||||
HorizontalAlignment="Right" VerticalAlignment="Center">
|
|
||||||
<Button Classes="btn" Content="{loc:Tr conflictResolver.continue}"
|
|
||||||
Command="{Binding ContinueCommand}" IsEnabled="{Binding CanContinue}"/>
|
|
||||||
<Button Classes="btn" Content="{loc:Tr conflictResolver.abort}" Command="{Binding AbortCommand}"/>
|
|
||||||
</StackPanel>
|
|
||||||
</ctl:ModalShell.Footer>
|
|
||||||
|
|
||||||
<Grid RowDefinitions="Auto,*" Margin="16,12">
|
|
||||||
<TextBlock Grid.Row="0" Classes="meta" Margin="0,0,0,8"
|
|
||||||
Text="{loc:Tr conflictResolver.loading}"
|
|
||||||
IsVisible="{Binding IsBusy}"/>
|
|
||||||
<TextBlock Grid.Row="0" Classes="meta" Foreground="{DynamicResource BloodBrush}"
|
|
||||||
Text="{Binding Error}" TextWrapping="Wrap"
|
|
||||||
IsVisible="{Binding Error, Converter={x:Static ObjectConverters.IsNotNull}}"/>
|
|
||||||
|
|
||||||
<ScrollViewer Grid.Row="1">
|
|
||||||
<ItemsControl ItemsSource="{Binding Files}">
|
|
||||||
<ItemsControl.ItemTemplate>
|
|
||||||
<DataTemplate x:DataType="vm:ConflictFile">
|
|
||||||
<StackPanel Spacing="8" Margin="0,0,0,16">
|
|
||||||
<TextBlock Classes="path-mono heading" Text="{Binding Path}"/>
|
|
||||||
<ItemsControl ItemsSource="{Binding Hunks}">
|
|
||||||
<ItemsControl.ItemTemplate>
|
|
||||||
<DataTemplate x:DataType="vm:ConflictHunk">
|
|
||||||
<Border BorderBrush="{DynamicResource LineBrush}" BorderThickness="1"
|
|
||||||
CornerRadius="6" Padding="10" Margin="0,0,0,8">
|
|
||||||
<StackPanel Spacing="6">
|
|
||||||
<TextBlock Classes="meta" Text="{loc:Tr conflictResolver.current}"/>
|
|
||||||
<TextBox Text="{Binding Ours, Mode=OneWay}" IsReadOnly="True"
|
|
||||||
AcceptsReturn="True" MaxHeight="120"/>
|
|
||||||
<TextBlock Classes="meta" Text="{loc:Tr conflictResolver.incoming}"/>
|
|
||||||
<TextBox Text="{Binding Theirs, Mode=OneWay}" IsReadOnly="True"
|
|
||||||
AcceptsReturn="True" MaxHeight="120"/>
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
|
||||||
<Button Classes="btn" Content="{loc:Tr conflictResolver.acceptCurrent}"
|
|
||||||
Command="{Binding AcceptCurrentCommand}"/>
|
|
||||||
<Button Classes="btn" Content="{loc:Tr conflictResolver.acceptIncoming}"
|
|
||||||
Command="{Binding AcceptIncomingCommand}"/>
|
|
||||||
<Button Classes="btn" Content="{loc:Tr conflictResolver.acceptBoth}"
|
|
||||||
Command="{Binding AcceptBothCommand}"/>
|
|
||||||
<Button Classes="btn" Content="{loc:Tr conflictResolver.editManually}"
|
|
||||||
Command="{Binding EditManuallyCommand}"/>
|
|
||||||
</StackPanel>
|
|
||||||
<TextBlock Classes="meta" Text="{loc:Tr conflictResolver.mergedResult}"/>
|
|
||||||
<TextBox Text="{Binding Resolution, Mode=TwoWay}"
|
|
||||||
AcceptsReturn="True" MinHeight="80" MaxHeight="200"/>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
</DataTemplate>
|
|
||||||
</ItemsControl.ItemTemplate>
|
|
||||||
</ItemsControl>
|
|
||||||
</StackPanel>
|
|
||||||
</DataTemplate>
|
|
||||||
</ItemsControl.ItemTemplate>
|
|
||||||
</ItemsControl>
|
|
||||||
</ScrollViewer>
|
|
||||||
</Grid>
|
|
||||||
</ctl:ModalShell>
|
|
||||||
</Window>
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
using Avalonia.Controls;
|
|
||||||
using ClaudeDo.Ui.ViewModels.Conflicts;
|
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.Views.Conflicts;
|
|
||||||
|
|
||||||
public partial class ConflictResolverView : Window
|
|
||||||
{
|
|
||||||
public ConflictResolverView()
|
|
||||||
{
|
|
||||||
InitializeComponent();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override void OnDataContextChanged(System.EventArgs e)
|
|
||||||
{
|
|
||||||
base.OnDataContextChanged(e);
|
|
||||||
if (DataContext is ConflictResolverViewModel vm)
|
|
||||||
vm.CloseRequested = Close;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,169 +0,0 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
||||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
|
|
||||||
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
|
|
||||||
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
|
||||||
x:Class="ClaudeDo.Ui.Views.Islands.Detail.DescriptionStepsCard"
|
|
||||||
x:DataType="vm:DetailsIslandViewModel">
|
|
||||||
|
|
||||||
<Border Classes="island"
|
|
||||||
Background="{DynamicResource Surface2Brush}"
|
|
||||||
BorderBrush="{DynamicResource LineBrush}">
|
|
||||||
<DockPanel>
|
|
||||||
|
|
||||||
<!-- Header: DETAILS · copy · preview/edit -->
|
|
||||||
<Border DockPanel.Dock="Top" Classes="island-header">
|
|
||||||
<Grid ColumnDefinitions="Auto,*,Auto,Auto" VerticalAlignment="Center">
|
|
||||||
|
|
||||||
<TextBlock Grid.Column="0" Classes="section-label" Text="DETAILS"
|
|
||||||
VerticalAlignment="Center"/>
|
|
||||||
|
|
||||||
<!-- Copy formatted -->
|
|
||||||
<Button Grid.Column="2"
|
|
||||||
Classes="icon-btn"
|
|
||||||
Margin="0,0,4,0"
|
|
||||||
ToolTip.Tip="{loc:Tr details.copyFormattedTip}"
|
|
||||||
Click="OnCopyClick">
|
|
||||||
<PathIcon Data="{StaticResource Icon.Copy}" Width="11" Height="11"/>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<!-- Preview/Edit toggle -->
|
|
||||||
<Button Grid.Column="3"
|
|
||||||
Classes="btn"
|
|
||||||
Padding="8,3"
|
|
||||||
ToolTip.Tip="{loc:Tr details.toggleEditPreviewTip}"
|
|
||||||
Command="{Binding ToggleEditDescriptionCommand}">
|
|
||||||
<Panel>
|
|
||||||
<TextBlock Text="Preview" IsVisible="{Binding IsEditingDescription}"/>
|
|
||||||
<TextBlock Text="Edit" IsVisible="{Binding !IsEditingDescription}"/>
|
|
||||||
</Panel>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
</Grid>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- Body (scrolls inside the card so the card fills its row to the divider) -->
|
|
||||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
|
||||||
<StackPanel Margin="14" Spacing="10">
|
|
||||||
|
|
||||||
<!-- Description (always visible) -->
|
|
||||||
<Panel>
|
|
||||||
<!-- Edit mode: raw TextBox -->
|
|
||||||
<TextBox Text="{Binding EditableDescription, Mode=TwoWay}"
|
|
||||||
AcceptsReturn="True"
|
|
||||||
TextWrapping="Wrap"
|
|
||||||
MinHeight="80"
|
|
||||||
MaxHeight="320"
|
|
||||||
Padding="8"
|
|
||||||
FontFamily="{DynamicResource MonoFont}"
|
|
||||||
FontSize="{StaticResource FontSizeBody}"
|
|
||||||
Background="{DynamicResource Surface3Brush}"
|
|
||||||
BorderBrush="{DynamicResource LineBrush}"
|
|
||||||
BorderThickness="1"
|
|
||||||
CornerRadius="8"
|
|
||||||
IsVisible="{Binding IsEditingDescription}"/>
|
|
||||||
<!-- Preview mode: rendered composed text (title + description + open steps) -->
|
|
||||||
<ctl:MarkdownView Markdown="{Binding ComposedPreview}"
|
|
||||||
IsVisible="{Binding !IsEditingDescription}"/>
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
<!-- Steps: always-visible summary strip; expand to manage -->
|
|
||||||
<Border BorderBrush="{DynamicResource LineBrush}"
|
|
||||||
BorderThickness="0,1,0,0"
|
|
||||||
Padding="0,8,0,0">
|
|
||||||
<StackPanel Spacing="6">
|
|
||||||
|
|
||||||
<!-- Summary header (click to expand/collapse) -->
|
|
||||||
<Button Classes="flat" Cursor="Hand"
|
|
||||||
HorizontalAlignment="Stretch"
|
|
||||||
HorizontalContentAlignment="Stretch"
|
|
||||||
Command="{Binding ToggleStepsExpandedCommand}">
|
|
||||||
<Grid ColumnDefinitions="Auto,Auto,*,Auto">
|
|
||||||
<Panel Grid.Column="0" Width="12" Margin="0,0,6,0" VerticalAlignment="Center">
|
|
||||||
<TextBlock Classes="meta" Text="▸" IsVisible="{Binding !IsStepsExpanded}"/>
|
|
||||||
<TextBlock Classes="meta" Text="▾" IsVisible="{Binding IsStepsExpanded}"/>
|
|
||||||
</Panel>
|
|
||||||
<TextBlock Grid.Column="1" Classes="section-label" Text="STEPS"
|
|
||||||
VerticalAlignment="Center"/>
|
|
||||||
<TextBlock Grid.Column="3" Classes="meta" Text="{Binding StepsSummary}"
|
|
||||||
Foreground="{DynamicResource TextMuteBrush}"
|
|
||||||
VerticalAlignment="Center"/>
|
|
||||||
</Grid>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<!-- Expanded: add-step input + step rows -->
|
|
||||||
<StackPanel IsVisible="{Binding IsStepsExpanded}" Spacing="6">
|
|
||||||
<TextBox Text="{Binding NewSubtaskTitle, Mode=TwoWay}"
|
|
||||||
PlaceholderText="Add step…"
|
|
||||||
Padding="8"
|
|
||||||
Background="{DynamicResource Surface3Brush}"
|
|
||||||
BorderBrush="{DynamicResource LineBrush}"
|
|
||||||
BorderThickness="1"
|
|
||||||
CornerRadius="8">
|
|
||||||
<TextBox.KeyBindings>
|
|
||||||
<KeyBinding Gesture="Enter" Command="{Binding AddSubtaskCommand}"/>
|
|
||||||
</TextBox.KeyBindings>
|
|
||||||
</TextBox>
|
|
||||||
|
|
||||||
<!-- Subtask rows -->
|
|
||||||
<ItemsControl ItemsSource="{Binding Subtasks}">
|
|
||||||
<ItemsControl.ItemTemplate>
|
|
||||||
<DataTemplate DataType="vm:SubtaskRowViewModel">
|
|
||||||
<Border Classes="subtask-row" Classes.done="{Binding Done}">
|
|
||||||
<Grid ColumnDefinitions="Auto,*">
|
|
||||||
|
|
||||||
<!-- Check circle -->
|
|
||||||
<Button Grid.Column="0"
|
|
||||||
Classes="flat"
|
|
||||||
Padding="0"
|
|
||||||
Margin="0,0,8,0"
|
|
||||||
VerticalAlignment="Center"
|
|
||||||
Command="{Binding $parent[ItemsControl].((vm:DetailsIslandViewModel)DataContext).ToggleSubtaskDoneCommand}"
|
|
||||||
CommandParameter="{Binding}">
|
|
||||||
<Ellipse Classes="task-check"
|
|
||||||
Classes.done="{Binding Done}"
|
|
||||||
Width="16" Height="16"
|
|
||||||
Cursor="Hand"/>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<!-- Title / edit -->
|
|
||||||
<Panel Grid.Column="1" VerticalAlignment="Center">
|
|
||||||
<TextBlock Classes="subtask-title"
|
|
||||||
Text="{Binding Title}"
|
|
||||||
IsVisible="{Binding !IsEditing}"
|
|
||||||
FontSize="{StaticResource FontSizeBody}"
|
|
||||||
Foreground="{DynamicResource TextDimBrush}"
|
|
||||||
VerticalAlignment="Center"
|
|
||||||
TextWrapping="Wrap"
|
|
||||||
Cursor="Ibeam"
|
|
||||||
Tapped="OnSubtaskTitleTapped"/>
|
|
||||||
<TextBox Classes="subtask-edit"
|
|
||||||
Text="{Binding Title, Mode=TwoWay}"
|
|
||||||
IsVisible="{Binding IsEditing}"
|
|
||||||
FontSize="{StaticResource FontSizeBody}"
|
|
||||||
AcceptsReturn="False"
|
|
||||||
TextWrapping="Wrap"
|
|
||||||
LostFocus="OnSubtaskEditLostFocus">
|
|
||||||
<TextBox.KeyBindings>
|
|
||||||
<KeyBinding Gesture="Enter"
|
|
||||||
Command="{Binding $parent[ItemsControl].((vm:DetailsIslandViewModel)DataContext).CommitSubtaskEditCommand}"
|
|
||||||
CommandParameter="{Binding}"/>
|
|
||||||
</TextBox.KeyBindings>
|
|
||||||
</TextBox>
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
</Grid>
|
|
||||||
</Border>
|
|
||||||
</DataTemplate>
|
|
||||||
</ItemsControl.ItemTemplate>
|
|
||||||
</ItemsControl>
|
|
||||||
</StackPanel>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
</StackPanel>
|
|
||||||
</ScrollViewer>
|
|
||||||
</DockPanel>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
</UserControl>
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
using Avalonia.Controls;
|
|
||||||
using Avalonia.Input;
|
|
||||||
using Avalonia.Input.Platform;
|
|
||||||
using Avalonia.Interactivity;
|
|
||||||
using ClaudeDo.Ui.ViewModels.Islands;
|
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.Views.Islands.Detail;
|
|
||||||
|
|
||||||
public partial class DescriptionStepsCard : UserControl
|
|
||||||
{
|
|
||||||
public DescriptionStepsCard()
|
|
||||||
{
|
|
||||||
InitializeComponent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void OnCopyClick(object? sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
if (DataContext is not DetailsIslandViewModel vm) return;
|
|
||||||
var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
|
|
||||||
if (clipboard is null) return;
|
|
||||||
await clipboard.SetTextAsync(vm.ComposedPreview);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnSubtaskTitleTapped(object? sender, TappedEventArgs e)
|
|
||||||
{
|
|
||||||
if (sender is TextBlock { DataContext: SubtaskRowViewModel row })
|
|
||||||
row.IsEditing = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnSubtaskEditLostFocus(object? sender, RoutedEventArgs e)
|
|
||||||
{
|
|
||||||
if (sender is TextBox { DataContext: SubtaskRowViewModel row })
|
|
||||||
row.IsEditing = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
||||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
|
|
||||||
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
|
|
||||||
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
|
||||||
x:Class="ClaudeDo.Ui.Views.Islands.Detail.TaskHeaderBar"
|
|
||||||
x:DataType="vm:DetailsIslandViewModel">
|
|
||||||
|
|
||||||
<Grid ColumnDefinitions="*,Auto,Auto">
|
|
||||||
|
|
||||||
<!-- Column 0: id badge + editable title -->
|
|
||||||
<StackPanel Grid.Column="0" Spacing="0">
|
|
||||||
<TextBlock Classes="meta"
|
|
||||||
Text="{Binding TaskIdBadge}"
|
|
||||||
Margin="0,0,0,4"
|
|
||||||
Cursor="Hand"
|
|
||||||
ToolTip.Tip="{loc:Tr details.copyTaskIdTip}"
|
|
||||||
Tapped="OnTaskIdTapped"/>
|
|
||||||
<TextBox Text="{Binding EditableTitle, Mode=TwoWay}"
|
|
||||||
Background="Transparent"
|
|
||||||
BorderThickness="0"
|
|
||||||
FontSize="{StaticResource FontSizeTaskTitle}"
|
|
||||||
FontWeight="Medium"
|
|
||||||
Foreground="{DynamicResource TextBrush}"
|
|
||||||
TextWrapping="Wrap"
|
|
||||||
AcceptsReturn="False"
|
|
||||||
Padding="0"/>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<!-- Column 1: trash button (not running) -->
|
|
||||||
<Button Grid.Column="1" Classes="icon-btn"
|
|
||||||
Command="{Binding DeleteTaskCommand}"
|
|
||||||
ToolTip.Tip="{loc:Tr details.deleteTaskTip}"
|
|
||||||
IsVisible="{Binding !IsRunning}"
|
|
||||||
VerticalAlignment="Top"
|
|
||||||
Margin="6,0,0,0">
|
|
||||||
<PathIcon Data="{StaticResource Icon.Trash}" Width="14" Height="14"
|
|
||||||
Foreground="{DynamicResource BloodBrush}"/>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<!-- Column 1: skull button (running) -->
|
|
||||||
<Button Grid.Column="1" Classes="icon-btn"
|
|
||||||
Command="{Binding StopCommand}"
|
|
||||||
ToolTip.Tip="{loc:Tr details.killSessionTip}"
|
|
||||||
IsVisible="{Binding IsRunning}"
|
|
||||||
VerticalAlignment="Top"
|
|
||||||
Margin="6,0,0,0">
|
|
||||||
<PathIcon Data="{StaticResource Icon.Skull}" Width="14" Height="14"
|
|
||||||
Foreground="{DynamicResource BloodBrush}"/>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<!-- Column 2: gear button with agent settings flyout -->
|
|
||||||
<Button Grid.Column="2" Classes="icon-btn"
|
|
||||||
ToolTip.Tip="{loc:Tr details.agentSettingsTip}"
|
|
||||||
IsEnabled="{Binding IsAgentSectionEnabled}"
|
|
||||||
VerticalAlignment="Top"
|
|
||||||
Margin="6,0,0,0">
|
|
||||||
<TextBlock Text="⚙" FontSize="{StaticResource FontSizeTaskTitle}"/>
|
|
||||||
<Button.Flyout>
|
|
||||||
<Flyout Placement="BottomEdgeAlignedRight" ShowMode="Standard">
|
|
||||||
<StackPanel Width="340" Spacing="10" Margin="4">
|
|
||||||
<TextBlock Text="{loc:Tr details.agentSettingsHeading}" FontWeight="SemiBold"/>
|
|
||||||
|
|
||||||
<StackPanel Spacing="2">
|
|
||||||
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
|
|
||||||
<TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr details.modelLabel}" VerticalAlignment="Center"/>
|
|
||||||
<ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding ModelBadge}"/>
|
|
||||||
<Button Grid.Column="3" Classes="icon-btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
|
|
||||||
Command="{Binding ResetTaskModelCommand}"/>
|
|
||||||
</Grid>
|
|
||||||
<ComboBox ItemsSource="{Binding TaskModelOptions}"
|
|
||||||
SelectedItem="{Binding TaskModelSelection, Mode=TwoWay}"
|
|
||||||
PlaceholderText="{Binding ModelInheritedHint}"
|
|
||||||
HorizontalAlignment="Stretch"/>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<StackPanel Spacing="2">
|
|
||||||
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
|
|
||||||
<TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr details.maxTurnsLabel}" VerticalAlignment="Center"/>
|
|
||||||
<ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding TurnsBadge}"/>
|
|
||||||
<Button Grid.Column="3" Classes="icon-btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
|
|
||||||
Command="{Binding ResetTaskTurnsCommand}"/>
|
|
||||||
</Grid>
|
|
||||||
<NumericUpDown Value="{Binding TaskMaxTurns, Mode=TwoWay}"
|
|
||||||
PlaceholderText="{Binding TurnsInheritedHint}"
|
|
||||||
Minimum="1" Maximum="200" Increment="1" FormatString="0"
|
|
||||||
HorizontalAlignment="Stretch"/>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<StackPanel Spacing="2">
|
|
||||||
<TextBlock Classes="field-label" Text="{loc:Tr details.systemPromptLabel}"/>
|
|
||||||
<TextBox Text="{Binding TaskSystemPrompt, Mode=TwoWay}"
|
|
||||||
AcceptsReturn="True" TextWrapping="Wrap" MinHeight="70"/>
|
|
||||||
<TextBlock Classes="meta" Opacity="0.6"
|
|
||||||
Text="{loc:Tr details.systemPromptPrepended}"
|
|
||||||
IsVisible="{Binding EffectiveSystemPromptHint, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
|
||||||
<TextBlock Classes="meta" Opacity="0.6" TextWrapping="Wrap"
|
|
||||||
Text="{Binding EffectiveSystemPromptHint}"
|
|
||||||
IsVisible="{Binding EffectiveSystemPromptHint, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<StackPanel Spacing="2">
|
|
||||||
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
|
|
||||||
<TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr details.agentFileLabel}" VerticalAlignment="Center"/>
|
|
||||||
<ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding AgentBadge}"/>
|
|
||||||
<Button Grid.Column="3" Classes="icon-btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
|
|
||||||
Command="{Binding ResetTaskAgentCommand}"/>
|
|
||||||
</Grid>
|
|
||||||
<ComboBox ItemsSource="{Binding TaskAgentOptions}"
|
|
||||||
SelectedItem="{Binding TaskSelectedAgent, Mode=TwoWay}"
|
|
||||||
HorizontalAlignment="Stretch">
|
|
||||||
<ComboBox.ItemTemplate>
|
|
||||||
<DataTemplate>
|
|
||||||
<TextBlock Text="{Binding Name}"/>
|
|
||||||
</DataTemplate>
|
|
||||||
</ComboBox.ItemTemplate>
|
|
||||||
</ComboBox>
|
|
||||||
</StackPanel>
|
|
||||||
</StackPanel>
|
|
||||||
</Flyout>
|
|
||||||
</Button.Flyout>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
</Grid>
|
|
||||||
</UserControl>
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
using Avalonia.Controls;
|
|
||||||
using Avalonia.Input;
|
|
||||||
using Avalonia.Input.Platform;
|
|
||||||
using ClaudeDo.Ui.ViewModels.Islands;
|
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.Views.Islands.Detail;
|
|
||||||
|
|
||||||
public partial class TaskHeaderBar : UserControl
|
|
||||||
{
|
|
||||||
public TaskHeaderBar()
|
|
||||||
{
|
|
||||||
InitializeComponent();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async void OnTaskIdTapped(object? sender, TappedEventArgs e)
|
|
||||||
{
|
|
||||||
if (DataContext is not DetailsIslandViewModel vm || vm.Task is null) return;
|
|
||||||
var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
|
|
||||||
if (clipboard is null) return;
|
|
||||||
await clipboard.SetTextAsync(vm.Task.Id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,360 +0,0 @@
|
|||||||
<UserControl xmlns="https://github.com/avaloniaui"
|
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
|
||||||
xmlns:vm="using:ClaudeDo.Ui.ViewModels.Islands"
|
|
||||||
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
|
||||||
x:DataType="vm:DetailsIslandViewModel"
|
|
||||||
x:Class="ClaudeDo.Ui.Views.Islands.Detail.WorkConsole">
|
|
||||||
|
|
||||||
<UserControl.Styles>
|
|
||||||
<Style Selector="Button.tab-btn">
|
|
||||||
<Setter Property="Background" Value="Transparent" />
|
|
||||||
<Setter Property="BorderThickness" Value="0" />
|
|
||||||
<Setter Property="Padding" Value="12,8" />
|
|
||||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
|
||||||
<Setter Property="FontSize" Value="{StaticResource FontSizeMono}" />
|
|
||||||
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
|
|
||||||
<Setter Property="CornerRadius" Value="0" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="Button.tab-btn:pointerover /template/ ContentPresenter">
|
|
||||||
<Setter Property="Background" Value="{StaticResource Surface2Brush}" />
|
|
||||||
<Setter Property="TextElement.Foreground" Value="{StaticResource TextDimBrush}" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="Button.tab-btn.active /template/ ContentPresenter">
|
|
||||||
<Setter Property="Background" Value="{StaticResource Surface3Brush}" />
|
|
||||||
<Setter Property="TextElement.Foreground" Value="{StaticResource AccentBrush}" />
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<!-- Traffic-light dot button: no chrome, just the ellipse -->
|
|
||||||
<Style Selector="Button.dot-btn">
|
|
||||||
<Setter Property="Background" Value="Transparent" />
|
|
||||||
<Setter Property="BorderThickness" Value="0" />
|
|
||||||
<Setter Property="Padding" Value="0" />
|
|
||||||
<Setter Property="CornerRadius" Value="0" />
|
|
||||||
<Setter Property="Cursor" Value="Hand" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="Button.dot-btn /template/ ContentPresenter">
|
|
||||||
<Setter Property="Background" Value="Transparent" />
|
|
||||||
</Style>
|
|
||||||
|
|
||||||
<!-- Terminal prompt action: bracketed text, no button chrome -->
|
|
||||||
<Style Selector="Button.prompt-action">
|
|
||||||
<Setter Property="Background" Value="Transparent" />
|
|
||||||
<Setter Property="BorderThickness" Value="0" />
|
|
||||||
<Setter Property="Padding" Value="2,0" />
|
|
||||||
<Setter Property="CornerRadius" Value="0" />
|
|
||||||
<Setter Property="Cursor" Value="Hand" />
|
|
||||||
<Setter Property="FontFamily" Value="{StaticResource MonoFont}" />
|
|
||||||
<Setter Property="FontSize" Value="{StaticResource FontSizeMono}" />
|
|
||||||
<Setter Property="Foreground" Value="{StaticResource TextMuteBrush}" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="Button.prompt-action /template/ ContentPresenter">
|
|
||||||
<Setter Property="Background" Value="Transparent" />
|
|
||||||
<Setter Property="TextElement.Foreground" Value="{StaticResource TextMuteBrush}" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="Button.prompt-action:pointerover /template/ ContentPresenter">
|
|
||||||
<Setter Property="Background" Value="Transparent" />
|
|
||||||
<Setter Property="TextElement.Foreground" Value="{StaticResource TextBrush}" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="Button.prompt-action.accent /template/ ContentPresenter">
|
|
||||||
<Setter Property="TextElement.Foreground" Value="{StaticResource AccentBrush}" />
|
|
||||||
</Style>
|
|
||||||
<Style Selector="Button.prompt-action.accent:pointerover /template/ ContentPresenter">
|
|
||||||
<Setter Property="TextElement.Foreground" Value="{StaticResource MossBrightBrush}" />
|
|
||||||
</Style>
|
|
||||||
</UserControl.Styles>
|
|
||||||
|
|
||||||
<!-- Outer terminal card — Padding="0" so header/strip span edge-to-edge;
|
|
||||||
ClipToBounds keeps tab content inside the rounded corners (no bottom clip). -->
|
|
||||||
<Border Classes="terminal" Padding="0" ClipToBounds="True">
|
|
||||||
<DockPanel LastChildFill="True">
|
|
||||||
|
|
||||||
<!-- ── Title bar ── -->
|
|
||||||
<Grid DockPanel.Dock="Top" ColumnDefinitions="Auto,*,Auto"
|
|
||||||
Background="{DynamicResource Surface2Brush}" Height="28">
|
|
||||||
|
|
||||||
<!-- Traffic-light dots (decorative) -->
|
|
||||||
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="6"
|
|
||||||
Margin="12,0" VerticalAlignment="Center">
|
|
||||||
<Ellipse Classes="dot-red" />
|
|
||||||
<Ellipse Classes="dot-yellow" />
|
|
||||||
<Ellipse Classes="dot-green" />
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<!-- Right cluster: info header (model · turns · diff) + status chip -->
|
|
||||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="12"
|
|
||||||
Margin="0,0,8,0" VerticalAlignment="Center">
|
|
||||||
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
|
|
||||||
<TextBlock Classes="meta" Text="{Binding Model}"
|
|
||||||
Foreground="{DynamicResource TextMuteBrush}" />
|
|
||||||
<TextBlock Classes="meta" Text="·"
|
|
||||||
Foreground="{DynamicResource TextFaintBrush}" />
|
|
||||||
<TextBlock Classes="meta" Text="{Binding TurnsText}"
|
|
||||||
Foreground="{DynamicResource TextMuteBrush}" />
|
|
||||||
<TextBlock Classes="meta" Text="·"
|
|
||||||
Foreground="{DynamicResource TextFaintBrush}" />
|
|
||||||
<TextBlock Classes="diff-add" Text="{Binding DiffAddText}" />
|
|
||||||
<TextBlock Classes="diff-del" Text="{Binding DiffDelText}" />
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<Panel VerticalAlignment="Center">
|
|
||||||
<Border Classes="live-chip pulsing"
|
|
||||||
IsVisible="{Binding IsRunning}">
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="5" VerticalAlignment="Center">
|
|
||||||
<Ellipse VerticalAlignment="Center" />
|
|
||||||
<TextBlock Text="{loc:Tr session.chipLive}" VerticalAlignment="Center" />
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
<Border Classes="live-chip done"
|
|
||||||
IsVisible="{Binding IsDone}">
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="5" VerticalAlignment="Center">
|
|
||||||
<Ellipse VerticalAlignment="Center" Fill="{DynamicResource MossBrush}" />
|
|
||||||
<TextBlock Text="{loc:Tr session.chipDone}" VerticalAlignment="Center"
|
|
||||||
Foreground="{DynamicResource MossBrush}" />
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
<Border Classes="live-chip failed"
|
|
||||||
IsVisible="{Binding IsFailed}">
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="5" VerticalAlignment="Center">
|
|
||||||
<Ellipse VerticalAlignment="Center" Fill="{DynamicResource BloodBrush}" />
|
|
||||||
<TextBlock Text="{loc:Tr session.chipFailed}" VerticalAlignment="Center"
|
|
||||||
Foreground="{DynamicResource BloodBrush}" />
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
</Panel>
|
|
||||||
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<!-- ── Roadblock band ── -->
|
|
||||||
<Border DockPanel.Dock="Top"
|
|
||||||
IsVisible="{Binding ShowRoadblock}"
|
|
||||||
Background="{DynamicResource ErrorTintBrush}"
|
|
||||||
BorderBrush="{DynamicResource BloodBrush}"
|
|
||||||
BorderThickness="0,1"
|
|
||||||
Padding="14,8">
|
|
||||||
<StackPanel Spacing="6">
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
|
||||||
<PathIcon Data="{StaticResource Icon.Warning}"
|
|
||||||
Foreground="{DynamicResource BloodBrush}"
|
|
||||||
Width="14" Height="14" VerticalAlignment="Center" />
|
|
||||||
<TextBlock Classes="meta" Text="{Binding RoadblockMessage}"
|
|
||||||
Foreground="{DynamicResource BloodBrush}"
|
|
||||||
TextWrapping="Wrap" VerticalAlignment="Center" />
|
|
||||||
</StackPanel>
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
|
||||||
<Button Classes="btn accent" Content="Continue"
|
|
||||||
Command="{Binding ContinueCommand}"
|
|
||||||
IsVisible="{Binding ShowContinue}" />
|
|
||||||
<Button Classes="btn" Content="Reset & Retry"
|
|
||||||
Command="{Binding ResetAndRetryCommand}"
|
|
||||||
IsVisible="{Binding ShowResetAndRetry}" />
|
|
||||||
</StackPanel>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- ── Tab strip ── -->
|
|
||||||
<Border DockPanel.Dock="Top"
|
|
||||||
Background="{DynamicResource Surface2Brush}"
|
|
||||||
BorderBrush="{DynamicResource LineBrush}"
|
|
||||||
BorderThickness="0,0,0,1">
|
|
||||||
<StackPanel Orientation="Horizontal">
|
|
||||||
<Button Classes="tab-btn"
|
|
||||||
Classes.active="{Binding IsOutputTab}"
|
|
||||||
Content="Output"
|
|
||||||
Command="{Binding SelectTabCommand}"
|
|
||||||
CommandParameter="output" />
|
|
||||||
<Button Classes="tab-btn"
|
|
||||||
Classes.active="{Binding IsGitTab}"
|
|
||||||
Content="Git"
|
|
||||||
Command="{Binding SelectTabCommand}"
|
|
||||||
CommandParameter="git" />
|
|
||||||
<Button Classes="tab-btn"
|
|
||||||
Classes.active="{Binding IsSessionTab}"
|
|
||||||
Content="Session"
|
|
||||||
IsVisible="{Binding HasChildOutcomes}"
|
|
||||||
Command="{Binding SelectTabCommand}"
|
|
||||||
CommandParameter="session" />
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- ── Tab body (bottom inset keeps content clear of the rounded corner) ── -->
|
|
||||||
<Grid Margin="0,0,0,8">
|
|
||||||
|
|
||||||
<!-- Output: log + review footer, both gated on IsOutputTab -->
|
|
||||||
<DockPanel IsVisible="{Binding IsOutputTab}" LastChildFill="True">
|
|
||||||
|
|
||||||
<!-- Session outcome: the run's result summary, incl. any roadblocks
|
|
||||||
reported (or the error for a hard failure). -->
|
|
||||||
<Border DockPanel.Dock="Top"
|
|
||||||
Margin="12,8,12,4" Padding="10,8"
|
|
||||||
IsVisible="{Binding ShowSessionOutcome}"
|
|
||||||
Background="{DynamicResource Surface2Brush}"
|
|
||||||
BorderBrush="{DynamicResource LineBrush}"
|
|
||||||
BorderThickness="1" CornerRadius="8">
|
|
||||||
<StackPanel Spacing="6">
|
|
||||||
<TextBlock Classes="section-label" Text="OUTCOME" />
|
|
||||||
<ScrollViewer MaxHeight="160" VerticalScrollBarVisibility="Auto">
|
|
||||||
<SelectableTextBlock Text="{Binding SessionOutcome}"
|
|
||||||
TextWrapping="Wrap"
|
|
||||||
Foreground="{DynamicResource TextDimBrush}"
|
|
||||||
FontFamily="{StaticResource MonoFont}"
|
|
||||||
FontSize="{StaticResource FontSizeMono}" />
|
|
||||||
</ScrollViewer>
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- Review prompt — sits directly on the terminal, like a shell input line;
|
|
||||||
only while awaiting review. No border/fill so it reads as part of the log. -->
|
|
||||||
<Grid DockPanel.Dock="Bottom"
|
|
||||||
IsVisible="{Binding IsWaitingForReview}"
|
|
||||||
ColumnDefinitions="Auto,*,Auto"
|
|
||||||
Margin="12,2,12,8">
|
|
||||||
<TextBlock Grid.Column="0" Text="❯"
|
|
||||||
FontFamily="{StaticResource MonoFont}"
|
|
||||||
FontSize="{StaticResource FontSizeMono}"
|
|
||||||
Foreground="{DynamicResource AccentBrush}"
|
|
||||||
VerticalAlignment="Top" Margin="0,2,8,0" />
|
|
||||||
<TextBox Grid.Column="1"
|
|
||||||
Name="ReviewInput"
|
|
||||||
KeyDown="OnReviewInputKeyDown"
|
|
||||||
Text="{Binding ReviewFeedback, Mode=TwoWay}"
|
|
||||||
AcceptsReturn="True"
|
|
||||||
TextWrapping="Wrap"
|
|
||||||
MaxHeight="160"
|
|
||||||
PlaceholderText="Feedback for the next run…"
|
|
||||||
Background="Transparent"
|
|
||||||
BorderThickness="0"
|
|
||||||
Padding="0"
|
|
||||||
VerticalContentAlignment="Center"
|
|
||||||
FontFamily="{StaticResource MonoFont}"
|
|
||||||
FontSize="{StaticResource FontSizeMono}" />
|
|
||||||
<StackPanel Grid.Column="2" Orientation="Horizontal" Spacing="10"
|
|
||||||
VerticalAlignment="Top" Margin="12,2,0,0">
|
|
||||||
<Button Classes="prompt-action accent" Content="[Continue]"
|
|
||||||
ToolTip.Tip="{loc:Tr session.reviewContinueTip}"
|
|
||||||
Command="{Binding RejectReviewCommand}" />
|
|
||||||
<Button Classes="prompt-action" Content="[Reset]"
|
|
||||||
ToolTip.Tip="{loc:Tr session.reviewResetTip}"
|
|
||||||
Command="{Binding ResetReviewCommand}" />
|
|
||||||
</StackPanel>
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
<ScrollViewer Name="LogScroll"
|
|
||||||
VerticalScrollBarVisibility="Visible"
|
|
||||||
AllowAutoHide="False"
|
|
||||||
Padding="12,8,12,4">
|
|
||||||
<ItemsControl ItemsSource="{Binding Log}">
|
|
||||||
<ItemsControl.ItemTemplate>
|
|
||||||
<DataTemplate DataType="vm:LogLineViewModel">
|
|
||||||
<Grid ColumnDefinitions="60,*" Margin="0,1">
|
|
||||||
<TextBlock Grid.Column="0"
|
|
||||||
Classes="log-ts"
|
|
||||||
Text="{Binding TimestampFormatted}" />
|
|
||||||
<SelectableTextBlock Grid.Column="1"
|
|
||||||
Text="{Binding Text}" Tag="{Binding ClassName}"
|
|
||||||
Foreground="{DynamicResource TextDimBrush}"
|
|
||||||
TextWrapping="Wrap" />
|
|
||||||
</Grid>
|
|
||||||
</DataTemplate>
|
|
||||||
</ItemsControl.ItemTemplate>
|
|
||||||
</ItemsControl>
|
|
||||||
</ScrollViewer>
|
|
||||||
|
|
||||||
</DockPanel>
|
|
||||||
|
|
||||||
<!-- Git: one Approve + merge cockpit -->
|
|
||||||
<ScrollViewer IsVisible="{Binding IsGitTab}" Padding="14,10">
|
|
||||||
<StackPanel Spacing="12" IsVisible="{Binding ShowMergeSection}">
|
|
||||||
<TextBlock Classes="section-label" Text="MERGE" />
|
|
||||||
|
|
||||||
<StackPanel Spacing="4">
|
|
||||||
<TextBlock Classes="field-label" Text="Target branch" />
|
|
||||||
<ComboBox ItemsSource="{Binding MergeTargetBranches}"
|
|
||||||
SelectedItem="{Binding SelectedMergeTarget, Mode=TwoWay}"
|
|
||||||
HorizontalAlignment="Stretch" />
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<StackPanel Spacing="0">
|
|
||||||
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
|
|
||||||
Foreground="{DynamicResource MossBrush}"
|
|
||||||
IsVisible="{Binding MergeIsClean}" />
|
|
||||||
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
|
|
||||||
Foreground="{DynamicResource BloodBrush}"
|
|
||||||
IsVisible="{Binding MergeIsConflict}" />
|
|
||||||
<TextBlock Classes="meta" Text="{Binding MergePreviewText}" TextWrapping="Wrap"
|
|
||||||
Foreground="{DynamicResource TextMuteBrush}"
|
|
||||||
IsVisible="{Binding ShowMergePreviewMuted}" />
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<!-- Primary action: Approve flows straight into the merge. -->
|
|
||||||
<WrapPanel Orientation="Horizontal">
|
|
||||||
<Button Classes="btn accent" Content="Approve & Merge" Margin="0,0,8,8"
|
|
||||||
Command="{Binding ApproveReviewCommand}"
|
|
||||||
IsVisible="{Binding IsWaitingForReview}" />
|
|
||||||
<Button Classes="btn" Content="Open Diff" Margin="0,0,8,8"
|
|
||||||
Command="{Binding OpenDiffCommand}" />
|
|
||||||
<Button Classes="btn" Margin="0,0,8,8"
|
|
||||||
ToolTip.Tip="{loc:Tr agent.openWorktreeTip}"
|
|
||||||
Command="{Binding OpenWorktreeCommand}">
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="5">
|
|
||||||
<TextBlock Text="Worktree" />
|
|
||||||
<PathIcon Data="{StaticResource Icon.ArrowOut}" Width="11" Height="11" />
|
|
||||||
</StackPanel>
|
|
||||||
</Button>
|
|
||||||
<Button Classes="btn" Content="Review Combined Diff" Margin="0,0,8,8"
|
|
||||||
Command="{Binding ReviewCombinedDiffCommand}" />
|
|
||||||
</WrapPanel>
|
|
||||||
</StackPanel>
|
|
||||||
</ScrollViewer>
|
|
||||||
|
|
||||||
<!-- Session: subtask outcomes (review lives in Output, merge in Git) -->
|
|
||||||
<ScrollViewer IsVisible="{Binding IsSessionTab}" Padding="14,10">
|
|
||||||
<StackPanel Spacing="14">
|
|
||||||
|
|
||||||
<!-- Attention band: a child failed, was cancelled, still needs its own
|
|
||||||
review, or reported roadblocks. The parent stays waiting until resolved. -->
|
|
||||||
<Border IsVisible="{Binding HasChildrenNeedingAttention}"
|
|
||||||
Background="{DynamicResource ErrorTintBrush}"
|
|
||||||
BorderBrush="{DynamicResource BloodBrush}"
|
|
||||||
BorderThickness="1" CornerRadius="8" Padding="10,8">
|
|
||||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
|
||||||
<PathIcon Data="{StaticResource Icon.Warning}"
|
|
||||||
Foreground="{DynamicResource BloodBrush}"
|
|
||||||
Width="14" Height="14" VerticalAlignment="Center" />
|
|
||||||
<TextBlock Classes="meta" Text="{Binding ChildrenAttentionText}"
|
|
||||||
Foreground="{DynamicResource BloodBrush}"
|
|
||||||
VerticalAlignment="Center" />
|
|
||||||
</StackPanel>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- Child outcomes -->
|
|
||||||
<StackPanel Spacing="6" IsVisible="{Binding HasChildOutcomes}">
|
|
||||||
<TextBlock Classes="section-label" Text="OUTCOMES" />
|
|
||||||
<ItemsControl ItemsSource="{Binding ChildOutcomes}">
|
|
||||||
<ItemsControl.ItemTemplate>
|
|
||||||
<DataTemplate x:DataType="vm:ChildOutcomeRowViewModel">
|
|
||||||
<Grid ColumnDefinitions="*,Auto,Auto" Margin="0,2">
|
|
||||||
<TextBlock Grid.Column="0" Text="{Binding Title}"
|
|
||||||
TextTrimming="CharacterEllipsis"
|
|
||||||
VerticalAlignment="Center" />
|
|
||||||
<TextBlock Grid.Column="1" Text="{Binding RoadblockText}"
|
|
||||||
IsVisible="{Binding HasRoadblock}"
|
|
||||||
Foreground="#E0A030"
|
|
||||||
Margin="8,0" VerticalAlignment="Center" />
|
|
||||||
<TextBlock Grid.Column="2" Text="{Binding StatusLabel}"
|
|
||||||
Opacity="0.75" VerticalAlignment="Center" />
|
|
||||||
</Grid>
|
|
||||||
</DataTemplate>
|
|
||||||
</ItemsControl.ItemTemplate>
|
|
||||||
</ItemsControl>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
</StackPanel>
|
|
||||||
</ScrollViewer>
|
|
||||||
|
|
||||||
</Grid>
|
|
||||||
</DockPanel>
|
|
||||||
</Border>
|
|
||||||
</UserControl>
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Specialized;
|
|
||||||
using Avalonia.Controls;
|
|
||||||
using Avalonia.Input;
|
|
||||||
using ClaudeDo.Ui.ViewModels.Islands;
|
|
||||||
|
|
||||||
namespace ClaudeDo.Ui.Views.Islands.Detail;
|
|
||||||
|
|
||||||
public partial class WorkConsole : UserControl
|
|
||||||
{
|
|
||||||
private INotifyCollectionChanged? _log;
|
|
||||||
|
|
||||||
public WorkConsole()
|
|
||||||
{
|
|
||||||
InitializeComponent();
|
|
||||||
DataContextChanged += OnDataContextChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnDataContextChanged(object? sender, EventArgs e)
|
|
||||||
{
|
|
||||||
if (_log is not null)
|
|
||||||
_log.CollectionChanged -= OnLogChanged;
|
|
||||||
|
|
||||||
_log = (DataContext as DetailsIslandViewModel)?.Log;
|
|
||||||
|
|
||||||
if (_log is not null)
|
|
||||||
_log.CollectionChanged += OnLogChanged;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnLogChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
|
||||||
{
|
|
||||||
if (e.Action != NotifyCollectionChangedAction.Add) return;
|
|
||||||
EventHandler? handler = null;
|
|
||||||
handler = (_, _) =>
|
|
||||||
{
|
|
||||||
LogScroll.LayoutUpdated -= handler;
|
|
||||||
LogScroll.ScrollToEnd();
|
|
||||||
};
|
|
||||||
LogScroll.LayoutUpdated += handler;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void OnReviewInputKeyDown(object? sender, KeyEventArgs e)
|
|
||||||
{
|
|
||||||
if (e.Key != Key.Enter || e.KeyModifiers.HasFlag(KeyModifiers.Shift))
|
|
||||||
return;
|
|
||||||
|
|
||||||
if (DataContext is DetailsIslandViewModel vm &&
|
|
||||||
vm.RejectReviewCommand.CanExecute(null))
|
|
||||||
{
|
|
||||||
vm.RejectReviewCommand.Execute(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
e.Handled = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,25 +2,32 @@
|
|||||||
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:islands="using:ClaudeDo.Ui.Views.Islands"
|
xmlns:islands="using:ClaudeDo.Ui.Views.Islands"
|
||||||
xmlns:detail="using:ClaudeDo.Ui.Views.Islands.Detail"
|
xmlns:ctl="using:ClaudeDo.Ui.Views.Controls"
|
||||||
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
||||||
x:Class="ClaudeDo.Ui.Views.Islands.DetailsIslandView"
|
x:Class="ClaudeDo.Ui.Views.Islands.DetailsIslandView"
|
||||||
x:DataType="vm:DetailsIslandViewModel">
|
x:DataType="vm:DetailsIslandViewModel">
|
||||||
<DockPanel>
|
<DockPanel>
|
||||||
|
|
||||||
<!-- ── Metadata footer (sticky bottom) — created-at + close — task detail only ── -->
|
<!-- ── Metadata footer (sticky bottom) — task detail only ── -->
|
||||||
<Border DockPanel.Dock="Bottom"
|
<Border DockPanel.Dock="Bottom"
|
||||||
IsVisible="{Binding IsTaskDetailVisible}"
|
IsVisible="{Binding IsTaskDetailVisible}"
|
||||||
BorderBrush="{DynamicResource LineBrush}"
|
BorderBrush="{DynamicResource LineBrush}"
|
||||||
BorderThickness="0,1,0,0"
|
BorderThickness="0,1,0,0"
|
||||||
Padding="14,8">
|
Padding="14,8">
|
||||||
<Grid ColumnDefinitions="*,Auto">
|
<Grid ColumnDefinitions="Auto,*,Auto">
|
||||||
<TextBlock Grid.Column="0"
|
<Button Grid.Column="0" Classes="icon-btn"
|
||||||
|
Command="{Binding DeleteTaskCommand}"
|
||||||
|
ToolTip.Tip="{loc:Tr details.deleteTaskTip}"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<PathIcon Data="{StaticResource Icon.Trash}" Width="14" Height="14"
|
||||||
|
Foreground="{DynamicResource BloodBrush}"/>
|
||||||
|
</Button>
|
||||||
|
<TextBlock Grid.Column="1"
|
||||||
Classes="meta"
|
Classes="meta"
|
||||||
Text="{Binding Task.CreatedAtFormatted}"
|
Text="{Binding Task.CreatedAtFormatted}"
|
||||||
HorizontalAlignment="Center"
|
HorizontalAlignment="Center"
|
||||||
VerticalAlignment="Center"/>
|
VerticalAlignment="Center"/>
|
||||||
<Button Grid.Column="1" Classes="icon-btn"
|
<Button Grid.Column="2" Classes="icon-btn"
|
||||||
Command="{Binding CloseDetailsCommand}"
|
Command="{Binding CloseDetailsCommand}"
|
||||||
ToolTip.Tip="{loc:Tr details.closeTip}"
|
ToolTip.Tip="{loc:Tr details.closeTip}"
|
||||||
VerticalAlignment="Center">
|
VerticalAlignment="Center">
|
||||||
@@ -29,98 +36,377 @@
|
|||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
<!-- ── Header (sticky top): id · title · trash/skull · gear — task detail only ── -->
|
<!-- ── Header (sticky top): check · eyebrow · title · status · star · gear — task detail only ── -->
|
||||||
<Border DockPanel.Dock="Top" Classes="island-header"
|
<Border DockPanel.Dock="Top" Classes="island-header"
|
||||||
IsVisible="{Binding IsTaskDetailVisible}">
|
IsVisible="{Binding IsTaskDetailVisible}">
|
||||||
<detail:TaskHeaderBar/>
|
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
|
||||||
|
<Button Grid.Column="0" Classes="flat"
|
||||||
|
Command="{Binding ToggleDoneCommand}"
|
||||||
|
Padding="0"
|
||||||
|
VerticalAlignment="Top"
|
||||||
|
Margin="0,2,10,0">
|
||||||
|
<Ellipse Classes="task-check"
|
||||||
|
Classes.done="{Binding Task.Done}"
|
||||||
|
Width="18" Height="18"
|
||||||
|
Cursor="Hand"/>
|
||||||
|
</Button>
|
||||||
|
<StackPanel Grid.Column="1" Spacing="0">
|
||||||
|
<TextBlock Classes="meta"
|
||||||
|
Text="{Binding TaskIdBadge}"
|
||||||
|
Margin="0,0,0,4"
|
||||||
|
Cursor="Hand"
|
||||||
|
ToolTip.Tip="{loc:Tr details.copyTaskIdTip}"
|
||||||
|
Tapped="OnTaskIdTapped"/>
|
||||||
|
<TextBox Text="{Binding EditableTitle, Mode=TwoWay}"
|
||||||
|
FontSize="{StaticResource FontSizeTaskTitle}" FontWeight="Medium"
|
||||||
|
BorderThickness="0" Background="Transparent"
|
||||||
|
Foreground="{DynamicResource TextBrush}"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
AcceptsReturn="False"
|
||||||
|
Padding="0"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Button Grid.Column="2"
|
||||||
|
Classes="icon-btn star-btn"
|
||||||
|
Classes.on="{Binding Task.IsStarred}"
|
||||||
|
Command="{Binding ToggleStarCommand}"
|
||||||
|
ToolTip.Tip="{loc:Tr details.starTip}"
|
||||||
|
VerticalAlignment="Top"
|
||||||
|
Margin="6,0,0,0">
|
||||||
|
<PathIcon Data="{StaticResource Icon.Star}" Width="14" Height="14"/>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button Grid.Column="3" Classes="icon-btn"
|
||||||
|
ToolTip.Tip="{loc:Tr details.agentSettingsTip}"
|
||||||
|
IsEnabled="{Binding IsAgentSectionEnabled}"
|
||||||
|
VerticalAlignment="Top"
|
||||||
|
Margin="6,0,0,0">
|
||||||
|
<TextBlock Text="⚙" FontSize="{StaticResource FontSizeTaskTitle}"/>
|
||||||
|
<Button.Flyout>
|
||||||
|
<Flyout Placement="BottomEdgeAlignedRight" ShowMode="Standard">
|
||||||
|
<StackPanel Width="340" Spacing="10" Margin="4">
|
||||||
|
<TextBlock Text="{loc:Tr details.agentSettingsHeading}" FontWeight="SemiBold"/>
|
||||||
|
|
||||||
|
<StackPanel Spacing="2">
|
||||||
|
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
|
||||||
|
<TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr details.modelLabel}" VerticalAlignment="Center"/>
|
||||||
|
<ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding ModelBadge}"/>
|
||||||
|
<Button Grid.Column="3" Classes="icon-btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
|
||||||
|
Command="{Binding ResetTaskModelCommand}"/>
|
||||||
|
</Grid>
|
||||||
|
<ComboBox ItemsSource="{Binding TaskModelOptions}"
|
||||||
|
SelectedItem="{Binding TaskModelSelection, Mode=TwoWay}"
|
||||||
|
PlaceholderText="{Binding ModelInheritedHint}"
|
||||||
|
HorizontalAlignment="Stretch"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Spacing="2">
|
||||||
|
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
|
||||||
|
<TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr details.maxTurnsLabel}" VerticalAlignment="Center"/>
|
||||||
|
<ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding TurnsBadge}"/>
|
||||||
|
<Button Grid.Column="3" Classes="icon-btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
|
||||||
|
Command="{Binding ResetTaskTurnsCommand}"/>
|
||||||
|
</Grid>
|
||||||
|
<NumericUpDown Value="{Binding TaskMaxTurns, Mode=TwoWay}"
|
||||||
|
PlaceholderText="{Binding TurnsInheritedHint}"
|
||||||
|
Minimum="1" Maximum="200" Increment="1" FormatString="0"
|
||||||
|
HorizontalAlignment="Stretch"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Spacing="2">
|
||||||
|
<TextBlock Classes="field-label" Text="{loc:Tr details.systemPromptLabel}"/>
|
||||||
|
<TextBox Text="{Binding TaskSystemPrompt, Mode=TwoWay}"
|
||||||
|
AcceptsReturn="True" TextWrapping="Wrap" MinHeight="70"/>
|
||||||
|
<TextBlock Classes="meta" Opacity="0.6"
|
||||||
|
Text="{loc:Tr details.systemPromptPrepended}"
|
||||||
|
IsVisible="{Binding EffectiveSystemPromptHint, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
|
<TextBlock Classes="meta" Opacity="0.6" TextWrapping="Wrap"
|
||||||
|
Text="{Binding EffectiveSystemPromptHint}"
|
||||||
|
IsVisible="{Binding EffectiveSystemPromptHint, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<StackPanel Spacing="2">
|
||||||
|
<Grid ColumnDefinitions="Auto,Auto,*,Auto" ColumnSpacing="6">
|
||||||
|
<TextBlock Grid.Column="0" Classes="field-label" Text="{loc:Tr details.agentFileLabel}" VerticalAlignment="Center"/>
|
||||||
|
<ctl:InheritedBadge Grid.Column="1" BadgeText="{Binding AgentBadge}"/>
|
||||||
|
<Button Grid.Column="3" Classes="icon-btn" Content="↺" ToolTip.Tip="{loc:Tr settings.inherit.resetToInherited}"
|
||||||
|
Command="{Binding ResetTaskAgentCommand}"/>
|
||||||
|
</Grid>
|
||||||
|
<ComboBox ItemsSource="{Binding TaskAgentOptions}"
|
||||||
|
SelectedItem="{Binding TaskSelectedAgent, Mode=TwoWay}"
|
||||||
|
HorizontalAlignment="Stretch">
|
||||||
|
<ComboBox.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<TextBlock Text="{Binding Name}"/>
|
||||||
|
</DataTemplate>
|
||||||
|
</ComboBox.ItemTemplate>
|
||||||
|
</ComboBox>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Flyout>
|
||||||
|
</Button.Flyout>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
|
|
||||||
|
<!-- ── Agent status strip (sticky, above metadata footer) — task detail only ── -->
|
||||||
|
<islands:AgentStripView DockPanel.Dock="Bottom"
|
||||||
|
IsVisible="{Binding IsTaskDetailVisible}"/>
|
||||||
|
|
||||||
<!-- ── Body: task details (normal), notes editor (notes mode), or prep log (prep mode) ── -->
|
<!-- ── Body: task details (normal), notes editor (notes mode), or prep log (prep mode) ── -->
|
||||||
<Grid>
|
<Grid>
|
||||||
|
<ScrollViewer VerticalScrollBarVisibility="Auto"
|
||||||
|
IsVisible="{Binding IsTaskDetailVisible}">
|
||||||
|
<StackPanel Spacing="0">
|
||||||
|
|
||||||
<!-- Task detail: description/steps card (upper) + pinned work console (lower) -->
|
<!-- Planning merge section — visible only for planning parent tasks -->
|
||||||
<Grid x:Name="DetailBodyGrid"
|
<Border Classes="section-divider"
|
||||||
IsVisible="{Binding IsTaskDetailVisible}"
|
IsVisible="{Binding Task.IsPlanningParent}">
|
||||||
Margin="14,12,14,12">
|
<StackPanel Spacing="8">
|
||||||
<Grid.RowDefinitions>
|
<TextBlock Classes="section-label" Text="{loc:Tr details.mergeLabel}" Margin="0,0,0,2"/>
|
||||||
<!-- Auto: the description sizes to its content so the console takes
|
<StackPanel Spacing="4">
|
||||||
every spare pixel when it's short. Row limits are proportional
|
<TextBlock Classes="field-label" Text="{loc:Tr details.mergeTargetLabel}"/>
|
||||||
and set in code-behind (UpdateRowLimits): the description row is
|
<ComboBox ItemsSource="{Binding MergeTargetBranches}"
|
||||||
capped at 2/3 of the island and the console row floored at 1/3,
|
SelectedItem="{Binding SelectedMergeTarget, Mode=TwoWay}"
|
||||||
so the console can be dragged down to (but not below) 1/3 and a
|
HorizontalAlignment="Stretch"/>
|
||||||
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>
|
</StackPanel>
|
||||||
</Border>
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
<detail:WorkConsole/>
|
<Button Classes="btn" Content="{loc:Tr details.reviewCombinedDiff}"
|
||||||
</DockPanel>
|
Command="{Binding ReviewCombinedDiffCommand}"/>
|
||||||
|
<Button Classes="btn" Content="{loc:Tr details.mergeAllSubtasks}"
|
||||||
|
IsEnabled="{Binding CanMergeAll}"
|
||||||
|
Command="{Binding MergeAllCommand}"
|
||||||
|
ToolTip.Tip="{Binding MergeAllDisabledReason}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<TextBlock Text="{Binding MergeAllError}"
|
||||||
|
Foreground="{DynamicResource BloodBrush}"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
IsVisible="{Binding MergeAllError, Converter={x:Static ObjectConverters.IsNotNull}}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!-- Resize by dragging the console's top edge — a transparent splitter
|
<!-- Review section — visible when task is WaitingForReview -->
|
||||||
over the gap above the console; no standalone separator bar.
|
<Border Classes="section-divider"
|
||||||
Stays draggable while maximized. -->
|
IsVisible="{Binding IsWaitingForReview}">
|
||||||
<GridSplitter x:Name="DetailSplitter" Grid.Row="1"
|
<StackPanel Spacing="8">
|
||||||
VerticalAlignment="Top"
|
<TextBlock Classes="section-label" Text="{loc:Tr tasks.reviewTitle}" Margin="0,0,0,2"/>
|
||||||
Height="10"
|
<TextBlock Classes="field-label" Text="{loc:Tr tasks.feedbackLabel}"/>
|
||||||
HorizontalAlignment="Stretch"
|
<TextBox Text="{Binding ReviewFeedback, Mode=TwoWay}"
|
||||||
ResizeDirection="Rows"
|
AcceptsReturn="True"
|
||||||
Background="Transparent"
|
TextWrapping="Wrap"
|
||||||
DragStarted="OnSplitterDragStarted"
|
MinHeight="60"
|
||||||
DragCompleted="OnSplitterDragCompleted"/>
|
MaxHeight="180"
|
||||||
</Grid>
|
PlaceholderText="{loc:Tr tasks.feedbackPlaceholder}"
|
||||||
|
Padding="8"
|
||||||
|
Background="{DynamicResource Surface2Brush}"
|
||||||
|
BorderBrush="{DynamicResource LineBrush}"
|
||||||
|
BorderThickness="1"
|
||||||
|
CornerRadius="8"/>
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||||
|
<Button Classes="btn accent"
|
||||||
|
Content="{loc:Tr tasks.approve}"
|
||||||
|
ToolTip.Tip="{loc:Tr tasks.approveTip}"
|
||||||
|
Command="{Binding ApproveReviewCommand}"/>
|
||||||
|
<Button Classes="btn"
|
||||||
|
Content="{loc:Tr tasks.reject}"
|
||||||
|
ToolTip.Tip="{loc:Tr tasks.rejectTip}"
|
||||||
|
Command="{Binding RejectReviewCommand}"/>
|
||||||
|
<Button Classes="btn"
|
||||||
|
Content="{loc:Tr tasks.park}"
|
||||||
|
ToolTip.Tip="{loc:Tr tasks.parkTip}"
|
||||||
|
Command="{Binding ParkReviewCommand}"/>
|
||||||
|
<Button Classes="btn"
|
||||||
|
Content="{loc:Tr tasks.cancel}"
|
||||||
|
ToolTip.Tip="{loc:Tr tasks.cancelTip}"
|
||||||
|
Command="{Binding CancelReviewCommand}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!-- Notes mode -->
|
<!-- Improvement-children outcomes — visible when this task has agent-suggested children -->
|
||||||
<Panel IsVisible="{Binding IsNotesMode}">
|
<Border Classes="section-divider"
|
||||||
<islands:NotesEditorView DataContext="{Binding Notes}"/>
|
IsVisible="{Binding HasChildOutcomes}">
|
||||||
</Panel>
|
<StackPanel Spacing="6">
|
||||||
|
<TextBlock Classes="section-label" Text="{loc:Tr details.childOutcomesLabel}" Margin="0,0,0,2"/>
|
||||||
|
<ItemsControl ItemsSource="{Binding ChildOutcomes}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:ChildOutcomeRowViewModel">
|
||||||
|
<Grid ColumnDefinitions="*,Auto,Auto" Margin="0,2">
|
||||||
|
<TextBlock Grid.Column="0" Text="{Binding Title}" TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Grid.Column="1" Text="{Binding RoadblockText}"
|
||||||
|
IsVisible="{Binding HasRoadblock}"
|
||||||
|
Foreground="#E0A030" Margin="8,0" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Grid.Column="2" Text="{Binding StatusLabel}"
|
||||||
|
Opacity="0.75" VerticalAlignment="Center"/>
|
||||||
|
</Grid>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
<!-- Daily-prep mode -->
|
<!-- Steps section -->
|
||||||
<Panel IsVisible="{Binding IsPrepMode}">
|
<Border Classes="section-divider">
|
||||||
<DockPanel>
|
<StackPanel Spacing="6">
|
||||||
<Border DockPanel.Dock="Top" Padding="12,8">
|
<TextBlock Classes="section-label" Text="{loc:Tr details.stepsLabel}" Margin="0,0,0,2"/>
|
||||||
<Button Classes="btn primary"
|
<TextBox Text="{Binding NewSubtaskTitle, Mode=TwoWay}"
|
||||||
Command="{Binding PlanDayCommand}"
|
PlaceholderText="{loc:Tr details.addStepPlaceholder}"
|
||||||
IsEnabled="{Binding !IsPrepRunning}"
|
Padding="8"
|
||||||
Content="{loc:Tr details.planDay}"/>
|
Background="{DynamicResource Surface2Brush}"
|
||||||
</Border>
|
BorderBrush="{DynamicResource LineBrush}"
|
||||||
<Panel>
|
BorderThickness="1"
|
||||||
<islands:SessionTerminalView
|
CornerRadius="8">
|
||||||
Margin="18,8,18,0"
|
<TextBox.KeyBindings>
|
||||||
Entries="{Binding PrepLog}" Label="daily-prep"
|
<KeyBinding Gesture="Enter" Command="{Binding AddSubtaskCommand}"/>
|
||||||
IsRunning="{Binding IsPrepRunning}"/>
|
</TextBox.KeyBindings>
|
||||||
<TextBlock IsVisible="{Binding ShowPrepEmptyState}"
|
</TextBox>
|
||||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
<ItemsControl ItemsSource="{Binding Subtasks}"
|
||||||
Foreground="{DynamicResource TextMuteBrush}"
|
IsVisible="{Binding Subtasks.Count}">
|
||||||
Text="{loc:Tr details.prepEmpty}"/>
|
<ItemsControl.ItemTemplate>
|
||||||
</Panel>
|
<DataTemplate DataType="vm:SubtaskRowViewModel">
|
||||||
</DockPanel>
|
<Border Classes="subtask-row"
|
||||||
</Panel>
|
Classes.done="{Binding Done}">
|
||||||
|
<Grid ColumnDefinitions="Auto,*">
|
||||||
|
<Button Grid.Column="0" Classes="flat"
|
||||||
|
Padding="0"
|
||||||
|
Margin="0,0,8,0"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
Command="{Binding $parent[ItemsControl].((vm:DetailsIslandViewModel)DataContext).ToggleSubtaskDoneCommand}"
|
||||||
|
CommandParameter="{Binding}">
|
||||||
|
<Ellipse Classes="task-check"
|
||||||
|
Classes.done="{Binding Done}"
|
||||||
|
Width="16" Height="16"
|
||||||
|
Cursor="Hand"/>
|
||||||
|
</Button>
|
||||||
|
<Panel Grid.Column="1" VerticalAlignment="Center">
|
||||||
|
<TextBlock Classes="subtask-title"
|
||||||
|
Text="{Binding Title}"
|
||||||
|
IsVisible="{Binding !IsEditing}"
|
||||||
|
FontSize="{StaticResource FontSizeBody}"
|
||||||
|
Foreground="{DynamicResource TextDimBrush}"
|
||||||
|
VerticalAlignment="Center"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
Cursor="Ibeam"
|
||||||
|
Tapped="OnSubtaskTitleTapped"/>
|
||||||
|
<TextBox Classes="subtask-edit"
|
||||||
|
Text="{Binding Title, Mode=TwoWay}"
|
||||||
|
IsVisible="{Binding IsEditing}"
|
||||||
|
FontSize="{StaticResource FontSizeBody}"
|
||||||
|
AcceptsReturn="False"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
LostFocus="OnSubtaskEditLostFocus">
|
||||||
|
<TextBox.KeyBindings>
|
||||||
|
<KeyBinding Gesture="Enter"
|
||||||
|
Command="{Binding $parent[ItemsControl].((vm:DetailsIslandViewModel)DataContext).CommitSubtaskEditCommand}"
|
||||||
|
CommandParameter="{Binding}"/>
|
||||||
|
</TextBox.KeyBindings>
|
||||||
|
</TextBox>
|
||||||
|
</Panel>
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Details (description) section -->
|
||||||
|
<Border Classes="section-divider">
|
||||||
|
<StackPanel Spacing="6">
|
||||||
|
<Grid ColumnDefinitions="Auto,*,Auto,Auto">
|
||||||
|
<Button Grid.Column="0"
|
||||||
|
Classes="flat"
|
||||||
|
Command="{Binding ToggleDescriptionExpandedCommand}"
|
||||||
|
Padding="0"
|
||||||
|
Margin="0,0,6,2"
|
||||||
|
VerticalAlignment="Center">
|
||||||
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
|
<TextBlock Classes="meta"
|
||||||
|
Text="▾"
|
||||||
|
IsVisible="{Binding IsDescriptionExpanded}"/>
|
||||||
|
<TextBlock Classes="meta"
|
||||||
|
Text="▸"
|
||||||
|
IsVisible="{Binding !IsDescriptionExpanded}"/>
|
||||||
|
<TextBlock Classes="section-label" Text="{loc:Tr details.detailsLabel}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Button>
|
||||||
|
<Button Grid.Column="2"
|
||||||
|
Classes="icon-btn"
|
||||||
|
Padding="6,2"
|
||||||
|
Margin="0,0,4,0"
|
||||||
|
ToolTip.Tip="{loc:Tr details.copyDescriptionTip}"
|
||||||
|
IsVisible="{Binding IsDescriptionExpanded}"
|
||||||
|
Click="OnCopyDescriptionClick">
|
||||||
|
<PathIcon Data="{StaticResource Icon.Copy}" Width="11" Height="11"/>
|
||||||
|
</Button>
|
||||||
|
<Button Grid.Column="3"
|
||||||
|
Classes="btn"
|
||||||
|
Command="{Binding ToggleEditDescriptionCommand}"
|
||||||
|
Padding="8,3"
|
||||||
|
ToolTip.Tip="{loc:Tr details.toggleEditPreviewTip}"
|
||||||
|
IsVisible="{Binding IsDescriptionEditorVisible}">
|
||||||
|
<TextBlock Text="{loc:Tr details.previewBtn}"/>
|
||||||
|
</Button>
|
||||||
|
<Button Grid.Column="3"
|
||||||
|
Classes="btn"
|
||||||
|
Command="{Binding ToggleEditDescriptionCommand}"
|
||||||
|
Padding="8,3"
|
||||||
|
ToolTip.Tip="{loc:Tr details.toggleEditPreviewTip}"
|
||||||
|
IsVisible="{Binding IsDescriptionPreviewVisible}">
|
||||||
|
<TextBlock Text="{loc:Tr details.editBtn}"/>
|
||||||
|
</Button>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<TextBox Text="{Binding EditableDescription, Mode=TwoWay}"
|
||||||
|
AcceptsReturn="True"
|
||||||
|
TextWrapping="Wrap"
|
||||||
|
MinHeight="80"
|
||||||
|
MaxHeight="320"
|
||||||
|
PlaceholderText="{loc:Tr details.descriptionPlaceholder}"
|
||||||
|
Padding="8"
|
||||||
|
FontFamily="{DynamicResource MonoFont}"
|
||||||
|
FontSize="{StaticResource FontSizeBody}"
|
||||||
|
Background="{DynamicResource Surface2Brush}"
|
||||||
|
BorderBrush="{DynamicResource LineBrush}"
|
||||||
|
BorderThickness="1"
|
||||||
|
CornerRadius="8"
|
||||||
|
IsVisible="{Binding IsDescriptionEditorVisible}"/>
|
||||||
|
|
||||||
|
<ctl:MarkdownView Markdown="{Binding EditableDescription}"
|
||||||
|
IsVisible="{Binding IsDescriptionPreviewVisible}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<!-- Session terminal — auto-sizes to output, scrolls internally after MaxHeight -->
|
||||||
|
<islands:SessionTerminalView MaxHeight="420"
|
||||||
|
Entries="{Binding Log}"
|
||||||
|
Label="{Binding BranchLine, StringFormat='claude-session · {0}'}"
|
||||||
|
IsRunning="{Binding IsRunning}" IsDone="{Binding IsDone}" IsFailed="{Binding IsFailed}"/>
|
||||||
|
|
||||||
|
</StackPanel>
|
||||||
|
</ScrollViewer>
|
||||||
|
<Panel IsVisible="{Binding IsNotesMode}">
|
||||||
|
<islands:NotesEditorView DataContext="{Binding Notes}"/>
|
||||||
|
</Panel>
|
||||||
|
<Panel IsVisible="{Binding IsPrepMode}">
|
||||||
|
<DockPanel>
|
||||||
|
<Border DockPanel.Dock="Top" Padding="12,8">
|
||||||
|
<Button Classes="btn primary"
|
||||||
|
Command="{Binding PlanDayCommand}"
|
||||||
|
IsEnabled="{Binding !IsPrepRunning}"
|
||||||
|
Content="{loc:Tr details.planDay}"/>
|
||||||
|
</Border>
|
||||||
|
<Panel>
|
||||||
|
<islands:SessionTerminalView
|
||||||
|
Entries="{Binding PrepLog}" Label="daily-prep"
|
||||||
|
IsRunning="{Binding IsPrepRunning}"/>
|
||||||
|
<TextBlock IsVisible="{Binding ShowPrepEmptyState}"
|
||||||
|
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||||
|
Foreground="{DynamicResource TextMuteBrush}"
|
||||||
|
Text="{loc:Tr details.prepEmpty}"/>
|
||||||
|
</Panel>
|
||||||
|
</DockPanel>
|
||||||
|
</Panel>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
|
using System.Linq;
|
||||||
using Avalonia;
|
using Avalonia;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Input;
|
using Avalonia.Input;
|
||||||
|
using Avalonia.Input.Platform;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
using Avalonia.Layout;
|
using Avalonia.Layout;
|
||||||
using Avalonia.Media;
|
using Avalonia.Media;
|
||||||
using Avalonia.Reactive;
|
using Avalonia.Threading;
|
||||||
|
using Avalonia.VisualTree;
|
||||||
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;
|
||||||
@@ -12,42 +16,16 @@ 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;
|
||||||
@@ -77,41 +55,6 @@ public partial class DetailsIslandView : UserControl
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restores the resize state for the currently-selected task: a task the
|
|
||||||
// user has dragged before gets its pinned pixel height (cap lifted); a task
|
|
||||||
// never dragged falls back to dynamic sizing (Auto row + the bound cap).
|
|
||||||
private void OnViewModelPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
|
||||||
{
|
|
||||||
if (e.PropertyName == nameof(DetailsIslandViewModel.Task))
|
|
||||||
ApplyResizeStateForCurrentTask();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ApplyResizeStateForCurrentTask()
|
|
||||||
{
|
|
||||||
// A task dragged before keeps its pixel height (clamped by the row's
|
|
||||||
// 2/3 MaxHeight); a task never dragged stays Auto-sized.
|
|
||||||
DetailBodyGrid.RowDefinitions[0].Height = _vm?.Task?.Id is string id && _descriptionHeights.TryGetValue(id, out var h)
|
|
||||||
? new GridLength(h, GridUnitType.Pixel)
|
|
||||||
: GridLength.Auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pin the (until now Auto-sized) description row to its current pixel
|
|
||||||
// height so the splitter resizes smoothly from there.
|
|
||||||
private void OnSplitterDragStarted(object? sender, VectorEventArgs e)
|
|
||||||
{
|
|
||||||
var descRow = DetailBodyGrid.RowDefinitions[0];
|
|
||||||
if (descRow.Height.IsAuto)
|
|
||||||
descRow.Height = new GridLength(DescriptionCard.Bounds.Height, GridUnitType.Pixel);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remember the dragged height for this task so switching tasks keeps each
|
|
||||||
// task's resize independent.
|
|
||||||
private void OnSplitterDragCompleted(object? sender, VectorEventArgs e)
|
|
||||||
{
|
|
||||||
if (_vm?.Task?.Id is string id)
|
|
||||||
_descriptionHeights[id] = DetailBodyGrid.RowDefinitions[0].Height.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async System.Threading.Tasks.Task ShowErrorDialogAsync(string message)
|
private async System.Threading.Tasks.Task ShowErrorDialogAsync(string message)
|
||||||
{
|
{
|
||||||
var owner = TopLevel.GetTopLevel(this) as Window;
|
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||||
@@ -195,4 +138,37 @@ public partial class DetailsIslandView : UserControl
|
|||||||
_ = dialog.ShowDialog(owner);
|
_ = dialog.ShowDialog(owner);
|
||||||
return await tcs.Task;
|
return await tcs.Task;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void OnSubtaskTitleTapped(object? sender, TappedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is not Control c || c.DataContext is not SubtaskRowViewModel row) return;
|
||||||
|
row.IsEditing = true;
|
||||||
|
|
||||||
|
var box = (c.GetVisualParent() as Panel)?.GetVisualDescendants().OfType<TextBox>().FirstOrDefault();
|
||||||
|
if (box is not null)
|
||||||
|
Dispatcher.UIThread.Post(() => { box.Focus(); box.SelectAll(); }, DispatcherPriority.Background);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnSubtaskEditLostFocus(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is DetailsIslandViewModel vm
|
||||||
|
&& sender is Control c && c.DataContext is SubtaskRowViewModel row)
|
||||||
|
vm.CommitSubtaskEditCommand.Execute(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnTaskIdTapped(object? sender, TappedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is not DetailsIslandViewModel vm || vm.Task is null) return;
|
||||||
|
var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
|
||||||
|
if (clipboard is null) return;
|
||||||
|
await clipboard.SetTextAsync(vm.Task.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnCopyDescriptionClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is not DetailsIslandViewModel vm) return;
|
||||||
|
var clipboard = TopLevel.GetTopLevel(this)?.Clipboard;
|
||||||
|
if (clipboard is null) return;
|
||||||
|
await clipboard.SetTextAsync(vm.EditableDescription ?? string.Empty);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
xmlns:loc="using:ClaudeDo.Ui.Localization"
|
||||||
x:Class="ClaudeDo.Ui.Views.Islands.SessionTerminalView"
|
x:Class="ClaudeDo.Ui.Views.Islands.SessionTerminalView"
|
||||||
x:Name="Root">
|
x:Name="Root">
|
||||||
<Border Classes="terminal" Margin="0">
|
<Border Classes="terminal" Margin="18,8,18,0">
|
||||||
<DockPanel LastChildFill="True">
|
<DockPanel LastChildFill="True">
|
||||||
|
|
||||||
<!-- ── Terminal header bar ── -->
|
<!-- ── Terminal header bar ── -->
|
||||||
|
|||||||
@@ -69,7 +69,7 @@
|
|||||||
Click="OnClearScheduleClick"/>
|
Click="OnClearScheduleClick"/>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
</Border.ContextMenu>
|
</Border.ContextMenu>
|
||||||
<Grid ColumnDefinitions="0,18,32,*,Auto,Auto,32" Margin="6,8,10,8">
|
<Grid ColumnDefinitions="0,18,32,*,Auto,32" Margin="6,8,10,8">
|
||||||
|
|
||||||
<!-- Chevron toggle (only for planning parent tasks) -->
|
<!-- Chevron toggle (only for planning parent tasks) -->
|
||||||
<Button Grid.Column="1"
|
<Button Grid.Column="1"
|
||||||
@@ -78,8 +78,7 @@
|
|||||||
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"/>
|
||||||
@@ -142,7 +141,7 @@
|
|||||||
Data="{StaticResource Icon.AgentSuggested}"
|
Data="{StaticResource Icon.AgentSuggested}"
|
||||||
Foreground="#5C8FA8"
|
Foreground="#5C8FA8"
|
||||||
IsVisible="{Binding IsAgentSuggested}"
|
IsVisible="{Binding IsAgentSuggested}"
|
||||||
ToolTip.Tip="{loc:Tr tasks.agentSuggestedTip}"/>
|
ToolTip.Tip="Suggested by the agent"/>
|
||||||
|
|
||||||
<!-- Status chip -->
|
<!-- Status chip -->
|
||||||
<Border Classes="chip"
|
<Border Classes="chip"
|
||||||
@@ -195,25 +194,12 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<!-- Refine button -->
|
|
||||||
<Button Grid.Column="5" Classes="icon-btn refine-btn"
|
|
||||||
IsVisible="{Binding CanRefine}"
|
|
||||||
VerticalAlignment="Top" Margin="0,2,0,0"
|
|
||||||
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).RefineTaskCommand}"
|
|
||||||
CommandParameter="{Binding}"
|
|
||||||
ToolTip.Tip="{loc:Tr tasks.refineTip}">
|
|
||||||
<Viewbox Width="16" Height="16">
|
|
||||||
<Path Classes="plan-icon" Data="{StaticResource Icon.Refine}"/>
|
|
||||||
</Viewbox>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<!-- Star toggle -->
|
<!-- Star toggle -->
|
||||||
<Button Grid.Column="6" Classes="icon-btn star-btn"
|
<Button Grid.Column="5" Classes="icon-btn star-btn"
|
||||||
Classes.on="{Binding IsStarred}"
|
Classes.on="{Binding IsStarred}"
|
||||||
VerticalAlignment="Top" Margin="0,2,0,0"
|
VerticalAlignment="Top" Margin="0,2,0,0"
|
||||||
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).ToggleStarCommand}"
|
Command="{Binding $parent[ItemsControl].((vm:TasksIslandViewModel)DataContext).ToggleStarCommand}"
|
||||||
CommandParameter="{Binding}"
|
CommandParameter="{Binding}">
|
||||||
ToolTip.Tip="{loc:Tr details.starTip}">
|
|
||||||
<PathIcon Width="14" Height="14" Data="{StaticResource Icon.Star}"/>
|
<PathIcon Width="14" Height="14" Data="{StaticResource Icon.Star}"/>
|
||||||
</Button>
|
</Button>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -81,10 +81,6 @@ 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) =>
|
||||||
@@ -99,11 +95,6 @@ public partial class MainWindow : Window
|
|||||||
connVm.CloseAction = () => dlg.Close();
|
connVm.CloseAction = () => dlg.Close();
|
||||||
await dlg.ShowDialog(this);
|
await dlg.ShowDialog(this);
|
||||||
};
|
};
|
||||||
vm.ShowConflictResolver = async (resolverVm) =>
|
|
||||||
{
|
|
||||||
var dlg = new ClaudeDo.Ui.Views.Conflicts.ConflictResolverView { DataContext = resolverVm };
|
|
||||||
await dlg.ShowDialog(this);
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,100 +26,51 @@
|
|||||||
</StackPanel>
|
</StackPanel>
|
||||||
</ctl:ModalShell.Footer>
|
</ctl:ModalShell.Footer>
|
||||||
|
|
||||||
<!-- Body: two islands — file list | diff content -->
|
<!-- Body: sidebar + diff content -->
|
||||||
<Grid ColumnDefinitions="280,12,*" Margin="16">
|
<Grid ColumnDefinitions="240,*">
|
||||||
|
|
||||||
<!-- Files island -->
|
<!-- File sidebar -->
|
||||||
<Border Grid.Column="0" Classes="island">
|
<Border Grid.Column="0"
|
||||||
<DockPanel>
|
Classes="sidebar-pane">
|
||||||
<Border DockPanel.Dock="Top" Classes="island-header">
|
<ListBox ItemsSource="{Binding Files}"
|
||||||
<TextBlock Classes="eyebrow" Text="{loc:Tr modals.diff.filesHeader}"/>
|
SelectedItem="{Binding SelectedFile, Mode=TwoWay}"
|
||||||
</Border>
|
Background="Transparent"
|
||||||
<ListBox ItemsSource="{Binding Files}"
|
BorderThickness="0"
|
||||||
SelectedItem="{Binding SelectedFile, Mode=TwoWay}"
|
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
|
||||||
Background="Transparent"
|
<ListBox.ItemTemplate>
|
||||||
BorderThickness="0"
|
<DataTemplate x:DataType="vm:DiffFileViewModel">
|
||||||
ScrollViewer.HorizontalScrollBarVisibility="Disabled">
|
<Border Padding="10,8" Background="Transparent">
|
||||||
<ListBox.ItemTemplate>
|
<StackPanel Spacing="4">
|
||||||
<DataTemplate x:DataType="vm:DiffFileViewModel">
|
<TextBlock Classes="path-mono" Text="{Binding Path}"
|
||||||
<Border Padding="10,8" Background="Transparent">
|
TextTrimming="PrefixCharacterEllipsis"/>
|
||||||
<StackPanel Spacing="4">
|
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||||
<Grid ColumnDefinitions="Auto,*">
|
<Border Classes="chip" Padding="5,2">
|
||||||
<Border Grid.Column="0" Tag="{Binding StatusCode}"
|
<TextBlock Foreground="{DynamicResource MossBrightBrush}"
|
||||||
CornerRadius="3" Padding="4,0" Margin="0,0,8,0"
|
Text="{Binding Additions, StringFormat='+{0}'}"/>
|
||||||
VerticalAlignment="Center">
|
</Border>
|
||||||
<TextBlock Text="{Binding StatusCode}"
|
<Border Classes="chip" Padding="5,2">
|
||||||
FontFamily="{DynamicResource MonoFont}"
|
<TextBlock Foreground="{DynamicResource BloodBrush}"
|
||||||
FontSize="{StaticResource FontSizeEyebrow}"
|
Text="{Binding Deletions, StringFormat='−{0}'}"/>
|
||||||
Foreground="{DynamicResource TextBrush}"/>
|
</Border>
|
||||||
</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>
|
||||||
</Border>
|
</StackPanel>
|
||||||
</DataTemplate>
|
|
||||||
</ListBox.ItemTemplate>
|
|
||||||
</ListBox>
|
|
||||||
</DockPanel>
|
|
||||||
</Border>
|
|
||||||
|
|
||||||
<!-- Diff content island -->
|
|
||||||
<Border Grid.Column="2" Classes="island">
|
|
||||||
<DockPanel>
|
|
||||||
<Border DockPanel.Dock="Top" Classes="island-header"
|
|
||||||
IsVisible="{Binding SelectedFile, Converter={x:Static ObjectConverters.IsNotNull}}">
|
|
||||||
<Grid ColumnDefinitions="Auto,*">
|
|
||||||
<Border Grid.Column="0" Tag="{Binding SelectedFile.StatusCode}"
|
|
||||||
CornerRadius="3" Padding="4,0" Margin="0,0,8,0"
|
|
||||||
VerticalAlignment="Center">
|
|
||||||
<TextBlock Text="{Binding SelectedFile.StatusCode}"
|
|
||||||
FontFamily="{DynamicResource MonoFont}"
|
|
||||||
FontSize="{StaticResource FontSizeEyebrow}"
|
|
||||||
Foreground="{DynamicResource TextBrush}"/>
|
|
||||||
</Border>
|
</Border>
|
||||||
<TextBlock Grid.Column="1" Classes="path-mono" Text="{Binding SelectedFile.Path}"
|
</DataTemplate>
|
||||||
VerticalAlignment="Center"
|
</ListBox.ItemTemplate>
|
||||||
TextTrimming="PrefixCharacterEllipsis"/>
|
</ListBox>
|
||||||
</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>
|
</Border>
|
||||||
|
|
||||||
|
<!-- Diff content -->
|
||||||
|
<Grid Grid.Column="1" Background="{DynamicResource VoidBrush}">
|
||||||
|
<TextBlock Classes="body" Text="{Binding StatusMessage}"
|
||||||
|
IsVisible="{Binding StatusMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"
|
||||||
|
HorizontalAlignment="Center"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
|
<ScrollViewer HorizontalScrollBarVisibility="Auto"
|
||||||
|
VerticalScrollBarVisibility="Auto">
|
||||||
|
<ctl:DiffLinesView Lines="{Binding SelectedFile.Lines}"/>
|
||||||
|
</ScrollViewer>
|
||||||
|
</Grid>
|
||||||
</Grid>
|
</Grid>
|
||||||
</ctl:ModalShell>
|
</ctl:ModalShell>
|
||||||
</Window>
|
</Window>
|
||||||
|
|||||||
@@ -243,7 +243,6 @@
|
|||||||
<TextBlock Classes="meta" Grid.Column="3" Text="{Binding LastRunLabel}" VerticalAlignment="Center"
|
<TextBlock Classes="meta" Grid.Column="3" Text="{Binding LastRunLabel}" VerticalAlignment="Center"
|
||||||
MinWidth="80"/>
|
MinWidth="80"/>
|
||||||
<Button Classes="icon-btn" Grid.Column="4" Content="✕"
|
<Button Classes="icon-btn" Grid.Column="4" Content="✕"
|
||||||
ToolTip.Tip="{loc:Tr settings.prime.removeScheduleTip}"
|
|
||||||
Command="{Binding $parent[ItemsControl].((vm:SettingsModalViewModel)DataContext).Prime.RemoveScheduleCommand}"
|
Command="{Binding $parent[ItemsControl].((vm:SettingsModalViewModel)DataContext).Prime.RemoveScheduleCommand}"
|
||||||
CommandParameter="{Binding}"/>
|
CommandParameter="{Binding}"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
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"
|
||||||
@@ -17,6 +16,10 @@
|
|||||||
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>
|
||||||
@@ -86,7 +89,17 @@
|
|||||||
HorizontalScrollBarVisibility="Auto"
|
HorizontalScrollBarVisibility="Auto"
|
||||||
VerticalScrollBarVisibility="Auto"
|
VerticalScrollBarVisibility="Auto"
|
||||||
Margin="4,0,8,8">
|
Margin="4,0,8,8">
|
||||||
<ctl:DiffLinesView Lines="{Binding SelectedFileDiffLines}"/>
|
<ItemsControl ItemsSource="{Binding SelectedFileDiffLines}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate DataType="vm:WorktreeDiffLineViewModel">
|
||||||
|
<SelectableTextBlock Text="{Binding Text}"
|
||||||
|
FontFamily="{DynamicResource MonoFont}"
|
||||||
|
FontSize="{StaticResource FontSizeMono}"
|
||||||
|
Foreground="{Binding Kind, Converter={StaticResource DiffLineKindToBrush}}"
|
||||||
|
TextWrapping="NoWrap"/>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -60,12 +60,8 @@
|
|||||||
CommandParameter="{Binding}"/>
|
CommandParameter="{Binding}"/>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
</Border.ContextMenu>
|
</Border.ContextMenu>
|
||||||
<Grid ColumnDefinitions="Auto,*,90,90,80,80">
|
<Grid ColumnDefinitions="*,90,80,80">
|
||||||
<CheckBox Grid.Column="0" VerticalAlignment="Center" Margin="0,0,8,0"
|
<StackPanel Grid.Column="0" Orientation="Vertical" Spacing="2">
|
||||||
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}"/>
|
||||||
@@ -76,16 +72,13 @@
|
|||||||
ToolTip.Tip="{loc:Tr modals.worktreesOverview.phantomTooltip}"/>
|
ToolTip.Tip="{loc:Tr modals.worktreesOverview.phantomTooltip}"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
<TextBlock Grid.Column="2" Classes="meta" VerticalAlignment="Center"
|
<Border Grid.Column="1" CornerRadius="3" Padding="6,2" 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="4" Classes="meta" Text="{Binding DiffStat}" VerticalAlignment="Center"/>
|
<TextBlock Grid.Column="2" Classes="meta" Text="{Binding DiffStat}" VerticalAlignment="Center"/>
|
||||||
<TextBlock Grid.Column="5" Classes="meta" Text="{Binding AgeText}" VerticalAlignment="Center"/>
|
<TextBlock Grid.Column="3" Classes="meta" Text="{Binding AgeText}" VerticalAlignment="Center"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</Border>
|
</Border>
|
||||||
</DataTemplate>
|
</DataTemplate>
|
||||||
@@ -105,20 +98,7 @@
|
|||||||
<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}"/>
|
||||||
<Button Classes="btn" Content="{loc:Tr modals.worktreesOverview.selectAll}" Command="{Binding ToggleSelectAllCommand}"/>
|
<TextBlock Text="{Binding StatusMessage}" VerticalAlignment="Center" Margin="12,0,0,0"
|
||||||
<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>
|
||||||
@@ -126,35 +106,12 @@
|
|||||||
<!-- 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="Auto,*,90,90,80,80" Margin="12,0,12,4">
|
<Grid ColumnDefinitions="*,90,80,80" Margin="12,0,12,4">
|
||||||
<TextBlock Grid.Column="1" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnTask}"/>
|
<TextBlock Grid.Column="0" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnTask}"/>
|
||||||
<TextBlock Grid.Column="2" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnOutcome}"/>
|
<TextBlock Grid.Column="1" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnState}"/>
|
||||||
<TextBlock Grid.Column="3" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnState}"/>
|
<TextBlock Grid.Column="2" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnDiff}"/>
|
||||||
<TextBlock Grid.Column="4" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnDiff}"/>
|
<TextBlock Grid.Column="3" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnAge}"/>
|
||||||
<TextBlock Grid.Column="5" Classes="eyebrow" Text="{loc:Tr modals.worktreesOverview.columnAge}"/>
|
|
||||||
</Grid>
|
</Grid>
|
||||||
<Border Height="1" Background="{DynamicResource LineBrush}" Margin="0,0,0,8"/>
|
<Border Height="1" Background="{DynamicResource LineBrush}" Margin="0,0,0,8"/>
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ Interfaces (e.g. `IQueueWaker`, `IPrimeClock`, `ITaskStateService`) live in an `
|
|||||||
|
|
||||||
| Field | Values | Meaning |
|
| Field | Values | Meaning |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `Status` | `Idle`, `Queued`, `Running`, `WaitingForChildren`, `WaitingForReview`, `Done`, `Failed`, `Cancelled` | Lifecycle only. `WaitingForChildren` = parent's own work is done, waiting on its children. |
|
| `Status` | `Idle`, `Queued`, `Running`, `WaitingForReview`, `Done`, `Failed`, `Cancelled` | Lifecycle only. |
|
||||||
| `PlanningPhase` | `None`, `Active`, `Finalized` | Parent-only marker. `Active` ≈ legacy `Planning`; `Finalized` ≈ legacy `Planned`. |
|
| `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,46 +66,24 @@ 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, no children)
|
Running → WaitingForReview (standalone success) | Done (planning child success) | Failed | Cancelled
|
||||||
| WaitingForChildren (parent with pending children)
|
WaitingForReview → Done (approve) | Queued (reject-rerun, +feedback) | Idle (reject-park) | Cancelled
|
||||||
| Done (planning/improvement child success) | Failed | Cancelled
|
Done → Idle (re-run)
|
||||||
WaitingForChildren → WaitingForReview (all children terminal) | Cancelled
|
Failed → Idle | Queued
|
||||||
WaitingForReview → Done (approve) | Queued (reject-rerun, +feedback) | Idle (reject-park) | Cancelled
|
Cancelled → Idle | Queued
|
||||||
Done → Idle (re-run)
|
|
||||||
Failed → Idle | Queued
|
|
||||||
Cancelled → Idle | Queued
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Unified parent model.** Every parent — planning *or* improvement — flows
|
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`).
|
||||||
`… → 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` and sets `Status` to `WaitingForChildren` (or `WaitingForReview` if the parent has no children).
|
1. `_state.FinalizePlanningAsync(parent)` flips parent `PlanningPhase` to `Finalized`.
|
||||||
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.
|
2. `PlanningChainCoordinator.SetupChainAsync` attaches the `agent` tag to every child, enqueues child[0], and `BlockOn`s child[i] → child[i-1].
|
||||||
3. Once queued, the first child is woken automatically; successors unblock as their predecessor reaches a terminal state via `OnChildFinishedAsync`.
|
3. 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.
|
||||||
|
|
||||||
@@ -143,7 +121,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` (childless task: merges its worktree then Done, conflict stays WaitingForReview; task with children: drives `PlanningMergeOrchestrator` to merge the whole unit), `ContinuePlanningMerge` / `AbortPlanningMerge` (resolve a unit-merge conflict), `PreviewMerge(taskId, targetBranch) -> MergePreviewDto` (non-destructive mergeability check), `RejectReviewToQueue`, `RejectReviewToIdle`, `CancelReview`, `GetAgents`, `RefreshAgents`, `GetAppSettings`, `UpdateAppSettings`, `CleanupFinishedWorktrees`, `ResetAllWorktrees`, `MergeTask`, `GetMergeTargets`, `UpdateList`, `UpdateListConfig`, `GetListConfig`, `UpdateTaskAgentSettings`, `GetWeekReport`, `GenerateWeekReport`, `GetDailyNotes`, `AddDailyNote`, `UpdateDailyNote`, `DeleteDailyNote`, `RunDailyPrepNow`, `ClearMyDay`, `GetLastPrepLog`
|
**WorkerHub** methods: `Ping`, `GetActive`, `RunNow`, `CancelTask`, `WakeQueue`, `ContinueTask`, `ResetTask`, `ApproveReview`, `RejectReviewToQueue`, `RejectReviewToIdle`, `CancelReview`, `GetAgents`, `RefreshAgents`, `GetAppSettings`, `UpdateAppSettings`, `CleanupFinishedWorktrees`, `ResetAllWorktrees`, `MergeTask`, `GetMergeTargets`, `UpdateList`, `UpdateListConfig`, `GetListConfig`, `UpdateTaskAgentSettings`, `GetWeekReport`, `GenerateWeekReport`, `GetDailyNotes`, `AddDailyNote`, `UpdateDailyNote`, `DeleteDailyNote`, `RunDailyPrepNow`, `ClearMyDay`, `GetLastPrepLog`
|
||||||
|
|
||||||
**HubBroadcaster** events: `TaskStarted`, `TaskFinished`, `TaskMessage`, `WorktreeUpdated`, `TaskUpdated`, `RunCreated`, `ListUpdated`, `PrimeFired`, `PrepStarted`, `PrepLine`, `PrepFinished`
|
**HubBroadcaster** events: `TaskStarted`, `TaskFinished`, `TaskMessage`, `WorktreeUpdated`, `TaskUpdated`, `RunCreated`, `ListUpdated`, `PrimeFired`, `PrepStarted`, `PrepLine`, `PrepFinished`
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\ClaudeDo.Data\ClaudeDo.Data.csproj" />
|
<ProjectReference Include="..\ClaudeDo.Data\ClaudeDo.Data.csproj" />
|
||||||
|
<ProjectReference Include="..\ClaudeDo.Logging\ClaudeDo.Logging.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -27,7 +28,6 @@
|
|||||||
<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.
|
Before Width: | Height: | Size: 58 KiB |
10
src/ClaudeDo.Worker/External/ConfigMcpTools.cs
vendored
10
src/ClaudeDo.Worker/External/ConfigMcpTools.cs
vendored
@@ -61,14 +61,4 @@ public sealed class ConfigMcpTools
|
|||||||
await _tasks.UpdateAgentSettingsAsync(taskId, model.NullIfBlank(), systemPrompt.NullIfBlank(), agentPath.NullIfBlank(), maxTurns, cancellationToken);
|
await _tasks.UpdateAgentSettingsAsync(taskId, model.NullIfBlank(), systemPrompt.NullIfBlank(), agentPath.NullIfBlank(), maxTurns, cancellationToken);
|
||||||
await _broadcaster.TaskUpdated(taskId);
|
await _broadcaster.TaskUpdated(taskId);
|
||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description("Get per-task config overrides (model/system prompt/agent path/max turns). Returns null if no override is set on this task.")]
|
|
||||||
public async Task<TaskConfigDto?> GetTaskConfig(string taskId, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var task = await _tasks.GetByIdAsync(taskId, cancellationToken)
|
|
||||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
|
||||||
if (task.Model is null && task.SystemPrompt is null && task.AgentPath is null && task.MaxTurns is null)
|
|
||||||
return null;
|
|
||||||
return new TaskConfigDto(task.Model, task.SystemPrompt, task.AgentPath, task.MaxTurns);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ public sealed class ExternalMcpService
|
|||||||
|
|
||||||
[McpServerTool, Description(
|
[McpServerTool, Description(
|
||||||
"List tasks in a given list. Optionally filter by creator (createdBy) and/or status. " +
|
"List tasks in a given list. Optionally filter by creator (createdBy) and/or status. " +
|
||||||
"Valid status values: Idle, Queued, Running, WaitingForReview, WaitingForChildren, Done, Failed, Cancelled.")]
|
"Valid status values: Idle, Queued, Running, WaitingForReview, 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, WaitingForChildren, Done, Failed, Cancelled.");
|
$"Unknown status '{status}'. Valid values: Idle, Queued, Running, WaitingForReview, Done, Failed, Cancelled.");
|
||||||
statusFilter = parsed;
|
statusFilter = parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,44 +207,6 @@ public sealed class ExternalMcpService
|
|||||||
return ToDto(reload);
|
return ToDto(reload);
|
||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description(
|
|
||||||
"Append a subtask (step) to a task. orderNum defaults to the end. " +
|
|
||||||
"Refuses if the task is currently Running. Subtasks are surfaced to the agent at run time and shown in the task's Steps list.")]
|
|
||||||
public async Task<TaskDto> AddSubtask(
|
|
||||||
string taskId,
|
|
||||||
string title,
|
|
||||||
int? orderNum,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(title))
|
|
||||||
throw new InvalidOperationException("title is required.");
|
|
||||||
|
|
||||||
await using var ctx = await _dbFactory.CreateDbContextAsync(cancellationToken);
|
|
||||||
var tasks = new TaskRepository(ctx);
|
|
||||||
var subtasks = new SubtaskRepository(ctx);
|
|
||||||
|
|
||||||
var task = await tasks.GetByIdAsync(taskId, cancellationToken)
|
|
||||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
|
||||||
if (task.Status == TaskStatus.Running)
|
|
||||||
throw new InvalidOperationException("Cannot add a subtask to a running task. Cancel it first.");
|
|
||||||
|
|
||||||
var existing = await subtasks.GetByTaskIdAsync(taskId, cancellationToken);
|
|
||||||
var order = orderNum ?? (existing.Count == 0 ? 0 : existing.Max(s => s.OrderNum) + 1);
|
|
||||||
|
|
||||||
await subtasks.AddAsync(new SubtaskEntity
|
|
||||||
{
|
|
||||||
Id = Guid.NewGuid().ToString(),
|
|
||||||
TaskId = taskId,
|
|
||||||
Title = title.Trim(),
|
|
||||||
Completed = false,
|
|
||||||
OrderNum = order,
|
|
||||||
CreatedAt = DateTime.UtcNow,
|
|
||||||
}, cancellationToken);
|
|
||||||
|
|
||||||
await _broadcaster.TaskUpdated(taskId);
|
|
||||||
return ToDto(task);
|
|
||||||
}
|
|
||||||
|
|
||||||
[McpServerTool, Description(
|
[McpServerTool, Description(
|
||||||
"Update a task's status. Only 'Idle' and 'Queued' are permitted externally — " +
|
"Update a task's status. Only 'Idle' and 'Queued' are permitted externally — " +
|
||||||
"use run_task_now or cancel_task for execution control, and review_task to act on a WaitingForReview task. " +
|
"use run_task_now or cancel_task for execution control, and review_task to act on a WaitingForReview task. " +
|
||||||
@@ -360,14 +322,13 @@ public sealed class ExternalMcpService
|
|||||||
[McpServerTool, Description("Returns all valid task status values and their meanings. Use before filtering by status or interpreting task state.")]
|
[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("WaitingForChildren", "Planning parent whose child tasks are still running. The parent resumes once all children reach a terminal state."),
|
new("Done", "Completed successfully and approved; result text is available in the result field. Can be reset to Idle for re-execution."),
|
||||||
new("Done", "Completed successfully and approved; result text is available in the result field. Can be reset to Idle for re-execution."),
|
new("Failed", "Execution ended with an error; task can be reset to Idle or re-queued directly."),
|
||||||
new("Failed", "Execution ended with an error; task can be reset to Idle or re-queued directly."),
|
new("Cancelled", "Cancelled by the user; task can be reset to Idle or re-queued directly."),
|
||||||
new("Cancelled", "Cancelled by the user; task can be reset to Idle or re-queued directly."),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// ── Worktree / git tools ──────────────────────────────────────────────────
|
// ── Worktree / git tools ──────────────────────────────────────────────────
|
||||||
@@ -429,7 +390,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'. " +
|
||||||
"allowWaitingForReview=true: also allows merging a task in WaitingForReview (default false, which only allows Done). " +
|
"Refuses if task status is not Done (status values: Idle, Queued, Running, Done, Failed, Cancelled). " +
|
||||||
"On success: merged=true, mergeCommit contains the new merge commit SHA. " +
|
"On 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(
|
||||||
@@ -437,17 +398,14 @@ 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.");
|
||||||
var canMerge = task.Status == TaskStatus.Done ||
|
if (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}). " +
|
||||||
"Pass allowWaitingForReview=true to also merge a WaitingForReview task.");
|
"Valid statuses for merge: Done.");
|
||||||
|
|
||||||
var list = await _lists.GetByIdAsync(task.ListId, cancellationToken);
|
var list = await _lists.GetByIdAsync(task.ListId, cancellationToken);
|
||||||
|
|
||||||
@@ -532,37 +490,7 @@ 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.BranchDeleted);
|
return new CleanupWorktreeResult(result.Removed, path, result.Removed);
|
||||||
}
|
|
||||||
|
|
||||||
[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 ───────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using ClaudeDo.Worker.Prime;
|
using ClaudeDo.Worker.Prime;
|
||||||
using ClaudeDo.Worker.Refine;
|
|
||||||
using Microsoft.AspNetCore.SignalR;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
|
||||||
namespace ClaudeDo.Worker.Hub;
|
namespace ClaudeDo.Worker.Hub;
|
||||||
|
|
||||||
public sealed class HubBroadcaster : IPrimeBroadcaster, IRefineBroadcaster
|
public sealed class HubBroadcaster : IPrimeBroadcaster
|
||||||
{
|
{
|
||||||
private readonly IHubContext<WorkerHub> _hub;
|
private readonly IHubContext<WorkerHub> _hub;
|
||||||
|
|
||||||
@@ -63,12 +62,4 @@ public sealed class HubBroadcaster : IPrimeBroadcaster, IRefineBroadcaster
|
|||||||
Task IPrimeBroadcaster.PrepStartedAsync() => PrepStarted();
|
Task IPrimeBroadcaster.PrepStartedAsync() => PrepStarted();
|
||||||
Task IPrimeBroadcaster.PrepLineAsync(string line) => PrepLine(line);
|
Task IPrimeBroadcaster.PrepLineAsync(string line) => PrepLine(line);
|
||||||
Task IPrimeBroadcaster.PrepFinishedAsync(bool success) => PrepFinished(success);
|
Task IPrimeBroadcaster.PrepFinishedAsync(bool success) => PrepFinished(success);
|
||||||
|
|
||||||
public Task RefineStarted(string taskId) => _hub.Clients.All.SendAsync("RefineStarted", taskId);
|
|
||||||
public Task RefineFinished(string taskId, bool success, string? error) =>
|
|
||||||
_hub.Clients.All.SendAsync("RefineFinished", taskId, success, error);
|
|
||||||
|
|
||||||
Task IRefineBroadcaster.RefineStartedAsync(string taskId) => RefineStarted(taskId);
|
|
||||||
Task IRefineBroadcaster.RefineFinishedAsync(string taskId, bool success, string? error) =>
|
|
||||||
RefineFinished(taskId, success, error);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ using ClaudeDo.Worker.Lifecycle;
|
|||||||
using ClaudeDo.Worker.Planning;
|
using ClaudeDo.Worker.Planning;
|
||||||
using ClaudeDo.Worker.Prime;
|
using ClaudeDo.Worker.Prime;
|
||||||
using ClaudeDo.Worker.Queue;
|
using ClaudeDo.Worker.Queue;
|
||||||
using ClaudeDo.Worker.Refine;
|
|
||||||
using ClaudeDo.Worker.Report;
|
using ClaudeDo.Worker.Report;
|
||||||
using ClaudeDo.Worker.Report.Interfaces;
|
using ClaudeDo.Worker.Report.Interfaces;
|
||||||
using ClaudeDo.Worker.State;
|
using ClaudeDo.Worker.State;
|
||||||
@@ -54,11 +53,7 @@ public record WorktreeOverviewDto(
|
|||||||
|
|
||||||
public record ForceRemoveResultDto(bool Removed, string? Reason);
|
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 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);
|
||||||
@@ -88,7 +83,6 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
private readonly IPrimeRunner _primeRunner;
|
private readonly IPrimeRunner _primeRunner;
|
||||||
private readonly ITaskStateService _state;
|
private readonly ITaskStateService _state;
|
||||||
private readonly IWeekReportService _report;
|
private readonly IWeekReportService _report;
|
||||||
private readonly IRefineRunner _refineRunner;
|
|
||||||
|
|
||||||
public WorkerHub(
|
public WorkerHub(
|
||||||
QueueService queue,
|
QueueService queue,
|
||||||
@@ -108,8 +102,7 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
IPrimeScheduleSignal primeSignal,
|
IPrimeScheduleSignal primeSignal,
|
||||||
IPrimeRunner primeRunner,
|
IPrimeRunner primeRunner,
|
||||||
ITaskStateService state,
|
ITaskStateService state,
|
||||||
IWeekReportService report,
|
IWeekReportService report)
|
||||||
IRefineRunner refineRunner)
|
|
||||||
{
|
{
|
||||||
_queue = queue;
|
_queue = queue;
|
||||||
_waker = waker;
|
_waker = waker;
|
||||||
@@ -129,7 +122,6 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
_primeRunner = primeRunner;
|
_primeRunner = primeRunner;
|
||||||
_state = state;
|
_state = state;
|
||||||
_report = report;
|
_report = report;
|
||||||
_refineRunner = refineRunner;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Maps the two exceptions service methods throw into client-facing HubExceptions:
|
// Maps the two exceptions service methods throw into client-facing HubExceptions:
|
||||||
@@ -324,56 +316,6 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
return new MergeTargetsDto(t.DefaultBranch, t.LocalBranches);
|
return new MergeTargetsDto(t.DefaultBranch, t.LocalBranches);
|
||||||
});
|
});
|
||||||
|
|
||||||
public Task<MergePreviewDto> PreviewMerge(string taskId, string targetBranch)
|
|
||||||
=> HubGuard(async () =>
|
|
||||||
{
|
|
||||||
var p = await _mergeService.PreviewAsync(taskId, targetBranch ?? "", CancellationToken.None);
|
|
||||||
return new MergePreviewDto(p.Status, p.ConflictFiles, p.ChangedFileCount);
|
|
||||||
});
|
|
||||||
|
|
||||||
public Task<MergeResultDto> StartConflictMerge(string taskId, string targetBranch)
|
|
||||||
=> HubGuard(async () =>
|
|
||||||
{
|
|
||||||
var r = await _mergeService.MergeAsync(
|
|
||||||
taskId, targetBranch ?? "", removeWorktree: false, "Merge task",
|
|
||||||
leaveConflictsInTree: true, CancellationToken.None);
|
|
||||||
if (r.Status == TaskMergeService.StatusBlocked)
|
|
||||||
throw new HubException(r.ErrorMessage ?? "merge blocked");
|
|
||||||
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
|
|
||||||
});
|
|
||||||
|
|
||||||
public Task<MergeConflictsDto> GetMergeConflicts(string taskId)
|
|
||||||
=> HubGuard(async () =>
|
|
||||||
{
|
|
||||||
var c = await _mergeService.GetConflictsAsync(taskId, CancellationToken.None);
|
|
||||||
return new MergeConflictsDto(
|
|
||||||
c.TaskId,
|
|
||||||
c.Files.Select(f => new ConflictFileDto(
|
|
||||||
f.Path,
|
|
||||||
new[] { new ConflictHunkDto(f.Ours, f.Theirs, f.Base) })).ToList());
|
|
||||||
});
|
|
||||||
|
|
||||||
public Task WriteConflictResolution(string taskId, string path, string resolvedContent)
|
|
||||||
=> HubGuard(() => _mergeService.WriteResolutionAsync(
|
|
||||||
taskId, path, resolvedContent ?? "", CancellationToken.None));
|
|
||||||
|
|
||||||
public Task<MergeResultDto> ContinueMerge(string taskId)
|
|
||||||
=> HubGuard(async () =>
|
|
||||||
{
|
|
||||||
var r = await _mergeService.ContinueMergeAsync(taskId, CancellationToken.None);
|
|
||||||
if (r.Status == TaskMergeService.StatusBlocked)
|
|
||||||
throw new HubException(r.ErrorMessage ?? "continue failed");
|
|
||||||
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
|
|
||||||
});
|
|
||||||
|
|
||||||
public Task AbortMerge(string taskId)
|
|
||||||
=> HubGuard(async () =>
|
|
||||||
{
|
|
||||||
var r = await _mergeService.AbortMergeAsync(taskId, CancellationToken.None);
|
|
||||||
if (r.Status == TaskMergeService.StatusBlocked)
|
|
||||||
throw new HubException(r.ErrorMessage ?? "abort failed");
|
|
||||||
});
|
|
||||||
|
|
||||||
public async Task UpdateList(UpdateListDto dto)
|
public async Task UpdateList(UpdateListDto dto)
|
||||||
{
|
{
|
||||||
using var ctx = _dbFactory.CreateDbContext();
|
using var ctx = _dbFactory.CreateDbContext();
|
||||||
@@ -438,24 +380,11 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
if (!result.Ok) throw new HubException(result.Reason ?? "set status failed");
|
if (!result.Ok) throw new HubException(result.Reason ?? "set status failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<MergeResultDto> ApproveReview(string taskId, string targetBranch)
|
public async Task ApproveReview(string taskId)
|
||||||
=> HubGuard(async () =>
|
{
|
||||||
{
|
var result = await _state.ApproveReviewAsync(taskId, Context.ConnectionAborted);
|
||||||
bool hasChildren;
|
if (!result.Ok) throw new HubException(result.Reason ?? "approve failed");
|
||||||
await using (var ctx = await _dbFactory.CreateDbContextAsync(CancellationToken.None))
|
}
|
||||||
hasChildren = await ctx.Tasks.AnyAsync(t => t.ParentTaskId == taskId, CancellationToken.None);
|
|
||||||
|
|
||||||
if (hasChildren)
|
|
||||||
{
|
|
||||||
await _planningMergeOrchestrator.StartAsync(taskId, targetBranch ?? "", CancellationToken.None);
|
|
||||||
return new MergeResultDto(TaskMergeService.StatusMerged, Array.Empty<string>(), null);
|
|
||||||
}
|
|
||||||
|
|
||||||
var r = await _mergeService.ApproveAndMergeAsync(taskId, targetBranch ?? "", CancellationToken.None);
|
|
||||||
if (r.Status == TaskMergeService.StatusBlocked)
|
|
||||||
throw new HubException(r.ErrorMessage ?? "approve failed");
|
|
||||||
return new MergeResultDto(r.Status, r.ConflictFiles, r.ErrorMessage);
|
|
||||||
});
|
|
||||||
|
|
||||||
public async Task RejectReviewToQueue(string taskId, string feedback)
|
public async Task RejectReviewToQueue(string taskId, string feedback)
|
||||||
{
|
{
|
||||||
@@ -560,6 +489,10 @@ 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); }
|
||||||
@@ -608,12 +541,6 @@ public sealed class WorkerHub : Microsoft.AspNetCore.SignalR.Hub
|
|||||||
_primeSignal.Signal();
|
_primeSignal.Signal();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task RefineTask(string taskId)
|
|
||||||
{
|
|
||||||
_ = _refineRunner.RefineAsync(taskId, CancellationToken.None);
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<bool> RunDailyPrepNow()
|
public async Task<bool> RunDailyPrepNow()
|
||||||
{
|
{
|
||||||
var schedule = new PrimeScheduleDto(Guid.Empty, 0, TimeSpan.Zero, true, null, null);
|
var schedule = new PrimeScheduleDto(Guid.Empty, 0, TimeSpan.Zero, true, null, null);
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ using ClaudeDo.Data.Git;
|
|||||||
using ClaudeDo.Data.Models;
|
using ClaudeDo.Data.Models;
|
||||||
using ClaudeDo.Data.Repositories;
|
using ClaudeDo.Data.Repositories;
|
||||||
using ClaudeDo.Worker.Hub;
|
using ClaudeDo.Worker.Hub;
|
||||||
using ClaudeDo.Worker.State;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
@@ -18,21 +17,6 @@ public sealed record MergeTargets(
|
|||||||
string DefaultBranch,
|
string DefaultBranch,
|
||||||
IReadOnlyList<string> LocalBranches);
|
IReadOnlyList<string> LocalBranches);
|
||||||
|
|
||||||
public sealed record MergePreviewResult(
|
|
||||||
string Status,
|
|
||||||
IReadOnlyList<string> ConflictFiles,
|
|
||||||
int ChangedFileCount);
|
|
||||||
|
|
||||||
public sealed record MergeConflicts(
|
|
||||||
string TaskId,
|
|
||||||
IReadOnlyList<ConflictFileContent> Files);
|
|
||||||
|
|
||||||
public sealed record ConflictFileContent(
|
|
||||||
string Path,
|
|
||||||
string Ours,
|
|
||||||
string Theirs,
|
|
||||||
string? Base);
|
|
||||||
|
|
||||||
public sealed class TaskMergeService
|
public sealed class TaskMergeService
|
||||||
{
|
{
|
||||||
public const string StatusMerged = "merged";
|
public const string StatusMerged = "merged";
|
||||||
@@ -40,27 +24,20 @@ public sealed class TaskMergeService
|
|||||||
public const string StatusBlocked = "blocked";
|
public const string StatusBlocked = "blocked";
|
||||||
public const string StatusAborted = "aborted";
|
public const string StatusAborted = "aborted";
|
||||||
|
|
||||||
public const string PreviewClean = "clean";
|
|
||||||
public const string PreviewConflict = "conflict";
|
|
||||||
public const string PreviewUnavailable = "unavailable";
|
|
||||||
|
|
||||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
private readonly GitService _git;
|
private readonly GitService _git;
|
||||||
private readonly HubBroadcaster _broadcaster;
|
private readonly HubBroadcaster _broadcaster;
|
||||||
private readonly ITaskStateService _state;
|
|
||||||
private readonly ILogger<TaskMergeService> _logger;
|
private readonly ILogger<TaskMergeService> _logger;
|
||||||
|
|
||||||
public TaskMergeService(
|
public TaskMergeService(
|
||||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||||
GitService git,
|
GitService git,
|
||||||
HubBroadcaster broadcaster,
|
HubBroadcaster broadcaster,
|
||||||
ITaskStateService state,
|
|
||||||
ILogger<TaskMergeService> logger)
|
ILogger<TaskMergeService> logger)
|
||||||
{
|
{
|
||||||
_dbFactory = dbFactory;
|
_dbFactory = dbFactory;
|
||||||
_git = git;
|
_git = git;
|
||||||
_broadcaster = broadcaster;
|
_broadcaster = broadcaster;
|
||||||
_state = state;
|
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,15 +62,6 @@ 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,
|
||||||
@@ -177,7 +145,6 @@ 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})",
|
||||||
@@ -197,7 +164,7 @@ public sealed class TaskMergeService
|
|||||||
|
|
||||||
public async Task<MergeResult> ContinueMergeAsync(string taskId, CancellationToken ct)
|
public async Task<MergeResult> ContinueMergeAsync(string taskId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var (task, list, wt) = await LoadMergeContextAsync(taskId, ct);
|
var (_, list, wt) = await LoadMergeContextAsync(taskId, ct);
|
||||||
|
|
||||||
if (wt is null) return Blocked("task has no worktree");
|
if (wt 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}");
|
||||||
@@ -215,7 +182,6 @@ 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);
|
||||||
@@ -238,35 +204,6 @@ 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);
|
||||||
@@ -279,57 +216,6 @@ public sealed class TaskMergeService
|
|||||||
return new MergeTargets(current, branches);
|
return new MergeTargets(current, branches);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<MergePreviewResult> PreviewAsync(string taskId, string targetBranch, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var (_, list, wt) = await LoadMergeContextAsync(taskId, ct);
|
|
||||||
|
|
||||||
if (wt is null || wt.State != WorktreeState.Active)
|
|
||||||
return new MergePreviewResult(PreviewUnavailable, Array.Empty<string>(), 0);
|
|
||||||
if (string.IsNullOrWhiteSpace(list.WorkingDir) || !await _git.IsGitRepoAsync(list.WorkingDir, ct))
|
|
||||||
return new MergePreviewResult(PreviewUnavailable, Array.Empty<string>(), 0);
|
|
||||||
|
|
||||||
var target = string.IsNullOrWhiteSpace(targetBranch)
|
|
||||||
? await _git.GetCurrentBranchAsync(list.WorkingDir, ct)
|
|
||||||
: targetBranch;
|
|
||||||
|
|
||||||
var preview = await _git.PreviewMergeAsync(list.WorkingDir, target, wt.BranchName, ct);
|
|
||||||
if (!preview.Supported)
|
|
||||||
return new MergePreviewResult(PreviewUnavailable, Array.Empty<string>(), 0);
|
|
||||||
if (!preview.Clean)
|
|
||||||
return new MergePreviewResult(PreviewConflict, preview.ConflictFiles, 0);
|
|
||||||
|
|
||||||
var count = await _git.CountChangedFilesAsync(list.WorkingDir, target, wt.BranchName, ct);
|
|
||||||
return new MergePreviewResult(PreviewClean, Array.Empty<string>(), count);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<MergeResult> ApproveAndMergeAsync(string taskId, string targetBranch, CancellationToken ct)
|
|
||||||
{
|
|
||||||
var (task, list, wt) = await LoadMergeContextAsync(taskId, ct);
|
|
||||||
|
|
||||||
if (task.Status != TaskStatus.WaitingForReview)
|
|
||||||
return Blocked("task is not waiting for review");
|
|
||||||
|
|
||||||
if (wt is null || wt.State != WorktreeState.Active)
|
|
||||||
{
|
|
||||||
var done = await _state.ApproveReviewAsync(taskId, ct);
|
|
||||||
return done.Ok
|
|
||||||
? new MergeResult(StatusMerged, Array.Empty<string>(), null)
|
|
||||||
: Blocked(done.Reason ?? "approve failed");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(list.WorkingDir))
|
|
||||||
return Blocked("list has no working directory");
|
|
||||||
|
|
||||||
var target = string.IsNullOrWhiteSpace(targetBranch)
|
|
||||||
? await _git.GetCurrentBranchAsync(list.WorkingDir, ct)
|
|
||||||
: targetBranch;
|
|
||||||
|
|
||||||
// MergeAsync transitions the task WaitingForReview -> Done on a successful merge.
|
|
||||||
// Remove the worktree on approve (matching the unit-merge path) so merged
|
|
||||||
// worktrees don't pile up; the merge commit on the target branch is the record.
|
|
||||||
return await MergeAsync(taskId, target, removeWorktree: true, $"Merge {wt.BranchName}", ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static MergeResult Blocked(string reason) =>
|
private static MergeResult Blocked(string reason) =>
|
||||||
new(StatusBlocked, Array.Empty<string>(), reason);
|
new(StatusBlocked, Array.Empty<string>(), reason);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,21 +19,17 @@ public sealed class PlanningChainCoordinator
|
|||||||
_state = state;
|
_state = state;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sets up a sequential chain over a planning parent's children.
|
// Sets up a sequential queue chain over a planning parent's children.
|
||||||
// - First non-terminal child gets BlockedByTaskId=null.
|
// - First non-terminal child gets Status=Queued, BlockedByTaskId=null.
|
||||||
// - Each subsequent non-terminal child gets BlockedByTaskId=<predecessor>,
|
// - Each subsequent non-terminal child gets Status=Queued + 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, bool enqueue, CancellationToken ct = default)
|
internal async Task<int> SetupChainAsync(string parentTaskId, 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)
|
||||||
@@ -60,8 +56,7 @@ 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++)
|
||||||
{
|
{
|
||||||
if (enqueue)
|
await state.EnqueueAsync(sequenceable[i].Id, ct);
|
||||||
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
|
||||||
@@ -86,14 +81,18 @@ 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, enqueue: true, ct);
|
return await SetupChainAsync(parentTaskId, 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)
|
||||||
@@ -102,16 +101,7 @@ public sealed class PlanningChainCoordinator
|
|||||||
.FirstOrDefaultAsync(ct);
|
.FirstOrDefaultAsync(ct);
|
||||||
if (nextId is null) return null;
|
if (nextId is null) return null;
|
||||||
|
|
||||||
if (finalStatus == TaskStatus.Done)
|
await _state().UnblockAsync(nextId, ct);
|
||||||
{
|
return nextId;
|
||||||
await _state().UnblockAsync(nextId, ct);
|
|
||||||
return nextId;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Child failed or was cancelled: cancel the immediate successor so the chain
|
|
||||||
// is not left wedged. CancelAsync triggers OnChildTerminalAsync → OnChildFinishedAsync
|
|
||||||
// for that successor, cascading cancellation through the rest of the chain.
|
|
||||||
await _state().CancelAsync(nextId, DateTime.UtcNow, ct);
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ public sealed class PlanningMcpService
|
|||||||
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
|
await BroadcastTaskUpdatedAsync(ctx.ParentTaskId, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
[McpServerTool, Description("Finalize the planning session. Child tasks are left idle and chain-linked (each blocked by its predecessor); they are NOT queued automatically — the user queues the plan from the app when ready. The queueAgentTasks argument is accepted for compatibility but ignored.")]
|
[McpServerTool, Description("Finalize the planning session, promoting all draft child tasks to queued or manual status.")]
|
||||||
public async Task<int> Finalize(
|
public async Task<int> Finalize(
|
||||||
bool queueAgentTasks,
|
bool queueAgentTasks,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
@@ -149,10 +149,8 @@ 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;
|
||||||
// Establish the blocked-by chain but leave children Idle; queueing is a
|
if (queueAgentTasks && children.Count > 0)
|
||||||
// deliberate user action ("Queue plan"), never an automatic finalize step.
|
count = await _chain.SetupChainAsync(ctx.ParentTaskId, cancellationToken);
|
||||||
if (children.Count > 0)
|
|
||||||
count = await _chain.SetupChainAsync(ctx.ParentTaskId, enqueue: false, cancellationToken);
|
|
||||||
|
|
||||||
foreach (var c in children)
|
foreach (var c in children)
|
||||||
await BroadcastTaskUpdatedAsync(c.Id, cancellationToken);
|
await BroadcastTaskUpdatedAsync(c.Id, cancellationToken);
|
||||||
|
|||||||
@@ -199,10 +199,6 @@ public sealed class PlanningMergeOrchestrator
|
|||||||
parent.FinishedAt = DateTime.UtcNow;
|
parent.FinishedAt = DateTime.UtcNow;
|
||||||
await ctx.SaveChangesAsync(ct);
|
await ctx.SaveChangesAsync(ct);
|
||||||
|
|
||||||
// Surface the Done transition to the UI. Without this the parent row stays
|
|
||||||
// visibly stuck in WaitingForReview even though the unit merge completed.
|
|
||||||
await _broadcaster.TaskUpdated(parentTaskId);
|
|
||||||
|
|
||||||
// Only planning builds an integration branch via the aggregator; skip cleanup otherwise.
|
// Only planning builds an integration branch via the aggregator; skip cleanup otherwise.
|
||||||
if (isPlanning)
|
if (isPlanning)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -209,13 +209,12 @@ 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}.");
|
||||||
|
|
||||||
// Establish the blocked-by chain but leave children Idle; queueing is a
|
int count = 0;
|
||||||
// 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);
|
||||||
int count = children.Count;
|
if (queueAgentTasks && children.Count > 0)
|
||||||
if (children.Count > 0)
|
count = await _chain.SetupChainAsync(taskId, ct);
|
||||||
count = await _chain.SetupChainAsync(taskId, enqueue: false, ct);
|
else
|
||||||
|
count = children.Count;
|
||||||
|
|
||||||
// Best-effort cleanup — don't block finalization on git state.
|
// Best-effort cleanup — don't block finalization on git state.
|
||||||
await TryCleanupWorktreeAsync(taskId, lists, settings, ct);
|
await TryCleanupWorktreeAsync(taskId, lists, settings, ct);
|
||||||
|
|||||||
@@ -8,13 +8,10 @@ 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 IReadOnlyList<string> BuildArgs(int maxTurns) =>
|
public static string BuildArgs(int maxTurns) =>
|
||||||
[
|
"-p --output-format stream-json --verbose --permission-mode acceptEdits " +
|
||||||
"-p", "--output-format", "stream-json", "--verbose",
|
$"--max-turns {maxTurns} " +
|
||||||
"--permission-mode", "acceptEdits",
|
$"--allowedTools {CandidatesTool} {SetMyDayTool}";
|
||||||
"--max-turns", maxTurns.ToString(),
|
|
||||||
"--allowedTools", CandidatesTool, SetMyDayTool,
|
|
||||||
];
|
|
||||||
|
|
||||||
public static string BuildPrompt(int maxTasks, DateOnly today) =>
|
public static string BuildPrompt(int maxTasks, DateOnly today) =>
|
||||||
ClaudeDo.Data.PromptFiles.Render(
|
ClaudeDo.Data.PromptFiles.Render(
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ using ClaudeDo.Worker.Queue;
|
|||||||
using ClaudeDo.Worker.Runner;
|
using ClaudeDo.Worker.Runner;
|
||||||
using ClaudeDo.Worker.State;
|
using ClaudeDo.Worker.State;
|
||||||
using ClaudeDo.Worker.Prime;
|
using ClaudeDo.Worker.Prime;
|
||||||
using ClaudeDo.Worker.Refine;
|
|
||||||
using ClaudeDo.Worker.Report;
|
using ClaudeDo.Worker.Report;
|
||||||
using ClaudeDo.Worker.Report.Interfaces;
|
using ClaudeDo.Worker.Report.Interfaces;
|
||||||
using ClaudeDo.Worker.Worktrees;
|
using ClaudeDo.Worker.Worktrees;
|
||||||
@@ -32,13 +31,8 @@ var builder = WebApplication.CreateBuilder(args);
|
|||||||
|
|
||||||
var logRoot = cfg.LogRoot;
|
var logRoot = cfg.LogRoot;
|
||||||
Directory.CreateDirectory(logRoot);
|
Directory.CreateDirectory(logRoot);
|
||||||
builder.Host.UseSerilog((ctx, lc) => lc
|
builder.Host.UseSerilog((ctx, lc) =>
|
||||||
.MinimumLevel.Information()
|
ClaudeDo.Logging.LoggingSetup.Configure(lc, "worker", logRoot));
|
||||||
.WriteTo.File(
|
|
||||||
System.IO.Path.Combine(logRoot, "worker-.log"),
|
|
||||||
rollingInterval: RollingInterval.Day,
|
|
||||||
retainedFileCountLimit: 7,
|
|
||||||
shared: true));
|
|
||||||
|
|
||||||
builder.Services.AddDbContextFactory<ClaudeDoDbContext>(opt =>
|
builder.Services.AddDbContextFactory<ClaudeDoDbContext>(opt =>
|
||||||
opt.UseSqlite($"Data Source={cfg.DbPath}"));
|
opt.UseSqlite($"Data Source={cfg.DbPath}"));
|
||||||
@@ -109,10 +103,6 @@ builder.Services.AddSingleton(PrimeSchedulerOptions.Default);
|
|||||||
builder.Services.AddSingleton<IPrimeBroadcaster>(sp => sp.GetRequiredService<HubBroadcaster>());
|
builder.Services.AddSingleton<IPrimeBroadcaster>(sp => sp.GetRequiredService<HubBroadcaster>());
|
||||||
builder.Services.AddHostedService<PrimeScheduler>();
|
builder.Services.AddHostedService<PrimeScheduler>();
|
||||||
|
|
||||||
// Refine
|
|
||||||
builder.Services.AddSingleton<IRefineRunner, RefineRunner>();
|
|
||||||
builder.Services.AddSingleton<IRefineBroadcaster>(sp => sp.GetRequiredService<HubBroadcaster>());
|
|
||||||
|
|
||||||
// QueueService: singleton + hosted service (same instance).
|
// QueueService: singleton + hosted service (same instance).
|
||||||
builder.Services.AddSingleton<QueueService>();
|
builder.Services.AddSingleton<QueueService>();
|
||||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<QueueService>());
|
builder.Services.AddHostedService(sp => sp.GetRequiredService<QueueService>());
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
namespace ClaudeDo.Worker.Refine;
|
|
||||||
|
|
||||||
public interface IRefineBroadcaster
|
|
||||||
{
|
|
||||||
Task RefineStartedAsync(string taskId);
|
|
||||||
Task RefineFinishedAsync(string taskId, bool success, string? error);
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
namespace ClaudeDo.Worker.Refine;
|
|
||||||
|
|
||||||
public interface IRefineRunner
|
|
||||||
{
|
|
||||||
Task<RefineRunOutcome> RefineAsync(string taskId, CancellationToken ct);
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed record RefineRunOutcome(bool Success, string Message);
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
using ClaudeDo.Data;
|
|
||||||
using ClaudeDo.Data.Models;
|
|
||||||
|
|
||||||
namespace ClaudeDo.Worker.Refine;
|
|
||||||
|
|
||||||
public static class RefinePrompt
|
|
||||||
{
|
|
||||||
public const string GetTaskTool = "mcp__claudedo__get_task";
|
|
||||||
public const string UpdateTaskTool = "mcp__claudedo__update_task";
|
|
||||||
public const string AddSubtaskTool = "mcp__claudedo__add_subtask";
|
|
||||||
|
|
||||||
public static string LogPath(string taskId) =>
|
|
||||||
System.IO.Path.Combine(Paths.AppDataRoot(), "logs", $"refine-{Short(taskId)}.log");
|
|
||||||
|
|
||||||
public static IReadOnlyList<string> BuildArgs(int maxTurns, bool canReadRepo)
|
|
||||||
{
|
|
||||||
var args = new List<string>
|
|
||||||
{
|
|
||||||
"-p", "--output-format", "stream-json", "--verbose",
|
|
||||||
"--permission-mode", "acceptEdits",
|
|
||||||
"--max-turns", maxTurns.ToString(),
|
|
||||||
"--allowedTools", GetTaskTool, UpdateTaskTool, AddSubtaskTool,
|
|
||||||
};
|
|
||||||
if (canReadRepo)
|
|
||||||
{
|
|
||||||
args.Add("Read");
|
|
||||||
args.Add("Grep");
|
|
||||||
args.Add("Glob");
|
|
||||||
}
|
|
||||||
return args;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static string BuildPrompt(TaskEntity task, IEnumerable<SubtaskEntity> subtasks)
|
|
||||||
{
|
|
||||||
var open = subtasks.Where(s => !s.Completed).Select(s => $"- {s.Title}").ToList();
|
|
||||||
var subText = open.Count == 0 ? "(none)" : string.Join("\n", open);
|
|
||||||
return PromptFiles.Render(PromptKind.Refine, new Dictionary<string, string>
|
|
||||||
{
|
|
||||||
["taskId"] = task.Id,
|
|
||||||
["title"] = task.Title,
|
|
||||||
["description"] = string.IsNullOrWhiteSpace(task.Description) ? "(empty)" : task.Description!,
|
|
||||||
["subtasks"] = subText,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string Short(string id) => id.Length >= 8 ? id[..8] : id;
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
using ClaudeDo.Data;
|
|
||||||
using ClaudeDo.Data.Repositories;
|
|
||||||
using ClaudeDo.Worker.Runner;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
|
||||||
|
|
||||||
namespace ClaudeDo.Worker.Refine;
|
|
||||||
|
|
||||||
public sealed class RefineRunner : IRefineRunner
|
|
||||||
{
|
|
||||||
private static readonly TimeSpan RunTimeout = TimeSpan.FromMinutes(5);
|
|
||||||
private const int MaxTurns = 5;
|
|
||||||
|
|
||||||
private readonly IClaudeProcess _claude;
|
|
||||||
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
|
||||||
private readonly ILogger<RefineRunner> _logger;
|
|
||||||
private readonly IRefineBroadcaster _broadcaster;
|
|
||||||
|
|
||||||
private readonly object _lock = new();
|
|
||||||
private readonly HashSet<string> _inFlight = new();
|
|
||||||
|
|
||||||
public RefineRunner(
|
|
||||||
IClaudeProcess claude,
|
|
||||||
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
|
||||||
ILogger<RefineRunner> logger,
|
|
||||||
IRefineBroadcaster broadcaster)
|
|
||||||
{
|
|
||||||
_claude = claude;
|
|
||||||
_dbFactory = dbFactory;
|
|
||||||
_logger = logger;
|
|
||||||
_broadcaster = broadcaster;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<RefineRunOutcome> RefineAsync(string taskId, CancellationToken ct)
|
|
||||||
{
|
|
||||||
lock (_lock)
|
|
||||||
{
|
|
||||||
if (!_inFlight.Add(taskId))
|
|
||||||
return new RefineRunOutcome(false, "Already refining this task");
|
|
||||||
}
|
|
||||||
|
|
||||||
var success = false;
|
|
||||||
string? error = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
ClaudeDo.Data.Models.TaskEntity task;
|
|
||||||
List<ClaudeDo.Data.Models.SubtaskEntity> subs;
|
|
||||||
string? workingDir;
|
|
||||||
await using (var dbCtx = await _dbFactory.CreateDbContextAsync(ct))
|
|
||||||
{
|
|
||||||
var tasks = new TaskRepository(dbCtx);
|
|
||||||
task = await tasks.GetByIdAsync(taskId, ct)
|
|
||||||
?? throw new InvalidOperationException($"Task {taskId} not found.");
|
|
||||||
if (task.Status != TaskStatus.Idle)
|
|
||||||
return new RefineRunOutcome(false, $"Task must be Idle to refine (is {task.Status}).");
|
|
||||||
subs = await new SubtaskRepository(dbCtx).GetByTaskIdAsync(taskId, ct);
|
|
||||||
var list = await new ListRepository(dbCtx).GetByIdAsync(task.ListId, ct);
|
|
||||||
workingDir = list?.WorkingDir;
|
|
||||||
}
|
|
||||||
|
|
||||||
var canReadRepo = !string.IsNullOrWhiteSpace(workingDir) && Directory.Exists(workingDir);
|
|
||||||
var cwd = canReadRepo ? workingDir! : Paths.AppDataRoot();
|
|
||||||
Directory.CreateDirectory(cwd);
|
|
||||||
|
|
||||||
var logPath = RefinePrompt.LogPath(taskId);
|
|
||||||
try { if (File.Exists(logPath)) File.Delete(logPath); } catch { }
|
|
||||||
await using var logWriter = new LogWriter(logPath);
|
|
||||||
|
|
||||||
await _broadcaster.RefineStartedAsync(taskId);
|
|
||||||
|
|
||||||
var prompt = RefinePrompt.BuildPrompt(task, subs);
|
|
||||||
var args = RefinePrompt.BuildArgs(MaxTurns, canReadRepo);
|
|
||||||
|
|
||||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
|
||||||
timeoutCts.CancelAfter(RunTimeout);
|
|
||||||
|
|
||||||
var result = await _claude.RunAsync(
|
|
||||||
arguments: args,
|
|
||||||
prompt: prompt,
|
|
||||||
workingDirectory: cwd,
|
|
||||||
onStdoutLine: async line => await logWriter.WriteLineAsync(line),
|
|
||||||
ct: timeoutCts.Token);
|
|
||||||
|
|
||||||
success = result.IsSuccess;
|
|
||||||
if (!success) error = $"exit code {result.ExitCode}";
|
|
||||||
return success
|
|
||||||
? new RefineRunOutcome(true, "Refine complete")
|
|
||||||
: new RefineRunOutcome(false, error!);
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
|
|
||||||
{
|
|
||||||
error = $"timed out after {RunTimeout.TotalMinutes:0} min";
|
|
||||||
return new RefineRunOutcome(false, error);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogWarning(ex, "Refine run failed for {TaskId}", taskId);
|
|
||||||
error = ex.Message;
|
|
||||||
return new RefineRunOutcome(false, ex.Message);
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
await _broadcaster.RefineFinishedAsync(taskId, success, error);
|
|
||||||
lock (_lock) { _inFlight.Remove(taskId); }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -69,12 +69,7 @@ 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";
|
||||||
IReadOnlyList<string> args =
|
var args = $"-p --output-format stream-json --verbose --permission-mode auto --model {safeModel}";
|
||||||
[
|
|
||||||
"-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.");
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user